diff --git a/common.c b/common.c index 2be45d4..5e138bc 100644 --- a/common.c +++ b/common.c @@ -185,6 +185,7 @@ static int start_listen_inet(struct listen_endpoint *sockfd[], int num_addr, str (*sockfd)[num_addr-1].socketfd = listen_single_addr(addr, cfg->keepalive, cfg->is_udp); (*sockfd)[num_addr-1].type = cfg->is_udp ? SOCK_DGRAM : SOCK_STREAM; (*sockfd)[num_addr-1].family = AF_INET; + (*sockfd)[num_addr-1].endpoint_cfg = cfg; print_message(msg_config, "%d:\t%s\t[%s] [%s]\n", (*sockfd)[num_addr-1].socketfd, sprintaddr(buf, sizeof(buf), addr), cfg->keepalive ? "keepalive" : "", cfg->is_udp ? "udp" : ""); @@ -219,6 +220,7 @@ static int start_listen_unix(struct listen_endpoint *sockfd[], int num_addr, str (*sockfd)[num_addr-1].socketfd = fd; (*sockfd)[num_addr-1].type = cfg->is_udp ? SOCK_DGRAM : SOCK_STREAM; (*sockfd)[num_addr-1].family = AF_INET; + (*sockfd)[num_addr-1].endpoint_cfg = cfg; return num_addr; } @@ -880,16 +882,23 @@ int check_access_rights(int in_socket, const char* service) return 0; } +volatile sig_atomic_t received_sigchld; + +void sig_sigchld(int sig) +{ + received_sigchld = 1; +} + + void setup_signals(void) { int res; struct sigaction action; - /* Request no SIGCHLD is sent upon termination of - * the children */ + /* Set SIGCHLD to sig_sigchld() */ memset(&action, 0, sizeof(action)); - action.sa_handler = NULL; - action.sa_flags = SA_NOCLDWAIT; + action.sa_handler = sig_sigchld; + action.sa_flags = SA_NOCLDSTOP; res = sigaction(SIGCHLD, &action, NULL); CHECK_RES_DIE(res, "sigaction"); diff --git a/common.h b/common.h index 16505f9..4e2d4ab 100644 --- a/common.h +++ b/common.h @@ -138,6 +138,8 @@ struct listen_endpoint { int socketfd; /* file descriptor of listening socket */ int type; /* SOCK_DGRAM | SOCK_STREAM */ int family; /* AF_INET | AF_UNIX */ + int num_connections; /* How many active connections on this endpoint */ + struct sslhcfg_listen_item* endpoint_cfg; /* the configuration item that corresponds to this endpoint */ }; #define FD_CNXCLOSED 0 @@ -201,4 +203,7 @@ void main_loop(struct listen_endpoint *listen_sockets, int num_addr_listen); void setup_landlock(void); +/* sslh-fork.c or processes.c, depending on the model */ +void setup_sigchld(void); + #endif diff --git a/doc/config.md b/doc/config.md index baa277d..effd334 100644 --- a/doc/config.md +++ b/doc/config.md @@ -200,3 +200,12 @@ process both single-datagram protocols such as DNS, and connection-based protocols such as QUIC. An example for supporting QUIC is shown in `example.cfg`. + + +Limiting file descriptor consumption +------------------------------------ + +There are various mechanisms to limit the number of +concurrent connections, which allows to limit the usage of +file descriptor. This is described in a separate +[guide](doc/max_connections.md). diff --git a/doc/max_connections.md b/doc/max_connections.md new file mode 100644 index 0000000..2d3b402 --- /dev/null +++ b/doc/max_connections.md @@ -0,0 +1,46 @@ +Limiting the number of connections +---------------------------------- + + +As a network-facing service, `sslh` can be attacked by very +simple denial-of-service attacks by creating a lot of +concurrent connections. Protecting against such attacks is +complicated from the server side. This attack will cause +`sslh` to create a large number of file handles, which can +cause exgagerated resource consumption. + +This threat can be mitigated using several limitation +mechanisms. Keep in mind that limiting the number of +connections means that in case of an attack, the server +resources are protected, but this might be at the expense of +serving legitimate connections. In particular, in some cases +this might mean SSH is no longer available. + +There are several mechanisms to limit the number of +connections: + +- Use `ulimit` (see bash(3) or your shell's man page) to +limit the number of file descriptors. + +- In `sslh-select` and `sslh-ev`, you can set +`max_connections` per protocol, and `sslh` will drop +connections after probing if the count is exceeded. +This should help in keeping SSH connections available even +if an attacker is stuffing other protocols. Currently this +does not work for forking protocols (support is planned) + +- `sslh-fork` has an explicit design goal to be as simple as +possible, which makes it impossible to implement limits per +protocol (because protocol probing is performed after the +process has forked). Instead, you can set `max_connections` +for each `listen` entry. So the `sslh` process for each port +will limit the number of concurrent connections to that +port. Currently this is not supported in `sslh-select` and +`sslh-ev` (support is planned). + + +As of sslh 2.2.5, this is an experimental feature and not +all use cases have been tested. If 2.2.5 does not exist yet, +you must be on the Git's head, so it is even more +experimtental! Do not hesitate to send feedback if you +notice inconsistent behaviour. diff --git a/example.cfg b/example.cfg index 9f58f9c..56f6141 100644 --- a/example.cfg +++ b/example.cfg @@ -49,9 +49,15 @@ syslog_facility: "auth"; # List of interfaces on which we should listen # Options: +# host: name of the interface to listen to +# port: name of the service to listen to +# is_udp: listen in the UDP domain (default is TCP) +# is_unix: listen in the UNIX socket domain (default is TCP) +# keepalive: set TCP_KEEPALIVE +# max_connections: drop incoming connections after this count (sslh-fork only) listen: ( - { host: "thelonious"; port: "443"; }, + { host: "thelonious"; port: "443"; max_connections: 5; }, { host: "thelonious"; port: "8080"; keepalive: true; }, { host: "thelonious"; is_udp: true; port: "443"; }, { host: "/tmp/unix_socket"; is_unix: true; port: ""; } diff --git a/sslh-conf.c b/sslh-conf.c index e7d8ab4..e1edafa 100644 --- a/sslh-conf.c +++ b/sslh-conf.c @@ -1,5 +1,5 @@ /* Generated by conf2struct (https://www.rutschle.net/tech/conf2struct/README) - * on Wed Jul 16 17:59:23 2025. + * on Mon Aug 4 18:25:44 2025. # conf2struct: generate libconf parsers that read to structs # Copyright (C) 2018-2024 Yves Rutschle @@ -809,7 +809,7 @@ static struct config_desc table_sslhcfg_protocols[] = { }, { 0 } }; - + static struct config_desc table_sslhcfg_listen[] = { @@ -845,6 +845,22 @@ static struct config_desc table_sslhcfg_listen[] = { /* default_val*/ .default_val.def_string = NULL }, + { + /* name */ "max_connections", + /* type */ CFG_INT, + /* sub_group*/ NULL, + /* arg_cl */ NULL, + /* base_addr */ NULL, + /* offset */ offsetof(struct sslhcfg_listen_item, max_connections), + /* offset_len */ 0, + /* offset_present */ offsetof(struct sslhcfg_listen_item, max_connections_is_present), + /* size */ sizeof(int), + /* array_type */ -1, + /* mandatory */ 0, + /* optional */ 1, + /* default_val*/ .default_val.def_int = 0 + }, + { /* name */ "is_udp", /* type */ CFG_BOOL, @@ -2484,6 +2500,11 @@ static void sslhcfg_listen_fprint( fprintf(out, "port: %s", sslhcfg_listen->port); fprintf(out, "\n"); indent(out, depth); + fprintf(out, "max_connections: %d", sslhcfg_listen->max_connections); + if (! sslhcfg_listen->max_connections_is_present) + fprintf(out, " "); + fprintf(out, "\n"); + indent(out, depth); fprintf(out, "is_udp: %d", sslhcfg_listen->is_udp); fprintf(out, "\n"); indent(out, depth); diff --git a/sslh-conf.h b/sslh-conf.h index 3eff28b..bccc0db 100644 --- a/sslh-conf.h +++ b/sslh-conf.h @@ -1,5 +1,5 @@ /* Generated by conf2struct (https://www.rutschle.net/tech/conf2struct/README) - * on Wed Jul 16 17:59:23 2025. + * on Mon Aug 4 18:25:44 2025. # conf2struct: generate libconf parsers that read to structs # Copyright (C) 2018-2024 Yves Rutschle @@ -43,6 +43,8 @@ struct sslhcfg_listen_item { char* host; char* port; + int max_connections_is_present; + int max_connections; int is_udp; int is_unix; int keepalive; diff --git a/sslh-fork.c b/sslh-fork.c index 12bbf37..d6ef311 100644 --- a/sslh-fork.c +++ b/sslh-fork.c @@ -1,7 +1,7 @@ /* sslh-fork: forking server -# Copyright (C) 2007-2021 Yves Rutschle +# Copyright (C) 2007-2025 Yves Rutschle # # This program is free software; you can redistribute it # and/or modify it under the terms of the GNU General Public @@ -160,6 +160,35 @@ void set_listen_procname(struct listen_endpoint *listen_socket) #endif } +static void mask_sigchld(void) +{ + struct sigaction action; + + memset(&action, 0, sizeof(action)); + action.sa_handler = NULL; + action.sa_flags = SA_NOCLDWAIT; + int res = sigaction(SIGCHLD, &action, NULL); + CHECK_RES_DIE(res, "sigaction"); +} + + +extern volatile sig_atomic_t received_sigchld; +/* EINTR, so we probably received a signal; check if it's SIGCHLD */ +static void process_signals(struct listen_endpoint* endpoint) +{ + int chld; + if (received_sigchld) { + received_sigchld = 0; + do { + chld = waitpid(-1, NULL, WNOHANG); + CHECK_RES_RETURN(chld, "waitpid", 0); + if (chld) { + endpoint->num_connections--; + print_message(msg_fd, "child died, %d concurrent connections remaining\n", endpoint->num_connections); + } + } while (chld); + } +} /* At least MacOS does not know these two options, so define them to something * equivalent for our use case */ @@ -196,11 +225,24 @@ void tcp_listener(struct listen_endpoint* endpoint, int num_endpoints, int activ case ECONNABORTED: continue; + case EINTR: + process_signals(endpoint); + continue; + default: /* Otherwise, it's something wrong in our parameters, we fail */ return; } } - print_message(msg_fd, "accepted fd %d\n", in_socket); + endpoint->num_connections++; + print_message(msg_fd, "accepted fd %d (%d concurrent connections)\n", in_socket, endpoint->num_connections); + if ((endpoint->endpoint_cfg->max_connections_is_present) + && (endpoint->num_connections > endpoint->endpoint_cfg->max_connections)) + { + print_message(msg_fd, "too many connection, reclosing fd %d\n", in_socket); + endpoint->num_connections--; + close(in_socket); + continue; + } switch(fork()) { case -1: print_message(msg_system_error, "fork failed: err %d: %s\n", errno, strerror(errno)); @@ -250,8 +292,10 @@ void main_loop(struct listen_endpoint listen_sockets[], int num_addr_listen) exit(0); break; - /* We're in the parent, we don't need to do anything */ + /* We're in the parent, which does nothing but wait to be killed by + * SIGTERM, which it will send to its children */ default: + mask_sigchld(); break; } } diff --git a/sslhconf.cfg b/sslhconf.cfg index e5fa5de..4274e45 100644 --- a/sslhconf.cfg +++ b/sslhconf.cfg @@ -97,6 +97,7 @@ config: { items: ( { name: "host"; type: "string"; var: true; }, { name: "port"; type: "string"; var: true; }, + { name: "max_connections"; type: "int"; optional: true; }, { name: "is_udp"; type: "bool"; default: false }, { name: "is_unix"; type: "bool"; default: false }, { name: "keepalive"; type: "bool"; default: false; } diff --git a/test.cfg b/test.cfg index 41cc499..4976e7b 100644 --- a/test.cfg +++ b/test.cfg @@ -19,7 +19,7 @@ verbose-config-error: 1; # print configuration errors verbose-connections: 1; # trace established incoming address to forward address verbose-connections-error: 1; # connection errors verbose-connections-try: 1; # connection attempts towards targets -verbose-fd: 0; # file descriptor activity, open/close/whatnot +verbose-fd: 1; # file descriptor activity, open/close/whatnot verbose-packets: 1; # hexdump packets on which probing is done verbose-probe-info: 0; # what's happening during the probe process verbose-probe-error: 1; # failures and problems during probing @@ -30,9 +30,9 @@ verbose-int-error: 1; # internal errors, the kind that should never happen # Options: listen: ( - { host: "localhost"; port: "8080"; keepalive: true; }, + { host: "localhost"; port: "8080"; keepalive: true; max_connections: 2; }, { host: "localhost"; port: "8081"; keepalive: true; }, - { host: "ip4-localhost"; is_udp: true; port: "8086"; }, +# { host: "ip4-localhost"; is_udp: true; port: "8086"; }, { host: "/tmp/sslh.sock"; is_unix: true; port: ""; } ); @@ -52,12 +52,12 @@ max_connections: 1; }, { name: "xmpp"; host: "localhost"; port: "9009"; }, { name: "adb"; host: "localhost"; port: "9010"; }, { name: "syslog"; host: "localhost"; port: "9013"; }, - { name: "regex"; host: "ip4-localhost"; is_udp: true; port: "9020"; - max_connections: 1; - udp_timeout: 30; - regex_patterns: [ "^foo" ]; - resolve_on_forward: true; - }, +# { name: "regex"; host: "ip4-localhost"; is_udp: true; port: "9020"; +# max_connections: 1; +# udp_timeout: 30; +# regex_patterns: [ "^foo" ]; +# resolve_on_forward: true; +# }, { name: "regex"; host: "localhost"; port: "9011"; regex_patterns: [ "^foo", "^bar" ]; minlength: 4; @@ -74,7 +74,7 @@ max_connections: 1; }, { name: "tls"; host: "localhost"; port: "9023"; alpn_protocols: [ "alpn3" ]; }, { name: "tls"; host: "localhost"; port: "9024"; sni_hostnames: [ "sni3" ]; }, { name: "tls"; host: "localhost"; port: "9025"; }, - { name: "anyprot"; host: "ip4-localhost"; is_udp: true; port: "9999"; }, +# { name: "anyprot"; host: "ip4-localhost"; is_udp: true; port: "9999"; }, { name: "anyprot"; host: "localhost"; port: "9099"; } );