diff options
| author | Denys Fedoryshchenko <denys.f@collabora.com> | 2026-06-02 15:18:19 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-02 15:18:19 +0300 |
| commit | 8494d63943e69e2f7217ca401f6b9ba46b44cca8 (patch) | |
| tree | 625b6c3adb1275fddb495b59d37dc51d06f87886 | |
| parent | d611c3240863f79029ab48d492659a67f82465d9 (diff) | |
| parent | 3776ae87bf363535aa5883008a922a7dcb1e2b86 (diff) | |
| download | accel-ppp-8494d63943e69e2f7217ca401f6b9ba46b44cca8.tar.gz accel-ppp-8494d63943e69e2f7217ca401f6b9ba46b44cca8.zip | |
Merge pull request #314 from nuclearcat/metrics-module
Metrics module
| -rw-r--r-- | CHANGELOG.md | 5 | ||||
| -rw-r--r-- | accel-pppd/accel-ppp.conf | 8 | ||||
| -rw-r--r-- | accel-pppd/accel-ppp.conf.5 | 49 | ||||
| -rw-r--r-- | accel-pppd/extra/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | accel-pppd/extra/metrics.c | 1077 | ||||
| -rw-r--r-- | tests/accel-pppd/general/test_metrics.py | 91 |
6 files changed, 1232 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index dca711a8..cbce9ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Features +- New `metrics` module: HTTP endpoint exposing the same numbers as `accel-cmd show stat` at `/metrics`, in either Prometheus exposition or JSON format. Configurable listen address and optional IPv4 CIDR allow-list. + ## 1.14.0 - 2026-01-24 ### Breaking / Compatibility diff --git a/accel-pppd/accel-ppp.conf b/accel-pppd/accel-ppp.conf index 329c3adc..f9c5245b 100644 --- a/accel-pppd/accel-ppp.conf +++ b/accel-pppd/accel-ppp.conf @@ -27,6 +27,7 @@ pppd_compat #shaper #net-snmp #logwtmp +#metrics #ipv6_nd @@ -317,6 +318,13 @@ limit=10/min burst=3 timeout=60 +#[metrics] +# Exposes daemon statistics over HTTP. Same numbers as "accel-cmd show stat". +# Enable by adding "metrics" to [modules] above. +#format=prometheus +#address=127.0.0.1:8080 +#allowed_ips=["127.0.0.1/32"] + [ipv6-pool] #gw-ip6-address=fc00:0:1::1 #vendor= diff --git a/accel-pppd/accel-ppp.conf.5 b/accel-pppd/accel-ppp.conf.5 index cefc74ed..076f7137 100644 --- a/accel-pppd/accel-ppp.conf.5 +++ b/accel-pppd/accel-ppp.conf.5 @@ -1293,6 +1293,55 @@ Specifies which Radius attribute contains stateful address pool name. .TP .BI "vendor=" vendor If attribute is vendor-specific then specify vendor name in this option. +.SH [metrics] +.br +Exposes daemon statistics over HTTP. Requires the +.B metrics +module to be listed in the +.B [modules] +section. The same numbers shown by +.B "accel-cmd show stat" +are returned for every GET on +.B /metrics +\&. Any other path responds with 404, and any method other than GET +responds with 405. +.TP +.BI "format=" prometheus|json +Selects the body format. Defaults to +.BR prometheus . +.TP +.BI "address=" host:port +IPv4 listen address. Required. Use +.BR 0.0.0.0:8080 +to listen on all interfaces. +.TP +.BI "allowed_ips=" cidr_list +Optional comma-separated list of IPv4 CIDR entries permitted to fetch +metrics. The bracketed form +.RB "\fBallowed_ips = [\(dq" "1.2.3.4/32" "\(dq, \(dq" "5.6.7.0/24" "\(dq]\fR" +and the bare form +.B "1.2.3.4/32,5.6.7.0/24" +are both accepted. A bare address without a prefix length is treated +as +.BR /32 . +When the option is missing or empty, all peers are allowed. Peers +that do not match a CIDR have their connection closed immediately +after +.BR accept (2). +.TP +.BI "read_timeout=" seconds +Maximum time a client may take to deliver a complete HTTP request. +Connections that have not produced a terminating +.BR \(dq\\r\\n\\r\\n\(dq +within this deadline are closed. Defaults to 5 seconds. Set to +.B 0 +to disable the deadline. +.TP +.BI "max_clients=" n +Maximum number of in-flight HTTP clients. Excess connections are +accepted then immediately closed. Defaults to 64. Set to +.B 0 +to disable the cap. .SH [connlimit] .br This module limits connection rate from single source. diff --git a/accel-pppd/extra/CMakeLists.txt b/accel-pppd/extra/CMakeLists.txt index 6badcd5e..6b5beaff 100644 --- a/accel-pppd/extra/CMakeLists.txt +++ b/accel-pppd/extra/CMakeLists.txt @@ -8,8 +8,9 @@ ADD_LIBRARY(chap-secrets SHARED chap-secrets.c) ADD_LIBRARY(logwtmp SHARED logwtmp.c) TARGET_LINK_LIBRARIES(logwtmp util) ADD_LIBRARY(connlimit SHARED connlimit.c) +ADD_LIBRARY(metrics SHARED metrics.c) -INSTALL(TARGETS pppd_compat ippool ipv6pool sigchld chap-secrets logwtmp connlimit +INSTALL(TARGETS pppd_compat ippool ipv6pool sigchld chap-secrets logwtmp connlimit metrics LIBRARY DESTINATION lib${LIB_SUFFIX}/accel-ppp ) diff --git a/accel-pppd/extra/metrics.c b/accel-pppd/extra/metrics.c new file mode 100644 index 00000000..14e007f8 --- /dev/null +++ b/accel-pppd/extra/metrics.c @@ -0,0 +1,1077 @@ +#include <stdio.h> +#include <stdarg.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <inttypes.h> +#include <errno.h> +#include <fcntl.h> +#include <time.h> +#include <unistd.h> +#include <arpa/inet.h> +#include <netinet/in.h> +#include <sys/socket.h> +#include <dlfcn.h> + +#include "triton.h" +#include "events.h" +#include "log.h" +#include "list.h" +#include "utils.h" +#include "ap_session.h" +#include "version.h" + +#include "memdebug.h" + +/* Per-protocol session counters live in their respective shared modules. + * Resolve them with dlsym(RTLD_DEFAULT, ...) lazily rather than via direct + * (weak) references: when metrics is dlopen()ed before the protocol module + * the loader binds undefined refs to NULL and never updates them when a + * later RTLD_GLOBAL dlopen brings the symbols in. dlsym walks the live + * global scope at call time, so it picks them up regardless of order. + */ +typedef unsigned int (*proto_stat_fn)(void); + +struct proto_stat { + const char *module; + const char *starting_sym; + const char *active_sym; + proto_stat_fn starting; + proto_stat_fn active; +}; + +static struct proto_stat proto_stats[] = { + { "pppoe", "pppoe_stat_starting", "pppoe_stat_active" }, + { "l2tp", "l2tp_stat_starting", "l2tp_stat_active" }, + { "pptp", "pptp_stat_starting", "pptp_stat_active" }, + { "sstp", "sstp_stat_starting", "sstp_stat_active" }, + { "ipoe", "ipoe_stat_starting", "ipoe_stat_active" }, +}; + +static int proto_resolve(struct proto_stat *p) +{ + if (!triton_module_loaded(p->module)) + return 0; + if (!p->starting) + p->starting = (proto_stat_fn)(uintptr_t)dlsym(RTLD_DEFAULT, p->starting_sym); + if (!p->active) + p->active = (proto_stat_fn)(uintptr_t)dlsym(RTLD_DEFAULT, p->active_sym); + return p->starting && p->active; +} + +enum metrics_format { + METRICS_FORMAT_PROMETHEUS, + METRICS_FORMAT_JSON, +}; + +#define METRICS_RECV_BUF_SIZE 2048 +#define METRICS_DEFAULT_READ_TIMEOUT 5 /* seconds */ +#define METRICS_DEFAULT_MAX_CLIENTS 64 + +struct metrics_client_t { + struct list_head entry; + struct triton_md_handler_t hnd; + struct triton_timer_t timer; + struct sockaddr_in addr; + char *recv_buf; + int recv_pos; + char *xmit_buf; + int xmit_pos; + int xmit_len; + unsigned int disconnect:1; +}; + +struct metrics_acl_t { + struct list_head entry; + uint32_t net; /* host byte order */ + uint32_t mask; /* host byte order */ +}; + +static enum metrics_format conf_format = METRICS_FORMAT_PROMETHEUS; +static char *conf_address; +static LIST_HEAD(conf_allowed); +static int conf_read_timeout = METRICS_DEFAULT_READ_TIMEOUT; +static int conf_max_clients = METRICS_DEFAULT_MAX_CLIENTS; + +#define METRICS_ACCEPT_BACKOFF 1 /* seconds */ +#define METRICS_ACCEPT_BATCH 16 /* max accept()s per serv_read tick */ + +static struct triton_context_t serv_ctx; +static struct triton_md_handler_t serv_hnd; +static struct triton_timer_t accept_resume_timer; +static LIST_HEAD(clients); +static unsigned int client_count; +static int serv_running; +static int accept_paused; + +static int parse_format(const char *opt, enum metrics_format *out) +{ + if (!strcasecmp(opt, "prometheus")) { + *out = METRICS_FORMAT_PROMETHEUS; + return 0; + } + if (!strcasecmp(opt, "json")) { + *out = METRICS_FORMAT_JSON; + return 0; + } + return -1; +} + +/* TODO: IPv6 support. The listener, the ACL, and every sockaddr below are + * IPv4-only for now; bracketed-host syntax ("[::1]:8080"), AF_INET6 sockets, + * and IPv6 CIDRs in allowed_ips are intentionally left for a follow-up. + */ +static int parse_listen_address(const char *str, struct sockaddr_in *addr) +{ + char *buf, *colon; + int port; + int ret = -1; + + buf = strdup(str); + if (!buf) + return -1; + + colon = strrchr(buf, ':'); + if (!colon) + goto out; + + *colon = 0; + port = atoi(colon + 1); + if (port <= 0 || port > 65535) + goto out; + + memset(addr, 0, sizeof(*addr)); + addr->sin_family = AF_INET; + addr->sin_port = htons(port); + if (buf[0] == 0 || !strcmp(buf, "*") || !strcmp(buf, "0.0.0.0")) + addr->sin_addr.s_addr = htonl(INADDR_ANY); + else if (inet_pton(AF_INET, buf, &addr->sin_addr) != 1) + goto out; + + ret = 0; +out: + free(buf); + return ret; +} + +static void free_acl(struct list_head *head) +{ + struct metrics_acl_t *acl; + + while (!list_empty(head)) { + acl = list_first_entry(head, typeof(*acl), entry); + list_del(&acl->entry); + _free(acl); + } +} + +/* Strip surrounding whitespace, optional matching single/double quotes, + * and any trailing comma. Returns NULL if the token becomes empty. + */ +static char *clean_token(char *s) +{ + char *end; + size_t len; + + while (*s == ' ' || *s == '\t') + s++; + + len = strlen(s); + while (len && (s[len - 1] == ' ' || s[len - 1] == '\t' || + s[len - 1] == ',' || s[len - 1] == '\r' || + s[len - 1] == '\n')) + s[--len] = 0; + + if (len >= 2 && ((s[0] == '"' && s[len - 1] == '"') || + (s[0] == '\'' && s[len - 1] == '\''))) { + s[len - 1] = 0; + s++; + len -= 2; + } + + while (*s == ' ' || *s == '\t') + s++; + end = s + strlen(s); + while (end > s && (end[-1] == ' ' || end[-1] == '\t')) + *--end = 0; + + return *s ? s : NULL; +} + +/* TODO: IPv6 support. Only IPv4 CIDR or a bare IPv4 address are accepted; + * IPv6 entries in allowed_ips are rejected at parse time. ip_allowed() and + * struct metrics_acl_t store the network in a 32-bit host-order word, so + * adding IPv6 here will also need the matching widening downstream. + */ +static int parse_acl_entry(const char *str, struct metrics_acl_t **out) +{ + struct metrics_acl_t *acl; + struct in_addr addr; + uint8_t prefix; + uint32_t mask; + + if (!u_parse_ip4cidr(str, &addr, &prefix)) { + /* Accept a bare IP as /32 */ + if (inet_pton(AF_INET, str, &addr) != 1) + return -1; + prefix = 32; + } + + acl = _malloc(sizeof(*acl)); + if (!acl) + return -1; + + mask = prefix ? (uint32_t)0xffffffffu << (32 - prefix) : 0; + acl->net = ntohl(addr.s_addr) & mask; + acl->mask = mask; + *out = acl; + return 0; +} + +/* Parse `allowed_ips` value. Accepts: + * "1.2.3.4/32, 5.6.7.0/24" + * ["1.2.3.4/32", "5.6.7.0/24"] + * On success the supplied list is populated and 0 is returned. Returns -1 on + * any parse error; in that case the partial list is freed. + */ +static int parse_allowed_ips(const char *value, struct list_head *list) +{ + char *buf, *p, *tok; + int ret = -1; + + if (!value || !*value) + return 0; + + buf = strdup(value); + if (!buf) + return -1; + + p = buf; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '[') + p++; + { + size_t len = strlen(p); + while (len && (p[len - 1] == ' ' || p[len - 1] == '\t' || + p[len - 1] == ']' || p[len - 1] == '\r' || + p[len - 1] == '\n')) + p[--len] = 0; + } + + while ((tok = strsep(&p, ",")) != NULL) { + struct metrics_acl_t *acl; + char *clean = clean_token(tok); + + if (!clean) + continue; + if (parse_acl_entry(clean, &acl) < 0) { + log_error("metrics: invalid entry in allowed_ips: '%s'\n", clean); + free_acl(list); + goto out; + } + list_add_tail(&acl->entry, list); + } + + ret = 0; +out: + free(buf); + return ret; +} + +static int ip_allowed(uint32_t addr_nbo) +{ + struct metrics_acl_t *acl; + uint32_t addr; + + if (list_empty(&conf_allowed)) + return 1; + + addr = ntohl(addr_nbo); + list_for_each_entry(acl, &conf_allowed, entry) { + if ((addr & acl->mask) == acl->net) + return 1; + } + return 0; +} + +static int load_config(void) +{ + const char *opt; + enum metrics_format fmt = METRICS_FORMAT_PROMETHEUS; + char *address = NULL; + struct sockaddr_in dummy; + LIST_HEAD(new_allowed); + + opt = conf_get_opt("metrics", "format"); + if (opt && parse_format(opt, &fmt) < 0) { + log_error("metrics: unknown format '%s', expected 'prometheus' or 'json'\n", opt); + return -1; + } + + opt = conf_get_opt("metrics", "address"); + if (!opt) { + log_emerg("metrics: 'address' option is required (host:port)\n"); + return -1; + } + if (parse_listen_address(opt, &dummy) < 0) { + log_emerg("metrics: invalid address '%s', expected host:port\n", opt); + return -1; + } + address = _strdup(opt); + if (!address) { + log_emerg("metrics: out of memory while loading config\n"); + return -1; + } + + opt = conf_get_opt("metrics", "allowed_ips"); + if (opt && parse_allowed_ips(opt, &new_allowed) < 0) { + _free(address); + return -1; + } + + conf_format = fmt; + if (conf_address) + _free(conf_address); + conf_address = address; + + free_acl(&conf_allowed); + list_replace_init(&new_allowed, &conf_allowed); + + opt = conf_get_opt("metrics", "read_timeout"); + if (opt) { + int n = atoi(opt); + conf_read_timeout = n > 0 ? n : 0; + } + + opt = conf_get_opt("metrics", "max_clients"); + if (opt) { + int n = atoi(opt); + conf_max_clients = n > 0 ? n : 0; + } + + return 0; +} + +static const char *content_type(void) +{ + switch (conf_format) { + case METRICS_FORMAT_JSON: + return "application/json"; + case METRICS_FORMAT_PROMETHEUS: + default: + return "text/plain; version=0.0.4; charset=utf-8"; + } +} + +struct strbuf { + char *data; + size_t len; + size_t cap; + int oom; +}; + +static int strbuf_reserve(struct strbuf *sb, size_t want) +{ + size_t need = sb->len + want + 1; + size_t ncap; + char *p; + + if (sb->oom) + return -1; + if (need <= sb->cap) + return 0; + + ncap = sb->cap ? sb->cap : 1024; + while (ncap < need) + ncap *= 2; + + p = _realloc(sb->data, ncap); + if (!p) { + sb->oom = 1; + return -1; + } + sb->data = p; + sb->cap = ncap; + return 0; +} + +static void strbuf_appendf(struct strbuf *sb, const char *fmt, ...) + __attribute__((format(gnu_printf, 2, 3))); + +static void strbuf_appendf(struct strbuf *sb, const char *fmt, ...) +{ + va_list ap; + int n; + char *dst; + size_t avail; + + if (sb->oom) + return; + + for (;;) { + /* On the very first append sb->data is still NULL and + * sb->cap is zero. Computing sb->data + sb->len in that + * state would be NULL pointer arithmetic (UB per the C + * standard); pass NULL directly to vsnprintf instead, + * which is well-defined when the size is zero. + */ + avail = sb->cap - sb->len; + dst = sb->data ? sb->data + sb->len : NULL; + + va_start(ap, fmt); + n = vsnprintf(dst, avail, fmt, ap); + va_end(ap); + + if (n < 0) { + sb->oom = 1; + return; + } + if ((size_t)n < avail) { + sb->len += n; + return; + } + if (strbuf_reserve(sb, n + 1) < 0) + return; + } +} + +static void strbuf_free(struct strbuf *sb) +{ + if (sb->data) + _free(sb->data); + sb->data = NULL; + sb->len = sb->cap = 0; +} + +struct accel_stats { + time_t uptime; + unsigned int cpu; + unsigned long rss_bytes; + unsigned long virt_bytes; + struct triton_stat_t core; + struct ap_session_stat sessions; +}; + +static void read_proc_mem(unsigned long *rss, unsigned long *virt) +{ + char path[64]; + unsigned long vmsize = 0, vmrss = 0; + long page_size = sysconf(_SC_PAGESIZE); + FILE *f; + + snprintf(path, sizeof(path), "/proc/%i/statm", getpid()); + f = fopen(path, "r"); + if (f) { + if (fscanf(f, "%lu %lu", &vmsize, &vmrss) != 2) { + vmsize = 0; + vmrss = 0; + } + fclose(f); + } + + *rss = (unsigned long)vmrss * (page_size > 0 ? page_size : 4096); + *virt = (unsigned long)vmsize * (page_size > 0 ? page_size : 4096); +} + +static void gather_stats(struct accel_stats *s) +{ + struct timespec ts; + + memset(s, 0, sizeof(*s)); + triton_stat_get(&s->core); + ap_session_stat_get(&s->sessions); + + clock_gettime(CLOCK_MONOTONIC, &ts); + s->uptime = ts.tv_sec - s->core.start_time; + s->cpu = s->core.cpu; + + read_proc_mem(&s->rss_bytes, &s->virt_bytes); +} + +static void emit_prom_gauge(struct strbuf *sb, const char *name, + const char *help, unsigned long long value) +{ + strbuf_appendf(sb, "# HELP %s %s\n", name, help); + strbuf_appendf(sb, "# TYPE %s gauge\n", name); + strbuf_appendf(sb, "%s %llu\n", name, value); +} + +static void render_prometheus(struct strbuf *sb) +{ + struct accel_stats s; + + gather_stats(&s); + + strbuf_appendf(sb, "# HELP accel_ppp_build_info accel-ppp build information\n"); + strbuf_appendf(sb, "# TYPE accel_ppp_build_info gauge\n"); + strbuf_appendf(sb, "accel_ppp_build_info{version=\"%s\"} 1\n", ACCEL_PPP_VERSION); + + emit_prom_gauge(sb, "accel_ppp_uptime_seconds", + "Daemon uptime in seconds", + (unsigned long long)s.uptime); + emit_prom_gauge(sb, "accel_ppp_cpu_percent", + "Daemon CPU usage in percent", + (unsigned long long)s.cpu); + emit_prom_gauge(sb, "accel_ppp_memory_rss_bytes", + "Resident set size of the daemon in bytes", + (unsigned long long)s.rss_bytes); + emit_prom_gauge(sb, "accel_ppp_memory_virt_bytes", + "Virtual memory size of the daemon in bytes", + (unsigned long long)s.virt_bytes); + + emit_prom_gauge(sb, "accel_ppp_core_mempool_allocated_bytes", + "Bytes currently allocated from triton mempools", + (unsigned long long)s.core.mempool_allocated); + emit_prom_gauge(sb, "accel_ppp_core_mempool_available_bytes", + "Bytes currently free in triton mempools", + (unsigned long long)s.core.mempool_available); + emit_prom_gauge(sb, "accel_ppp_core_threads", + "Total number of triton worker threads", + s.core.thread_count); + emit_prom_gauge(sb, "accel_ppp_core_threads_active", + "Number of triton worker threads currently active", + s.core.thread_active); + emit_prom_gauge(sb, "accel_ppp_core_contexts", + "Total number of triton contexts", + s.core.context_count); + emit_prom_gauge(sb, "accel_ppp_core_contexts_sleeping", + "Number of triton contexts currently sleeping", + s.core.context_sleeping); + emit_prom_gauge(sb, "accel_ppp_core_contexts_pending", + "Number of triton contexts waiting to run", + s.core.context_pending); + emit_prom_gauge(sb, "accel_ppp_core_md_handlers", + "Total number of triton md handlers", + s.core.md_handler_count); + emit_prom_gauge(sb, "accel_ppp_core_md_handlers_pending", + "Number of triton md handlers with pending events", + s.core.md_handler_pending); + emit_prom_gauge(sb, "accel_ppp_core_timers", + "Total number of triton timers", + s.core.timer_count); + emit_prom_gauge(sb, "accel_ppp_core_timers_pending", + "Number of triton timers pending fire", + s.core.timer_pending); + + strbuf_appendf(sb, "# HELP accel_ppp_sessions Number of sessions in each state\n"); + strbuf_appendf(sb, "# TYPE accel_ppp_sessions gauge\n"); + strbuf_appendf(sb, "accel_ppp_sessions{state=\"starting\"} %u\n", s.sessions.starting); + strbuf_appendf(sb, "accel_ppp_sessions{state=\"active\"} %u\n", s.sessions.active); + strbuf_appendf(sb, "accel_ppp_sessions{state=\"finishing\"} %u\n", s.sessions.finishing); + + strbuf_appendf(sb, "# HELP accel_ppp_protocol_sessions Sessions per protocol and state\n"); + strbuf_appendf(sb, "# TYPE accel_ppp_protocol_sessions gauge\n"); + for (size_t i = 0; i < sizeof(proto_stats) / sizeof(proto_stats[0]); i++) { + struct proto_stat *p = &proto_stats[i]; + + if (!proto_resolve(p)) + continue; + strbuf_appendf(sb, "accel_ppp_protocol_sessions{protocol=\"%s\",state=\"starting\"} %u\n", + p->module, p->starting()); + strbuf_appendf(sb, "accel_ppp_protocol_sessions{protocol=\"%s\",state=\"active\"} %u\n", + p->module, p->active()); + } +} + +static void append_json_string(struct strbuf *sb, const char *s) +{ + strbuf_appendf(sb, "\""); + for (; *s; s++) { + switch (*s) { + case '"': + strbuf_appendf(sb, "\\\""); + break; + case '\\': + strbuf_appendf(sb, "\\\\"); + break; + case '\b': + strbuf_appendf(sb, "\\b"); + break; + case '\f': + strbuf_appendf(sb, "\\f"); + break; + case '\n': + strbuf_appendf(sb, "\\n"); + break; + case '\r': + strbuf_appendf(sb, "\\r"); + break; + case '\t': + strbuf_appendf(sb, "\\t"); + break; + default: + if ((unsigned char)*s < 0x20) + strbuf_appendf(sb, "\\u%04x", (unsigned)*s); + else + strbuf_appendf(sb, "%c", *s); + break; + } + } + strbuf_appendf(sb, "\""); +} + +static void emit_json_proto(struct strbuf *sb, const char *name, int *first, + unsigned int starting, unsigned int active) +{ + if (!*first) + strbuf_appendf(sb, ","); + *first = 0; + strbuf_appendf(sb, "\"%s\":{\"starting\":%u,\"active\":%u}", + name, starting, active); +} + +static void render_json(struct strbuf *sb) +{ + struct accel_stats s; + int first = 1; + + gather_stats(&s); + + strbuf_appendf(sb, "{"); + strbuf_appendf(sb, "\"build\":{\"version\":"); + append_json_string(sb, ACCEL_PPP_VERSION); + strbuf_appendf(sb, "},"); + + strbuf_appendf(sb, "\"uptime_seconds\":%llu,", (unsigned long long)s.uptime); + strbuf_appendf(sb, "\"cpu_percent\":%u,", s.cpu); + strbuf_appendf(sb, "\"memory\":{\"rss_bytes\":%lu,\"virt_bytes\":%lu},", + s.rss_bytes, s.virt_bytes); + + strbuf_appendf(sb, "\"core\":{"); + strbuf_appendf(sb, "\"mempool_allocated_bytes\":%" PRIu64 ",", s.core.mempool_allocated); + strbuf_appendf(sb, "\"mempool_available_bytes\":%" PRIu64 ",", s.core.mempool_available); + strbuf_appendf(sb, "\"threads\":%u,", s.core.thread_count); + strbuf_appendf(sb, "\"threads_active\":%u,", s.core.thread_active); + strbuf_appendf(sb, "\"contexts\":%u,", s.core.context_count); + strbuf_appendf(sb, "\"contexts_sleeping\":%u,", s.core.context_sleeping); + strbuf_appendf(sb, "\"contexts_pending\":%u,", s.core.context_pending); + strbuf_appendf(sb, "\"md_handlers\":%u,", s.core.md_handler_count); + strbuf_appendf(sb, "\"md_handlers_pending\":%u,", s.core.md_handler_pending); + strbuf_appendf(sb, "\"timers\":%u,", s.core.timer_count); + strbuf_appendf(sb, "\"timers_pending\":%u", s.core.timer_pending); + strbuf_appendf(sb, "},"); + + strbuf_appendf(sb, + "\"sessions\":{\"starting\":%u,\"active\":%u,\"finishing\":%u},", + s.sessions.starting, s.sessions.active, s.sessions.finishing); + + strbuf_appendf(sb, "\"protocols\":{"); + for (size_t i = 0; i < sizeof(proto_stats) / sizeof(proto_stats[0]); i++) { + struct proto_stat *p = &proto_stats[i]; + + if (!proto_resolve(p)) + continue; + emit_json_proto(sb, p->module, &first, p->starting(), p->active()); + } + strbuf_appendf(sb, "}"); + + strbuf_appendf(sb, "}\n"); +} + +/* Try to drain cln->xmit_buf to the socket. Returns 0 if the entire + * response was flushed, 1 if a partial write occurred and MD_MODE_WRITE + * was enabled to finish later, -1 if the connection is broken (caller + * should disconnect). + */ +static int xmit_flush(struct metrics_client_t *cln) +{ + int n; + + while (cln->xmit_pos < cln->xmit_len) { + n = write(cln->hnd.fd, + cln->xmit_buf + cln->xmit_pos, + cln->xmit_len - cln->xmit_pos); + if (n < 0) { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + triton_md_enable_handler(&cln->hnd, MD_MODE_WRITE); + return 1; + } + return -1; + } + cln->xmit_pos += n; + } + return 0; +} + +static void send_response(struct metrics_client_t *cln, int status, const char *reason, + const char *ctype, const char *body, int body_len) +{ + char header[256]; + int hlen, total; + int rc; + + if (cln->xmit_buf) + return; /* response already in flight */ + + hlen = snprintf(header, sizeof(header), + "HTTP/1.1 %d %s\r\n" + "Server: accel-ppp\r\n" + "Content-Type: %s\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n" + "\r\n", + status, reason, ctype, body_len); + if (hlen <= 0 || hlen >= (int)sizeof(header)) { + cln->disconnect = 1; + return; + } + + total = hlen + (body_len > 0 ? body_len : 0); + cln->xmit_buf = _malloc(total); + if (!cln->xmit_buf) { + cln->disconnect = 1; + return; + } + memcpy(cln->xmit_buf, header, hlen); + if (body_len > 0) + memcpy(cln->xmit_buf + hlen, body, body_len); + cln->xmit_pos = 0; + cln->xmit_len = total; + + rc = xmit_flush(cln); + if (rc < 0) + cln->disconnect = 1; + else if (rc == 0) + cln->disconnect = 1; /* fully flushed, ready to close */ +} + +static void send_simple(struct metrics_client_t *cln, int status, const char *reason) +{ + char body[128]; + int len; + + len = snprintf(body, sizeof(body), "%d %s\n", status, reason); + send_response(cln, status, reason, "text/plain; charset=utf-8", body, len); +} + +static void serve_metrics(struct metrics_client_t *cln) +{ + struct strbuf sb = {0}; + + switch (conf_format) { + case METRICS_FORMAT_PROMETHEUS: + render_prometheus(&sb); + break; + case METRICS_FORMAT_JSON: + render_json(&sb); + break; + } + + if (sb.oom || !sb.data) { + send_simple(cln, 500, "Internal Server Error"); + goto out; + } + + send_response(cln, 200, "OK", content_type(), sb.data, (int)sb.len); +out: + strbuf_free(&sb); +} + +static void client_timeout(struct triton_timer_t *t); + +static void disconnect_client(struct metrics_client_t *cln) +{ + if (cln->timer.tpd) + triton_timer_del(&cln->timer); + list_del(&cln->entry); + client_count--; + triton_md_unregister_handler(&cln->hnd, 1); + if (cln->recv_buf) + _free(cln->recv_buf); + if (cln->xmit_buf) + _free(cln->xmit_buf); + _free(cln); +} + +static void client_timeout(struct triton_timer_t *t) +{ + struct metrics_client_t *cln = container_of(t, typeof(*cln), timer); + + disconnect_client(cln); +} + +static void handle_request(struct metrics_client_t *cln) +{ + char *line_end, *space1, *space2; + char *method, *path; + + line_end = strstr(cln->recv_buf, "\r\n"); + if (!line_end) + return; + *line_end = 0; + + method = cln->recv_buf; + space1 = strchr(method, ' '); + if (!space1) { + send_simple(cln, 400, "Bad Request"); + return; + } + *space1 = 0; + path = space1 + 1; + space2 = strchr(path, ' '); + if (space2) + *space2 = 0; + + if (strcmp(method, "GET")) { + send_simple(cln, 405, "Method Not Allowed"); + return; + } + + if (!strcmp(path, "/metrics")) + serve_metrics(cln); + else + send_simple(cln, 404, "Not Found"); +} + +static int cln_read(struct triton_md_handler_t *h) +{ + struct metrics_client_t *cln = container_of(h, typeof(*cln), hnd); + int n; + + while (1) { + if (cln->recv_pos >= METRICS_RECV_BUF_SIZE - 1) { + send_simple(cln, 413, "Request Entity Too Large"); + break; + } + + n = read(h->fd, cln->recv_buf + cln->recv_pos, + METRICS_RECV_BUF_SIZE - 1 - cln->recv_pos); + if (n == 0) { + cln->disconnect = 1; + break; + } + if (n < 0) { + if (errno == EAGAIN) + return 0; + cln->disconnect = 1; + break; + } + + cln->recv_pos += n; + cln->recv_buf[cln->recv_pos] = 0; + + if (strstr(cln->recv_buf, "\r\n\r\n")) { + handle_request(cln); + break; + } + } + + if (cln->disconnect) { + disconnect_client(cln); + return -1; + } + if (cln->xmit_buf) + triton_md_disable_handler(&cln->hnd, MD_MODE_READ); + return 0; +} + +static int cln_write(struct triton_md_handler_t *h) +{ + struct metrics_client_t *cln = container_of(h, typeof(*cln), hnd); + int rc; + + rc = xmit_flush(cln); + if (rc == 1) + return 0; /* still partial — keep MD_MODE_WRITE enabled */ + if (rc < 0) { + disconnect_client(cln); + return -1; + } + triton_md_disable_handler(&cln->hnd, MD_MODE_WRITE); + disconnect_client(cln); + return -1; +} + +static void accept_resume(struct triton_timer_t *t) +{ + triton_timer_del(t); + accept_paused = 0; + triton_md_enable_handler(&serv_hnd, MD_MODE_READ); +} + +static void accept_pause(void) +{ + if (accept_paused) + return; + accept_paused = 1; + triton_md_disable_handler(&serv_hnd, MD_MODE_READ); + accept_resume_timer.expire = accept_resume; + accept_resume_timer.expire_tv.tv_sec = METRICS_ACCEPT_BACKOFF; + triton_timer_add(&serv_ctx, &accept_resume_timer, 0); +} + +static int serv_read(struct triton_md_handler_t *h) +{ + struct sockaddr_in addr; + socklen_t size = sizeof(addr); + int sock; + struct metrics_client_t *cln; + int batch; + + /* Cap the number of accepts handled per dispatch. Without this, an + * overflow burst (peers exceeding max_clients or denied by + * allowed_ips) keeps us in this loop accept()ing and immediately + * closing sockets, never yielding back to the triton dispatcher. + * Since the per-client read-timeout timers share serv_ctx, that + * would delay client_timeout() and let stalled clients live past + * read_timeout. Return after METRICS_ACCEPT_BATCH iterations; if + * the listening fd is still readable triton will dispatch us again + * on the next loop after timers have had a chance to fire. + */ + for (batch = 0; batch < METRICS_ACCEPT_BATCH; batch++) { + sock = accept(h->fd, (struct sockaddr *)&addr, &size); + if (sock < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; + if (errno == EINTR || errno == ECONNABORTED) + continue; + /* For persistent resource-exhaustion errors + * (EMFILE, ENFILE, ENOBUFS, ENOMEM) the kernel will + * keep the listening fd readable, so a bare + * `continue` spins the worker. Disable the listener + * briefly and retry via a one-shot timer. + */ + log_error("metrics: accept failed: %s; backing off %ds\n", + strerror(errno), METRICS_ACCEPT_BACKOFF); + accept_pause(); + return 0; + } + + if (!ip_allowed(addr.sin_addr.s_addr)) { + close(sock); + continue; + } + + if (conf_max_clients && client_count >= (unsigned int)conf_max_clients) { + close(sock); + continue; + } + + if (fcntl(sock, F_SETFL, O_NONBLOCK)) { + log_error("metrics: failed to set nonblocking mode: %s\n", strerror(errno)); + close(sock); + continue; + } + + cln = _malloc(sizeof(*cln)); + if (!cln) { + close(sock); + continue; + } + memset(cln, 0, sizeof(*cln)); + cln->addr = addr; + cln->hnd.fd = sock; + cln->hnd.read = cln_read; + cln->hnd.write = cln_write; + cln->recv_buf = _malloc(METRICS_RECV_BUF_SIZE); + if (!cln->recv_buf) { + close(sock); + _free(cln); + continue; + } + + list_add_tail(&cln->entry, &clients); + client_count++; + triton_md_register_handler(&serv_ctx, &cln->hnd); + triton_md_enable_handler(&cln->hnd, MD_MODE_READ); + + if (conf_read_timeout > 0) { + cln->timer.expire = client_timeout; + cln->timer.expire_tv.tv_sec = conf_read_timeout; + triton_timer_add(&serv_ctx, &cln->timer, 0); + } + } + + return 0; +} + +static void serv_close(struct triton_context_t *ctx) +{ + struct metrics_client_t *cln; + + if (accept_resume_timer.tpd) + triton_timer_del(&accept_resume_timer); + + while (!list_empty(&clients)) { + cln = list_entry(clients.next, typeof(*cln), entry); + disconnect_client(cln); + } + + triton_md_unregister_handler(&serv_hnd, 1); + triton_context_unregister(ctx); +} + +static struct triton_context_t serv_ctx = { + .close = serv_close, +}; + +static struct triton_md_handler_t serv_hnd = { + .read = serv_read, +}; + +static int start_server(void) +{ + struct sockaddr_in addr; + int reuse = 1; + + if (parse_listen_address(conf_address, &addr) < 0) { + log_emerg("metrics: invalid listen address '%s'\n", conf_address); + return -1; + } + + serv_hnd.fd = socket(PF_INET, SOCK_STREAM, 0); + if (serv_hnd.fd < 0) { + log_emerg("metrics: failed to create socket: %s\n", strerror(errno)); + return -1; + } + + fcntl(serv_hnd.fd, F_SETFD, fcntl(serv_hnd.fd, F_GETFD) | FD_CLOEXEC); + setsockopt(serv_hnd.fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + if (bind(serv_hnd.fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + log_emerg("metrics: failed to bind %s: %s\n", conf_address, strerror(errno)); + goto err; + } + + if (listen(serv_hnd.fd, 16) < 0) { + log_emerg("metrics: failed to listen on %s: %s\n", conf_address, strerror(errno)); + goto err; + } + + if (fcntl(serv_hnd.fd, F_SETFL, O_NONBLOCK)) { + log_emerg("metrics: failed to set nonblocking mode: %s\n", strerror(errno)); + goto err; + } + + triton_context_register(&serv_ctx, NULL); + triton_context_set_priority(&serv_ctx, 0); + triton_md_register_handler(&serv_ctx, &serv_hnd); + triton_md_enable_handler(&serv_hnd, MD_MODE_READ); + triton_context_wakeup(&serv_ctx); + + serv_running = 1; + return 0; + +err: + close(serv_hnd.fd); + serv_hnd.fd = -1; + return -1; +} + +static void init(void) +{ + if (load_config() < 0) + return; + + if (start_server() < 0) + return; + + log_info2("metrics: listening on %s, format %s\n", + conf_address, + conf_format == METRICS_FORMAT_PROMETHEUS ? "prometheus" : "json"); +} + +DEFINE_INIT(100, init); diff --git a/tests/accel-pppd/general/test_metrics.py b/tests/accel-pppd/general/test_metrics.py new file mode 100644 index 00000000..6344d3d0 --- /dev/null +++ b/tests/accel-pppd/general/test_metrics.py @@ -0,0 +1,91 @@ +import http.client +import json + +import pytest + + +PROM_PORT = 9099 + + +def _config(fmt): + return f""" + [modules] + metrics + + [core] + log-error=/dev/stderr + + [log] + log-emerg=/dev/stderr + level=1 + + [cli] + tcp=127.0.0.1:2001 + + [metrics] + address=127.0.0.1:{PROM_PORT} + format={fmt} + """ + + +def _request(path, method="GET"): + conn = http.client.HTTPConnection("127.0.0.1", PROM_PORT, timeout=5) + try: + conn.request(method, path) + resp = conn.getresponse() + body = resp.read().decode("utf-8", "replace") + headers = {k.lower(): v for k, v in resp.getheaders()} + return resp.status, headers, body + finally: + conn.close() + + +class TestPrometheus: + @pytest.fixture() + def accel_pppd_config(self): + return _config("prometheus") + + def test_metrics_prometheus(self, accel_pppd_instance): + assert accel_pppd_instance + + status, headers, body = _request("/metrics") + + assert status == 200 + assert "text/plain" in headers.get("content-type", "") + assert "accel_ppp_build_info{version=" in body + assert "# TYPE accel_ppp_uptime_seconds gauge" in body + assert 'accel_ppp_sessions{state="active"}' in body + + def test_metrics_404_unknown_path(self, accel_pppd_instance): + assert accel_pppd_instance + + status, _, _ = _request("/nope") + + assert status == 404 + + def test_metrics_405_non_get(self, accel_pppd_instance): + assert accel_pppd_instance + + status, _, _ = _request("/metrics", method="POST") + + assert status == 405 + + +class TestJson: + @pytest.fixture() + def accel_pppd_config(self): + return _config("json") + + def test_metrics_json(self, accel_pppd_instance): + assert accel_pppd_instance + + status, headers, body = _request("/metrics") + + assert status == 200 + assert headers.get("content-type") == "application/json" + + doc = json.loads(body) + assert "build" in doc and "version" in doc["build"] + assert "uptime_seconds" in doc + assert "active" in doc["sessions"] + assert "threads" in doc["core"] |
