Public Access
8e61fc72b4a75a3254e52963439642a23de4c358
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e2733e5cbc |
Notice when a shell ends, say which way it ended, and offer the way back
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.
◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.
ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.
That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:
- Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
nobody writes to, and TCP has no way to discover that the far end has gone
without writing to it — nothing was asked, so nothing could fail. Thirty
seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
so this is traffic rather than a heartbeat with a deadline, and there is
no timeout invented here to be wrong about a slow link.
- Disposing the stream from the error handler makes it ARRIVE. Queued to the
thread pool and not done inline, which is not tidiness: this runs on
SSH.NET's message-listener thread, and closing a channel waits for the
server's close — processed by that same thread — so an inline dispose is
the listener thread waiting on itself.
SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.
◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.
Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.
So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.
Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.
◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.
The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.
Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.
RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.
Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.
TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.
◆ WHAT IS ACTUALLY VERIFIED.
Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.
The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.
What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.
Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.
Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
|
||
|
|
e252d337d1 |
Take the SSH.NET release that fixes the SCP path traversal
Every project in the repository stopped building, on a warning about a package
none of them had changed:
error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high
severity vulnerability
GHSA-q939-rpr3-3284 was published after the pin was written. ScpClient.Download
in recursive mode takes the filenames the server sends and joins them to the
local destination without validating them, so a malicious or compromised SCP
server can name "../" its way out of the download directory and write anywhere
the client process can — CWE-22, 7.1, and the same shape as OpenSSH's
CVE-2019-6111 with the traversal left in. Everything up to and including
2025.1.0 is affected; 2026.0.0 is the fix.
◆ THIS APPLICATION WAS NEVER EXPOSED, AND THE UPGRADE IS STILL THE RIGHT ANSWER.
Nothing here constructs a ScpClient. File transfer is SftpClient throughout —
SshNetSftpSession is the only path to a remote file — so the vulnerable method
has no caller to reach it from. That makes this a hygiene bump rather than an
incident, and it is worth saying plainly because the alternative on offer was a
scoped NuGetAuditSuppress with that reasoning written beside it. A suppression is
what you reach for when there is nothing to upgrade TO. There is: the maintainers
shipped the fix, taking it costs two nullable annotations, and a suppression
would have left this repository carrying a known-vulnerable library and a comment
explaining why that is fine — which stays true only until somebody adds the first
ScpClient call and has no reason to look here.
BouncyCastle.Cryptography moves 2.6.2 -> 2.7.0 as a consequence, not a
preference. CentralPackageTransitivePinningEnabled makes the PackageVersion here
the resolved version for a transitive dependency, and SSH.NET 2026.0.0 asks for
2.7.0, so a pin left at 2.6.2 is a downgrade error rather than a version this
repository gets to choose. Its comment records the constraint so the next person
does not try to walk it back.
◆ THE RELEASE NOTES SAY NO BREAKING CHANGES. THE NULLABLE ANNOTATIONS DISAGREE.
ConnectionInfo.CurrentServerEncryption is now string? — correct of them, since it
really is null until the key exchange completes — and that is CS8601 at both
places this codebase reads a cipher off a live connection. Neither can observe
the null: SshNetConnection and SshNetSftpSession are only ever constructed after
ConnectAsync has been awaited, which is what their existing remarks already say
and why the reads are at construction rather than lazy.
Coalesced to string.Empty rather than silenced with !, because empty is already
this codebase's word for a cipher that could not be read. MainWindowViewModel's
NullIfEmpty exists for exactly that, TerminalWorkspace.GetSessionFacts already
hands over string.Empty on the same grounds, and the status bar collapses on it.
So the boundary keeps the interface's non-nullable string, no consumer changes,
and an unreachable null would degrade the way a real one already does.
SshNetConnection's remark asserted that SSH.NET types the property non-nullable
and that reading straight through was therefore honest rather than lazy. That
sentence is now false, and a comment contradicting the line beneath it is worse
than no comment, so it says what is actually true: the coalesce is there for the
annotation and the pre-key-exchange window this call site cannot be in.
The Android head needed its own restore. It is outside DodoSSH.slnx, so the
solution-wide --force-evaluate does not reach it, and left alone it would have
kept the vulnerable pin and gone stale under locked mode — the hazard
platform-flags.md records, arriving on schedule.
Heeding the warning left by the ILLink commit: --force-evaluate on Windows
rewrote all 39 lock files with CRLF, and 21 of them had no content change. Only
the 18 that really moved are here.
Verified with `dotnet build` at the root, no audit-disabling flags: 0 errors,
NU1903 gone, and the 21 remaining warnings are the pre-existing Meziantou/CA and
Avalonia ones that were present in the failing build too.
A library upgrade is not a version string, so the suites that exercise it were
run against live containers rather than trusted: 87 Ssh, 80 Terminal, 27
ObjectStore, 10 Transfer, none failing.
|
||
|
|
8a77b7ca68 |
Say how far a connection has got while it is still being made
The connecting card set its status string once, when the tab was created, and never touched it again. Every connection therefore looked identical from the outside: one three seconds into a key exchange, one waiting out a fifteen-second timeout against a machine that is asleep, and one that had hung all drew the same "connecting…". The card now draws the five steps of getting there, each lit at the moment the handshake reports reaching it, over an amber track that fills as they finish. ◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below it said so, at the instant the thing it names actually began. That is the whole reason it is worth showing, and it is why most of this commit is plumbing rather than XAML: there was no progress reporting anywhere in the stack to hook a step list onto, and a card animating plausible progress would have been indistinguishable from one that had stopped receiving any. SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the entire handshake inside one ConnectAsync and raises exactly one event from the middle of it — HostKeyReceived, once the key exchange has produced a key to show — so that event is the only interior moment there is to report. Everything before it is Reaching and everything after it is Authenticating. A fifth phase in that assembly would have to be a timer, so there is not one. OpeningShell is reported by TerminalWorkspace instead, because that is where it happens: the factory's work ends with an authenticated connection, and asking for a pseudo-terminal on one is a separate round trip. The SFTP path passes null — a second connection opened behind an already-open shell has nobody watching a step list for it. The card's fifth step, "Starting the terminal", is the renderer wait and lives in the shell rather than in the SSH assembly, which has never heard of a renderer. On the first connection after a cold start it is a real wait with a real failure mode of its own — a missing WebView2 runtime — so a list that began at "reaching the host" would leave the one wait most likely to hang unnamed. Amber for the step in flight, and that follows the palette's rule rather than bending it. Green is what is true and purple is what you can press; a step still happening is neither, and it is exactly the caveat-worth-reading that amber exists for. Steps behind it go green as they become true. Nothing animates, which is the argument TransfersScreen.axaml already makes for its own track, reaching a screen with far more reason to want a spinner: a spinner is furniture invented to fill a state nobody measured, and these states are measured, so the track fills to what has finished and then waits there. A refusal keeps the step it stopped on, in red, with the ones behind it still green. That is the half a progress bar could not do, and it is the difference between "that host is not there" and "that host is there and would not have me" — a question the reason sentence alone frequently does not settle. The strip's dot goes amber while a tab is connecting, on both heads. It was grey, and so is a tab whose shell has exited: the two states in that strip with the least in common, one worth waiting for and one over. PhoneShell's own comment already recorded half of this — the dot stopped being green before anything had answered — and this is the other half. Progress is raised inline rather than through System.Progress<T>, which captures whatever synchronisation context it was constructed on and posts to it. That reads like a convenience and is really a second place the marshalling decision gets made: silently, differently under a test with no context, and out of order with respect to the failure that follows a phase. The shell marshals once, in one handler, through a new optional post parameter on MainWindowViewModel — the same seam TransfersViewModel already uses, and for the reason its own remark gives. The three Dispatcher.UIThread.Post calls that predate it are the ones this suite's comments record as out of reach; they are left alone rather than swept in here. Both heads draw the list. They differ in one place: Phone.axaml's mono class sets a colour and a size along with the family, so the caption rule names its own family instead of composing the two and asking two rules for one Foreground. The desktop's mono sets the family alone, which is why ConnectingCard does compose them. Each head also gains SHOW LOGS beside the button that gives up — the step list is this attempt and the log is every other one, which is what a connection taking too long actually raises. Seven tests, and the two that matter most run against the container rather than a fake: a real handshake reports its phases in order, and a host-key refusal never claims to have authenticated. A fake asserting what it was written to assert would have established nothing about either. The rest cover the tab advancing while the connection is gated, the step a refusal stops on, and a phase reported after the user has given up on the tab. 1,861 tests, none failing. The Android head's layout is not verified by anything. It compiles, and compiled bindings mean every new binding path resolves, but that project is not in DodoSSH.slnx, there is no test project for it and no device here — so unlike the desktop card, whose shapes the layout harness measures, these rows have not been drawn. Vertical fit is reasoned, not observed. |
||
|
|
8209f15741 |
Let a session's transport say what it negotiated
ISshConnection and ISftpSession both carry Cipher now — the server-to-client algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is not an event that library raises — and TerminalWorkspace.GetSessionFacts hands that plus the host key's algorithm back per session, without ever handing over the connection itself. Nothing reads either yet; the status bar that will is the next commit. |
||
|
|
82966af37b |
Let a connection be reached through a proxy on this machine's loopback
Step 1 of docs/reaching-a-host-you-cannot-dial.md, and it is not the step that document said it was. SshConnectionRequest carries an optional SshLoopbackProxy and BuildConnectionInfo hands SSH.NET its proxy ConnectionInfo when there is one. Nothing passes one yet: the callers are jump hosts and the relay, which are steps 2 and 3. ◆ THE BRIDGE WAS THE WRONG FIRST STEP, AND BUILDING IT WOULD HAVE BEEN THE MISTAKE THIS DOCUMENT IS ABOUT. ADR 0004 says the relay's loopback bridge "also provides ProxyJump via a SOCKS5 dynamic forward — one mechanism, two features", and the plan took that to mean the bridge was the shared foundation. It is not: ForwardedPortDynamic *is* the listener for a jump host — SSH.NET accepts on it, speaks SOCKS5 on it and tunnels through the bastion — so nothing is left for a bridge of ours to do on that path. The relay is the case with no SshClient to hang a forward off, so it is the bridge's only consumer, and the bridge belongs in the commit that uses it. What the two actually share is one level down and a tenth of the size: being told to reach a target through a loopback proxy while staying about the target. That is what this is. Three properties, one test each. A port and nothing else, so a proxy anywhere but loopback cannot be expressed. The failure that shape rules out is an open SOCKS proxy on the user's network for the life of a shell, which nothing would report — so it is made unrepresentable rather than validated, on the same grounds AuthenticationChoice carries a kind. SOCKS5 rather than a dumb pipe, which is what keeps host key pinning honest. The target's own name and port stay in the request, travel to the proxy in the CONNECT, and are what the gate pins — so a machine reached through a bastion is pinned under its own name instead of under 127.0.0.1 on whatever ephemeral port that day's forward got, which is not an identity at all. A pipe would have meant handing SSH.NET a stand-in and remembering everywhere else that it was one. And a proxy that is not listening fails as a connection error rather than as an unknown host key. The gate turns "no host key seen" into a fingerprint prompt, and a connection that never reached a server has seen none either; the prompt would offer to fix the wrong thing, with no fingerprint to show. TWO THINGS THE TESTS MEASURED RATHER THAN ASSUMED, both found by the first run failing. The target is resolved at the *bastion*, not here — a SOCKS CONNECT names it and the far end looks it up. So the test asks for localhost:2222, the address inside the container, and the published port this host would use means nothing there. That is not a quirk of the fixture; it is what ProxyJump means, and it is why an ssh_config writes the target's internal address beside its jump host. Getting it wrong is a SOCKS "general failure" that names neither end. And the test server refuses forwarding. linuxserver/openssh-server ships AllowTcpForwarding no, which a dynamic forward does not notice — opening one asks the server nothing — so every connection through it is refused at channel-open and reported as the same general failure. The fixture patches it and HUPs sshd. There are two sshd_config files in that image and the running server uses /config/sshd/sshd_config; the first attempt patched /etc/ssh/sshd_config, which is the one a search finds first, changed the text and nothing else, and left the failure exactly where it was. VERIFIED. Build clean with no new warnings, 85 tests in Client.Ssh.Tests against the real sshd, and the solution builds. The proxy test was seen to fail — proxy.Port + 1 in BuildConnectionInfo — and seen green again. An earlier mutation attempt did not compile, and the log said 85 passing because the run never started and the previous log was still on disk; the second attempt deletes the log first, which is worth copying whenever a mutation "passes". dotnet format reports one pre-existing IDE1006 in DodoSSH.Api/Features/Events/EventsEndpoint.cs, in a project nothing here touches. Left alone. |
||
|
|
04faef6597 |
Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a transfer queue in a project of its own, and the two-pane browser the design asked for replacing the screen that said it did not exist. Remote listings carry names, sizes, modification times and a real drwxr-xr-x — nothing in this repository could render a POSIX mode before — and the queue moves one file at a time with progress, throughput and resume. The design import assumed this would be an SFTP subsystem channel on ISshConnection, beside the shell on a transport that is already up. SSH.NET does not offer that: SftpClient derives from BaseClient and owns its own transport, and there is no supported way to hand it an SshClient's session. So file transfer opens a second authenticated connection, and it is named for that rather than dressed up as a channel — OpenSftpAsync is on ISftpSessionFactory, not on a connection. The difference is visible to a user: the host records a second login, and a host whose password is typed each time asks for it again on this screen. It goes through the same host key gate, the same pin and the same two refusals a shell does, so a fingerprint approved for a terminal is approved here and one approved here reaches the other machines with the next sync. docs/design-import-gaps.md is corrected, and marked as the one row where what shipped differs from what it predicted. Nothing is written at its final name until it is complete. Every transfer goes to a .dodossh-part file beside its destination and is renamed into place at the end, so an interrupted transfer can never be mistaken for a finished one — which matters most for what this screen is actually for, which is copying a build artefact onto a server and then running it. A destination that already exists is refused outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody's process is serving is the worse of the two failures. The remote pane has DELETE and MKDIR so that refusal is not a dead end. A test against the container pins the assumption underneath all of this — that SFTP's rename does not clobber. Resume works within a run of the application and not across a restart, and the limit is deliberate rather than unfinished. Nothing records which source wrote a part file, and resuming one on the strength of its name matching is how a corrupt artefact gets delivered with nothing reporting a failure; a part file found at startup is started over. Making it survive a restart needs the preferences store this client still has not got. The offset a resume starts at is the part file's own length rather than the transfer's recorded progress: a cancellation can land between a write completing and the counter moving, and only one of those two is a fact about the bytes that are there. The queue and its connection outlive a lock, as shells do. LockAsync already argues that locking must not destroy work in flight — it is what somebody does when they walk away from the machine, which is exactly when a long transfer is most likely to be running — so TransfersViewModel is created once and the vault is attached on unlock and detached on lock. What locking takes is the host list, and it has to: those rows carry decrypted secrets. DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The two answer different questions — one is about reaching a host, the other about moving bytes and what to do when moving them stops halfway — and this is the only client project that deliberately touches the local filesystem. Three defects the tests found, none of which review would have. SftpPath.Name answered an empty string for the root. NavigateRemoteAsync wrapped itself in the busy guard, so navigating from inside another command did nothing at all and the remote pane simply stayed empty after connecting, with no failure anywhere to explain it. And opening an SFTP session per test made two handshakes per test — this client learns a host key by being refused — which pushed the SSH assembly past sshd's MaxStartups and failed a different few unrelated tests each run; the session is shared through the fixture now, with the reason written where the next person will hit it. 1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against the OpenSSH container, the queue against a real temporary directory and a fake host, and three more layout measurements because a screen this window has never laid out is a screen never checked. Not verified: the screen has not been looked at running. The layout harness measures it at the window's minimum in three shapes, which is the class of defect that has shipped here before, but reaching it in the application needs the compose stack, the migrations, the API and a browser sign-in. What is still absent — the status bar's transfer count, dragging between the panes, transferring a directory, and sftp over a bastion — is in docs/design-import-gaps.md. |
||
|
|
5fccd53824 |
Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in a container through a real PTY, the real pump, the real loopback WebSocket with its token and origin checks, and a ClientWebSocket standing in for the page: the login banner arrives, typed input round-trips, and `stty size` reports the 100x30 the session asked for. The only untested link left is xterm drawing bytes it was handed. The WebView is de-risked on Windows, which was the plan's largest risk. Not by assertion: with the app running there is an established TCP connection from msedgewebview2 to the data plane port, so WebView2 launched, navigated to the loopback page, executed terminal.js, and completed the WebSocket handshake against the real token and origin checks. Linux remains unproven and the package's own release notes now corroborate the concern -- Linux uses a WPE backend, and it ships a NativeWebDialog described as useful where embedded WebViews may be unavailable. Two bugs found by building it, both of which would have shipped: - ShellStream.Write buffers and needs an explicit Flush. Without one a keystroke is accepted, reported as written, and never reaches the remote: the terminal displays output perfectly and simply stops responding to input. SSH.NET's own WriteLine flushes, which is why the earlier spike never hit it. Found by isolating the pump against real SSH and reading BytesRead=51 -- banner and prompt through, nothing after. - The Windows app manifest needs a supportedOS list, or Avalonia's native control host fails outright and the terminal never starts. Also fixed a genuinely flaky test I happened to catch: SyncCursorTests tampered with the *last* base64url character, whose low bits the decoder ignores when the input length is not a multiple of three -- so a tampered cursor sometimes decoded to identical bytes and verified. It failed roughly one run in thirty, depending on a random key. Now tampers the penultimate character, which is fully significant at every length; 40 consecutive runs are clean. xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather than built with npm, so a clean clone needs only the .NET SDK. Provenance and licences are recorded next to them, along with the UMD global names terminal.js depends on -- a bundle that switched to ES modules would load without error and leave Terminal undefined. The renderer acknowledges output from term.write's completion callback, not on receipt. Acknowledging early would return flow-control credit for bytes the screen has not caught up with, which is the one thing the credit window exists to measure. TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia dependency, and having it there is what let the end-to-end test exist at all. 404 tests pass, zero warnings on a clean rebuild, format clean. |
||
|
|
eb354bcdd9 |
Add the SSH session layer and the terminal data plane
The throughput harness the plan requires before any UI, plus the SSH plumbing under it. 94 new tests, no WebView involved. Credit-based flow control is what makes `yes` survivable. A terminal renders at 60 Hz at best while a remote produces output as fast as the network allows, and the difference has to accumulate somewhere or be refused. Credit is reserved *before* reading, never after: because the pump cannot read more than the renderer has room for, the coalescing buffer is bounded by the window rather than by how fast the remote can talk. When credit runs out the pump stops reading, SSH's own receive window closes, and the remote sshd blocks -- backpressure to the source with no custom protocol. Verified by falsification, not just by passing: with the credit gate removed three tests fail, including the throughput harness's bounded-memory assertion. Acknowledgements are clamped because they cross into JavaScript, where a buggy or hostile page could otherwise claim to have rendered a gigabyte and talk the host into an unbounded read. Host key trust is enforced by *failing* the connection rather than prompting inside the handshake. SSH.NET raises verification synchronously, so consulting the user there would block the handshake on a UI round trip and deadlock the first time the prompt needed the UI thread. Unknown host and changed key become distinct exceptions the caller resolves asynchronously. A mismatch has no retry path at all: a dialog offering to continue is how users are trained to click through the one warning that actually indicates interception. A legitimately rebuilt server is handled by removing the pin in settings, away from the moment of connecting. The data plane serves the renderer page from the same loopback listener as the socket, which makes Origin predictable -- always http://127.0.0.1:{port} -- where a WebView virtual-host mapping would give a different origin per backend and nothing to validate. The token is substituted at serve time, so it never touches disk and never appears in a URL. Being clear about what that buys: not protection from a process running as this user, which can read our memory anyway, but from a page in the user's browser attempting WebSocket connections to loopback ports, which is a real and routine thing. Two bugs the tests caught. The accept loop handled connections serially, so an upgraded WebSocket parked it inside the receive loop and every later request went unanswered -- the page's own script among them. The suite hung rather than failed, which is how I found it. And SHA-1 is unavoidable here: RFC 6455 mandates it for Sec-WebSocket-Accept, where it authenticates nothing. Suppressed narrowly with that reasoning; the alternative, HttpListener.AcceptWebSocketAsync, throws PlatformNotSupportedException off Windows. |