udphole

Basic UDP wormhole proxy
git clone git://git.finwo.net/app/udphole
Log | Files | Refs | README | LICENSE

commit 944a0d8985625fb191fee7a99e23ba6df8b4ac34
parent 9e4d9727e4cf0003614b54d45da888a0b2951f03
Author: Robin Bron <robin.bron@yourhosting.nl>
Date:   Tue,  3 Mar 2026 23:36:16 +0100

Simplify config structure and listener parsing

Diffstat:
MDOCKER.md | 45+++++++++++++++++++++++++++++++++------------
MREADME.md | 14+++-----------
Mconfig.ini.example | 2++
Mentrypoint.sh | 44+++++---------------------------------------
Mpackage.ini | 1+
Msrc/common/socket_util.c | 8++++----
Asrc/common/url_utils.c | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/common/url_utils.h | 8++++++++
Msrc/domain/cluster/cluster.c | 32+++++++++++++++++++++-----------
Msrc/domain/cluster/node.c | 55++++++++++++++++++++++++++++---------------------------
Msrc/infrastructure/config.c | 10+++++-----
Msrc/interface/api/server.c | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mtest/config-cluster.ini | 14++------------
13 files changed, 240 insertions(+), 135 deletions(-)

diff --git a/DOCKER.md b/DOCKER.md @@ -26,7 +26,7 @@ docker run -p 6379:6379 -p 7000-7999:7000-7999/udp finwo/udphole If no config file is mounted, the container auto-generates `/etc/udphole.conf` from environment variables: -```yaml +```ini [udphole] ports = 7000-7999 listen = :6379 @@ -46,6 +46,29 @@ docker run -p 6379:6379 -p 7000-7999:7000-7999/udp \ finwo/udphole ``` +### Listen Address Formats + +The `listen` directive supports multiple addresses: + +| Format | Description | +|--------|-------------| +| `:6379` | All interfaces, port 6379 | +| `6379` | All interfaces, port 6379 | +| `localhost:6379` | Loopback only | +| `192.168.1.1:6379` | Specific IPv4 address | +| `tcp://:6379` | Explicit TCP, all interfaces | +| `tcp://localhost:6379` | Explicit TCP, loopback | +| `unix:///tmp/udphole.sock` | Unix socket | + +Multiple listen addresses can be specified: + +```ini +[udphole] +listen = :6379 +listen = 192.168.1.1:6380 +listen = unix:///tmp/udphole.sock +``` + ## Cluster Mode To run in cluster mode, set the `CLUSTER` environment variable and define node addresses: @@ -64,23 +87,21 @@ This generates: [udphole] ports = 7000-7999 listen = :6379 -cluster = node1 -cluster = node2 +cluster = tcp://user:pass@192.168.1.10:6379 +cluster = tcp://user:pass@192.168.1.11:6379 [user:admin] permit = * secret = supers3cret +``` -[cluster:node1] -address = tcp://user:pass@192.168.1.10:6379 -username = user -password = pass +### Cluster Node Address Formats -[cluster:node2] -address = tcp://user:pass@192.168.1.11:6379 -username = user -password = pass -``` +| Format | Description | +|--------|-------------| +| `tcp://host:port` | TCP connection | +| `tcp://user:pass@host:port` | TCP with authentication | +| `unix:///path/to/socket` | Unix socket | ## Docker Compose diff --git a/README.md b/README.md @@ -178,19 +178,11 @@ permit = ping | Option | Description | |--------|-------------| | `ports` | Port range for UDP sockets, as `low-high` (e.g. `7000-7999`). Default 7000–7999. (daemon only) | -| `listen` | API server listen address. If not set, API server is disabled. | +| `listen` | API server listen address. Can be repeated for multiple addresses. If not set, API server is disabled. | | `advertise` | Optional. IP address to advertise in API responses instead of the port number. Useful when behind NAT. (daemon only) | -| `cluster` | Repeat for each backing node name. Enables cluster mode and specifies node names. | +| `cluster` | Repeat for each backing node. Format: `tcp://[user:pass@]host:port` or `unix:///path/to/socket`. Enables cluster mode. | -### `[cluster:<name>]` - -| Option | Description | -|--------|-------------| -| `address` | Connection string for the backing node (e.g., `tcp://127.0.0.1:19122` or `unix:///path/to/socket`). | -| `username` | Username for authentication to the backing node. | -| `password` | Password for authentication to the backing node. | - -### `[api:<name>]` +### `[user:<name>]` | Option | Description | |--------|-------------| diff --git a/config.ini.example b/config.ini.example @@ -1,6 +1,8 @@ [udphole] ports = 7000-7999 listen = :12345 +listen = 192.168.1.1:12345 +listen = unix:///tmp/udphole.sock [user:admin] secret = adminpass diff --git a/entrypoint.sh b/entrypoint.sh @@ -28,7 +28,11 @@ else if [ -n "$CLUSTER" ]; then for name in $(echo "$CLUSTER" | tr ',' ' '); do - echo "cluster = $name" + env_var="CLUSTER_$name" + eval "value=\$$env_var" + if [ -n "$value" ]; then + echo "cluster = $value" + fi done fi @@ -36,44 +40,6 @@ else echo "[user:${API_ADMIN_USER:-admin}]" echo "permit = *" echo "secret = ${API_ADMIN_PASS:-supers3cret}" - - if [ -n "$CLUSTER" ]; then - for name in $(echo "$CLUSTER" | tr ',' ' '); do - env_var="CLUSTER_$name" - eval "value=\$$env_var" - if [ -n "$value" ]; then - proto="${value%%://*}" - rest="${value#*://}" - - # Check if URL contains user:pass@ (has credentials) - case "$rest" in - *@*) - user="${rest%%:*}" - rest="${rest#*:}" - pass="${rest%%@*}" - rest="${rest#*@}" - has_creds=1 - ;; - *) - user="" - pass="" - has_creds=0 - ;; - esac - - host="${rest%%:*}" - port="${rest#*:}" - - echo "" - echo "[cluster:$name]" - echo "address = $value" - if [ "$has_creds" -eq 1 ]; then - echo "username = $user" - echo "password = $pass" - fi - fi - done - fi } > "$CONFIG_PATH" echo "Generated config:" diff --git a/package.ini b/package.ini @@ -4,6 +4,7 @@ cofyc/argparse=master graphitemaster/incbin=main rxi/log=master tidwall/hashmap=master +finwo/url-parser=main [package] name=finwo/udphole diff --git a/src/common/socket_util.c b/src/common/socket_util.c @@ -104,7 +104,7 @@ int *tcp_listen(const char *addr, const char *default_host, const char *default_ hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; hints.ai_flags = AI_PASSIVE; - if (getaddrinfo(host[0] ? host : NULL, port, &hints, &res) != 0 || !res) { + if (getaddrinfo(host[0] ? (strcmp(host, "*") == 0 ? NULL : host) : NULL, port, &hints, &res) != 0 || !res) { log_error("tcp_listen: getaddrinfo failed for %s:%s", host, port); return NULL; } @@ -116,7 +116,7 @@ int *tcp_listen(const char *addr, const char *default_host, const char *default_ } fds[0] = 0; - int listen_all = (host[0] == '\0'); + int listen_all = (host[0] == '\0' || strcmp(host, "*") == 0); struct addrinfo *p; for (p = res; p; p = p->ai_next) { @@ -234,7 +234,7 @@ int *udp_recv(const char *addr, const char *default_host, const char *default_po hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; hints.ai_flags = AI_PASSIVE; - if (getaddrinfo(host[0] ? host : NULL, port, &hints, &res) != 0 || !res) { + if (getaddrinfo(host[0] ? (strcmp(host, "*") == 0 ? NULL : host) : NULL, port, &hints, &res) != 0 || !res) { log_error("udp_recv: getaddrinfo failed for %s:%s", host, port); return NULL; } @@ -246,7 +246,7 @@ int *udp_recv(const char *addr, const char *default_host, const char *default_po } fds[0] = 0; - int listen_all = (host[0] == '\0'); + int listen_all = (host[0] == '\0' || strcmp(host, "*") == 0); struct addrinfo *p; for (p = res; p; p = p->ai_next) { diff --git a/src/common/url_utils.c b/src/common/url_utils.c @@ -0,0 +1,51 @@ +#include "url_utils.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "rxi/log.h" + +int parse_address_url(const char *addr, struct parsed_url **out) { + if (!addr || !addr[0]) { + return -1; + } + + if (strstr(addr, "://") != NULL) { + struct parsed_url *purl = parse_url(addr); + if (!purl) { + log_error("url_utils: failed to parse URL '%s'", addr); + return -1; + } + *out = purl; + return 0; + } + + size_t len = strlen(addr); + char *normalized = NULL; + + if (addr[0] == ':') { + normalized = malloc(len + 8); + if (!normalized) return -1; + snprintf(normalized, len + 8, "tcp://*%s", addr); + } else if (addr[0] >= '0' && addr[0] <= '9') { + normalized = malloc(len + 8); + if (!normalized) return -1; + snprintf(normalized, len + 8, "tcp://*:%s", addr); + } else { + normalized = malloc(len + 8); + if (!normalized) return -1; + snprintf(normalized, len + 8, "tcp://%s", addr); + } + + struct parsed_url *purl = parse_url(normalized); + free(normalized); + + if (!purl) { + log_error("url_utils: failed to parse normalized address '%s'", addr); + return -1; + } + + *out = purl; + return 0; +} diff --git a/src/common/url_utils.h b/src/common/url_utils.h @@ -0,0 +1,8 @@ +#ifndef UDPHOLE_URL_UTILS_H +#define UDPHOLE_URL_UTILS_H + +#include "finwo/url-parser.h" + +int parse_address_url(const char *addr, struct parsed_url **out); + +#endif diff --git a/src/domain/cluster/cluster.c b/src/domain/cluster/cluster.c @@ -7,7 +7,9 @@ #include "common/resp.h" #include "common/scheduler.h" +#include "common/url_utils.h" #include "domain/config.h" +#include "finwo/url-parser.h" #include "rxi/log.h" cluster_state_t *cluster_state = NULL; @@ -81,23 +83,29 @@ void cluster_init(void) { resp_object *elem = &cluster_nodes->u.arr.elem[i]; if (elem->type != RESPT_BULK || !elem->u.s) continue; - const char *node_name = elem->u.s; + const char *address = elem->u.s; - char node_section[256]; - snprintf(node_section, sizeof(node_section), "cluster:%s", node_name); - resp_object *node_sec = resp_map_get(domain_cfg, node_section); - - const char *address = node_sec ? resp_map_get_string(node_sec, "address") : NULL; - const char *username = node_sec ? resp_map_get_string(node_sec, "username") : NULL; - const char *password = node_sec ? resp_map_get_string(node_sec, "password") : NULL; + struct parsed_url *purl = NULL; + if (parse_address_url(address, &purl) != 0) { + log_error("cluster: failed to parse address '%s'", address); + continue; + } - if (!address) { - log_error("cluster: node '%s' has no address configured", node_name); + char *node_name = NULL; + if (purl->host && purl->port) { + asprintf(&node_name, "%s-%s", purl->host, purl->port); + } else if (purl->scheme && strcmp(purl->scheme, "unix") == 0 && purl->path) { + const char *basename = strrchr(purl->path, '/'); + basename = basename ? basename + 1 : purl->path; + asprintf(&node_name, "unix-%s", basename); + } else { + log_error("cluster: cannot generate node name for address '%s'", address); + parsed_url_free(purl); continue; } cluster_node_t *node = calloc(1, sizeof(cluster_node_t)); - if (cluster_node_init(node, node_name, address, username, password) == 0) { + if (cluster_node_init(node, node_name, address, purl->username, purl->password) == 0) { cluster_nodes_add(cluster_state->nodes, node); sched_create(cluster_node_healthcheck_pt, node); log_info("cluster: added node '%s' at %s", node->name, node->address); @@ -105,6 +113,8 @@ void cluster_init(void) { cluster_node_free(node); free(node); } + free(node_name); + parsed_url_free(purl); } cluster_state->initialized = 1; diff --git a/src/domain/cluster/node.c b/src/domain/cluster/node.c @@ -15,6 +15,7 @@ #include "common/resp.h" #include "common/socket_util.h" +#include "common/url_utils.h" #include "rxi/log.h" #define QUERY_TIMEOUT_MS 500 @@ -23,39 +24,39 @@ static int parse_address(const char *address, char **host, int *port, char **unix_path) { if (!address) return -1; - if (strncmp(address, "tcp://", 6) == 0) { - const char *hp = address + 6; - const char *colon = strchr(hp, ':'); - if (!colon) { - log_error("cluster: invalid tcp address '%s' (missing port)", address); - return -1; - } - - size_t host_len = colon - hp; - *host = malloc(host_len + 1); - if (!*host) return -1; - memcpy(*host, hp, host_len); - (*host)[host_len] = '\0'; - - *port = atoi(colon + 1); - if (*port <= 0) { - log_error("cluster: invalid port in tcp address '%s'", address); - free(*host); - *host = NULL; - return -1; - } - *unix_path = NULL; - return 0; + struct parsed_url *purl = NULL; + if (parse_address_url(address, &purl) != 0) { + return -1; + } - } else if (strncmp(address, "unix://", 7) == 0) { - *unix_path = strdup(address + 7); + if (purl->scheme && strcmp(purl->scheme, "unix") == 0) { + *unix_path = strdup(purl->path ? purl->path : ""); *host = NULL; *port = 0; + parsed_url_free(purl); return 0; } - log_error("cluster: unknown address scheme in '%s' (expected tcp:// or unix://)", address); - return -1; + if (!purl->host) { + log_error("cluster: no host in address '%s'", address); + parsed_url_free(purl); + return -1; + } + + *host = strdup(purl->host); + *port = purl->port ? atoi(purl->port) : 0; + *unix_path = NULL; + + if (*port <= 0) { + log_error("cluster: invalid port in address '%s'", address); + free(*host); + *host = NULL; + parsed_url_free(purl); + return -1; + } + + parsed_url_free(purl); + return 0; } int cluster_node_init(cluster_node_t *node, const char *name, const char *address, const char *username, diff --git a/src/infrastructure/config.c b/src/infrastructure/config.c @@ -22,15 +22,15 @@ static int config_handler(void *user, const char *section, const char *name, con sec = resp_map_get(cfg, section); } - if (strcmp(name, "cluster") == 0) { - resp_object *arr = resp_map_get(sec, "cluster"); + if (strcmp(name, "cluster") == 0 || strcmp(name, "listen") == 0) { + resp_object *arr = resp_map_get(sec, name); if (!arr) { arr = resp_array_init(); - resp_map_set(sec, "cluster", arr); - arr = resp_map_get(sec, "cluster"); + resp_map_set(sec, name, arr); + arr = resp_map_get(sec, name); } if (!arr || arr->type != RESPT_ARRAY) { - log_error("config: 'cluster' key already exists as non-array"); + log_error("config: '%s' key already exists as non-array", name); return 0; } resp_array_append_bulk(arr, value); diff --git a/src/interface/api/server.c b/src/interface/api/server.c @@ -24,6 +24,7 @@ #include "common/resp.h" #include "common/scheduler.h" #include "common/socket_util.h" +#include "common/url_utils.h" #include "domain/config.h" #include "infrastructure/config.h" #include "rxi/log.h" @@ -61,7 +62,6 @@ typedef struct { domain_cmd_fn func; } domain_cmd_entry; -static char *current_listen = NULL; static struct hashmap *cmd_map = NULL; static struct hashmap *domain_cmd_map = NULL; @@ -430,10 +430,17 @@ static int *create_listen_socket(const char *listen_addr) { if (cfg_port && cfg_port[0]) default_port = cfg_port; } - if (listen_addr && strncmp(listen_addr, "unix://", 7) == 0) { - const char *socket_path = listen_addr + 7; + struct parsed_url *purl = NULL; + if (parse_address_url(listen_addr, &purl) != 0) { + log_error("api: failed to parse listen address '%s'", listen_addr); + return NULL; + } + + if (purl->scheme && strcmp(purl->scheme, "unix") == 0) { + const char *socket_path = purl->path; const char *socket_owner = api_sec ? resp_map_get_string(api_sec, "socket_owner") : NULL; int *fds = unix_listen(socket_path, SOCK_STREAM, socket_owner); + parsed_url_free(purl); if (!fds) { return NULL; } @@ -441,7 +448,17 @@ static int *create_listen_socket(const char *listen_addr) { return fds; } - int *fds = tcp_listen(listen_addr, NULL, default_port); + char addr_buf[512]; + if (purl->host && purl->port) { + snprintf(addr_buf, sizeof(addr_buf), "%s:%s", purl->host, purl->port); + } else if (purl->port) { + snprintf(addr_buf, sizeof(addr_buf), ":%s", purl->port); + } else { + snprintf(addr_buf, sizeof(addr_buf), ":%s", default_port); + } + + int *fds = tcp_listen(addr_buf, NULL, default_port); + parsed_url_free(purl); if (!fds) { return NULL; } @@ -483,24 +500,70 @@ int api_server_pt(int64_t timestamp, struct pt_task *task) { if (udata->server_fds == NULL) { resp_object *api_sec = resp_map_get(domain_cfg, "udphole"); - const char *listen_str = api_sec ? resp_map_get_string(api_sec, "listen") : NULL; + resp_object *listen_arr = api_sec ? resp_map_get(api_sec, "listen") : NULL; - if (!listen_str || !listen_str[0]) { + if (!listen_arr || listen_arr->type != RESPT_ARRAY || listen_arr->u.arr.n == 0) { return SCHED_RUNNING; } - current_listen = strdup(listen_str); - if (!current_listen) { - return SCHED_ERROR; + int **socket_arrays = NULL; + size_t num_listeners = 0; + + for (size_t i = 0; i < listen_arr->u.arr.n; i++) { + if (listen_arr->u.arr.elem[i].type != RESPT_BULK || !listen_arr->u.arr.elem[i].u.s) { + continue; + } + const char *listen_addr = listen_arr->u.arr.elem[i].u.s; + int *fds = create_listen_socket(listen_addr); + if (!fds) { + log_fatal("api: failed to listen on %s", listen_addr); + for (size_t j = 0; j < num_listeners; j++) { + if (socket_arrays[j]) { + for (int k = 1; k <= socket_arrays[j][0]; k++) { + close(socket_arrays[j][k]); + } + free(socket_arrays[j]); + } + } + free(socket_arrays); + return SCHED_ERROR; + } + socket_arrays = realloc(socket_arrays, sizeof(int *) * (num_listeners + 1)); + socket_arrays[num_listeners++] = fds; } - init_builtins(); - udata->server_fds = create_listen_socket(current_listen); + + int total_fds = 0; + for (size_t i = 0; i < num_listeners; i++) { + total_fds += socket_arrays[i][0]; + } + + udata->server_fds = calloc(total_fds + 1, sizeof(int)); if (!udata->server_fds) { - log_fatal("api: failed to listen on %s", current_listen); - free(current_listen); - current_listen = NULL; + log_fatal("api: out of memory for listen sockets"); + for (size_t i = 0; i < num_listeners; i++) { + free(socket_arrays[i]); + } + free(socket_arrays); + return SCHED_ERROR; + } + udata->server_fds[0] = 0; + + for (size_t i = 0; i < num_listeners; i++) { + for (int j = 1; j <= socket_arrays[i][0]; j++) { + udata->server_fds[++udata->server_fds[0]] = socket_arrays[i][j]; + } + free(socket_arrays[i]); + } + free(socket_arrays); + + if (udata->server_fds[0] == 0) { + log_fatal("api: no listen sockets created"); + free(udata->server_fds); + udata->server_fds = NULL; return SCHED_ERROR; } + + init_builtins(); } if (udata->server_fds && udata->server_fds[0] > 0) { diff --git a/test/config-cluster.ini b/test/config-cluster.ini @@ -1,17 +1,7 @@ [udphole] listen = :19121 -cluster = node1 -cluster = node2 - -[cluster:node1] -address = tcp://127.0.0.1:19122 -username = nodeuser -password = nodepass - -[cluster:node2] -address = tcp://127.0.0.1:19123 -username = nodeuser -password = nodepass +cluster = tcp://nodeuser:nodepass@127.0.0.1:19122 +cluster = tcp://nodeuser:nodepass@127.0.0.1:19123 [user:test] permit = *