diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fc73081..821ab93 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,4 +10,13 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: ./scripts/test.sh - - run: ./scripts/build.sh --check + - run: ./scripts/build.sh + id: build + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: canonical-objects + path: objects/ + - name: Verify committed artifacts and complete file set + run: | + git diff --exit-code -- objects/ + test -z "$(git ls-files --others --exclude-standard objects/)" diff --git a/.gitignore b/.gitignore index 84c048a..4ea31d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /build/ +__pycache__/ diff --git a/README.md b/README.md index 0ff53e5..58e6925 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ them. These run inside Synchronicity's socket runtime, not the Linux kernel. | `echo` | Echoes binary streams with backpressure; 30-second idle timeout | Any caller able to connect; 16 concurrent streams | | `whoami` | Prints authenticated origin, device key and peer kind, then closes | Any caller able to connect; 8 concurrent streams | | `tcp-proxy` | Bidirectional TCP forwarding to `127.0.0.1` on a configured port | Same peer allowlist as SSH; 32 concurrent connections | +| `ssh-session` | Same shell and SFTP as ssh-shell; existing connections close at the deadline | Pinned agent key; hosted delegate registration, default 10 minutes | | `ssh-shell` | Interactive `/bin/bash` with PTY; read/write SFTP under `files` | Configured origins or node public keys; 4 concurrent connections, one session per connection | Built against the SDK revision in [UPSTREAM.md](UPSTREAM.md). Review the @@ -127,6 +128,86 @@ an activated socket path: replacing its bytes deploys a new program immediately. Activation configuration stays on the serving node; it is not embedded in the published object. +## Temporary agent support sessions + +`ssh-session` is `ssh-shell` with deadline checks: the same shell, PTY and scoped +SFTP/tree-write capabilities. It requires an absolute `expires_at` Unix second, +rejects missing/malformed/expired or >1-hour deadlines, and closes existing +sessions at the deadline. Its admitted timer is monotonic. SSH `exec` remains +rejected, just as in `ssh-shell`; use the interactive PTY shell. + +### User: start a temporary device + +The only helper is a standalone bootstrap, not a private invitation generator: + +```sh +python3 tools/session-client.py --agent-key --seconds 600 +``` + +After explicit `yes` consent, it downloads pinned Synchronicity v0.1.10 and +checks the release SHA-256, creates a device key **locally**, trusts only the +controller, and activates `temporary-shell` with the controller's public-key +allowlist and deadline. The program comes from this checkout or a pinned, +checksum-verified artifact when the script is used standalone. The script prints +the temporary public key and the exact registration JSON. Send these public +values to the operator; the private key never leaves the device. + +Python 3, curl, `/bin/bash`, and macOS or glibc Linux are required. v0.1.10 Linux +needs glibc 2.39. The lifetime includes consent/download/setup time, defaults to +600 seconds and accepts 30–3600. No permanent service, installation or profile +change is made. Keep the terminal open; Ctrl-C stops the node. There is no +short-link server, bearer ticket store or embedded database to distribute. +Command length is not restricted; applications may provide their own public +bootstrap distribution or UI. + +### Operator: register through the managed control plane + +**Requires [synchronicity PR #148](https://github.com/AFK-surf/synchronicity/pull/148) +on the control plane and managed data plane (write-tunnel v2). This PR does not +deploy that API.** The controller must be a named member of that hosted network. +Use an org member/admin API key only on the operator's side, never in the user's +script. Set `CP`, `ORG`, `NET`, `DEVICE_KEY`, and `REGISTRATION_JSON` from the +chosen network and the bootstrap's output: + +```sh +curl -fsS -X PUT "$CP/api/orgs/$ORG/networks/$NET/delegations/$DEVICE_KEY" \ + -H "Authorization: Bearer $CP_API_KEY" -H 'content-type: application/json' \ + --data "$REGISTRATION_JSON" +``` + +The data plane signs an ordinary delegation for the fresh temporary space. The +agent no longer creates device keys, copies SQLite databases or signs grants +from its local daemon. Wait for ordinary grant replication before connecting: + +```sh +ssh -tt -o "ProxyCommand=synch socket connect key:$DEVICE_KEY:temporary-shell" "temporary-shell-$DEVICE_KEY" +``` + +Keep host-key verification enabled. `LOCAL READY` means the local socket is +active, not that API registration or replication has completed. SFTP retains +`ssh-shell`'s `files` scope; this bootstrap does not grant access to any existing +network file spaces or automatically populate that scope. + +Early network revocation uses the same issuer, not local `synch delegate rm`: + +```sh +curl -fsS -X DELETE "$CP/api/orgs/$ORG/networks/$NET/delegations/$DEVICE_KEY" \ + -H "Authorization: Bearer $CP_API_KEY" +``` + +A timeout has an unknown outcome; retry the same absolute-expiry registration, +not a new relative TTL. Do not retry registration after deciding to revoke. + +### Security and cleanup + +The shell runs as the user's account, **not in a sandbox**: it may read secrets, +change files and launch detached processes. Expiry cannot undo those effects or +guarantee removal of detached descendants. Delegation expiry/revocation governs +network admission; the socket deadline and supervisor separately stop active +access. Ctrl-C, SIGHUP, SIGTERM and normal expiry stop the owned daemon and remove +temporary data/binary files. SIGKILL, crashes or power loss may leave local files +requiring manual cleanup. Local clock integrity is part of admission trust. + ## TCP reverse proxy `tcp-proxy` forwards an authenticated Synchronicity stream to a TCP service on diff --git a/SECURITY.md b/SECURITY.md index eb02382..7a90eda 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -79,3 +79,21 @@ Tests run the committed object against loopback TCP to check denied dials, invalid configuration, host egress enforcement, backpressure, both half-close orders and asynchronous connection failure. Local listener clients inherit the forwarding node's identity and receive the selected service's protocol access. + +## Temporary support devices + +`ssh-session` shares the capabilities and request handling of `ssh-shell`, with +additional absolute-expiry admission and active-session deadline checks. The +bootstrap creates a private device identity locally and prints only its public +key and registration scope/deadline. It neither accepts nor embeds control-plane +credentials and never distributes a device database or private invitation. + +An authorized operator registers/revokes the key through the hosted delegation +API; the data plane signs the existing delegation record. The socket separately +pins the controller's public key. Network admission is not application access, +and delegation revocation is not the timer for an existing connection. + +The bootstrap checks runtime/program downloads, asks explicit consent before +starting, and owns the temporary daemon's normal shutdown and cleanup. See +README for clock, direct-process, detached-child and crash-cleanup boundaries. +This is authorized remote administration, not sandboxed or exactly-once execution. diff --git a/include/session-deadline.h b/include/session-deadline.h new file mode 100644 index 0000000..2f593b9 --- /dev/null +++ b/include/session-deadline.h @@ -0,0 +1,23 @@ +#ifndef SYKIT_SESSION_DEADLINE_H +#define SYKIT_SESSION_DEADLINE_H +/* expires_at is an absolute Unix second. Invitations last at most one hour; + * the creator normally issues ten minutes. Wall time admits, monotonic time + * prevents a backwards clock adjustment extending an established session. */ +static sy_u64 session_deadline(void) { + char value[12]; + sy_s64 n = sy_config_get(SY_STR("expires_at"), value, sizeof value); + if (n <= 0 || n > 11) return 0; + sy_u64 seconds = 0; + for (sy_s64 i = 0; i < n; ++i) { + if (value[i] < '0' || value[i] > '9') return 0; + seconds = seconds * 10 + (sy_u64)(value[i] - '0'); + } + sy_u64 now = sy_now_ms(), expires = seconds * 1000; + if (expires <= now || expires - now > 3600000) return 0; + return sy_monotonic_ns() / 1000000 + expires - now; +} +static sy_s64 session_remaining(sy_u64 deadline) { + sy_u64 now = sy_monotonic_ns() / 1000000; + return now < deadline ? (sy_s64)(deadline - now) : 0; +} +#endif diff --git a/objects/SHA256SUMS b/objects/SHA256SUMS index 2b526d5..16fc46a 100644 --- a/objects/SHA256SUMS +++ b/objects/SHA256SUMS @@ -1,4 +1,5 @@ 794391490778ce7c870a27d40c5e0d64b26b74b68a6d8c7c67612b0c431a891a ./echo.o +6f41f4cd89f640a1a36f5625a6c85306ca3778850ec425a8575450fec8a50f72 ./ssh-session.o 201f113b11affda8b32f13d523efdb42dd885ae4f3549bb11582ee761044b91a ./ssh-shell.o a123028c6ad3a09cc6d23720e18f108d5d2bf236bb6d777bbf3a199863e50081 ./tcp-proxy.o 0a6b1f3e3bc2cdc082a7d8130002aa6ad77bb07febc3c3514c9af22530973363 ./whoami.o diff --git a/objects/ssh-session.o b/objects/ssh-session.o new file mode 100644 index 0000000..e8757c4 Binary files /dev/null and b/objects/ssh-session.o differ diff --git a/scripts/test.sh b/scripts/test.sh index 48755ea..af3070d 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,4 +6,8 @@ trap 'rm -rf "$tmp"' EXIT HUP INT TERM ${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/node-auth.c -o "$tmp/node-auth" "$tmp/node-auth" echo 'Node authentication tests passed.' +${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -Iinclude tests/session-deadline.c -o "$tmp/session-deadline" +"$tmp/session-deadline" +python3 tests/session-parity.py +python3 tests/session-tools.py python3 tests/build.py diff --git a/src/ssh-session.c b/src/ssh-session.c new file mode 100644 index 0000000..f4f9eae --- /dev/null +++ b/src/ssh-session.c @@ -0,0 +1,3 @@ +/* ssh-shell with deadline admission and active-session expiry checks. */ +#define SHELL_SESSION 1 +#include "ssh-shell.c" diff --git a/src/ssh-shell.c b/src/ssh-shell.c index cd8eef8..d031c05 100644 --- a/src/ssh-shell.c +++ b/src/ssh-shell.c @@ -24,16 +24,24 @@ executable, argv, PTY permission, signals, and scoped SFTP access. `write` opts the service into mutation; the same-id tree-write capability supplies create/replace/delete authority and the default 16 MiB staging bound. */ -SY_MANIFEST("{\"manifest\":1,\"name\":\"ssh-shell\",\"max_streams\":4," +#ifdef SHELL_SESSION +#include "session-deadline.h" +#define PROGRAM_NAME "ssh-session" +#else +#define PROGRAM_NAME "ssh-shell" +#endif +SY_MANIFEST("{\"manifest\":1,\"name\":\"" PROGRAM_NAME "\",\"max_streams\":4," "\"processes\":[{\"id\":1,\"allow\":[\"pty\"]," "\"executable\":\"" SHELL_EXECUTABLE "\"," "\"argv\":[\"" SHELL_ARGV0 "\"]," - "\"allowed_signals\":[\"HUP\",\"INT\",\"TERM\"]}]," + "\"allowed_signals\":[\"HUP\",\"INT\",\"TERM\"]}]" + "," "\"file_transfers\":[{\"id\":1,\"protocol\":\"sftp\"," "\"access\":[\"read\",\"write\",\"recursive\"]," "\"scope\":\"" SFTP_SCOPE "\"}]," "\"tree_writes\":[{\"id\":1,\"prefix\":\"" SFTP_SCOPE "\"," - "\"allow\":[\"create\",\"replace\",\"delete\"]}]}"); + "\"allow\":[\"create\",\"replace\",\"delete\"]}]" + "}"); /* JSON strings are length-delimited; strlen would accept "sftp\0suffix". * Compare the reported length as well as the bytes, rejecting truncation. */ @@ -208,6 +216,12 @@ static void collect_status(struct session *s) { } SY_ENTRY sy_s64 entry(void) { +#ifdef SHELL_SESSION + /* Absolute invitation expiry; once admitted, monotonic time also bounds it. + Missing/malformed/expired config is never an unlimited session. */ + sy_u64 deadline = session_deadline(); + if (!deadline) return 1; +#endif /* Gate the authenticated transport identity before starting SSH or a backend. Inner SSH none is safe only because this check is mandatory. */ if (!node_is_authorized()) { @@ -215,6 +229,9 @@ SY_ENTRY sy_s64 entry(void) { return 1; } +#ifdef SHELL_SESSION + if (!session_remaining(deadline)) return 1; +#endif sy_s64 methods = sy_json_parse(SY_STR("[\"none\"]")); if (methods < 0) return 1; sy_s64 started = sy_ssh_start(SY_SELF, methods); @@ -261,11 +278,20 @@ SY_ENTRY sy_s64 entry(void) { fds[nfds++] = (struct sy_pollfd){s.channel, SY_POLL_RDHUP, 0}; } - if (sy_poll(fds, nfds, -1) <= 0) break; /* idle deadline, or all quiet */ +#ifdef SHELL_SESSION + sy_s64 left = session_remaining(deadline); + if (!left || sy_poll(fds, nfds, left) <= 0) break; + if (!session_remaining(deadline)) break; +#else + if (sy_poll(fds, nfds, -1) <= 0) break; +#endif /* idle deadline, or all quiet */ if (fds[0].revents & SY_POLL_IN) { sy_s64 event; while ((event = sy_ssh_next(SY_SELF)) > 0) { +#ifdef SHELL_SESSION + if (!session_remaining(deadline)) { sy_close(event); goto expired; } +#endif sy_s64 id = 0; if (sy_json_get_i64(event, SY_STR("id"), &id) < 0 || id <= 0) { sy_close(event); @@ -340,6 +366,9 @@ SY_ENTRY sy_s64 entry(void) { if (fds[0].revents & (SY_POLL_ERR | SY_POLL_HUP)) break; } +#ifdef SHELL_SESSION +expired: +#endif close_session(&s); return 0; } diff --git a/tests/node-auth.c b/tests/node-auth.c index bd885f3..41cee3a 100644 --- a/tests/node-auth.c +++ b/tests/node-auth.c @@ -1,6 +1,9 @@ #include #include #include +#undef memcpy +#undef memset +#undef memmove #define memcpy sdk_memcpy #define memset sdk_memset #define memmove sdk_memmove diff --git a/tests/session-deadline.c b/tests/session-deadline.c new file mode 100644 index 0000000..924e582 --- /dev/null +++ b/tests/session-deadline.c @@ -0,0 +1,41 @@ +#include +#include +#undef memcpy +#undef memset +#undef memmove +#define memcpy sdk_memcpy +#define memset sdk_memset +#define memmove sdk_memmove +#include +#undef memcpy +#undef memset +#undef memmove +#include "session-deadline.h" +static const char *config; +static sy_u64 wall, mono; +sy_s64 sy_config_get(const char *key, sy_u64 klen, char *out, sy_u64 len) { + assert(klen == 10 && memcmp(key, "expires_at", 10) == 0); + if (!config) return SY_ENOENT; + size_t n = strlen(config); + memcpy(out, config, n < len ? n : len); + return (sy_s64)n; +} +sy_u64 sy_now_ms(void) { return wall; } +sy_u64 sy_monotonic_ns(void) { return mono * 1000000; } +int main(void) { + wall = 1000000; mono = 50; + const char *bad[] = {NULL, "", "0", "1000", "999", "1001x", "-1", "+1001", " 1001", "4601", "9999999999999999999999"}; + for (unsigned i = 0; i < sizeof bad / sizeof *bad; ++i) { + config = bad[i]; assert(session_deadline() == 0); + } + config = "1001"; + sy_u64 deadline = session_deadline(); + assert(deadline == 1050); + assert(session_remaining(deadline) == 1000); + wall = 0; mono = 1049; + assert(session_remaining(deadline) == 1); /* backwards wall time cannot extend */ + mono = 1050; assert(session_remaining(deadline) == 0); + mono = 999999; assert(session_remaining(deadline) == 0); + wall = 1000000; config = "4600"; + assert(session_deadline() != 0); /* exact one-hour maximum */ +} diff --git a/tests/session-parity.py b/tests/session-parity.py new file mode 100644 index 0000000..400638e --- /dev/null +++ b/tests/session-parity.py @@ -0,0 +1,31 @@ +"""Both variants must retain identical capabilities and SSH request handling.""" +import os +from pathlib import Path +import re +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def preprocess(name): + return subprocess.check_output( + [os.environ.get("CC", "cc"), "-E", "-P", "-I", str(ROOT / "include"), + str(ROOT / "src" / (name + ".c"))], text=True) + + +class SessionParity(unittest.TestCase): + def test_manifest_and_handlers_match(self): + shell, session = (preprocess(n) for n in ("ssh-shell", "ssh-session")) + # Adjacent C strings may remain separate; normalize the program name only. + manifest = lambda s: re.search(r'static const char[^;]*sy_manifest[^;]*;', s).group() + self.assertEqual(manifest(shell), manifest(session).replace('ssh-session', 'ssh-shell')) + # This entire shared region includes channel admission, shell and SFTP requests. + def handlers(source): + return source[source.index("static sy_s64 handle_open"): + source.index("static void move_terminal")] + self.assertEqual(handlers(shell), handlers(session)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/session-tools.py b/tests/session-tools.py new file mode 100644 index 0000000..183ae48 --- /dev/null +++ b/tests/session-tools.py @@ -0,0 +1,120 @@ +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('session_client', ROOT / 'tools/session-client.py') +client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(client) + + +class SessionTests(unittest.TestCase): + def test_standalone_program_pin_matches_committed_object(self): + import hashlib + self.assertEqual(hashlib.sha256((ROOT / 'objects/ssh-session.o').read_bytes()).hexdigest(), + client.PROGRAM_SHA256) + + def test_expired_session_never_downloads_or_starts(self): + with patch.object(client.time, 'time', return_value=100), patch.object(client.subprocess, 'run') as run: + with self.assertRaises(SystemExit): + client.main({'expires_at': 100, 'agent_key': 'y' * 52}) + run.assert_not_called() + + def test_refusing_consent_does_not_touch_machine(self): + session = dict(expires_at=150, agent_key='y' * 52) + with patch.object(client.time, 'time', return_value=100), patch('builtins.input', return_value='no'), \ + patch.object(client.subprocess, 'run') as run, patch('sys.stdout', new=io.StringIO()): + with self.assertRaises(SystemExit): + client.main(session) + run.assert_not_called() + + def test_remaining_is_bounded_by_wall_and_monotonic_time(self): + session = {'expires_at': 200} + with patch.object(client.time, 'time', return_value=50), patch.object(client.time, 'monotonic', return_value=150): + self.assertEqual(client.remaining(session, 170), 20) + with patch.object(client.time, 'time', return_value=201), patch.object(client.time, 'monotonic', return_value=150): + self.assertEqual(client.remaining(session, 170), 0) + + def test_stop_waits_for_real_foreground_process(self): + import subprocess + import sys + process = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']) + try: + client.stop_node(['/nonexistent/synch'], process, {}) + self.assertIsNotNone(process.poll()) + finally: + if process.poll() is None: + process.kill() + process.wait() + + def test_bootstrap_retries_recovery_and_cleans_up_on_expiry(self): + import hashlib + import os + import subprocess + import sys + import tarfile + import time + # A real foreground test process and control CLI, but no network or shell + # access. The first control command deliberately reports runtime recovery. + fake = "#!" + sys.executable + "\n" + """ +import os, pathlib, signal, sys, time +root = pathlib.Path(sys.argv[2]); args = [a for a in sys.argv[3:] if a != '--offline'] +if args == ['init']: + print('device key: ' + 'b' * 52) +elif args[:2] == ['trust', 'add']: + assert args[2] == 'y' * 52 +elif args == ['daemon', 'run']: + (root / 'pid').write_text(str(os.getpid())) + (root / 'control.sock').touch() + time.sleep(60) +elif args == ['daemon', 'stop']: + os.kill(int((root / 'pid').read_text()), signal.SIGTERM) + (root / 'control.sock').unlink(missing_ok=True) +elif args[:2] == ['source', 'add']: + if not (root / 'retry').exists(): + (root / 'retry').touch() + print('synch: daemon starting: recovering own head', file=sys.stderr) + sys.exit(1) +elif args[:2] == ['socket', 'activate']: + assert 'allowed_peers=' + 'y' * 52 in args + assert any(arg.startswith('expires_at=') for arg in args) +else: + assert args[:2] == ['source', 'scan'] +""" + name = 'synchronicity-v0.1.10-aarch64-apple-darwin' + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w:gz') as tar: + entry = tarfile.TarInfo(name + '/synch') + content = fake.encode() + entry.size = len(content) + tar.addfile(entry, io.BytesIO(content)) + raw = archive.getvalue() + session = dict(expires_at=time.time() + 3, agent_key='y' * 52) + real_run = subprocess.run + directories = [] + def run(args, **kwargs): + if args[0] == 'curl': + destination = Path(args[-1]) + directories.append(destination.parent) + destination.write_bytes(raw) + return subprocess.CompletedProcess(args, 0) + return real_run(args, **kwargs) + output = io.StringIO() + with patch.object(client.subprocess, 'run', side_effect=run), \ + patch.object(client.subprocess, 'check_output', return_value=hashlib.sha256(raw).hexdigest() + ' ' + name + '.tar.gz'), \ + patch.object(client.platform, 'system', return_value='Darwin'), \ + patch.object(client.platform, 'machine', return_value='arm64'), \ + patch('builtins.input', return_value='yes'), patch('sys.stdout', new=output): + client.main(session) + self.assertIn('LOCAL READY (operator must register delegate): ' + 'b' * 52, output.getvalue()) + self.assertIn('Registration JSON:', output.getvalue()) + self.assertIn('Temporary node stopped', output.getvalue()) + self.assertEqual(len(directories), 1) + self.assertFalse(directories[0].exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/session-client.py b/tools/session-client.py new file mode 100644 index 0000000..44825e3 --- /dev/null +++ b/tools/session-client.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Start an isolated temporary node; an operator registers its public key via CP.""" +import argparse +import re +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import signal +import subprocess +import tarfile +import tempfile +import time + +VERSION = 'v0.1.10' +PROGRAM_REV = '531c9c068f887a6d07a717f66da7d84a6160e550' +PROGRAM_SHA256 = '6f41f4cd89f640a1a36f5625a6c85306ca3778850ec425a8575450fec8a50f72' +KEY = r'[ybndrfg8ejkmcpqxot1uwisza345h769]{52}' + + + +def remaining(session, deadline): + return max(0, min(session['expires_at'] - time.time(), deadline - time.monotonic())) + + +def stop_node(command, process, env): + if process is None or process is None or process.poll() is not None: + return + try: + subprocess.run(command + ['daemon', 'stop'], env=env, timeout=5, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + process.wait(timeout=5) + except (subprocess.TimeoutExpired, OSError): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def main(session): + if not re.fullmatch(KEY, session['agent_key']): + raise SystemExit('Agent key must be a device public key.') + left = session['expires_at'] - time.time() + if not 0 < left <= 3600: + raise SystemExit('Session expired or clock incorrect; restart with a new deadline.') + deadline = time.monotonic() + left + print('TEMPORARY REMOTE SHELL: the named agent can run commands as your OS user.') + print('Agent key:', session['agent_key']) + print('Expires:', time.strftime('%Y-%m-%d %H:%M:%S UTC+8', time.gmtime(session['expires_at'] + 8 * 3600))) + print('Not a sandbox: commands may change files or start detached background processes.') + print('Keep this terminal open. Ctrl-C stops the connection. No permanent service is installed.') + if input('Allow this agent until the expiry above? Type yes: ').strip() != 'yes': + raise SystemExit('Cancelled.') + targets = {('Darwin', 'arm64'): 'aarch64-apple-darwin', + ('Darwin', 'x86_64'): 'x86_64-apple-darwin', + ('Linux', 'aarch64'): 'aarch64-unknown-linux-gnu', + ('Linux', 'x86_64'): 'x86_64-unknown-linux-gnu'} + target = targets.get((platform.system(), platform.machine())) + if not target: + raise SystemExit('Unsupported platform; requires macOS or glibc Linux, Python 3 and curl.') + name = 'synchronicity-' + VERSION + '-' + target + env = {k: v for k, v in os.environ.items() if not k.startswith('SYNCH_')} + process = None + command = [] + def interrupted(_signum, _frame): + raise KeyboardInterrupt + handlers = {sig: signal.signal(sig, interrupted) + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} + with tempfile.TemporaryDirectory(prefix='sykit-session-') as directory: + root = Path(directory) + try: + archive = root / 'release.tar.gz' + left = remaining(session, deadline) + if not left: + raise RuntimeError('Session expired before download.') + subprocess.run(['curl', '-fL', '--connect-timeout', '15', '--max-time', str(min(120, int(left))), + 'https://github.com/AFK-surf/synchronicity/releases/download/' + + VERSION + '/' + name + '.tar.gz', '-o', str(archive)], + check=True, timeout=left) + sums = subprocess.check_output(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', + 'https://github.com/AFK-surf/synchronicity/releases/download/' + VERSION + '/SHA256SUMS'], + text=True, timeout=remaining(session, deadline)) + checksums = {line.split()[1].lstrip('*'): line.split()[0] + for line in sums.splitlines() if len(line.split()) == 2} + expected = checksums[name + '.tar.gz'] + if hashlib.sha256(archive.read_bytes()).hexdigest() != expected: + raise RuntimeError('Release checksum mismatch.') + with tarfile.open(archive, 'r:gz') as tar: + member = tar.getmember(name + '/synch') + if not member.isfile(): + raise RuntimeError('Expected a regular binary in the archive.') + with tar.extractfile(member) as src, (root / 'synch').open('wb') as dst: + shutil.copyfileobj(src, dst) + binary = root / 'synch' + binary.chmod(0o700) + data = root / 'node' + data.mkdir(mode=0o700) + source = root / 'source' + source.mkdir(mode=0o700) + program = source / 'ssh-session.o' + local_program = Path(__file__).resolve().parents[1] / 'objects/ssh-session.o' + if local_program.is_file(): + shutil.copyfile(local_program, program) + else: + subprocess.run(['curl', '-fsSL', '--connect-timeout', '10', '--max-time', '25', + 'https://raw.githubusercontent.com/AFK-surf/sykit/' + PROGRAM_REV + '/objects/ssh-session.o', + '-o', str(program)], check=True, timeout=remaining(session, deadline)) + if hashlib.sha256(program.read_bytes()).hexdigest() != PROGRAM_SHA256: + raise RuntimeError('Session program checksum mismatch.') + command = [str(binary), '--data-dir', str(data)] + def call(*args): + while True: + left = remaining(session, deadline) + if not left: + raise RuntimeError('Session expired.') + result = subprocess.run(command + list(args), env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, timeout=min(10, left)) + if result.returncode == 0: + return result.stdout + if 'daemon starting:' not in result.stderr or process is None or process.poll() is not None: + raise RuntimeError(' '.join(args) + ': ' + result.stderr.strip()) + time.sleep(min(0.1, left)) + initialized = call('init') + device_key = re.search(r'^device key: (' + KEY + r')$', initialized, re.M) + if not device_key: + raise RuntimeError('Could not read generated device public key.') + device_key = device_key[1] + space = 'temporary-shell-' + device_key[:16] + print('Register this public key through the control plane:', device_key, flush=True) + print('Registration JSON:', json.dumps(dict(spaces=[space], expires_at=session['expires_at'])), flush=True) + with (root / 'daemon.log').open('w') as log: + def start(offline=False): + nonlocal process + process = subprocess.Popen(command + (['--offline'] if offline else []) + ['daemon', 'run'], + env=env, stdout=log, stderr=log) + while not (data / 'control.sock').exists(): + if process.poll() is not None or not remaining(session, deadline): + raise RuntimeError('Daemon did not become ready: ' + (root / 'daemon.log').read_text()[-2000:]) + time.sleep(0.05) + start(offline=True) + call('trust', 'add', session['agent_key']) + stop_node(command, process, env) + start() + call('source', 'add', space, str(source)) + call('source', 'scan', space) + call('socket', 'activate', 'temporary-shell', '--program', space + '/ssh-session.o', + '--config', 'allowed_peers=' + session['agent_key'], + '--config', 'expires_at=' + str(session['expires_at'])) + print('LOCAL READY (operator must register delegate):', device_key, flush=True) + while remaining(session, deadline) > 0 and process.poll() is None: + time.sleep(min(0.2, remaining(session, deadline))) + except KeyboardInterrupt: + print('\nStopping temporary access…') + finally: + # A second Ctrl-C must not interrupt the bounded teardown. + for sig in handlers: + signal.signal(sig, signal.SIG_IGN) + try: + stop_node(command, process, env) + finally: + for sig, previous in handlers.items(): + signal.signal(sig, previous) + print('Temporary node stopped; local session files removed.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--agent-key', required=True, help='only controller admitted by the socket') + parser.add_argument('--seconds', type=int, default=600, help='total lifetime, including setup (30–3600)') + args = parser.parse_args() + if not 30 <= args.seconds <= 3600: + parser.error('--seconds must be between 30 and 3600') + main(dict(agent_key=args.agent_key, expires_at=int(time.time()) + args.seconds))