mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-10-21 22:43:48 +00:00
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>
151 lines
6.5 KiB
Python
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())
|