Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/)"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/build/
__pycache__/
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <controller-public-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
Expand Down
18 changes: 18 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 23 additions & 0 deletions include/session-deadline.h
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions objects/SHA256SUMS
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
794391490778ce7c870a27d40c5e0d64b26b74b68a6d8c7c67612b0c431a891a ./echo.o
6f41f4cd89f640a1a36f5625a6c85306ca3778850ec425a8575450fec8a50f72 ./ssh-session.o
201f113b11affda8b32f13d523efdb42dd885ae4f3549bb11582ee761044b91a ./ssh-shell.o
a123028c6ad3a09cc6d23720e18f108d5d2bf236bb6d777bbf3a199863e50081 ./tcp-proxy.o
0a6b1f3e3bc2cdc082a7d8130002aa6ad77bb07febc3c3514c9af22530973363 ./whoami.o
Binary file added objects/ssh-session.o
Binary file not shown.
4 changes: 4 additions & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions src/ssh-session.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* ssh-shell with deadline admission and active-session expiry checks. */
#define SHELL_SESSION 1
#include "ssh-shell.c"
37 changes: 33 additions & 4 deletions src/ssh-shell.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -208,13 +216,22 @@ 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()) {
sy_log(SY_STR("ssh-shell: node authorization denied\n"));
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions tests/node-auth.c
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#undef memcpy
#undef memset
#undef memmove
#define memcpy sdk_memcpy
#define memset sdk_memset
#define memmove sdk_memmove
Expand Down
41 changes: 41 additions & 0 deletions tests/session-deadline.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#include <assert.h>
#include <string.h>
#undef memcpy
#undef memset
#undef memmove
#define memcpy sdk_memcpy
#define memset sdk_memset
#define memmove sdk_memmove
#include <synch.h>
#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 */
}
31 changes: 31 additions & 0 deletions tests/session-parity.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading