From 454bdc0a67dec2782ac0a4db71e110c5ef5c50ac Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sun, 13 Sep 2026 07:15:28 +0800 Subject: [PATCH] Accept combined server endpoints with strict IPv6 parsing --- README.md | 4 +++ doc/index.md | 7 +++- doc/ss-local.md | 2 +- doc/ss-tunnel.md | 2 +- src/jconf.c | 86 ++++++++++++++++++++++++++++++++++++++++++++ src/jconf.h | 2 ++ src/local.c | 16 ++++++--- src/redir.c | 16 ++++++--- src/tunnel.c | 16 ++++++--- src/utils.c | 8 ++--- tests/stress_test.py | 3 +- tests/test_cli.py | 25 +++++++++++-- tests/test_jconf.c | 50 ++++++++++++++++++++++++++ 13 files changed, 214 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a10058c63..ea6d0f617 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,10 @@ See [image and build details](docker/static/README.md). ## Usage +Clients accept a combined remote endpoint: `--server example.com:8388` or +`--server '[2001:db8::1]:8388'`. Repeat the flag for multiple servers. Legacy +`-s HOST -p PORT` remains supported; an embedded port takes priority. + For a detailed and complete list of all supported arguments, you may refer to the man pages of the applications, respectively. diff --git a/doc/index.md b/doc/index.md index 3bbd16573..a99986be1 100644 --- a/doc/index.md +++ b/doc/index.md @@ -20,7 +20,12 @@ overview lookup names. Use descriptive long flags or their existing short aliases. Connections use different names for the remote server and the local listener: -- Clients: `--server HOST --server-port PORT` selects the remote Shadowsocks server. +- Clients: `--server HOST:PORT` selects the remote Shadowsocks server. + IPv6 endpoints must use brackets, for example `--server '[2001:db8::1]:8388'` + or `--server '[fe80::1%eth0]:8388'`. Bare IPv6 remains a host-only address. + Repeat `--server` for multiple endpoints, each with its own port. The legacy + `--server-port` / `-p` and configured server port are fallbacks only; embedded + ports always win. SIP003 plugins require all endpoints to share one port. - Listeners: `--listen-address ADDRESS --listen-port PORT` controls where a client or server accepts connections. The manager uses `--manager-address` for its control socket and configuration or its API for individual server ports. diff --git a/doc/ss-local.md b/doc/ss-local.md index e7f44f539..2e6a06921 100644 --- a/doc/ss-local.md +++ b/doc/ss-local.md @@ -29,7 +29,7 @@ ss-local(1) can be started from command line and run in foreground. Here is an example: ``` # Start ss-local with given parameters -ss-local --server example.com --server-port 12345 --listen-port 1080 --password foobar --cipher aes-256-gcm +ss-local --server example.com:12345 --listen-port 1080 --password foobar --cipher aes-256-gcm ``` \section ss_local_see_also SEE ALSO diff --git a/doc/ss-tunnel.md b/doc/ss-tunnel.md index 06b3f0a01..67897e9b2 100644 --- a/doc/ss-tunnel.md +++ b/doc/ss-tunnel.md @@ -31,7 +31,7 @@ through the shadowsocks tunnel. Here is an example: ``` # Forward local UDP port 5353 to 8.8.8.8:53 through the ss-server -ss-tunnel --server example.com --server-port 12345 --listen-port 5353 --password foobar --cipher aes-256-gcm --destination 8.8.8.8:53 --udp +ss-tunnel --server example.com:12345 --listen-port 5353 --password foobar --cipher aes-256-gcm --destination 8.8.8.8:53 --udp # Then configure your system to use 127.0.0.1:5353 as the DNS server dig @127.0.0.1 -p 5353 www.google.com diff --git a/src/jconf.c b/src/jconf.c index 410e69cea..bed2887a5 100644 --- a/src/jconf.c +++ b/src/jconf.c @@ -135,6 +135,92 @@ parse_addr(const char *str_in, ss_addr_t *addr) free(str); } +/* CLI endpoints are strict; keep the legacy JSON address parser unchanged. */ +int +parse_server_endpoint(const char *str, ss_addr_t *addr) +{ + ss_addr_t parsed = { 0 }; + uint16_t port; + if (str == NULL || *str == '\0') + return -1; + for (const char *p = str; *p; p++) { + if ((unsigned char)*p <= ' ' || *p == '/' || *p == '@') + return -1; + } + parse_addr(str, &parsed); + if (parsed.host == NULL || *parsed.host == '\0') + goto invalid; + if (str[0] == '[') { + const char *end = strchr(str, ']'); + if (end == NULL || (end[1] != '\0' && end[1] != ':') + || (end[1] == ':' && end[2] == '\0')) + goto invalid; + } else if (strchr(str, '[') || strchr(str, ']')) { + goto invalid; + } + if (str[0] == '[' || strchr(parsed.host, ':')) { + /* Validate IPv6 separately from an optional interface scope ID. */ + struct ss_ip ip; + char *literal = strdup(parsed.host); + char *scope = strchr(literal, '%'); + int valid_scope = 1; + if (scope != NULL) { + *scope++ = '\0'; + valid_scope = *scope != '\0'; + for (const char *p = scope; *p; p++) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') + || (*p >= '0' && *p <= '9') || *p == '_' || *p == '-' || *p == '.')) + valid_scope = 0; + } + } + int valid = strchr(literal, ':') != NULL && ss_ip_init(&ip, literal) != -1; + free(literal); + if (!valid || !valid_scope) + goto invalid; + } else if (strchr(parsed.host, '%')) { + goto invalid; + } + if (parsed.port != NULL) { + if (ss_parse_uint16_port(parsed.port, &port) != 0) + goto invalid; + for (const char *p = parsed.port; *p; p++) { + if (*p < '0' || *p > '9') + goto invalid; + } + } else if (str[strlen(str) - 1] == ':' && !strchr(parsed.host, ':')) { + goto invalid; + } + *addr = parsed; + return 0; +invalid: + free_addr(&parsed); + return -1; +} + +/* Resolve fallback ports before callers reuse the first port for SIP003. */ +int +complete_server_ports(ss_addr_t *addr, int count, const char *fallback, int plugin) +{ + uint16_t first = 0; + if (count == 0) + return -1; + for (int i = 0; i < count; i++) { + uint16_t port; + const char *value = addr[i].port != NULL ? addr[i].port : fallback; + if (value == NULL || ss_parse_uint16_port(value, &port) != 0) + return -1; + if (plugin && i > 0 && port != first) + return -1; + if (i == 0) + first = port; + } + for (int i = 0; i < count; i++) { + if (addr[i].port == NULL) + addr[i].port = strdup(fallback); + } + return 0; +} + static int parse_dscp(char *str) { diff --git a/src/jconf.h b/src/jconf.h index 544011d3d..d39c043d3 100644 --- a/src/jconf.h +++ b/src/jconf.h @@ -95,5 +95,7 @@ typedef struct { jconf_t *read_jconf(const char *file); void parse_addr(const char *str, ss_addr_t *addr); void free_addr(ss_addr_t *addr); +int parse_server_endpoint(const char *str, ss_addr_t *addr); +int complete_server_ports(ss_addr_t *addr, int count, const char *fallback, int plugin); #endif // _JCONF_H diff --git a/src/local.c b/src/local.c index 1f664ae95..3aec66a1e 100644 --- a/src/local.c +++ b/src/local.c @@ -1563,13 +1563,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`. [cli_long_verbose] */ /* [cli_long_server] -\par `--server ` -Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`. +\par `--server ` +Set a remote server endpoint; may be repeated with different ports. Use `HOST:PORT` for a hostname or IPv4, and `[IPv6]:PORT` for IPv6 (including `%scope` when needed). Bare IPv6 is always a host, never split into a port. Host-only values require the legacy `--server-port` fallback or a configured port. An endpoint port takes priority over the fallback regardless of option order. SIP003 plugins require the same port for all endpoints. Short alias: `-s`. [cli_long_server] */ /* [cli_long_server_port] \par `--server-port ` -Set the remote Shadowsocks server port. Short alias: `-p`. +Legacy fallback port for server values without an embedded port. Prefer `--server HOST:PORT`. Short alias: `-p`. [cli_long_server_port] */ /* [cli_long_listen_address] @@ -1802,7 +1802,11 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. break; case 's': if (remote_num < MAX_REMOTE_NUM) { - parse_addr(optarg, &remote_addr[remote_num++]); + if (parse_server_endpoint(optarg, &remote_addr[remote_num]) != 0) + cli_error("invalid server endpoint; use HOST:PORT or [IPv6]:PORT", 's', NULL); + remote_num++; + } else { + cli_error("too many server endpoints", 's', NULL); } break; case 'p': @@ -1991,6 +1995,10 @@ Same behavior as `-S`; see that option for details. Short alias: `-S`. } } + if (complete_server_ports(remote_addr, remote_num, remote_port, plugin != NULL) != 0) + cli_error("each server needs a port (1 to 65535); plugins require a shared port", 's', NULL); + remote_port = remote_addr[0].port; + if (remote_num == 0) { fprintf(stderr, "remote_num is 0\n"); exit(EXIT_FAILURE); diff --git a/src/redir.c b/src/redir.c index 89f3f872a..b5309d007 100644 --- a/src/redir.c +++ b/src/redir.c @@ -978,13 +978,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`. [cli_long_verbose] */ /* [cli_long_server] -\par `--server ` -Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`. +\par `--server ` +Set a remote server endpoint; may be repeated with different ports. Use `HOST:PORT` for a hostname or IPv4, and `[IPv6]:PORT` for IPv6 (including `%scope` when needed). Bare IPv6 is always a host, never split into a port. Host-only values require the legacy `--server-port` fallback or a configured port. An endpoint port takes priority over the fallback regardless of option order. SIP003 plugins require the same port for all endpoints. Short alias: `-s`. [cli_long_server] */ /* [cli_long_server_port] \par `--server-port ` -Set the remote Shadowsocks server port. Short alias: `-p`. +Legacy fallback port for server values without an embedded port. Prefer `--server HOST:PORT`. Short alias: `-p`. [cli_long_server_port] */ /* [cli_long_listen_address] @@ -1162,7 +1162,11 @@ Same behavior as `-T`; see that option for details. Short alias: `-T`. break; case 's': if (remote_num < MAX_REMOTE_NUM) { - parse_addr(optarg, &remote_addr[remote_num++]); + if (parse_server_endpoint(optarg, &remote_addr[remote_num]) != 0) + cli_error("invalid server endpoint; use HOST:PORT or [IPv6]:PORT", 's', NULL); + remote_num++; + } else { + cli_error("too many server endpoints", 's', NULL); } break; case 'p': @@ -1345,6 +1349,10 @@ Same behavior as `-T`; see that option for details. Short alias: `-T`. dscp = conf->dscp; } + if (complete_server_ports(remote_addr, remote_num, remote_port, plugin != NULL) != 0) + cli_error("each server needs a port (1 to 65535); plugins require a shared port", 's', NULL); + remote_port = remote_addr[0].port; + if (remote_num == 0 || remote_port == NULL || local_port == NULL || (password == NULL && key == NULL)) { usage(); diff --git a/src/tunnel.c b/src/tunnel.c index bef217a6f..48c01c05a 100644 --- a/src/tunnel.c +++ b/src/tunnel.c @@ -1008,13 +1008,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`. [cli_long_verbose] */ /* [cli_long_server] -\par `--server ` -Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`. +\par `--server ` +Set a remote server endpoint; may be repeated with different ports. Use `HOST:PORT` for a hostname or IPv4, and `[IPv6]:PORT` for IPv6 (including `%scope` when needed). Bare IPv6 is always a host, never split into a port. Host-only values require the legacy `--server-port` fallback or a configured port. An endpoint port takes priority over the fallback regardless of option order. SIP003 plugins require the same port for all endpoints. Short alias: `-s`. [cli_long_server] */ /* [cli_long_server_port] \par `--server-port ` -Set the remote Shadowsocks server port. Short alias: `-p`. +Legacy fallback port for server values without an embedded port. Prefer `--server HOST:PORT`. Short alias: `-p`. [cli_long_server_port] */ /* [cli_long_listen_address] @@ -1215,7 +1215,11 @@ Same behavior as `-V`; see that option for details. Short alias: `-V`. break; case 's': if (remote_num < MAX_REMOTE_NUM) { - parse_addr(optarg, &remote_addr[remote_num++]); + if (parse_server_endpoint(optarg, &remote_addr[remote_num]) != 0) + cli_error("invalid server endpoint; use HOST:PORT or [IPv6]:PORT", 's', NULL); + remote_num++; + } else { + cli_error("too many server endpoints", 's', NULL); } break; case 'p': @@ -1404,6 +1408,10 @@ Same behavior as `-V`; see that option for details. Short alias: `-V`. #endif } + if (complete_server_ports(remote_addr, remote_num, remote_port, plugin != NULL) != 0) + cli_error("each server needs a port (1 to 65535); plugins require a shared port", 's', NULL); + remote_port = remote_addr[0].port; + if (remote_num == 0 || remote_port == NULL || tunnel_addr_str == NULL || local_port == NULL || (password == NULL && key == NULL)) { usage(); diff --git a/src/utils.c b/src/utils.c index d89615892..52aca2e3e 100644 --- a/src/utils.c +++ b/src/utils.c @@ -319,12 +319,12 @@ ss_is_ipv6addr(const char *addr) /* [cli_short_s] \par `-s ` -Set the server's hostname or IP. +For clients, set a server endpoint as `HOST:PORT` or `[IPv6]:PORT`; host-only values use the legacy `-p` or configured fallback. Bare IPv6 is never split at its last colon. For server and manager, set the listening address. [cli_short_s] */ /* [cli_short_p] \par `-p ` -Set the server's port number. +For clients, set the legacy fallback port for server values without an embedded port. For ss-server, set the listening port. [cli_short_p] */ /* [cli_short_l] @@ -638,8 +638,8 @@ usage(void) cli_help_option("-b, --outbound-address ADDRESS", "Source address for outbound connections."); #endif #else - cli_help_option("-s, --server HOST", "Remote server hostname or IP; may be repeated."); - cli_help_option("-p, --server-port PORT", "Remote server port."); + cli_help_option("-s, --server HOST:PORT", "Remote endpoint; IPv6: [ADDRESS]:PORT. Repeatable."); + cli_help_option("-p, --server-port PORT", "Legacy fallback for servers without a port."); cli_help_option("-b, --listen-address ADDRESS", "Local address to bind."); cli_help_option("-l, --listen-port PORT", "Local listening port."); #endif diff --git a/tests/stress_test.py b/tests/stress_test.py index 0ec13aae1..2a00d489e 100755 --- a/tests/stress_test.py +++ b/tests/stress_test.py @@ -130,8 +130,7 @@ def tunnel_args(ss_tunnel, cipher, password, server_port, local_port, fwd_host, """Build command-line args for ss-tunnel.""" return [ ss_tunnel, - "-s", "127.0.0.1", - "-p", str(server_port), + "--server", "127.0.0.1:%d" % server_port, "-l", str(local_port), "-k", password, "-m", cipher, diff --git a/tests/test_cli.py b/tests/test_cli.py index 38d12fcec..398714f4a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,12 +104,12 @@ def test_transport_and_address_family_override_configuration(self): reservation.bind(('127.0.0.1', 0)) port = reservation.getsockname()[1] config = Path(directory) / 'config.json' - config.write_text(json.dumps({'server': '127.0.0.1', 'server_port': 9, + config.write_text(json.dumps({'server': '127.0.0.1', 'server_port': 0, 'local_address': '127.0.0.1', 'local_port': port, 'password': 'test-password', 'method': 'aes-128-gcm', 'mode': 'udp_only', 'ipv6_first': True})) with tempfile.TemporaryFile(mode='w+') as log: proc = subprocess.Popen([str(self.binaries['local']), '--config', str(config), - '--listen-port', str(port), '--verbose', *args], stdout=log, stderr=log) + '--server', '127.0.0.1:9', '--listen-port', str(port), '--verbose', *args], stdout=log, stderr=log) try: deadline = time.monotonic() + 5 connected = False @@ -130,6 +130,27 @@ def test_transport_and_address_family_override_configuration(self): self.assertEqual('udprelay enabled' in output, udp, output) self.assertEqual('resolving hostname to IPv6 address first' in output, ipv6, output) + def test_server_endpoint_arguments(self): + for name in ('local', 'tunnel', 'redir'): + if name not in self.binaries: + continue + for endpoint in ('example.com:8388', '127.0.0.1:8388', '[::1]:8388', + '[fe80::1%eth0]:8388', '2001:db8::1:8388'): + result = self.run_cli(name, '--server', endpoint, '--help') + self.assertEqual(result.returncode, 0, (name, endpoint, result.stderr)) + for endpoint in ('[::1', '[::1]:', '[::1]junk', '[::1]:65536', + 'host:', ':8388', 'host:0', 'host:bad', '[host]:80'): + result = self.run_cli(name, '--server', endpoint) + self.assertEqual(result.returncode, 2, (name, endpoint)) + self.assertEqual(result.stdout, '') + self.assertIn('invalid server endpoint', result.stderr) + for args in (['--server', '2001:db8::1:8388'], + ['--server', 'host:8388', '--server', 'other'], + ['--server', 'host:8388', '--server', 'other:8389', '--plugin', 'unused']): + result = self.run_cli(name, *args) + self.assertEqual(result.returncode, 2, (name, args)) + self.assertIn('each server needs a port', result.stderr) + def test_numeric_option_validation(self): for name in self.binaries: for value in ('0', '-1', 'abc', '2147483648'): diff --git a/tests/test_jconf.c b/tests/test_jconf.c index 03f2a5f93..363533258 100644 --- a/tests/test_jconf.c +++ b/tests/test_jconf.c @@ -181,6 +181,55 @@ test_read_jconf_rejects_out_of_range_int_options(void) remove(path); } +static void +test_server_endpoints(void) +{ + const char *valid[][3] = { + { "example.com:8388", "example.com", "8388" }, + { "127.0.0.1:1", "127.0.0.1", "1" }, + { "[2001:db8::1]:65535", "2001:db8::1", "65535" }, + { "[fe80::1%eth0]:8388", "fe80::1%eth0", "8388" }, + { "[fe80::1%3]:8388", "fe80::1%3", "8388" }, + { "2001:db8::1:8388", "2001:db8::1:8388", NULL }, + { "fe80::1%eth0", "fe80::1%eth0", NULL }, + { "::", "::", NULL }, + { "[::1]", "::1", NULL }, + { "example.com", "example.com", NULL } + }; + for (size_t i = 0; i < sizeof(valid) / sizeof(valid[0]); i++) { + ss_addr_t addr = { 0 }; + assert(parse_server_endpoint(valid[i][0], &addr) == 0); + assert(strcmp(addr.host, valid[i][1]) == 0); + assert(valid[i][2] ? addr.port && strcmp(addr.port, valid[i][2]) == 0 : addr.port == NULL); + free_addr(&addr); + } + const char *invalid[] = { "", ":8388", "host:", "host:0", "host:65536", + "host:-1", "host:+1", "host:abc", "host:80:90", "[::1", "[::1]junk", + "[::1]:", "[::1]:0", "[::1]:65536", "[::1]:80:90", "[]:80", + "[example.com]:80", "[127.0.0.1]:80", "[bad::host]:80", "host]:80", + "[fe80::1%]:80", "[fe80::1%a%b]:80", "host :80", "ss://host:80" }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + ss_addr_t addr = { 0 }; + assert(parse_server_endpoint(invalid[i], &addr) == -1); + assert(addr.host == NULL && addr.port == NULL); + } + ss_addr_t addr[2] = { { 0 }, { 0 } }; + assert(parse_server_endpoint("[::1]:8388", &addr[0]) == 0); + assert(parse_server_endpoint("localhost:8389", &addr[1]) == 0); + assert(complete_server_ports(addr, 2, NULL, 0) == 0); + assert(complete_server_ports(addr, 2, "9000", 0) == 0); + assert(strcmp(addr[0].port, "8388") == 0 && strcmp(addr[1].port, "8389") == 0); + assert(complete_server_ports(addr, 2, NULL, 1) == -1); + free_addr(&addr[1]); + memset(&addr[1], 0, sizeof(addr[1])); + assert(parse_server_endpoint("::1", &addr[1]) == 0); + assert(complete_server_ports(addr, 2, NULL, 0) == -1); + assert(complete_server_ports(addr, 2, "8388", 1) == 0); + assert(strcmp(addr[1].port, "8388") == 0); + free_addr(&addr[0]); + free_addr(&addr[1]); +} + int main(int argc, char **argv) { @@ -190,6 +239,7 @@ main(int argc, char **argv) read_jconf(argv[2]); return 0; } + test_server_endpoints(); test_parse_addr_ipv4_with_port(); test_parse_addr_ipv6_with_port(); test_parse_addr_hostname_with_port();