Repository navigation
sshserver on toyos-ssh with std threads; russh, tokio and rsa are gone (SSH stage 2) - #1026
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Review, round 1: #1026 at b6e6912 (sshserver on toyos-ssh), this PR's commits cdd3ddf and b6e6912 onlyArchitecture. The shape is right. The sans-IO core owns every byte a client sends before a program runs. The daemon owns the sockets, the processes, the files and the policy: the deadline, the cap and the
There are two problems in that model, B1 and B2. One concerns the resource bound this PR claims for unauthenticated clients; the other is a host regression in reaping. FindingsBLOCKER B1
BLOCKER B2
BLOCKER B3
BLOCKER B4: outstanding by the orchestrator's decision. The guest check of one thread blocked reading a BLOCKER B5 SUGGESTION SUGGESTION SUGGESTION
SUGGESTION (cosmetic): Records.
Evidence.
SEND BACK for B1–B5. |
…, ended programs are reaped - A client that floods offers, each answered PK_OK at no cost, and never reads could block the connection's write forever and hold its unauthenticated place. Before login every write now has the time left to the deadline as its timeout, so such a connection is closed at the deadline and gives its place back. The socket's reader waits until its chunk is taken, as a pipe's reader does, so a client that sends faster than the connection answers waits on its socket instead of filling memory. - A program ended by its channel's or connection's end is killed and reaped, so a host keeps no zombie. - A channel's waiting output is a type of its own, held by a host test of a narrow window, which dropping the rest of a partial send turns red. - serve takes its authorizer and deadline, so a host test drives it over loopback with a client that floods offers and never reads. - finish's drain invariant is an assert. A connection thread that cannot be spawned closes that connection rather than the daemon. - Comments state the contracts: the wait begins once both output streams reach end of file, and until then a program can be killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
|
Round 1 answered at 81e922b (04f7df6, 6fa3a2d, 81e922b). Evidence, all masked: https://github.com/ToyOSOrg/evidence/tree/7af56087898bea4b8953bb1b7484f51d498ebc78/pr-1026/r1
|
Review of #1026, round 2 (head 81e922b; fixes in 04f7df6, 6fa3a2d and 81e922b)Scope: round 1's B1–B5, the code the fixes added (+505/−83: about +60 net production in Architecture. The fixes stay inside round 1's model.
Base. The PR already targets Round 1 blockersB1 (pre-auth deadline, unbounded input): input resolved; the write bound is incomplete. See BLOCKER 1.
B2 (zombies): resolved. B3 (driver host test): resolved.
B4 (guest concurrency check): resolved.
B5 (false comments): mostly resolved. The FindingsBLOCKER 1
BLOCKER 2
SUGGESTION 1
SUGGESTION 2 (cosmetic): two small points.
Evidence and privacy
SEND BACK for BLOCKER 1 and BLOCKER 2. |
sshserver drives toyos-ssh's sans-IO server from one thread per connection. Every event reaches that thread on one queue: the socket's bytes, a program's output, what its stdin took, and its exit. That thread alone touches the protocol and writes the socket. Flow control is the protocol's end to end. The client's window comes back only as a program's stdin takes the bytes, so the old 30 s input stall is gone. A pipe's reader waits until its chunk is in the client's window before reading again. It keeps what it served: a shell (with CR LF for a terminal), exec through command.rs, and the sftp subsystem over sftp.rs. It adds what russh did not give: - several channels on one connection, keyed by toyos-ssh's ChannelId, which closes the input aliasing; - an authorized_keys reader in toyos-ssh, after sshd(8)'s format, where an entry with options authorizes nothing; - a login deadline of 120 s, which bounds a client that only offers keys; - a cap of 10 connections that have not logged in, after which one more is closed at once. toyos-ssh gains the requests this needs: shell, the sftp subsystem and one pty-req before a program, and an Authenticated event that names the key's fingerprint. The host key is shared by Arc. A program is ended when its connection or channel ends. std's Child takes wait and kill through one &mut, so the wait begins once both of its pipes are at their end. A program that closed them and runs on is not ended (issues/sshserver-cannot-end-a-program-that-closed-its-output.md). Before, a try_wait loop with a backoff polled for it. toyos-ssh now has two users, the build and sshserver, so it moves to lib/ssh under the tree's placement rule. Deleted: russh, russh-cryptovec and tokio from the root [patch], and with them rsa (RUSTSEC-2023-0071), pageant, internal-russh-num-bigint, internal-russh-forked-ssh-key, p384 0.14 and fiat-crypto 0.3 from Cargo.lock; and the p384_backend setting. Recorded with OpenSSH_10.3p1 against toyos-ssh's scripted driver, each on the hybrid key exchange and each replayed byte for byte: - a shell with a terminal; - an sftp session, whose server is in memory so its replies are the same on every host; - two channels open at once through one multiplexed connection. Closes issues/sshserver-holds-one-channel-and-does-not-say-so.md, issues/sshserver-still-speaks-rsa.md and issues/the-tokio-forks-root-manifest-patches-mio.md (no consumer). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
…, ended programs are reaped - A client that floods offers, each answered PK_OK at no cost, and never reads could block the connection's write forever and hold its unauthenticated place. Before login every write now has the time left to the deadline as its timeout, so such a connection is closed at the deadline and gives its place back. The socket's reader waits until its chunk is taken, as a pipe's reader does, so a client that sends faster than the connection answers waits on its socket instead of filling memory. - A program ended by its channel's or connection's end is killed and reaped, so a host keeps no zombie. - A channel's waiting output is a type of its own, held by a host test of a narrow window, which dropping the rest of a partial send turns red. - serve takes its authorizer and deadline, so a host test drives it over loopback with a client that floods offers and never reads. - finish's drain invariant is an assert. A connection thread that cannot be spawned closes that connection rather than the daemon. - Comments state the contracts: the wait begins once both output streams reach end of file, and until then a program can be killed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
…eads sshserver reads each connection on one thread and writes it on another, and runs every program with its stdin, stdout, stderr and wait on threads of their own. Two checks hold what it relies on, in the guest because std's ToyOS arms build only for toyos: - A client writes on one thread while its other thread is in a read the peer has not answered. The write lands in the send ring, and the read then takes the bytes the peer publishes after it. - A child's stdin is written, its stdout and stderr are read and its exit is waited for, each on its own thread, with more than a pipe holds in flight. A child that holds its pipes open is killed from a thread other than the one reading them, and that read ends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
A write timeout bounds one send. A reader that takes a little at a time let write_all go on past the login deadline, because each send made progress. Each send is now given only the time left (write_by), held by a test whose reader takes one byte a send. Round 1's single timeout over write_all reds it. program.rs no longer claims a program whose own child holds its pipes cannot be ended. The closed-output issue records what happens: it is reaped when its channel or connection ends, and the child runs on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
81e922b to
2b38b4f
Compare
|
Round 2 answered at 2b38b4f, rebased onto main 35e9301. Evidence: https://github.com/ToyOSOrg/evidence/tree/28a0e72a9e398dafb29069d2679186e54d6f1e98/pr-1026/r2 (built on c79c17fef;
|
Review of #1026, round 3 (head 2b38b4f, rebased onto main 35e9301)Scope: round 2's two blockers and the code their fixes added in 2b38b4f (+66/−12). GitHub now reports the PR MERGEABLE against Round 2 blockersBLOCKER 1 (the deadline bounded each send, not the whole write): resolved.
BLOCKER 2 (the false clause in
Evidence
No blockers remain. LAND |
- A `login` row holds no launcher of the machine's session (`holds_machine_launcher`): its launches are its sessions' alone. The compositor started with no desktop session opens one, and again at each launch asked for while none is open, saying why it is refused; the supervisor refuses every launch into a session that has ended. A `login` test-runner opens one session for its jobs and hands them its launcher. - A session's launcher acceptor lives in its `Login` alone, so its end closes the port and a launch connected to it reads its end. The screen's row is handed its session's launcher and control minted for each start. The compositor's launcher thread takes a copy of the reopened launcher and holds no lock across a launch (`Namespace::duplicate`). - Reopens are budgeted on failures: a login authority whose sessions failed more than three times in ten seconds is refused its next open, and no open spends anything, so one session's failure or a sibling's sign-out changes no other login. - The last session slot is the desktop's, and an owner that breaks its link is killed, its session ending as a failure. - `session-admin` has no user dimension: the machine has one user, so a login session's `logout` acts on every session. - sshserver's session half is rewritten on #1026's server: a connection opens its session with its first program and signs it out when it ends, and a channel's end asks its program to quit with a hang-up through a handle of its own (`spawn::Ender`), on a host still killing it. - `app_view` loses its machine-session grants arm, since a `login` test-runner's jobs launch in its session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
- A `login` row holds no launcher of the machine's session (`holds_machine_launcher`): its launches are its sessions' alone. The compositor started with no desktop session opens one, and again at each launch asked for while none is open, saying why it is refused; the supervisor refuses every launch into a session that has ended. A `login` test-runner opens one session for its jobs and hands them its launcher. - A session's launcher acceptor lives in its `Login` alone, so its end closes the port and a launch connected to it reads its end. The screen's row is handed its session's launcher and control minted for each start. The compositor's launcher thread takes a copy of the reopened launcher and holds no lock across a launch (`Namespace::duplicate`). - Reopens are budgeted on failures: a login authority whose sessions failed more than three times in ten seconds is refused its next open, and no open spends anything, so one session's failure or a sibling's sign-out changes no other login. - The last session slot is the desktop's, and an owner that breaks its link is killed, its session ending as a failure. - `session-admin` has no user dimension: the machine has one user, so a login session's `logout` acts on every session. - sshserver's session half is rewritten on #1026's server: a connection opens its session with its first program and signs it out when it ends, and a channel's end asks its program to quit with a hang-up through a handle of its own (`spawn::Ender`), on a host still killing it. - `app_view` loses its machine-session grants arm, since a `login` test-runner's jobs launch in its session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
- A `login` row holds no launcher of the machine's session (`holds_machine_launcher`): its launches are its sessions' alone. The compositor started with no desktop session opens one, and again at each launch asked for while none is open, saying why it is refused; the supervisor refuses every launch into a session that has ended. A `login` test-runner opens one session for its jobs and hands them its launcher. - A session's launcher acceptor lives in its `Login` alone, so its end closes the port and a launch connected to it reads its end. The screen's row is handed its session's launcher and control minted for each start. The compositor's launcher thread takes a copy of the reopened launcher and holds no lock across a launch (`Namespace::duplicate`). - Reopens are budgeted on failures: a login authority whose sessions failed more than three times in ten seconds is refused its next open, and no open spends anything, so one session's failure or a sibling's sign-out changes no other login. - The last session slot is the desktop's, and an owner that breaks its link is killed, its session ending as a failure. - `session-admin` has no user dimension: the machine has one user, so a login session's `logout` acts on every session. - sshserver's session half is rewritten on #1026's server: a connection opens its session with its first program and signs it out when it ends, and a channel's end asks its program to quit with a hang-up through a handle of its own (`spawn::Ender`), on a host still killing it. - `app_view` loses its machine-session grants arm, since a `login` test-runner's jobs launch in its session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Problem
system/sshserverserved SSH on the russh fork over tokio. That broughtrsa(RUSTSEC-2023-0071),pageant, the russh-internal bignum and ssh-key forks, andp3840.14.p384needed the experimentalbignumbackend set for every build to keep fiat-crypto out. The daemon also kept one input sender per connection, so a second channel's data reached the first channel's program. Stage 2 ofissues/the-ssh-server-is-toyos-own-and-russh-is-gone.mdputs it ontoyos-ssh. The owner ruled on 2026-10-22: "Use our own crypto where possible; we optimize and improve until done, but we can already use it."Result
toyos-ssh's sans-IO server (system/sshserver/src/connection.rs).execthroughcommand.rs; andsubsystem sftpoversftp.rs, on a thread of its own.toyos-ssh'sChannelId, which ends the input aliasing;authorized_keysreader intoyos-ssh, written from sshd(8)'s format: an entry with options authorizes nothing, and another key type is named and not taken;LoginGraceTime, which bounds a client that only offers keys. Before login it bounds every wait and the whole of every write, each send given only the time left, so a client that stops reading, or reads too slowly, is closed at the deadline too;MaxStartups. One more is closed at once.toyos-sshgainsshell, thesftpsubsystem (other subsystems are declined by name) and onepty-reqbefore a program.Event::Authenticatednames the key's fingerprint. The host key is shared byArc.[patch]: russh, russh-cryptovec and tokio.Cargo.lock:rsa,pageant,internal-russh-num-bigint,internal-russh-forked-ssh-key,p3840.14 andfiat-crypto0.3..cargo/config.toml: thep384_backendsetting.toyos-sshmoves tolib/ssh. It now has two users, the build and sshserver, which is step 5 ofissues/the-tree-says-who-uses-each-thing.md.sshserver-holds-one-channel-and-does-not-say-so,sshserver-still-speaks-rsa, andthe-tokio-forks-root-manifest-patches-mio, which has no consumer left.Net code change: +1,243/−889, outside fixtures,
Cargo.lockand issues.connection.rs423,main.rs122 against 799 deleted,program.rs46, andauthorized_keyswith its tests 148. The core's request handling is about 90.Decisions
Childtakeswaitandkillthrough one&mut, so the wait starts only once both pipes have ended. Until then a program is ended when its channel or its connection ends.try_waitwith a backoff, which is a fixed delay where an event exists.toyos::process::Processtakeswaitandkillby&self, which would close the hole. Reaching it needs acfg(target_os = "toyos")in sshserver, andbuild/userlandhost.rsrefuses anycfgbutcfg(test)in a program crate that holds tests.issues/sshserver-cannot-end-a-program-that-closed-its-output.md. The fix depends on spawn: a plain spawn is a helper, and a launch is asked for #1024, whose gate rule counts acfgonly in a file that holds a test or declares a module: it is a ToyOS-only leaf file, as spawn: a plain spawn is a helper, and a launch is asked for #1024'ssystem/sshserver/src/spawn.rs. It follows once spawn: a plain spawn is a helper, and a launch is asked for #1024 lands.main.rs, deletescommand.rsand starts programs in their own manifest row. This PR and spawn: a plain spawn is a helper, and a launch is asked for #1024 conflict, and whichever lands second rebases.toyos-ssh's scripted driver, whose SFTP server is in memory (lib/ssh/tests/common/sftp.rs). It is notsftp.rs.sftp.rsanswersREALPATH "."with the process's working directory, and OpenSSH builds its absolute paths from that answer. No recording made on the Mac could replay byte for byte on another host.sftp.rsis covered by its own 11 tests and by the Mac end-to-end run below with OpenSSH'ssftp.Checks and evidence
Evidence, every log masked: https://github.com/ToyOSOrg/evidence/tree/28a0e72a9e398dafb29069d2679186e54d6f1e98/pr-1026 (round 1 in
r1/, round 2 inr2/)Head 2b38b4f, on main 35e9301.
cargo run -- --ci hostat 2b38b4f, after the rebase changedCargo.lock: exit 0, 101 steps green (r2/ci-host.log).--build-onlyexit 0, and the x86_64 image built for the QEMU run below. Round 2 changes the write loop's portable code and a comment, both covered by--ci host's clippy and tests.system/sshserver):output_goes_whole_and_in_order_through_a_narrow_window: a chunk the window takes in part waits for the rest, in order, and its reader is told only once all of it went.a_client_that_floods_offers_and_never_reads_is_closed_at_the_deadline: a loopback client completes the exchange, then floods unsigned offers, each answeredPK_OK, and never reads. The connection ends at its 2 s deadline and gives its place back.a_write_to_a_slow_reader_ends_at_the_deadline: a sink that takes one byte a send, each send making progress, is given up at the deadline.a_program_ended_is_reaped.stream_handlesgains two parts.r1/guest/temporary-qemu-row.patch, not committed): a discovered binary otherwise runs only in the T14's shared boot, where it stays registered.toyos-ssh(OpenSSH_10.3p1). Each negotiatedmlkem768x25519-sha256and replays byte for byte, also cut at every byte and at every 61st:ssh -ttwith a shell;sftp -bwith a put, anls -l, a get compared with what was put, and anrmthe in-memory server refuses;ssh -Mconnection carrying acatheld open while anechoran on a second channel.r1/e2e/), at the round-1 code: the host build of sshserver on port 22, OpenSSH as a black-box client, 17 steps, all as intended:restrictkey refused;r2/controls/): round 1's single timeout overwrite_allfails the slow-reader test, writing 200 bytes over 5.4 s against a 200 ms deadline.r1/controls/):controls/):Each fails its named test.
Risks
authorized_keysignores the user name, as before: every login runs as the daemon.issues/sshserver-reports-a-killed-program-as-exit-lost.mdstays open:toyos-sshsends noexit-signal.🤖 Generated with Claude Code
https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q