Files
shadowsocks-libev/tests/experiments/route_server.py
Max LvandClaude 553cdc7945 Add opt-in printable TCP salt experiment for ss-local (#3063)
Add `ss-local --printable-salt` / `"printable_salt": true`, disabled by
default. For each outgoing TCP salt it picks a prefix length uniformly
from 6-12 bytes and samples those bytes as printable ASCII (0x20-0x7e)
immediately before key derivation. The salt stays 32 bytes with no
framing or markers, so the wire format is unchanged and any stock
server interoperates. Only chacha20-ietf-poly1305 and aes-256-gcm are
accepted; other ciphers fail at startup. UDP, server responses, and
AEAD-2022 are untouched.

Crypto unit tests cover length distribution, empty first writes, exact
framing overhead, salt-tail preservation, byte-at-a-time decryption,
tampering and replay rejection. CLI tests check local-only exposure and
cipher rejection via flag, config key and bad config type. interop.py
gains --printable-salt and --server-bin for independent stock servers.

docs/printable-salt.md records the design, entropy bound, fingerprint
caveats and route-experiment procedure; docs/encoding-experiments.md
and tests/experiments/ hold the Base85/lowpop85/TLS outer-transport
prototypes. docs/measurements/printable-salt-2026-09-23/ archives the
sanitized results of the aborted field trials.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 09:09:51 +08:00

151 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Private remote supervisor for route_screen.py; JSON commands on SSH stdin."""
import asyncio
import contextlib
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
from transport import serve
def free_port():
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]
async def echo(reader, writer):
try:
while data := await asyncio.wait_for(reader.read(65536), 30):
writer.write(data)
await writer.drain()
except (OSError, TimeoutError):
pass
finally:
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
def stop(processes):
for process in reversed(processes):
if process.poll() is None:
process.send_signal(signal.SIGINT)
for process in processes:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
async def check_control(port, origin_port):
writer = None
try:
reader, writer = await asyncio.open_connection('127.0.0.1', port)
writer.write(bytes([5, 1, 0]))
await writer.drain()
assert await reader.readexactly(2) == bytes([5, 0])
writer.write(bytes([5, 1, 0, 1, 127, 0, 0, 1]) + origin_port.to_bytes(2, 'big'))
await writer.drain()
header = await reader.readexactly(4)
assert header[:3] == bytes([5, 0, 0])
await reader.readexactly(6 if header[3] == 1 else 18)
data = os.urandom(1024)
writer.write(data)
await writer.drain()
assert await reader.readexactly(len(data)) == data
return True
except (OSError, AssertionError, asyncio.IncompleteReadError):
return False
finally:
if writer:
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
async def main():
reader = asyncio.StreamReader()
loop = asyncio.get_running_loop()
pipe, _ = await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
cfg = json.loads(await asyncio.wait_for(reader.readline(), 30))
directory = Path(__file__).parent
processes, phase_processes, servers, controls = [], [], [], []
# A controller disconnect or a bounded lifetime closes all owned resources.
task = asyncio.current_task()
loop.call_later(1200, task.cancel)
loop.add_signal_handler(signal.SIGTERM, task.cancel)
with tempfile.TemporaryDirectory(prefix='ss-encoding-config-') as temp, contextlib.ExitStack() as stack:
def spawn(binary, config, name):
path = Path(temp) / (name + '.json')
path.write_text(json.dumps(config))
log = stack.enter_context((Path(temp) / (name + '.log')).open('wb'))
return subprocess.Popen([binary, '-c', str(path)], stdout=log, stderr=log)
origin = await asyncio.start_server(echo, '127.0.0.1', 0)
origin_port = origin.sockets[0].getsockname()[1]
backend = free_port()
common = dict(server='127.0.0.1', server_port=backend, password=cfg['password'],
method='chacha20-ietf-poly1305', mode='tcp_only')
try:
processes.append(spawn(cfg.get('server_binary', '/usr/local/bin/ssserver'), common, 'backend'))
capture_log = stack.enter_context((directory / 'capture.log').open('wb'))
if cfg.get('capture', True):
processes.append(subprocess.Popen(['tcpdump', '-i', 'any', '-U', '-s', '0', '-w',
str(directory / 'server.pcap'), 'tcp and (' + ' or '.join('port ' + str(p) for p in cfg['ports'])
+ ' or (host ' + os.environ['SSH_CONNECTION'].split()[0] + ' and port 22))'],
stdout=subprocess.DEVNULL, stderr=capture_log))
await asyncio.sleep(1)
assert all(p.poll() is None for p in processes)
print(json.dumps(dict(ready=True, origin=origin_port, backend=backend, client_address=os.environ['SSH_CONNECTION'].split()[0])), flush=True)
# Missing control commands also stop the service if the SSH
# connection is blackholed and never delivers EOF.
while line := await asyncio.wait_for(reader.readline(), cfg.get('control_timeout', 60)):
command = json.loads(line)
if command['event'] == 'stop':
break
if command['event'] == 'phase':
stop(phase_processes)
phase_processes.clear()
for server in servers:
server.close()
await server.wait_closed()
servers.clear()
controls.clear()
for index, mode in enumerate(command['modes']):
port = cfg['ports'][index]
server = await serve('server', mode, ('0.0.0.0', port), ('127.0.0.1', backend),
str(directory / 'certificate.pem'), str(directory / 'key.pem'))
servers.append(server)
client = await serve('client', mode, ('127.0.0.1', 0), ('127.0.0.1', port),
str(directory / 'certificate.pem'))
servers.append(client)
control_port = free_port()
config = dict(common, server_port=client.sockets[0].getsockname()[1],
local_address='127.0.0.1', local_port=control_port)
phase_processes.append(spawn('/usr/local/bin/sslocal', config, 'control-' + str(index)))
controls.append(control_port)
await asyncio.sleep(.4)
checks = await asyncio.gather(*(asyncio.wait_for(check_control(port, origin_port), 5)
for port in controls), return_exceptions=True)
healthy = all(p.poll() is None for p in processes + phase_processes)
values = [result is True for result in checks]
print(json.dumps(dict(healthy=healthy and all(values), local_transfers=values)), flush=True)
finally:
pipe.close()
stop(phase_processes)
stop(processes)
for server in servers + [origin]:
server.close()
await server.wait_closed()
if __name__ == '__main__':
asyncio.run(main())