Accept combined server endpoints with strict IPv6 parsing

This commit is contained in:
Max Lv
2026-09-13 07:15:28 +08:00
parent 243f1f92ec
commit 454bdc0a67
13 changed files with 214 additions and 23 deletions
+4
View File
@@ -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.
+6 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+86
View File
@@ -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)
{
+2
View File
@@ -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
+12 -4
View File
@@ -1563,13 +1563,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`.
[cli_long_verbose] */
/* [cli_long_server]
\par `--server <server_host>`
Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`.
\par `--server <HOST:PORT>`
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 <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);
+12 -4
View File
@@ -978,13 +978,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`.
[cli_long_verbose] */
/* [cli_long_server]
\par `--server <server_host>`
Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`.
\par `--server <HOST:PORT>`
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 <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();
+12 -4
View File
@@ -1008,13 +1008,13 @@ Same behavior as `-v`; see that option for details. Short alias: `-v`.
[cli_long_verbose] */
/* [cli_long_server]
\par `--server <server_host>`
Set a remote Shadowsocks server hostname or IP address; may be repeated. Short alias: `-s`.
\par `--server <HOST:PORT>`
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 <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();
+4 -4
View File
@@ -319,12 +319,12 @@ ss_is_ipv6addr(const char *addr)
/* [cli_short_s]
\par `-s <server_host>`
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 <server_port>`
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
+1 -2
View File
@@ -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,
+23 -2
View File
@@ -104,12 +104,12 @@ class CliTests(unittest.TestCase):
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 @@ class CliTests(unittest.TestCase):
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'):
+50
View File
@@ -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();