[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