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:
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 = *