[Asterisk-code-review] res_redisd: add REDIS support modules to send asterisk DeviceState up... (asterisk[master])

Alec Davis asteriskteam at digium.com
Wed Jul 20 23:27:42 CDT 2022


Alec Davis has uploaded this change for review. ( https://gerrit.asterisk.org/c/asterisk/+/18826 )


Change subject: res_redisd: add REDIS support modules to send asterisk DeviceState updates to a REDIS server
......................................................................

res_redisd: add REDIS support modules to send asterisk DeviceState updates to a REDIS server

Code based on res_statsd and res_chan_stats, thank you David M. Lee

Tested on production system running asterisk 16.

ASTERISK-30147
Reported By: Alec Davis

Change-Id: Ibd70851ecf7aebe41b3830b52599fe7d14c311e9
---
A configs/samples/redisd.conf.sample
A include/asterisk/redisd.h
A res/res_redis_device_state.c
A res/res_redisd.c
4 files changed, 672 insertions(+), 0 deletions(-)



  git pull ssh://gerrit.asterisk.org:29418/asterisk refs/changes/26/18826/1

diff --git a/configs/samples/redisd.conf.sample b/configs/samples/redisd.conf.sample
new file mode 100644
index 0000000..e41fd93
--- /dev/null
+++ b/configs/samples/redisd.conf.sample
@@ -0,0 +1,10 @@
+[general]
+enabled = yes			; When set to yes, redisd support is enabled
+server = 127.0.0.1		; server[:port] of REDIS server to use.
+				; If not specified, the port is 6379
+prefix = pabx:			; Prefix to prepend to all metrics
+;dbname = 0			; DB to select, default = 0
+
+bgsave = yes			; When set to yes, initiate a Background save when unloading module
+
+;password = 12345678
diff --git a/include/asterisk/redisd.h b/include/asterisk/redisd.h
new file mode 100644
index 0000000..b88357e
--- /dev/null
+++ b/include/asterisk/redisd.h
@@ -0,0 +1,33 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2013, Digium, Inc.
+ *
+ * David M. Lee, II <dlee at digium.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+#ifndef _ASTERISK_REDISD_H
+#define _ASTERISK_REDISD_H
+
+/*!
+ * \brief Support for publishing to a REDIS server.
+ *
+ * \author David M. Lee, II <dlee at digium.com>
+ * \since 12
+ */
+
+#include "asterisk/optional_api.h"
+
+AST_OPTIONAL_API(void, ast_redisd_command, (const char *command, const char *key_path, const char *_keyvalue, char *return_buffer, size_t return_buffwe_len), {});
+
+#endif /* _ASTERISK_REDISD_H */
diff --git a/res/res_redis_device_state.c b/res/res_redis_device_state.c
new file mode 100644
index 0000000..34bf9fb
--- /dev/null
+++ b/res/res_redis_device_state.c
@@ -0,0 +1,109 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2005-2006, Digium, Inc.
+
+ * Copyright (C) 2022, Alec Davis
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \brief Publish stasis devicestate to REDIS database.
+ *
+ * This module subscribes to the stasis Device State topic and issues REDIS updates
+ * based on the received messages.
+ *
+ * \author Alec Davis <alecbdt.co.nz>
+ */
+
+/*** MODULEINFO
+    <defaultenabled>no</defaultenabled>
+    <depend>res_redisd</depend>
+    <support_level>extended</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include <asterisk/module.h>
+#include <asterisk/pbx.h>
+
+#include "asterisk/redisd.h"
+
+/*! Regular Stasis subscription */
+static struct stasis_subscription *device_state_sub;
+
+/*! \brief Stasis subscription callback for device state updates */
+static void device_state_cb(void *data, struct stasis_subscription *device_state_sub, struct stasis_message *message)
+{
+    struct ast_device_state_message *dev_state;
+    const char *device;
+    struct ast_str *key_path;
+
+    if (stasis_subscription_final_message(device_state_sub, message)) {
+        ast_log(LOG_DEBUG, "remove stasis subscription\n");
+        return;
+    }
+
+    if (stasis_message_type(message) != ast_device_state_message_type()) {
+        ast_log(LOG_DEBUG, "message type doesn't match\n");
+        return;
+    }
+
+    dev_state = stasis_message_data(message);
+    if (dev_state->eid) {
+        /* ignore non-aggregate states */
+        return;
+    }
+
+    device = dev_state->device;
+    if (ast_strlen_zero(device)) {
+        ast_log(LOG_DEBUG, "device length zero\n");
+        return;
+    }
+
+    key_path = ast_str_create(256);
+    if (!key_path) {
+        return;
+    }
+
+    ast_str_set(&key_path, 0, "deviceState:%s", device);
+
+    //throw away result
+    char return_buffer[4096];
+    return_buffer[0] = '\0';
+
+    ast_redisd_command( "SET", ast_str_buffer(key_path), ast_devstate_str(dev_state->state), return_buffer, sizeof(return_buffer));
+
+    ast_free(key_path);
+}
+
+static int unload_module(void)
+{
+    stasis_unsubscribe_and_join(device_state_sub);
+    device_state_sub = NULL;
+
+    return 0;
+}
+
+static int load_module(void)
+{
+    device_state_sub = stasis_subscribe(ast_device_state_topic_all(), device_state_cb, NULL);
+
+    return 0;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_GLOBAL_SYMBOLS | AST_MODFLAG_LOAD_ORDER, "REDIS Device State support",
+    .support_level = AST_MODULE_SUPPORT_EXTENDED,
+    .load = load_module,
+    .unload = unload_module,
+    .requires = "res_redisd"
+);
diff --git a/res/res_redisd.c b/res/res_redisd.c
new file mode 100644
index 0000000..8a573e0
--- /dev/null
+++ b/res/res_redisd.c
@@ -0,0 +1,520 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2005-2006, Digium, Inc.
+ *
+ * Copyright (C) 2022, Alec Davis
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \brief Support for working with a REDIS server.
+ *
+ * \author Alec Davis <alec at bdt.co.nz>
+ */
+
+/*
+ * For external depends  refer /https://wiki.asterisk.org/wiki/display/AST/Build+System+Architecture
+ *
+ */
+
+/*** MODULEINFO
+    <defaultenabled>no</defaultenabled>
+    <depend>hiredis</depend>
+    <support_level>extended</support_level>
+ ***/
+
+#include "asterisk.h"
+
+#include "asterisk/config_options.h"
+#include <asterisk/module.h>
+#include <asterisk/pbx.h>
+#include <asterisk/logger.h>
+
+#define AST_API_MODULE
+#include "asterisk/redisd.h"
+
+#include <hiredis/hiredis.h>
+
+/*** DOCUMENTATION
+    <configInfo name="res_redisd" language="en_US">
+        <synopsis>REDIS client</synopsis>
+        <description>
+            <para>The <literal>res_redisd</literal> module provides an API that
+            allows Asterisk and its modules to communicate with a REDIS
+            server.</para>
+        </description>
+        <configFile name="redisd.conf">
+            <configObject name="global">
+                <synopsis>Global configuration settings</synopsis>
+                <configOption name="enabled">
+                    <synopsis>Enable/Disable the REDIS module</synopsis>
+                </configOption>
+                <configOption name="server">
+                    <synopsis>Address of the REDIS server</synopsis>
+                </configOption>
+                <configOption name="prefix">
+                    <synopsis>Prefix to prepend to every command</synopsis>
+                </configOption>
+                <configOption name="dbname">
+                    <synopsis>REDIS database to use</synopsis>
+                </configOption>
+                <configOption name="password">
+                    <synopsis>REDIS password</synopsis>
+                </configOption>
+                <configOption name="bgsave">
+                    <synopsis>Background Save</synopsis>
+                </configOption>
+            </configObject>
+        </configFile>
+    </configInfo>
+ ***/
+
+#define DEFAULT_REDIS_PORT 6379
+
+#define MAX_PREFIX 40
+
+#define STR_CONF_SZ 256
+
+static struct timeval timeout;
+
+/*! \brief Global configuration options for REDIS client. */
+struct conf_global_options {
+    /*! Disabled by default, Enabled if true. */
+    int enabled;
+    /*! REDIS server address[:port]. */
+    struct ast_sockaddr redis_server;
+    /*! Prefix to put on every keypath. */
+    char prefix[MAX_PREFIX + 1];
+    /*! AUTH Password. */
+    char password[STR_CONF_SZ];
+    /*! DB to select. */
+    char dbname[STR_CONF_SZ];
+    /*! BGSAVE on exit. */
+    int bgsave;
+};
+
+/*! \brief All configuration options for REDIS client. */
+struct conf {
+    /*! The general section configuration options. */
+    struct conf_global_options *global;
+};
+
+/*! \brief Locking container for safe configuration access. */
+static AO2_GLOBAL_OBJ_STATIC(confs);
+
+static char is_enabled(void);
+static void conf_server(const struct conf *cfg, struct ast_sockaddr *addr);
+
+
+/*!
+ * \brief Connect to REDIS, with optional AUTH and DB selection
+ */
+static int redisd_connect(void *data)
+{
+    RAII_VAR(struct conf *, cfg, ao2_global_obj_ref(confs), ao2_cleanup);
+
+    if (!is_enabled()) {
+        ast_log(AST_LOG_WARNING, "REDIS module is not enabled. Reason: redisd.conf enabled=no?\n");
+        return -1;
+    }
+
+    struct ast_sockaddr redis_server;
+
+    conf_server(cfg, &redis_server);
+
+    redisContext *redisd_context = redisConnectWithTimeout(\
+        ast_sockaddr_stringify_addr(&redis_server),\
+        ast_sockaddr_port(&redis_server), timeout);
+
+    if (redisd_context == NULL) {
+        ast_log(AST_LOG_ERROR, "Couldn't establish connection. Reason: UNKNOWN\n");
+        return -1;
+    }
+
+    if (redisd_context->err != 0) {
+
+        ast_log(AST_LOG_ERROR, "Couldn't establish connection to %s:%d socket_fd[%d] Reason: %s\n",\
+            ast_sockaddr_stringify_addr(&redis_server),\
+            ast_sockaddr_port(&redis_server),\
+            redisd_context->fd, redisd_context->errstr);
+
+        redisFree(redisd_context);
+        return -1;
+    }
+
+    ast_log(LOG_DEBUG, "socket_fd[%d]\n", redisd_context->fd);
+
+    redisReply *reply = NULL;
+    if (!ast_strlen_zero(cfg->global->password)) {
+
+        reply = redisCommand(redisd_context,"AUTH %s", cfg->global->password);
+        if (reply != NULL && reply->type == REDIS_REPLY_ERROR) {
+            ast_log(LOG_ERROR, "Unable to authenticate. Reason: %s\n", reply->str);
+            freeReplyObject(reply);
+
+            redisFree(redisd_context);
+            return -1;
+        }
+        ast_log(LOG_DEBUG, "Authenticated.\n");
+        freeReplyObject(reply);
+    }
+
+    if (!ast_strlen_zero(cfg->global->dbname)) {
+
+        reply = redisCommand(redisd_context,"SELECT %s", cfg->global->dbname);
+        if (reply != NULL && reply->type == REDIS_REPLY_ERROR) {
+            ast_log(AST_LOG_ERROR, "Unable to select DB %s. Reason: %s\n", cfg->global->dbname, reply->str);
+            freeReplyObject(reply);
+
+            redisFree(redisd_context);
+            return -1;
+        }
+        ast_log(LOG_DEBUG, "Database %s selected.\n", cfg->global->dbname);
+        freeReplyObject(reply);
+    }
+
+    memcpy(data, redisd_context, sizeof(redisContext));
+    ast_free(redisd_context);
+
+    return 0;
+}
+
+static void redisd_disconnect(void *data)
+{
+    redisContext *redisd_context = data;
+
+    if (redisd_context == NULL) {
+        ast_log(AST_LOG_ERROR, "No redisd_context. Reason: UNKNOWN\n");
+        return;
+    }
+
+    ast_log(LOG_DEBUG, "socket_fd[%d]\n", redisd_context->fd);
+
+    redisFree(redisd_context);
+
+    return;
+}
+
+AST_THREADSTORAGE_CUSTOM(redisd_instance, redisd_connect, redisd_disconnect)
+
+
+static int redisd_command(const char *cmd, char *return_buffer, size_t return_buffer_length)
+{
+    int attempt = 1;
+    redisReply *reply = NULL;
+    return_buffer[0] = '\0';
+
+    redisContext *redisd_context = ast_threadstorage_get(&redisd_instance, sizeof(redisContext));
+
+    if (!redisd_context) {
+        ast_log(AST_LOG_ERROR, "Error retrieving the redis context from thread\n");
+        return -1;
+    }
+
+try:
+    ast_log(LOG_DEBUG, "socket_fd[%d] cmd[%s]\n", redisd_context->fd, cmd);
+    reply = redisCommand(redisd_context, cmd);
+
+    if (reply == NULL) {
+
+        if (attempt++ < 3) {
+            ast_log(LOG_NOTICE, "Attempting to reconnect to REDIS,\
+                socket_fd[%d] Attempt[%d] cmd[%s] \n",\
+                redisd_context->fd, attempt, cmd);
+
+            redisReconnect(redisd_context);
+            goto try;
+        }
+
+        return -1;
+    }
+
+    if (reply != NULL && reply->type == REDIS_REPLY_ERROR) {
+        ast_log(AST_LOG_ERROR, "%s\n", reply->str);
+        freeReplyObject(reply);
+
+        return -1;
+    }
+        
+    struct ast_str *msg;
+    msg = ast_str_create(4096);
+    if (!msg) {
+        freeReplyObject(reply);
+        return -1;
+    }
+
+    size_t reply_str_len = 0;
+
+    switch (reply->type) {
+
+    case REDIS_REPLY_STATUS:
+    case REDIS_REPLY_ERROR:
+    case REDIS_REPLY_STRING:
+        reply_str_len = ast_str_set(&msg, 0, "%s", reply->str);
+        break;
+
+    case REDIS_REPLY_INTEGER:
+        reply_str_len = ast_str_set(&msg, 0, "%lld", reply->integer);
+        break;
+
+    case REDIS_REPLY_ARRAY:
+        for (size_t element_idx = 0; element_idx < reply->elements; ++element_idx) {
+
+            if (element_idx) {
+                reply_str_len += ast_str_append(&msg, 0, ",%s", reply->element[element_idx]->str);
+
+            } else {
+                reply_str_len = ast_str_set(&msg, 0, "%s", reply->element[element_idx]->str);
+            }
+
+        }
+        break;
+
+    case REDIS_REPLY_NIL:
+        reply_str_len = ast_str_set(&msg, 0, "%s", "nil");
+        break;
+
+    default:
+        break;
+    }
+
+    if (reply_str_len) {
+        strncpy(return_buffer, ast_str_buffer(msg), return_buffer_length);
+    }
+
+    freeReplyObject(reply);
+
+    return 0;
+}
+
+
+void AST_OPTIONAL_API_NAME(ast_redisd_command)(const char *command, const char *key_path,
+    const char *key_value, char *return_buffer, size_t return_buffer_len)
+{
+    RAII_VAR(struct conf *, cfg, ao2_global_obj_ref(confs), ao2_cleanup);
+ 
+    if (!is_enabled()) {
+        return;
+    }
+
+    struct ast_str *msg;
+    msg = ast_str_create(256);
+    if (!msg) {
+        return;
+    }
+    ast_str_append(&msg, 0, "%s", command);
+
+    if (strlen(key_path)) {
+        if (!ast_strlen_zero(cfg->global->prefix)) {
+            ast_str_append(&msg, 0, " %s%s", cfg->global->prefix, key_path);
+        } else {
+            ast_str_append(&msg, 0, " %s", key_path);
+        }
+
+        if (strlen(key_value)) {
+            ast_str_append(&msg, 0, " %s", key_value);
+        }
+    }
+
+    redisd_command(ast_str_buffer(msg), return_buffer, return_buffer_len);
+
+    ast_free(msg);
+
+}
+
+static void conf_server(const struct conf *cfg, struct ast_sockaddr *addr)
+{
+    *addr = cfg->global->redis_server;
+    if (ast_sockaddr_port(addr) == 0) {
+        ast_sockaddr_set_port(addr, DEFAULT_REDIS_PORT);
+    }
+}
+
+/*! \brief Mapping of the REDIS conf struct's globals to the
+ *         general context in the config file. */
+static struct aco_type global_option = {
+    .type = ACO_GLOBAL,
+    .name = "global",
+    .item_offset = offsetof(struct conf, global),
+    .category = "general",
+    .category_match = ACO_WHITELIST_EXACT,
+};
+
+static struct aco_type *global_options[] = ACO_TYPES(&global_option);
+
+/*! \brief Disposes of the REDIS conf object */
+static void conf_destructor(void *obj)
+{
+    struct conf *cfg = obj;
+    ao2_cleanup(cfg->global);
+}
+
+/*! \brief Creates the REDIS conf object. */
+static void *conf_alloc(void)
+{
+    struct conf *cfg;
+
+    if (!(cfg = ao2_alloc(sizeof(*cfg), conf_destructor))) {
+        return NULL;
+    }
+
+    if (!(cfg->global = ao2_alloc(sizeof(*cfg->global), NULL))) {
+        ao2_ref(cfg, -1);
+        return NULL;
+    }
+    return cfg;
+}
+
+/*! \brief The conf file that's processed for the module. */
+static struct aco_file conf_file = {
+    /*! The config file name. */
+    .filename = "redisd.conf",
+    /*! The mapping object types to be processed. */
+    .types = ACO_TYPES(&global_option),
+};
+
+CONFIG_INFO_STANDARD(cfg_info, confs, conf_alloc,
+             .files = ACO_FILES(&conf_file));
+
+/*! \brief Helper function to check if module is enabled. */
+static char is_enabled(void)
+{
+    RAII_VAR(struct conf *, cfg, ao2_global_obj_ref(confs), ao2_cleanup);
+    return cfg->global->enabled;
+}
+
+static int redisd_init(void)
+{
+    RAII_VAR(struct conf *, cfg, ao2_global_obj_ref(confs), ao2_cleanup);
+    struct ast_sockaddr redis_server;
+
+    ast_assert(is_enabled());
+
+    ast_log(LOG_DEBUG, "Configuring REDIS client.\n");
+
+    conf_server(cfg, &redis_server);
+
+    ast_log(LOG_DEBUG, "  server = %s:%d\n", ast_sockaddr_stringify_addr(&redis_server), ast_sockaddr_port(&redis_server));
+    ast_log(LOG_DEBUG, "  prefix = %s\n", cfg->global->prefix);
+    ast_log(LOG_DEBUG, "  dbname = %s\n", cfg->global->dbname);
+    ast_log(LOG_DEBUG, "  password = %s\n", cfg->global->password);
+    ast_log(LOG_DEBUG, "  bgsave = %s\n", cfg->global->bgsave?"yes":"no");
+
+    return 0;
+}
+
+static void redisd_shutdown(void)
+{
+    RAII_VAR(struct conf *, cfg, ao2_global_obj_ref(confs), ao2_cleanup);
+
+    ast_log(LOG_DEBUG, "Shutting down REDIS client.\n");
+
+    if (cfg->global->bgsave) {
+        ast_log(AST_LOG_NOTICE, "Sending BGSAVE before closing connection.\n");
+
+        //throw away result
+        char return_buffer[4096];
+        return_buffer[0] = '\0';
+
+        ast_redisd_command("BGSAVE", "", "", return_buffer, sizeof(return_buffer));
+    }
+
+
+    return;
+}
+
+static int unload_module(void)
+{
+    redisd_shutdown();
+    aco_info_destroy(&cfg_info);
+    ao2_global_obj_release(confs);
+
+    return 0;
+}
+
+static int load_module(void)
+{
+    if (aco_info_init(&cfg_info)) {
+        ast_log(LOG_NOTICE, "aco_info_init failed\n");
+
+        aco_info_destroy(&cfg_info);
+        return AST_MODULE_LOAD_DECLINE;
+    }
+
+    aco_option_register(&cfg_info, "enabled", ACO_EXACT, global_options,
+        "no", OPT_BOOL_T, 1,
+        FLDSET(struct conf_global_options, enabled));
+
+    aco_option_register(&cfg_info, "server", ACO_EXACT, global_options,
+        "127.0.0.1", OPT_SOCKADDR_T, 0,
+        FLDSET(struct conf_global_options, redis_server));
+
+    aco_option_register(&cfg_info, "prefix", ACO_EXACT, global_options,
+        "", OPT_CHAR_ARRAY_T, 0,
+        CHARFLDSET(struct conf_global_options, prefix));
+
+    aco_option_register(&cfg_info, "dbname", ACO_EXACT, global_options,
+        "", OPT_CHAR_ARRAY_T, 0,
+        CHARFLDSET(struct conf_global_options, dbname));
+
+    aco_option_register(&cfg_info, "password", ACO_EXACT, global_options,
+        "", OPT_CHAR_ARRAY_T, 0,
+        CHARFLDSET(struct conf_global_options, password));
+
+    aco_option_register(&cfg_info, "bgsave", ACO_EXACT, global_options,
+        "no", OPT_BOOL_T, 1,
+        FLDSET(struct conf_global_options, bgsave));
+
+    if (aco_process_config(&cfg_info, 0) == ACO_PROCESS_ERROR) {
+        struct conf *cfg;
+
+        ast_log(LOG_NOTICE, "Could not load redis config; using defaults\n");
+        cfg = conf_alloc();
+        if (!cfg) {
+            aco_info_destroy(&cfg_info);
+            return AST_MODULE_LOAD_DECLINE;
+        }
+
+        if (aco_set_defaults(&global_option, "general", cfg->global)) {
+            ast_log(LOG_ERROR, "Failed to initialize redis defaults.\n");
+            ao2_ref(cfg, -1);
+            aco_info_destroy(&cfg_info);
+            return AST_MODULE_LOAD_DECLINE;
+        }
+
+        ao2_global_obj_replace_unref(confs, cfg);
+        ao2_ref(cfg, -1);
+    }
+
+    if (!is_enabled()) {
+        return AST_MODULE_LOAD_SUCCESS;
+    }
+
+    if (redisd_init()) {
+        unload_module();
+        return AST_MODULE_LOAD_DECLINE;
+    }
+
+    return AST_MODULE_LOAD_SUCCESS;
+}
+
+/* The priority of this module is set just after realtime, since it loads
+ * configuration and could be used by any other sort of module.
+ */
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_GLOBAL_SYMBOLS | AST_MODFLAG_LOAD_ORDER, "REDIS client support",
+        .support_level = AST_MODULE_SUPPORT_EXTENDED,
+        .load = load_module,
+        .unload = unload_module,
+        .load_pri = AST_MODPRI_REALTIME_DRIVER + 5,
+);
+

-- 
To view, visit https://gerrit.asterisk.org/c/asterisk/+/18826
To unsubscribe, or for help writing mail filters, visit https://gerrit.asterisk.org/settings

Gerrit-Project: asterisk
Gerrit-Branch: master
Gerrit-Change-Id: Ibd70851ecf7aebe41b3830b52599fe7d14c311e9
Gerrit-Change-Number: 18826
Gerrit-PatchSet: 1
Gerrit-Owner: Alec Davis <alec at bdt.co.nz>
Gerrit-MessageType: newchange
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.digium.com/pipermail/asterisk-code-review/attachments/20220720/0aef0d5b/attachment-0001.html>


More information about the asterisk-code-review mailing list