Public Access
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20f7cae04 | ||
|
|
54bc142dce | ||
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 |
No files matched your search
@@ -29,6 +29,18 @@ jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: [linux]
|
||||
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||
#
|
||||
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||
# already passed.
|
||||
#
|
||||
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||
# has to go and stop.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||
# runner's image has none — the run died on the first line of actions/checkout with
|
||||
@@ -220,7 +232,12 @@ jobs:
|
||||
- name: build
|
||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||
|
||||
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||
- name: test
|
||||
timeout-minutes: 10
|
||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||
|
||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||
@@ -556,6 +573,8 @@ jobs:
|
||||
exit 0
|
||||
android:
|
||||
name: android head
|
||||
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||
timeout-minutes: 40
|
||||
runs-on: [linux]
|
||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||
@@ -915,7 +934,10 @@ jobs:
|
||||
# never made.
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $names; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -926,6 +948,8 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: desktop nightly
|
||||
# See the build job. Two publishes, a pack and an upload of about 120 MB.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||
# commit whose suite was red.
|
||||
@@ -1226,7 +1250,10 @@ jobs:
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||
|
||||
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -1279,7 +1306,8 @@ jobs:
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $linuxNames; do
|
||||
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
|
||||
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$withRpm" >&2
|
||||
exit 1
|
||||
@@ -1290,6 +1318,8 @@ jobs:
|
||||
|
||||
image:
|
||||
name: api image
|
||||
# See the build job. A cold layer cache is the slow case here.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||
|
||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
||||
*.pfx
|
||||
*.p12
|
||||
*.snk
|
||||
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
|
||||
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
|
||||
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
|
||||
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
|
||||
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
|
||||
*.keystore
|
||||
*.jks
|
||||
!build/nightly.keystore
|
||||
appsettings.Local.json
|
||||
appsettings.*.Local.json
|
||||
.env
|
||||
.env.*
|
||||
!deploy/.env.example
|
||||
secrets/
|
||||
@@ -873,6 +873,13 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
||||
are consequences rather than choices.
|
||||
|
||||
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
|
||||
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
|
||||
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
|
||||
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
|
||||
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
|
||||
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
|
||||
|
||||
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
|
||||
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
|
||||
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# Development dependencies only: PostgreSQL and Keycloak.
|
||||
#
|
||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
||||
# schema is still churning. The production stack is deploy/docker-compose.yml.
|
||||
# schema is still churning.
|
||||
#
|
||||
# There is no production compose file in this repository. This one said there was, naming a
|
||||
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
|
||||
# container image the CI image job builds and pushes; a compose file to run it belongs to the
|
||||
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
|
||||
#
|
||||
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||
# dotnet run --project src/DodoSSH.Api
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||
},
|
||||
"redirectUris": [
|
||||
"http://127.0.0.1/callback"
|
||||
"http://127.0.0.1/callback",
|
||||
"dev.dodotech.dodossh:/callback"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"protocolMappers": [
|
||||
|
||||
@@ -99,3 +99,22 @@ real ones.
|
||||
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
|
||||
proxy into the operator's network.
|
||||
- **Allow-listing by hostname.** Defeated by DNS rebinding.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
|
||||
`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
|
||||
|
||||
- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
|
||||
writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
|
||||
the origin — and the bound is tighter: two 32 KiB buffers per session.
|
||||
- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
|
||||
credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
|
||||
client ever needs it; the scrubbing requirement stands.
|
||||
- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
|
||||
nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
|
||||
honouring one would mean taking the port from the client.
|
||||
|
||||
The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
|
||||
listener, so "one mechanism, two features" is true one level down: both features reach their target through
|
||||
a loopback SOCKS5 proxy named on the connection request.
|
||||
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | v5 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
|
||||
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. |
|
||||
> | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
|
||||
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
||||
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
||||
|
||||
@@ -202,6 +202,44 @@ That is what the "is this still the page's socket" guard in `connect()` exists t
|
||||
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
|
||||
currently see — see the note at the end of 11.12a.
|
||||
|
||||
### 1.10a A remote that floods the terminal does not grow the process · **watch Task Manager**
|
||||
|
||||
Open a shell and run `yes dodossh`. Leave it running for a full minute with the tab in front of you, then
|
||||
switch to another screen and leave it another minute, then come back. Watch the application's memory in
|
||||
Task Manager (Details tab, *Memory (active private working set)*) the whole time.
|
||||
|
||||
**Pass:** memory settles at a plateau and stays there — the number moves as the GC breathes, but there is no
|
||||
staircase. Ctrl+C, and the prompt comes back straight away. Somewhere in the scrollback there may be a dim
|
||||
grey line reading `── N MB of output was dropped; the terminal could not keep up ──`; that line is a pass,
|
||||
not a failure. It is this client saying it discarded output it could not draw, which is what keeps the
|
||||
plateau flat, and the number after it tells you how far behind the display got.
|
||||
|
||||
**Failure means:** a working set that climbs steadily for the whole minute and does not come back down after
|
||||
Ctrl+C is the defect this check exists for, and it will not be in this repository's own buffers — it is
|
||||
SSH.NET's. `ShellStream` copies everything the channel delivers into a buffer that doubles and is never
|
||||
given back, and the channel keeps delivering because `Channel.OnData` re-opens the SSH receive window as
|
||||
bytes *arrive* rather than as they are consumed. Measured against the container in
|
||||
`DodoSSH.Client.Ssh.Tests`, a reader that stops entirely leaves that buffer holding 44 MB after five
|
||||
seconds. The only thing that keeps it empty is `TerminalSessionPump` reading continuously and bounding what
|
||||
it holds in `OutputBacklog` instead — so a staircase here means something has put a wait back into that read
|
||||
loop. `ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog` is the test that fails first.
|
||||
|
||||
### 1.10b A tab whose shell exited stops costing anything · **needs a debugger or `netstat`**
|
||||
|
||||
Open a shell, type `exit`, and **leave the tab open**. Note the host and port. Then, from a terminal outside
|
||||
the application, run `netstat -ano | findstr <port>` and look for the connection to that host.
|
||||
|
||||
**Pass:** the connection is gone within a second or two of the shell exiting, even though the tab is still on
|
||||
screen showing the session ended and still offering RECONNECT. Clicking that reconnect still works, and
|
||||
closing the tab afterwards still works.
|
||||
|
||||
**Failure means:** a connection that is still ESTABLISHED for as long as the tab is open is the session
|
||||
holding an authenticated socket it has no further use for — with a keep-alive putting a packet on the wire
|
||||
every thirty seconds and SSH.NET's read buffer still at whatever size the session's busiest moment grew it
|
||||
to. It used to do exactly that, until whichever tab it was got closed. `TerminalWorkspace.ReleaseAsync` is
|
||||
what gives it back now, and `ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab` is
|
||||
the test that says so.
|
||||
|
||||
### 1.11 The right button copies and pastes, and never opens a menu
|
||||
|
||||
Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the
|
||||
@@ -252,6 +290,38 @@ registered in the capture phase, that no native menu is drawn once it is prevent
|
||||
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
|
||||
when neither is there, because there is no status line on this screen to say it on.
|
||||
|
||||
### 1.12 Something is on screen while it starts
|
||||
|
||||
Close DodoSSH and start it again. Watch the first second.
|
||||
|
||||
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*,
|
||||
and is then replaced by the main window. Measured on a debug build against a cold
|
||||
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
|
||||
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
|
||||
|
||||
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
|
||||
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
|
||||
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
|
||||
over whatever you gave up and went back to.
|
||||
|
||||
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
|
||||
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
|
||||
start the application.
|
||||
|
||||
**Pass:** the panel stays and the line becomes *DodoSSH could not start:* followed by the reason and
|
||||
*Press Escape to close.* Escape closes it. Remove the directory afterwards.
|
||||
|
||||
There is deliberately nothing else on the card to contradict that line — the progress bar this check used to
|
||||
describe was taken off in "Take the frozen bar off the splash, and leave the line that is true", because an
|
||||
indeterminate bar over a composition that had stopped was the one thing a splash must never do.
|
||||
|
||||
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
|
||||
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
|
||||
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
|
||||
closing the only open window ends the process, so the real window is shown while the panel is still up.
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -1810,6 +1880,27 @@ bottom and no scroll position left part way down.
|
||||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||||
event, or the margin is only ever added to.
|
||||
|
||||
### 10.4a Nothing is stranded when the keyboard is still up on the way *out* · **the one 10.4 missed**
|
||||
|
||||
Focus any box, and with the keyboard still on screen leave the application — Home, or the task switcher,
|
||||
or anything that puts another app in front. Come back to it.
|
||||
|
||||
**Pass:** the interface fills the screen. Do it from the terminal too, where the keypad and the shell should
|
||||
both be where they were.
|
||||
|
||||
**Failure means:** `PhoneShell` is not re-reading the inset on the way back in. This is a separate path from
|
||||
10.4 and it is the one that has actually shipped broken: Android takes the keyboard away when the activity
|
||||
stops and Avalonia's input pane raises no `StateChanged` for it, because the pane goes with the window
|
||||
rather than closing in front of one. So the last value applied was for a keyboard that was up, and the
|
||||
application returns holding a whole keyboard's height of empty canvas — measured at 838 of 2340 pixels on a
|
||||
Pixel 5, on every screen, and staying that way until something opens and closes the keyboard. See
|
||||
`PhoneShell.OnActivityResumed` and `PhoneEnvironment.Resumed`.
|
||||
|
||||
Worth doing in both directions: a resume that *should* keep the inset is the other half. Trigger a
|
||||
permission prompt or the biometric sheet with the keyboard up, dismiss it, and check the box being typed
|
||||
into is still clear of a keyboard that is genuinely still there — a fix that flattened the inset on every
|
||||
resume would pass the paragraph above and fail this one.
|
||||
|
||||
### 10.5 Rotating with the keyboard up
|
||||
|
||||
Focus a passphrase box, then turn the phone sideways.
|
||||
@@ -2060,6 +2151,25 @@ cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-te
|
||||
Worth pressing Enter on an empty prompt a few times too: each one should give a fresh prompt and nothing
|
||||
else. A doubled prompt per press means a line ending is arriving as both CR and LF.
|
||||
|
||||
### 11.10f Every key on the keypad is there, and pressing one presses it
|
||||
|
||||
With a shell open, look at the two rows above the keyboard without touching anything: **esc tab ctrl | - ~ /**
|
||||
on the upper row and **← ↓ ↑ →** with **A−** and **A+** on the lower one. All thirteen, at once, on a 360dp
|
||||
phone in portrait.
|
||||
|
||||
Then press them with a thumb rather than a fingertip, at an angle, twenty or so times across both rows —
|
||||
including the keys at the far left and far right ends.
|
||||
|
||||
**Pass:** every press registers, the key darkens under the thumb while it is down, and **nothing slides
|
||||
sideways**. The row is exactly where it was after twenty presses as it was before the first.
|
||||
|
||||
**Failure means:** a key that misses is a target that has got smaller — `Button.hotkey` is 40 tall for a
|
||||
reason and Android asks for 48. A row that *moves* is the one that matters: it means something has put a
|
||||
`ScrollViewer` back around the keys. That is how this row shipped originally and it does not survive contact
|
||||
with a thumb — a pan and a press begin with the same few pixels of travel, the scroller claims them, and the
|
||||
press is never delivered. If the set of keys outgrows the width, add a third row; do not make it scroll.
|
||||
See the keypad's own remark in `TerminalScreen.axaml`.
|
||||
|
||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||
|
||||
Open a shell, close its tab, then open a different one from HOSTS.
|
||||
@@ -2850,6 +2960,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
|
||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||
rather than as a bug in the nightly.
|
||||
|
||||
### 16.11 One instance, and what a second launch does instead
|
||||
|
||||
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
|
||||
times: with the window in front, with it minimised, and with it behind another application.
|
||||
|
||||
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
|
||||
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
|
||||
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
|
||||
something to defeat. Nothing happening at all is the failure.
|
||||
|
||||
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
|
||||
both builds installed, both started.
|
||||
|
||||
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
|
||||
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
|
||||
other direction and needs no check of its own.
|
||||
|
||||
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
|
||||
|
||||
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
|
||||
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
|
||||
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
|
||||
lock is held by something that does not answer its pipe, which is what covers it.
|
||||
|
||||
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
|
||||
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
|
||||
problem would be the wrong trade — so check that the profile exists, is writable, and has an
|
||||
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
|
||||
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
|
||||
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
|
||||
the opposite failure — the outgoing process never released the lock, which means something is holding it
|
||||
open past `Shutdown`.
|
||||
|
||||
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
|
||||
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
|
||||
second *executable* and a real window coming forward.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Installing and updating the phone
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
# Reaching a host you cannot dial
|
||||
|
||||
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
|
||||
neither of them works.
|
||||
one of them works.
|
||||
|
||||
- The **relay** pipes raw TCP through the deployment. The server half is built and shipped; the client
|
||||
half does not exist, and both heads offer a checkbox that promises it.
|
||||
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
|
||||
pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
|
||||
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
|
||||
stores the chain; nothing writes it and nothing reads it.
|
||||
|
||||
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
|
||||
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
|
||||
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
|
||||
|
||||
This document is the comparison between them, which is the thing that has to be settled before either is
|
||||
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
|
||||
jump chain — see the last section for why that was wrong.
|
||||
|
||||
> **Status: step 0 done, the rest planned.**
|
||||
> **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
|
||||
>
|
||||
> | Step | State | Notes |
|
||||
> | --- | --- | --- |
|
||||
> | 0. Stop promising the relay | **Done** | Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it |
|
||||
> | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
|
||||
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
|
||||
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
|
||||
> | 3. The relay: the bridge, then the ticket | Not started | The bridge's only consumer, so it lands with the feature that uses it |
|
||||
> | 4. File transfer parity | Not started | The transfers screen opens its own connection. Already half-done — `OpenSftpAsync` shares `BuildConnectionInfo`, so it honours a proxy today; what is missing is anything passing it one |
|
||||
> | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
|
||||
> | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
|
||||
|
||||
## ◆ The relay's checkbox is a false promise, and that is a defect
|
||||
|
||||
@@ -171,7 +175,64 @@ the sentence step 0 added comes out of both heads.
|
||||
|
||||
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
|
||||
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
|
||||
work exists for.
|
||||
work exists for. ✅ for the relay.
|
||||
|
||||
## What step 3 built
|
||||
|
||||
**The server**, in `DodoSSH.Api/Features/Relay`:
|
||||
|
||||
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
|
||||
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
|
||||
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
|
||||
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
|
||||
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
|
||||
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
|
||||
reason is logged (event 2301).
|
||||
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
|
||||
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
|
||||
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
|
||||
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
|
||||
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
|
||||
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
|
||||
|
||||
**The client:**
|
||||
|
||||
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
|
||||
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
|
||||
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
|
||||
failed connect disposes it before throwing.
|
||||
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
|
||||
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
|
||||
is; a server without the feature supplies `UnavailableServerRelay`.
|
||||
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
|
||||
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
|
||||
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
|
||||
reachability probe is skipped for a relayed host, because it would test the direct path.
|
||||
|
||||
### ◆ Two things that are not what ADR 0004 wrote
|
||||
|
||||
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
|
||||
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
|
||||
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
|
||||
rather than ~2 MiB.
|
||||
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
|
||||
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
|
||||
URL — that access logs keep. A device is never visible to the server on an ordinary request.
|
||||
|
||||
### ◆ A WebSocket operation is never cancelled, on either side
|
||||
|
||||
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
|
||||
frame. The close frame is the only way the client learns that the server could not reach the host, so both
|
||||
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
|
||||
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
|
||||
the socket ended.
|
||||
|
||||
### What a developer meets
|
||||
|
||||
The relay refuses loopback with no configuration escape, so the development stack's sshd on
|
||||
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
|
||||
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
|
||||
network.
|
||||
|
||||
## Traps already known
|
||||
|
||||
@@ -226,8 +287,8 @@ is right, sharing a lifetime policy is not.
|
||||
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
|
||||
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
|
||||
remark above it.
|
||||
- `README.md` and `docs/android-port.md` wherever the relay is described as available.
|
||||
- ADR 0004 gains a note that its last consequence was built, and how.
|
||||
- `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
|
||||
- ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
|
||||
|
||||
## What the first draft of this document got wrong
|
||||
|
||||
|
||||
+24
-5
@@ -223,6 +223,25 @@ echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||
#
|
||||
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
||||
# that happens are visible in an exit code. Each check below is one of them.
|
||||
#
|
||||
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
|
||||
#
|
||||
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
|
||||
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
|
||||
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
|
||||
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
|
||||
# to `exit 1` kills the script under `set -e` before it gets there.
|
||||
#
|
||||
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
|
||||
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
|
||||
# is one line per packaged file, so this passed for as long as the published tree was small and then
|
||||
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
|
||||
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
|
||||
#
|
||||
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
|
||||
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
|
||||
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
|
||||
# real failure of the writer alongside the imaginary one.
|
||||
|
||||
requires="$(rpm -qp --requires "$rpm_file")"
|
||||
provides="$(rpm -qp --provides "$rpm_file")"
|
||||
@@ -236,7 +255,7 @@ files="$(rpm -qp --list "$rpm_file")"
|
||||
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||
unsatisfiable=""
|
||||
for soname in $carried; do
|
||||
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
||||
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
|
||||
unsatisfiable="$unsatisfiable $soname"
|
||||
fi
|
||||
done
|
||||
@@ -251,7 +270,7 @@ fi
|
||||
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
||||
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
||||
# the macro would break it silently and in the direction nobody looks.
|
||||
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
||||
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
|
||||
echo "This package advertises bundled libraries to the system:" >&2
|
||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
||||
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
||||
@@ -262,7 +281,7 @@ fi
|
||||
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
||||
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
||||
# self-contained .NET tree cannot fail to have.
|
||||
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
||||
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
|
||||
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
||||
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||
printf '%s\n' "$requires" >&2
|
||||
@@ -279,9 +298,9 @@ for expected in \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
do
|
||||
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
||||
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
|
||||
echo "The package was built and $expected is not in it." >&2
|
||||
printf '%s\n' "$files" | head -40 >&2
|
||||
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
+60
-10
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=$storePlain `
|
||||
-p:AndroidSigningKeyPass=$keyPlain
|
||||
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||
#
|
||||
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||
# every argument of every other with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||
# comment above says the passwords are never stored, and with the arguments written that way it was
|
||||
# true of the disk and false of everything watching the machine.
|
||||
#
|
||||
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
|
||||
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
|
||||
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
|
||||
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
|
||||
# fails loudly rather than silently signing with the literal string if that ever changes.
|
||||
#
|
||||
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
|
||||
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
|
||||
# echoes the build invocation, which is the whole of the difference being bought here.
|
||||
#
|
||||
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
|
||||
# The finally clears them from this process either way, and a persistent node would have kept its own
|
||||
# inherited copy for the next quarter of an hour regardless of what happens here.
|
||||
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
|
||||
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
if ($packageFormat -eq 'aab') {
|
||||
Stop-With @'
|
||||
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
|
||||
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
|
||||
|
||||
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
|
||||
that file removes it again.
|
||||
'@
|
||||
}
|
||||
|
||||
try {
|
||||
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
|
||||
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-nodeReuse:false `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
|
||||
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
# xcrun notarytool store-credentials DodoSSH \
|
||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||
#
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
|
||||
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
|
||||
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
||||
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-macos.sh
|
||||
@@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||
# the work for: without it the second platform to publish a given version fails on a release that
|
||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||
#
|
||||
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
||||
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
||||
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
||||
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
||||
# Exported for the one call and unset straight after; an environment block is still readable by a
|
||||
# process with rights over this one, but it is out of every process listing and audit log.
|
||||
UPLOAD_ARGS=(
|
||||
upload gitea
|
||||
--repoUrl "$REPO_URL"
|
||||
--token "$TOKEN"
|
||||
--outputDir "$RELEASES_DIR"
|
||||
--channel "$CHANNEL"
|
||||
--releaseName "$TAG"
|
||||
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
esac
|
||||
|
||||
write_step 'Uploading'
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||
|
||||
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
||||
# sitting in this shell for whatever runs next.
|
||||
export VPK_TOKEN="$TOKEN"
|
||||
vpk_status=0
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
||||
unset VPK_TOKEN
|
||||
|
||||
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
|
||||
|
||||
write_step "Published $TAG."
|
||||
exit 0
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||
so the bytes that reach users are the bytes that were installed and checked.
|
||||
|
||||
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
||||
in a shell's memory the better.
|
||||
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
|
||||
and cleared afterwards, because the alternative is --token on a command line every process on this
|
||||
machine can read. See the comment at the upload itself.
|
||||
|
||||
.PARAMETER Upload
|
||||
Publish the packages already in Releases/ instead of building.
|
||||
@@ -152,10 +154,24 @@ try {
|
||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||
#
|
||||
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
|
||||
#
|
||||
# The comment above says the token never lands in shell history, which was true and was not the
|
||||
# whole story: it went straight into vpk's command line, and any process running as this user can
|
||||
# read that off a running process with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
|
||||
# can publish an update every installed copy will fetch and run deserves better than that.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
|
||||
# value never becomes an argument at all. An environment block is still readable by a process with
|
||||
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
|
||||
$uploadArgs = @(
|
||||
'upload', 'gitea',
|
||||
'--repoUrl', $RepoUrl,
|
||||
'--token', $token,
|
||||
'--outputDir', $ReleasesDir,
|
||||
'--channel', $Channel,
|
||||
'--releaseName', $tag,
|
||||
@@ -169,7 +185,17 @@ try {
|
||||
}
|
||||
|
||||
Write-Step 'Uploading'
|
||||
& dotnet vpk @uploadArgs
|
||||
|
||||
try {
|
||||
$env:VPK_TOKEN = $token
|
||||
& dotnet vpk @uploadArgs
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||
|
||||
Write-Step "Published $tag."
|
||||
|
||||
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
|
||||
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||
internal const int SyncProtocolVersion = 1;
|
||||
|
||||
/// <summary>Feature flag for the relay.</summary>
|
||||
internal const string RelayFeature = "relay";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
|
||||
List<string> features = ["teams"];
|
||||
if (relay.Value.Enabled)
|
||||
{
|
||||
features.Add(RelayFeature);
|
||||
features.Add(RelayProtocol.Feature);
|
||||
}
|
||||
|
||||
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using DodoSSH.Api.Setup;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
|
||||
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
|
||||
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
|
||||
/// one for the dial — which is the attack ADR 0004 is built around.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
|
||||
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
|
||||
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
|
||||
/// then their allow-list, then the private-network switch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
|
||||
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
|
||||
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
|
||||
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayAddressPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Addresses the relay never dials, whatever it is configured to do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
|
||||
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
|
||||
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
|
||||
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
|
||||
/// </remarks>
|
||||
private static readonly IPNetwork[] Refused =
|
||||
[
|
||||
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
|
||||
IPNetwork.Parse("0.0.0.0/8"),
|
||||
IPNetwork.Parse("127.0.0.0/8"),
|
||||
IPNetwork.Parse("169.254.0.0/16"),
|
||||
IPNetwork.Parse("224.0.0.0/4"),
|
||||
IPNetwork.Parse("240.0.0.0/4"),
|
||||
|
||||
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
|
||||
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
|
||||
// block cannot simply be refused.
|
||||
IPNetwork.Parse("100.100.100.200/32"),
|
||||
|
||||
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
|
||||
// metadata endpoint's IPv6 address.
|
||||
IPNetwork.Parse("::/96"),
|
||||
IPNetwork.Parse("fe80::/10"),
|
||||
IPNetwork.Parse("ff00::/8"),
|
||||
IPNetwork.Parse("fd00:ec2::254/128"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
|
||||
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
|
||||
/// <c>fec0::/10</c> is deprecated and was private while it lived.
|
||||
/// </remarks>
|
||||
private static readonly IPNetwork[] Private =
|
||||
[
|
||||
IPNetwork.Parse("10.0.0.0/8"),
|
||||
IPNetwork.Parse("172.16.0.0/12"),
|
||||
IPNetwork.Parse("192.168.0.0/16"),
|
||||
IPNetwork.Parse("100.64.0.0/10"),
|
||||
IPNetwork.Parse("fc00::/7"),
|
||||
IPNetwork.Parse("fec0::/10"),
|
||||
];
|
||||
|
||||
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
|
||||
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
|
||||
|
||||
/// <summary>
|
||||
/// Ports the relay never dials.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
|
||||
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
|
||||
/// and the control that matters there is who may dial the host at all.
|
||||
/// </remarks>
|
||||
private static readonly int[] RefusedPorts = [25, 465, 587];
|
||||
|
||||
private readonly bool allowPrivate;
|
||||
private readonly IPNetwork[] allowed;
|
||||
private readonly IPNetwork[] denied;
|
||||
|
||||
public RelayAddressPolicy(RelayOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
allowPrivate = options.AllowPrivateNetworks;
|
||||
allowed = ParseAll(options.AllowedNetworks);
|
||||
denied = ParseAll(options.DeniedNetworks);
|
||||
}
|
||||
|
||||
/// <summary>Whether a port may be dialled at all.</summary>
|
||||
internal static bool PermitsPort(int port) =>
|
||||
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
|
||||
|
||||
/// <summary>
|
||||
/// Whether every entry is a network this policy can read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
|
||||
/// operator believes is closed, so an unreadable entry stops the server instead.
|
||||
/// <para>
|
||||
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
|
||||
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
|
||||
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static bool AreNetworks(IEnumerable<string> entries) =>
|
||||
entries.All(entry =>
|
||||
IPNetwork.TryParse(entry, out var network)
|
||||
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
|
||||
&& written.Equals(network.BaseAddress));
|
||||
|
||||
/// <summary>
|
||||
/// The address the relay would actually be talking to, with any embedding unwrapped.
|
||||
/// </summary>
|
||||
internal static IPAddress Normalise(IPAddress address)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(address);
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
return address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
return new IPAddress(bytes.AsSpan(12, 4));
|
||||
}
|
||||
|
||||
// A scope id is how an address names an interface on this machine, which is not something a
|
||||
// host row gets to choose. Dropped, so what is judged is what is dialled.
|
||||
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
|
||||
? new IPAddress(address.GetAddressBytes())
|
||||
: address;
|
||||
}
|
||||
|
||||
/// <summary>Whether the relay may dial this address.</summary>
|
||||
/// <remarks>
|
||||
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
|
||||
/// the address checked and the address connected to are the same object rather than two spellings
|
||||
/// the operating system is trusted to agree about.
|
||||
/// </remarks>
|
||||
internal bool Permits(IPAddress address)
|
||||
{
|
||||
var target = Normalise(address);
|
||||
|
||||
if (Refused.Any(network => Within(network, target)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (denied.Any(network => Within(network, target)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowed.Length > 0)
|
||||
{
|
||||
return allowed.Any(network => Within(network, target));
|
||||
}
|
||||
|
||||
return allowPrivate || !Private.Any(network => Within(network, target));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
|
||||
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
|
||||
/// alternative reads as though it compared across families.
|
||||
/// </remarks>
|
||||
private static bool Within(IPNetwork network, IPAddress address) =>
|
||||
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
|
||||
|
||||
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
|
||||
[.. entries.Select(entry => IPNetwork.Parse(entry))];
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// The relay socket: a ticket in, raw bytes to one host and back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
|
||||
/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
|
||||
/// endpoint can accept it from a client that cannot set an <c>Authorization</c> header on an upgrade,
|
||||
/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
|
||||
/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here is scoped to the request.</b> A session lasts as long as a shell does, which is hours,
|
||||
/// and anything scoped held across it is a pooled database connection held across it. The database is
|
||||
/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
|
||||
/// disposed before the next byte moves. That is ADR 0004's consequence about <c>DbContext</c> lifetime.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
|
||||
/// the write has completed, so a slow reader on either side stops this side reading, which fills the
|
||||
/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
|
||||
/// a session costs its two buffers whatever the peers do.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayConnectEndpoint(
|
||||
IServiceScopeFactory scopes,
|
||||
IRelayNetwork network,
|
||||
RelayTicketCodec codec,
|
||||
RelaySessionRegistry sessions,
|
||||
IOptions<RelayOptions> options,
|
||||
TimeProvider clock,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<RelayConnectEndpoint> logger)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// One direction's buffer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
|
||||
/// one write per packet rather than several.
|
||||
/// </remarks>
|
||||
private const int BufferBytes = 32 * 1024;
|
||||
|
||||
/// <summary>How long a close handshake, or a peer's last write, may take before the socket is dropped.</summary>
|
||||
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get(RelayProtocol.ConnectPath);
|
||||
|
||||
// The ticket is the authorization. See the remarks on this class, and ADR 0004.
|
||||
AllowAnonymous();
|
||||
|
||||
Description(b => b
|
||||
.WithName("RelayConnect")
|
||||
.WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
|
||||
.WithTags("Relay"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (ticket is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lease = sessions.TryAdmit(ticket.UserId);
|
||||
|
||||
try
|
||||
{
|
||||
using var socket = await HttpContext.WebSockets
|
||||
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (lease is null)
|
||||
{
|
||||
RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
|
||||
|
||||
await CloseAsync(
|
||||
socket,
|
||||
new Ending(
|
||||
RelayCloseReason.Unspecified,
|
||||
RelayProtocol.TooManySessionsCloseCode,
|
||||
"Too many relay sessions are open."))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (lifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
|
||||
// accept that throws would otherwise hold a place against this user's cap forever.
|
||||
lease?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static Ending ShuttingDown { get; } = new(
|
||||
RelayCloseReason.ServerShutdown,
|
||||
(int)WebSocketCloseStatus.EndpointUnavailable,
|
||||
"The server is shutting down.");
|
||||
|
||||
/// <summary>
|
||||
/// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
|
||||
/// </summary>
|
||||
/// <returns>The redeemed ticket, or null when a response has been sent.</returns>
|
||||
/// <remarks>
|
||||
/// A ticket that is forged, expired, spent, or simply absent all answer 401
|
||||
/// <c>relay-ticket-invalid</c>, with one remedy — ask for another — and no hint of which it was.
|
||||
/// </remarks>
|
||||
private async Task<RelayTicket?> AdmitTicketAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Value.Enabled)
|
||||
{
|
||||
await Send.ResultAsync(Problems.Coded(
|
||||
StatusCodes.Status404NotFound,
|
||||
ProblemCodes.RelayUnavailable,
|
||||
"This server does not relay. GET /api/v1/meta lists the features it does offer."))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
|
||||
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest
|
||||
|| !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
|
||||
{
|
||||
await Send.ResultAsync(Problems.Coded(
|
||||
StatusCodes.Status400BadRequest,
|
||||
ProblemCodes.MalformedRequest,
|
||||
"This endpoint is a WebSocket. Send an upgrade request offering the "
|
||||
+ $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
|
||||
+ $"'{RelayProtocol.TicketProtocolPrefix}<ticket>'."))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var sealedTicket = offered
|
||||
.FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
|
||||
?[RelayProtocol.TicketProtocolPrefix.Length..];
|
||||
|
||||
var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
|
||||
|
||||
if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
RelayLog.TicketRefused(logger, ticket?.SessionId);
|
||||
|
||||
await Send.ResultAsync(Problems.Coded(
|
||||
StatusCodes.Status401Unauthorized,
|
||||
ProblemCodes.RelayTicketInvalid,
|
||||
"This relay ticket is not valid: it is expired, already used, or was not issued here. "
|
||||
+ $"Ask for another from POST {RelayProtocol.TicketsPath}."))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spends the ticket, or answers false when it has been spent already.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
|
||||
/// on two nodes both attempt the insert and the database lets exactly one of them win.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
|
||||
/// here — on a path that runs once per session rather than once per request — keeps the table the
|
||||
/// size of the last thirty seconds without a background job to forget about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<bool> RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
await database.RelayTicketUses
|
||||
.Where(use => use.ExpiresAtUtc < now)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
database.RelayTicketUses.Add(new RelayTicketUse
|
||||
{
|
||||
TicketId = ticket.TicketId,
|
||||
ExpiresAtUtc = ticket.ExpiresAt,
|
||||
UsedAtUtc = now,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
when (string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
||||
{
|
||||
var started = clock.GetUtcNow();
|
||||
|
||||
await OpenAuditAsync(ticket, started).ConfigureAwait(false);
|
||||
|
||||
var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
|
||||
|
||||
if (dialled is not var (target, address))
|
||||
{
|
||||
RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
|
||||
|
||||
var unreachable = new Ending(
|
||||
RelayCloseReason.TargetUnreachable,
|
||||
RelayProtocol.TargetUnreachableCloseCode,
|
||||
"The relay could not reach the host.");
|
||||
|
||||
await CloseAsync(socket, unreachable).ConfigureAwait(false);
|
||||
await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var counters = new Counters(clock, started);
|
||||
Ending ending;
|
||||
|
||||
await using (target.ConfigureAwait(false))
|
||||
{
|
||||
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
||||
|
||||
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
RelayLog.SessionClosed(
|
||||
logger,
|
||||
ticket.SessionId,
|
||||
ending.Reason,
|
||||
counters.ClientToTarget,
|
||||
counters.TargetToClient);
|
||||
|
||||
await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
|
||||
/// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
|
||||
/// </remarks>
|
||||
private async Task<(Stream Target, IPAddress Address)?> DialAsync(
|
||||
RelayTicket ticket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(options.Value.ConnectTimeout);
|
||||
|
||||
foreach (var address in ticket.TargetAddresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = await network
|
||||
.ConnectAsync(address, ticket.TargetPort, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (target, address);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
|
||||
{
|
||||
// The next address, if there is one.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs both directions and the clock until one of them ends the session, then ends it politely.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on a
|
||||
/// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
|
||||
/// a client learns <em>why</em> its session ended — which, for an idle timeout or a drain, is the
|
||||
/// difference between "reconnect" and "something is broken". So the token stops the target side and
|
||||
/// the clock; the sender is let finish its last write; the close goes out; and the receiver ends on
|
||||
/// the client's answering close. Each wait is bounded, and the socket is aborted only when a peer
|
||||
/// has stopped answering altogether.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<Ending> PumpAsync(
|
||||
WebSocket socket,
|
||||
Stream target,
|
||||
Counters counters,
|
||||
DateTimeOffset started,
|
||||
CancellationToken requestAborted)
|
||||
{
|
||||
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
||||
|
||||
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
||||
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
||||
var minding = MindAsync(counters, started, pump.Token);
|
||||
|
||||
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
||||
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
||||
RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||
|
||||
await pump.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
// The only sender must be done before the close frame is written: a WebSocket permits one send
|
||||
// at a time and faults permanently on a second.
|
||||
if (!await SettlesAsync(downstream).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
}
|
||||
|
||||
await CloseAsync(socket, ending).ConfigureAwait(false);
|
||||
|
||||
if (!await SettlesAsync(upstream).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
await SettlesAsync(upstream).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await SettlesAsync(minding).ConfigureAwait(false);
|
||||
|
||||
return ending;
|
||||
}
|
||||
|
||||
/// <summary>Client to target: every binary message is the next bytes of the stream.</summary>
|
||||
private static async Task<Ending?> ClientToTargetAsync(
|
||||
WebSocket socket,
|
||||
Stream target,
|
||||
Counters counters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
ValueWebSocketReceiveResult received;
|
||||
|
||||
try
|
||||
{
|
||||
// Not cancellable, deliberately. See PumpAsync.
|
||||
received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
|
||||
{
|
||||
return ClientGone;
|
||||
}
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return ClientGone;
|
||||
}
|
||||
|
||||
// A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
|
||||
// and forwarding it into an SSH stream would corrupt that stream at best.
|
||||
if (received.MessageType != WebSocketMessageType.Binary)
|
||||
{
|
||||
return new Ending(
|
||||
RelayCloseReason.Error,
|
||||
(int)WebSocketCloseStatus.InvalidMessageType,
|
||||
"The relay carries binary messages only.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return TargetGone;
|
||||
}
|
||||
|
||||
counters.AddClientToTarget(received.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Target to client: every read becomes one binary message.</summary>
|
||||
private static async Task<Ending?> TargetToClientAsync(
|
||||
Stream target,
|
||||
WebSocket socket,
|
||||
Counters counters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int read;
|
||||
|
||||
try
|
||||
{
|
||||
read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return TargetGone;
|
||||
}
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
return TargetGone;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Not cancellable, deliberately. See PumpAsync.
|
||||
await socket
|
||||
.SendAsync(
|
||||
buffer.AsMemory(0, read),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
return ClientGone;
|
||||
}
|
||||
|
||||
counters.AddTargetToClient(read);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
|
||||
/// <c>docker compose up -d</c> not guillotine live shells, so a session in progress when the server is
|
||||
/// told to stop is given the drain timeout to finish — most will, because the user was already
|
||||
/// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
|
||||
/// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
|
||||
/// longer than the drain for this to be possible at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
|
||||
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var hardDeadline = started + settings.MaxSessionDuration;
|
||||
DateTimeOffset? drainDeadline = null;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
drainDeadline = now + settings.DrainTimeout;
|
||||
}
|
||||
|
||||
if (now >= hardDeadline)
|
||||
{
|
||||
return new Ending(
|
||||
RelayCloseReason.DurationLimit,
|
||||
RelayProtocol.DurationLimitCloseCode,
|
||||
"This relay session reached its maximum lifetime.");
|
||||
}
|
||||
|
||||
if (drainDeadline is { } drain && now >= drain)
|
||||
{
|
||||
return ShuttingDown;
|
||||
}
|
||||
|
||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||
|
||||
if (now >= idleDeadline)
|
||||
{
|
||||
return new Ending(
|
||||
RelayCloseReason.IdleTimeout,
|
||||
RelayProtocol.IdleTimeoutCloseCode,
|
||||
"This relay session carried nothing for the idle timeout.");
|
||||
}
|
||||
|
||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||
|
||||
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
||||
// than from whenever the idle clock next came round.
|
||||
using var wake = drainDeadline is null
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The shutdown began. Round again to start the drain.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Ending ClientGone { get; } =
|
||||
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||
|
||||
private static Ending TargetGone { get; } =
|
||||
new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
|
||||
|
||||
/// <summary>
|
||||
/// Opens the audit row, before the target is dialled, so a session that never reached its host is
|
||||
/// still one the operator can see.
|
||||
/// </summary>
|
||||
private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
database.RelaySessions.Add(new RelaySession
|
||||
{
|
||||
Id = ticket.SessionId,
|
||||
UserId = ticket.UserId,
|
||||
HostId = ticket.HostId,
|
||||
VaultId = ticket.VaultId,
|
||||
TargetHost = ticket.TargetHost,
|
||||
TargetPort = ticket.TargetPort,
|
||||
ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
StartedAtUtc = started,
|
||||
});
|
||||
|
||||
// Not the request's token: the row has to be written even for a client that has already gone,
|
||||
// or a session that was cut off would be the one with no record.
|
||||
await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
|
||||
/// throwing here would only turn a finished session into an unhandled exception.
|
||||
/// </summary>
|
||||
private async Task CloseAuditAsync(
|
||||
Guid sessionId,
|
||||
IPAddress? address,
|
||||
Counters counters,
|
||||
RelayCloseReason reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
var ended = clock.GetUtcNow();
|
||||
var dialled = address?.ToString();
|
||||
|
||||
await database.RelaySessions
|
||||
.Where(session => session.Id == sessionId)
|
||||
.ExecuteUpdateAsync(
|
||||
set => set
|
||||
.SetProperty(session => session.EndedAtUtc, ended)
|
||||
.SetProperty(session => session.TargetAddress, dialled)
|
||||
.SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
|
||||
.SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
|
||||
.SetProperty(session => session.CloseReason, reason),
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
|
||||
{
|
||||
RelayLog.AuditCloseFailed(logger, sessionId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CloseAsync(WebSocket socket, Ending ending)
|
||||
{
|
||||
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(CloseTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
await socket
|
||||
.CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// The peer is already gone. There is nothing to tell it.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for a loop to finish, for at most the close timeout.</summary>
|
||||
/// <returns>Whether it finished.</returns>
|
||||
private static async Task<bool> SettlesAsync(Task loop)
|
||||
{
|
||||
try
|
||||
{
|
||||
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How a session ended, and what the client is told.</summary>
|
||||
private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
|
||||
|
||||
/// <summary>
|
||||
/// Byte counts and the time of the last byte, written by the two directions and read by the clock.
|
||||
/// </summary>
|
||||
private sealed class Counters(TimeProvider clock, DateTimeOffset started)
|
||||
{
|
||||
private long clientToTarget;
|
||||
private long targetToClient;
|
||||
private long lastActivityTicks = started.UtcTicks;
|
||||
|
||||
internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
|
||||
|
||||
internal long TargetToClient => Interlocked.Read(ref targetToClient);
|
||||
|
||||
internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
|
||||
|
||||
internal void AddClientToTarget(int count)
|
||||
{
|
||||
Interlocked.Add(ref clientToTarget, count);
|
||||
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
|
||||
}
|
||||
|
||||
internal void AddTargetToClient(int count)
|
||||
{
|
||||
Interlocked.Add(ref targetToClient, count);
|
||||
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DodoSSH.Domain;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>Source-generated log messages for the relay.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
|
||||
/// the target's address: that is in the audit table, where retention is the operator's decision, and
|
||||
/// copying it into a log would make it the log pipeline's decision too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
|
||||
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
|
||||
/// an operator answers "why will the relay not reach this host".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static partial class RelayLog
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 2301,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
|
||||
internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2302,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
|
||||
+ "{AddressCount} permitted address(es).")]
|
||||
internal static partial void TicketIssued(
|
||||
ILogger logger,
|
||||
Guid userId,
|
||||
Guid hostId,
|
||||
Guid sessionId,
|
||||
int addressCount);
|
||||
|
||||
/// <remarks>
|
||||
/// The session id is known only for a ticket that verified and had already been spent, which is the
|
||||
/// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2303,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
|
||||
internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2304,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
|
||||
+ "open on this node.")]
|
||||
internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2305,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
|
||||
internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2306,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
|
||||
+ "open on this node.")]
|
||||
internal static partial void SessionOpened(
|
||||
ILogger logger,
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
Guid hostId,
|
||||
int sessionCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2307,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
|
||||
+ "{TargetToClient} down.")]
|
||||
internal static partial void SessionClosed(
|
||||
ILogger logger,
|
||||
Guid sessionId,
|
||||
RelayCloseReason reason,
|
||||
long clientToTarget,
|
||||
long targetToClient);
|
||||
|
||||
/// <remarks>
|
||||
/// A warning rather than an error: the session itself is over and was fine. What is lost is the
|
||||
/// audit row's second half, which leaves it looking like a session still open.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2308,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
||||
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A seam for tests, and a narrow one on purpose. <see cref="RelayAddressPolicy"/> is not behind it:
|
||||
/// whatever this answers, the policy judges, and a test that replaces this still runs every address
|
||||
/// through the real rules. What a test cannot do without the seam is dial anything, because every
|
||||
/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal interface IRelayNetwork
|
||||
{
|
||||
/// <summary>Resolves a stored hostname, which may be an address literal.</summary>
|
||||
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Opens a TCP connection to one address.</summary>
|
||||
/// <returns>A stream that owns the connection.</returns>
|
||||
Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The operating system's resolver and sockets.</summary>
|
||||
internal sealed class SystemRelayNetwork : IRelayNetwork
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken) =>
|
||||
|
||||
// A literal parses to itself without a lookup, which is what an address typed into a host
|
||||
// editor should do — and what makes "10.0.0.5" mean the same thing on every node.
|
||||
IPAddress.TryParse(host, out var literal)
|
||||
? Task.FromResult<IPAddress[]>([literal])
|
||||
: Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
// An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
|
||||
// acknowledgement of the last, which is the latency the user feels as a sticky terminal.
|
||||
NoDelay = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using DodoSSH.Api.Setup;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per node, and so per user <em>per node</em>: a user behind a round-robin balancer with three nodes
|
||||
/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
|
||||
/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
|
||||
/// node — which a per-node count does exactly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
|
||||
/// the sockets belong to the handlers that opened them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
||||
{
|
||||
private readonly Lock gate = new();
|
||||
private readonly Dictionary<Guid, int> perUser = [];
|
||||
private int total;
|
||||
|
||||
/// <summary>How many sessions are open on this node.</summary>
|
||||
internal int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether a session for this user would be admitted now.</summary>
|
||||
/// <remarks>
|
||||
/// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
|
||||
/// before it has spent a ticket, but only <see cref="TryAdmit"/> decides, since the answer here can
|
||||
/// be stale by the time the socket arrives — possibly at another node.
|
||||
/// </remarks>
|
||||
internal bool HasRoomFor(Guid userId)
|
||||
{
|
||||
var settings = options.Value;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
return total < settings.MaxConcurrentSessionsTotal
|
||||
&& perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||
internal Lease? TryAdmit(Guid userId)
|
||||
{
|
||||
var settings = options.Value;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
var held = perUser.GetValueOrDefault(userId);
|
||||
|
||||
if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
perUser[userId] = held + 1;
|
||||
total++;
|
||||
|
||||
return new Lease(this, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
total--;
|
||||
|
||||
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
||||
// session open and not of every user who ever had one.
|
||||
if (perUser[userId] <= 1)
|
||||
{
|
||||
perUser.Remove(userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
perUser[userId]--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
||||
{
|
||||
private int released;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||
{
|
||||
registry.Release(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the relay socket needs to know, decided by the ticket endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
|
||||
/// to resolve again — <see cref="TargetHost"/> is carried for the audit row only — which is what keeps a
|
||||
/// DNS answer that changes between issue and connect from changing where the bytes go.
|
||||
/// </remarks>
|
||||
/// <param name="TicketId">The replay guard's key.</param>
|
||||
/// <param name="SessionId">The audit row's id, already given to the client.</param>
|
||||
/// <param name="UserId">Who asked.</param>
|
||||
/// <param name="HostId">The host it is for.</param>
|
||||
/// <param name="VaultId">The host's vault at issue.</param>
|
||||
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
|
||||
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
|
||||
/// <param name="TargetPort">The port.</param>
|
||||
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
|
||||
internal sealed record RelayTicket(
|
||||
Guid TicketId,
|
||||
Guid SessionId,
|
||||
Guid UserId,
|
||||
Guid HostId,
|
||||
Guid VaultId,
|
||||
string TargetHost,
|
||||
IReadOnlyList<IPAddress> TargetAddresses,
|
||||
int TargetPort,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Seals and opens relay tickets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
|
||||
/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
|
||||
/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
|
||||
/// the resolved address of a machine on the operator's network.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, rather than left to
|
||||
/// the framework's time-limited protector, which reads the system clock directly — the thing this
|
||||
/// repository bans so that time can be faked in tests.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
|
||||
/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
|
||||
{
|
||||
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
|
||||
private const string Purpose = "DodoSSH.Relay.Ticket";
|
||||
|
||||
private const byte LayoutVersion = 1;
|
||||
|
||||
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
|
||||
internal const int MaxAddresses = 16;
|
||||
|
||||
private readonly IDataProtector protector = protection.CreateProtector(Purpose);
|
||||
|
||||
internal string Seal(RelayTicket ticket)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ticket);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(buffer))
|
||||
{
|
||||
writer.Write(LayoutVersion);
|
||||
writer.Write(ticket.TicketId.ToByteArray());
|
||||
writer.Write(ticket.SessionId.ToByteArray());
|
||||
writer.Write(ticket.UserId.ToByteArray());
|
||||
writer.Write(ticket.HostId.ToByteArray());
|
||||
writer.Write(ticket.VaultId.ToByteArray());
|
||||
writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
|
||||
writer.Write(ticket.TargetPort);
|
||||
writer.Write(ticket.TargetHost);
|
||||
writer.Write((byte)ticket.TargetAddresses.Count);
|
||||
|
||||
foreach (var address in ticket.TargetAddresses)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
writer.Write((byte)bytes.Length);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
|
||||
return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
|
||||
/// <remarks>
|
||||
/// One answer for all three, because the caller's remedy is the same — ask for another — and a
|
||||
/// distinct "expired" would tell a forger which of their attempts had verified.
|
||||
/// </remarks>
|
||||
internal RelayTicket? TryOpen(string sealedTicket)
|
||||
{
|
||||
byte[] payload;
|
||||
|
||||
try
|
||||
{
|
||||
payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
|
||||
}
|
||||
catch (Exception exception) when (exception is CryptographicException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
RelayTicket ticket;
|
||||
|
||||
try
|
||||
{
|
||||
ticket = Read(payload);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is EndOfStreamException or ArgumentException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
|
||||
}
|
||||
|
||||
private static RelayTicket Read(byte[] payload)
|
||||
{
|
||||
using var reader = new BinaryReader(new MemoryStream(payload));
|
||||
|
||||
if (reader.ReadByte() != LayoutVersion)
|
||||
{
|
||||
throw new FormatException("Unknown ticket layout.");
|
||||
}
|
||||
|
||||
var ticketId = new Guid(reader.ReadBytes(16));
|
||||
var sessionId = new Guid(reader.ReadBytes(16));
|
||||
var userId = new Guid(reader.ReadBytes(16));
|
||||
var hostId = new Guid(reader.ReadBytes(16));
|
||||
var vaultId = new Guid(reader.ReadBytes(16));
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
|
||||
var port = reader.ReadInt32();
|
||||
var host = reader.ReadString();
|
||||
var count = reader.ReadByte();
|
||||
|
||||
if (count is 0 or > MaxAddresses)
|
||||
{
|
||||
throw new FormatException("A ticket names between one and sixteen addresses.");
|
||||
}
|
||||
|
||||
var addresses = new IPAddress[count];
|
||||
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var length = reader.ReadByte();
|
||||
|
||||
if (length is not (4 or 16))
|
||||
{
|
||||
throw new FormatException("An address is four or sixteen bytes.");
|
||||
}
|
||||
|
||||
addresses[index] = new IPAddress(reader.ReadBytes(length));
|
||||
}
|
||||
|
||||
return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
using DodoSSH.Infrastructure;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every decision the relay makes is made here, with the full bearer token and the full access model in
|
||||
/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
|
||||
/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
|
||||
/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every refusal about the target is the same refusal.</b> A host that does not exist, one in a vault
|
||||
/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
|
||||
/// whose every address the policy refuses all answer 403 <c>relay-target-rejected</c> with the same
|
||||
/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
|
||||
/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
|
||||
/// real reason goes to the server log, where the operator can read it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayTicketEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
DodoDbContext database,
|
||||
IRelayNetwork network,
|
||||
RelayTicketCodec codec,
|
||||
RelaySessionRegistry sessions,
|
||||
IOptions<RelayOptions> options,
|
||||
TimeProvider clock,
|
||||
ILogger<RelayTicketEndpoint> logger)
|
||||
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
|
||||
{
|
||||
/// <summary>What every target refusal says, whatever the reason was.</summary>
|
||||
private const string RejectedDetail =
|
||||
"The relay will not connect to this host. Check that the relay is switched on for it and that its "
|
||||
+ "address is one this server may reach; the server's log records which.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post(RelayProtocol.TicketsPath);
|
||||
|
||||
// Enrolled, matching sync. A relayed host is still a host whose name, username and key are
|
||||
// ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("IssueRelayTicket")
|
||||
.WithSummary("Issues a single-use ticket for one relay connection to one host.")
|
||||
.WithTags("Relay"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<RelayTicketResponse>, ProblemHttpResult>> ExecuteAsync(
|
||||
RelayTicketRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var settings = options.Value;
|
||||
|
||||
if (!settings.Enabled)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status404NotFound,
|
||||
ProblemCodes.RelayUnavailable,
|
||||
"This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
|
||||
+ "features this server does offer.");
|
||||
}
|
||||
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
|
||||
|
||||
if (target is not var (hostname, port, vaultId))
|
||||
{
|
||||
return Rejected(user.Id, req.HostId, refusal!);
|
||||
}
|
||||
|
||||
// Before resolving rather than after: a caller already at the cap is told so without this server
|
||||
// spending a DNS query on a ticket that could not be redeemed.
|
||||
if (!sessions.HasRoomFor(user.Id))
|
||||
{
|
||||
RelayLog.LimitReached(logger, user.Id, sessions.Count);
|
||||
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
ProblemCodes.RelayLimitReached,
|
||||
"You have as many relay sessions open as this server allows. Close one and try again.");
|
||||
}
|
||||
|
||||
var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
|
||||
|
||||
if (addresses is not { Length: > 0 })
|
||||
{
|
||||
return Rejected(user.Id, req.HostId, addresses is null
|
||||
? "its name did not resolve"
|
||||
: "every address its name resolved to is refused by policy");
|
||||
}
|
||||
|
||||
var ticket = new RelayTicket(
|
||||
TicketId: Guid.CreateVersion7(),
|
||||
SessionId: Guid.CreateVersion7(),
|
||||
UserId: user.Id,
|
||||
HostId: req.HostId,
|
||||
VaultId: vaultId,
|
||||
TargetHost: hostname,
|
||||
TargetAddresses: addresses,
|
||||
TargetPort: port,
|
||||
ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
|
||||
|
||||
RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
|
||||
|
||||
return TypedResults.Ok(new RelayTicketResponse(
|
||||
Ticket: codec.Seal(ticket),
|
||||
WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
|
||||
SubProtocol: RelayProtocol.SubProtocol,
|
||||
SessionId: ticket.SessionId,
|
||||
ExpiresAt: ticket.ExpiresAt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the host, and decides whether this caller may be relayed to it at all.
|
||||
/// </summary>
|
||||
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
|
||||
private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
|
||||
Guid userId,
|
||||
RelayTicketRequest req,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (req.PortForwardId is not null)
|
||||
{
|
||||
return (null, "a port forward was requested, which v1 does not relay");
|
||||
}
|
||||
|
||||
var host = await database.Hosts
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (host is null)
|
||||
{
|
||||
return (null, "no such host");
|
||||
}
|
||||
|
||||
var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Connect rather than Read, because Connect is the permission that says what this is. Every role
|
||||
// that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
|
||||
// them should be refused a relay here without anybody remembering to look.
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
|
||||
{
|
||||
return (null, "the caller may not connect to this host's vault");
|
||||
}
|
||||
|
||||
// The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
|
||||
// because the cost of trusting it is dialling a null.
|
||||
if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
|
||||
{
|
||||
return (null, "the relay is not switched on for this host");
|
||||
}
|
||||
|
||||
return RelayAddressPolicy.PermitsPort(port)
|
||||
? ((hostname, port, host.VaultId), null)
|
||||
: (null, $"port {port} is refused by policy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the stored name, and keeps the addresses the policy permits.
|
||||
/// </summary>
|
||||
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
|
||||
/// address and a link-local one is dialled on the public address, and never on the other — the
|
||||
/// ticket carries only what passed, so there is nothing else for the socket to try.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
|
||||
/// than a second spelling of it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<IPAddress[]?> ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = new RelayAddressPolicy(options.Value);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(options.Value.ConnectTimeout);
|
||||
|
||||
IPAddress[] resolved;
|
||||
|
||||
try
|
||||
{
|
||||
resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
.. resolved
|
||||
.Where(policy.Permits)
|
||||
.Select(RelayAddressPolicy.Normalise)
|
||||
.Distinct()
|
||||
.Take(RelayTicketCodec.MaxAddresses),
|
||||
];
|
||||
}
|
||||
|
||||
private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
|
||||
{
|
||||
RelayLog.TargetRejected(logger, userId, hostId, reason);
|
||||
|
||||
return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ builder.Services.AddSingleton<VaultEventHub>();
|
||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||
provider => provider.GetRequiredService<VaultEventHub>());
|
||||
|
||||
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||
builder.Services.AddDodoRelay();
|
||||
|
||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||
|
||||
@@ -54,6 +54,13 @@ internal static class Configuration
|
||||
+ "setting exists to provide.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddRelayOptions();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>The relay's options, which carry more rules than any other section. See ADR 0004.</summary>
|
||||
private static void AddRelayOptions(this IServiceCollection services) =>
|
||||
services.AddOptions<RelayOptions>()
|
||||
.BindConfiguration(RelayOptions.SectionName)
|
||||
.ValidateDataAnnotations()
|
||||
@@ -63,11 +70,28 @@ internal static class Configuration
|
||||
.Validate(
|
||||
options => options.IdleTimeout < options.MaxSessionDuration,
|
||||
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
|
||||
.Validate(
|
||||
options => options.TicketLifetime > TimeSpan.Zero
|
||||
&& options.TicketLifetime <= TimeSpan.FromSeconds(30),
|
||||
"Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
|
||||
+ "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
|
||||
.Validate(
|
||||
options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
|
||||
"Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
|
||||
.Validate(
|
||||
options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
|
||||
&& Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
|
||||
"Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
|
||||
+ "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
|
||||
+ "the operator believes is closed.")
|
||||
.Validate(
|
||||
options => !options.Enabled
|
||||
|| (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
|
||||
&& (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
|
||||
|| string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
|
||||
"Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warns about configuration that is individually valid but dangerous in combination.
|
||||
/// </summary>
|
||||
|
||||
@@ -115,13 +115,50 @@ public sealed class RelayOptions
|
||||
/// Whether RFC1918 and other private ranges may be dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
||||
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
|
||||
/// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
|
||||
/// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
|
||||
/// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool AllowPrivateNetworks { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
|
||||
/// rules permit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
|
||||
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
|
||||
/// operator means by setting both. It still cannot reach the non-overridable list, and
|
||||
/// <see cref="DeniedNetworks"/> still wins over it.
|
||||
/// </remarks>
|
||||
public IList<string> AllowedNetworks { get; } = [];
|
||||
|
||||
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
|
||||
/// <remarks>
|
||||
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
|
||||
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
|
||||
/// </remarks>
|
||||
public IList<string> DeniedNetworks { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// A directory to keep the ticket key ring in. Unset means the platform default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
|
||||
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
|
||||
/// again. What does need it is more than one node — every node must be able to read every other's
|
||||
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
|
||||
/// </remarks>
|
||||
public string? KeyRingDirectory { get; set; }
|
||||
|
||||
/// <summary>Maximum concurrent sessions per user.</summary>
|
||||
[Range(1, 1000)]
|
||||
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Meta;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Contracts;
|
||||
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
|
||||
typeof(SyncPullEndpoint),
|
||||
typeof(SyncPushEndpoint),
|
||||
typeof(VaultEventsEndpoint),
|
||||
typeof(RelayTicketEndpoint),
|
||||
typeof(RelayConnectEndpoint),
|
||||
typeof(CreateTeamEndpoint),
|
||||
typeof(ListTeamsEndpoint),
|
||||
typeof(UpdateTeamEndpoint),
|
||||
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
|
||||
// Registered as each feature lands:
|
||||
// Identity — key rotation, passphrase change
|
||||
// Vaults — per-item ACLs
|
||||
// Relay — tickets and the WebSocket
|
||||
// Audit, Admin
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.AspNetCore.DataProtection.Repositories;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DodoSSH.Api.Setup;
|
||||
|
||||
/// <summary>The relay's services. See ADR 0004.</summary>
|
||||
internal static class RelaySetup
|
||||
{
|
||||
/// <summary>
|
||||
/// How much longer than the drain the host waits before it stops regardless.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
|
||||
/// is the part of a shutdown an operator would otherwise lose without noticing.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
|
||||
|
||||
internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
|
||||
{
|
||||
// Singletons: a session outlives the request that opened it, a protector is safe to share, and
|
||||
// the network seam holds no state.
|
||||
services.AddSingleton<RelayTicketCodec>();
|
||||
services.AddSingleton<RelaySessionRegistry>();
|
||||
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||
|
||||
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||
// sharing a key directory, and — unlike the default, which is derived from the content root —
|
||||
// is the same on every node, which is what lets any node verify any node's ticket.
|
||||
services.AddDataProtection().SetApplicationName("DodoSSH");
|
||||
|
||||
services.AddOptions<KeyManagementOptions>()
|
||||
.Configure<IOptions<RelayOptions>, ILoggerFactory>((keys, relay, loggers) =>
|
||||
{
|
||||
if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
|
||||
{
|
||||
keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
|
||||
}
|
||||
});
|
||||
|
||||
// Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
|
||||
// promises them had run. Harmless with the relay off: nothing else holds a request open that long,
|
||||
// and the host stops as soon as the last request has.
|
||||
services.AddOptions<HostOptions>()
|
||||
.Configure<IOptions<RelayOptions>>((host, relay) =>
|
||||
{
|
||||
var wanted = relay.Value.DrainTimeout + ShutdownMargin;
|
||||
|
||||
if (wanted > host.ShutdownTimeout)
|
||||
{
|
||||
host.ShutdownTimeout = wanted;
|
||||
}
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
|
||||
PhoneEnvironment.CurrentActivity = this;
|
||||
|
||||
AndroidRedirectCallback.Abandon();
|
||||
|
||||
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
|
||||
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
|
||||
// PhoneEnvironment.Resumed, which carries the whole of why.
|
||||
PhoneEnvironment.RaiseResumed();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
||||
/// </remarks>
|
||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||
|
||||
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
|
||||
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
|
||||
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
|
||||
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
|
||||
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
|
||||
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
|
||||
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
|
||||
/// dead, and staying that way until the keyboard was opened and closed again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
|
||||
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
|
||||
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
|
||||
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
|
||||
/// it is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
|
||||
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static event EventHandler? Resumed;
|
||||
|
||||
/// <summary>Announces that the launcher activity is foreground again.</summary>
|
||||
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
|
||||
|
||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||
public static Context Require() =>
|
||||
context ?? throw new InvalidOperationException(
|
||||
|
||||
@@ -167,10 +167,124 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
|
||||
|
||||
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
|
||||
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
|
||||
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
|
||||
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
|
||||
the fill was #6275AC where the accent is #5D42DE.
|
||||
|
||||
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
|
||||
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
|
||||
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
|
||||
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
|
||||
|
||||
Transparent rather than the desktop's gradient, and the difference is the control rather than an
|
||||
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
|
||||
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
|
||||
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
|
||||
slab would make one header icon a button and its neighbours not.
|
||||
|
||||
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
|
||||
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
|
||||
feedback the plain icon buttons give and there is no reason this one should differ.
|
||||
-->
|
||||
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
============ one key of the terminal's accessory keypad ============
|
||||
|
||||
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
|
||||
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
|
||||
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
|
||||
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
|
||||
|
||||
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
|
||||
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
|
||||
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
|
||||
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
|
||||
which is the same figure TextBox.field uses for a box a thumb has to hit.
|
||||
|
||||
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
|
||||
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
|
||||
between two wide neighbours — which is the layout the flexed row exists to stop.
|
||||
|
||||
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
|
||||
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
|
||||
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
|
||||
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
|
||||
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
|
||||
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
|
||||
worth answering in the interface rather than in the scrollback.
|
||||
-->
|
||||
<Style Selector="Button.hotkey">
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<!--
|
||||
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
|
||||
look on is how somebody sends ^L to a database prompt believing they typed an l.
|
||||
-->
|
||||
<Style Selector="Button.hotkey.latched">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||
@@ -398,6 +512,58 @@
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ The two port boxes, which are NumericUpDowns and were the only unthemed controls on this head.
|
||||
|
||||
They sit in a two-column Grid with a plain TextBox.field beside them — port next to username in the host
|
||||
editor, and port next to default-user in the group editor — so the mismatch was not subtle: a bright
|
||||
Fluent border and a near-black fill against this theme's #101019 and hairline, at the one place on the
|
||||
screen where the two are a hand's width apart and directly comparable.
|
||||
|
||||
Not `Classes="field"` on the two call sites, because that class is a TextBox class and this is not a
|
||||
TextBox: NumericUpDown is a ButtonSpinner wrapping one, so half the chrome being corrected belongs to
|
||||
the spinner and would not take a TextBox setter. Typing the control instead also means a third port box
|
||||
added later is right without anybody remembering, which is the property the desktop's own bare
|
||||
`Selector="NumericUpDown"` in App.axaml has had since it was written.
|
||||
|
||||
Three selectors rather than one for the same reason Button.icon.accent needs two: Fluent puts the fill
|
||||
and the border on template parts, and a value set on the control alone is a TemplateBinding the theme's
|
||||
own wins over. The numbers are TextBox.field's, exactly — 44 tall, 10 radius, Field on BorderMid — so
|
||||
the pair reads as one row of two boxes rather than as a box and a visitor.
|
||||
-->
|
||||
<Style Selector="NumericUpDown">
|
||||
<Setter Property="Height" Value="44" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="NumericUpDown /template/ ButtonSpinner">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
</Style>
|
||||
<!--
|
||||
The inner box gives its chrome up entirely: the spinner above is drawing the border and the fill, and a
|
||||
TextBox that kept its own would put a second rounded rectangle a pixel inside the first.
|
||||
-->
|
||||
<Style Selector="NumericUpDown /template/ TextBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="14,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Every box on this head that takes a secret, and it is a class rather than two attributes repeated five
|
||||
times because the two attributes are not interchangeable and both are needed.
|
||||
@@ -473,10 +639,29 @@
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
|
||||
|
||||
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
|
||||
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
|
||||
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
|
||||
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
|
||||
a sentence somebody forgot to finish rather than as text that was cut.
|
||||
|
||||
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
|
||||
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
|
||||
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
|
||||
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
|
||||
|
||||
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
|
||||
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||
that was always meant and was previously being had by accident.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -49,12 +49,29 @@
|
||||
|
||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||
|
||||
<!--
|
||||
◆ The port and the full stop are one Run, and that is the whole of the fix.
|
||||
|
||||
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
|
||||
host and the separator read as a sentence without any of them carrying padding. The trailing
|
||||
Run began with a full stop, so it got that space too and the sheet said
|
||||
|
||||
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
|
||||
|
||||
— a gap before the stop, on the one dialogue in this application whose entire job is to be
|
||||
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
|
||||
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
|
||||
its own so it still gets the space it wants.
|
||||
|
||||
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
|
||||
as a markup extension.
|
||||
-->
|
||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||
<Run Text="Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
@@ -1199,19 +1199,18 @@
|
||||
port into plaintext columns the server can read, which is the single deliberate concession in
|
||||
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this
|
||||
client has no path to it — the connect path dials the address directly whether this is ticked or
|
||||
not — so the box as it stood spent that concession and delivered nothing for it. Left tickable
|
||||
rather than disabled, so a host already carrying the flag can lose it. See
|
||||
It also says the two things a user would otherwise find out by failing: the server, not this
|
||||
phone, has to be able to reach the address, and a relayed host does not fall back to dialling
|
||||
directly, so it connects only while the server is reachable. See
|
||||
docs/reaching-a-host-you-cannot-dial.md.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Reach this host through the server relay (not wired up yet)"
|
||||
Text="Reach this host through the server relay"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
|
||||
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
||||
|
||||
<!--
|
||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
|
||||
@@ -106,11 +106,11 @@
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
||||
@@ -124,7 +124,7 @@
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||
@@ -158,7 +158,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!-- The names of the fields that changed, never their values. -->
|
||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||
|
||||
@@ -391,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is { } top)
|
||||
{
|
||||
top.BackRequested -= OnBackRequested;
|
||||
@@ -410,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the keyboard inset on the way back into the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
|
||||
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
|
||||
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
|
||||
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
|
||||
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
|
||||
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
|
||||
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
|
||||
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
|
||||
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
|
||||
/// which of the two happened; this only has to ask it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
|
||||
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
|
||||
/// reading it on this turn can return the state that is on the way out. Background priority puts the
|
||||
/// read after that.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnActivityResumed(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (keyboard is not null)
|
||||
{
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||
VerticalAlignment="Center" Text="{Binding Started}" />
|
||||
@@ -505,13 +505,50 @@
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<!--
|
||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||
|
||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||
that has gone wrong rather than as two that are different things.
|
||||
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||
and it was wrong in two ways that compounded each other.
|
||||
|
||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||
to be the first thing anybody noticed about this row.
|
||||
|
||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||
|
||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||
while reading the output above it. A key that might have scrolled has to be found first.
|
||||
|
||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||
ones that benefit, because the arrows were the keys living past the right edge.
|
||||
|
||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||
Ctrl is not the better surface.
|
||||
|
||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||
surface that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
@@ -562,62 +599,70 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
||||
on a surface this change exists to give back.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
||||
screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
|
||||
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
|
||||
resign the page's focus. Making the text smaller must not cost the keyboard.
|
||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<!--
|
||||
The cursor keys, and the two text-size buttons riding at the end.
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||
them every time the row's contents changed.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
|
||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||
|
||||
<!--
|
||||
The text-size buttons.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||
any more, so that argument is now satisfied by construction rather than by placement.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||
this screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||
smaller must not cost the keyboard.
|
||||
-->
|
||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||
UniformGrid cells' without being counted twice. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
@@ -39,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
/// keys to fit 360dp produces ten targets too small to hit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
|
||||
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
||||
/// <remarks>
|
||||
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
|
||||
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
|
||||
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
||||
/// simply gets narrower keys.
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
|
||||
[
|
||||
("esc", [0x1B], false),
|
||||
("tab", [0x09], false),
|
||||
@@ -48,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
("-", [(byte)'-'], false),
|
||||
("~", [(byte)'~'], false),
|
||||
("/", [(byte)'/'], false),
|
||||
];
|
||||
|
||||
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
|
||||
// the difference between CSI A and SS3 A is the difference between working in bash and not.
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
|
||||
/// CSI A and SS3 A is the difference between working in bash and not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
|
||||
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
|
||||
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
|
||||
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
|
||||
/// in, which is a different thing from the order they are used in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
|
||||
/// pressed keys on the keypad, and the ones you had to go looking for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
|
||||
[
|
||||
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||
];
|
||||
|
||||
@@ -232,8 +259,6 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
private void BuildAccessoryRow()
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
|
||||
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||
@@ -242,7 +267,18 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||
}
|
||||
|
||||
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
||||
/// <remarks>
|
||||
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
||||
/// they are filled by one method rather than by two that would drift.
|
||||
/// </remarks>
|
||||
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
||||
{
|
||||
foreach (var (label, bytes, latches) in keys)
|
||||
{
|
||||
var key = CreateKey(label);
|
||||
|
||||
@@ -265,29 +301,17 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
Content = label,
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||
//
|
||||
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||
// disagree.
|
||||
Classes = { "hotkey" },
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
@@ -316,18 +340,12 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
|
||||
// ^L to a database prompt believing they typed an l.
|
||||
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
|
||||
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
|
||||
//
|
||||
// A class rather than two brush assignments, so what "latched" looks like is stated once in
|
||||
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
|
||||
controlKey.Classes.Set("latched", controlLatched);
|
||||
}
|
||||
|
||||
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
|
||||
/// <remarks>
|
||||
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
|
||||
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
|
||||
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
|
||||
/// </remarks>
|
||||
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
|
||||
|
||||
/// <remarks>
|
||||
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
||||
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
◆ The one fact on this row that decides whether the button at the foot of the screen
|
||||
|
||||
@@ -259,6 +259,22 @@ public interface ISyncApi
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Asks the server for leave to relay one connection to one host.</summary>
|
||||
/// <remarks>
|
||||
/// The request names a host and nothing else. Where it is, and whether this caller may reach it, is the
|
||||
/// server's decision, made from its own copy of the host's relay address — which is what keeps the relay
|
||||
/// from being an open proxy that a client points wherever it likes. See ADR 0004.
|
||||
/// </remarks>
|
||||
public interface IRelayApi
|
||||
{
|
||||
/// <summary>Issues a single-use ticket, good for thirty seconds, for one relay socket to this host.</summary>
|
||||
/// <exception cref="DodoSshApiException">
|
||||
/// The server refused: <c>relay-unavailable</c>, <c>relay-target-rejected</c> or
|
||||
/// <c>relay-limit-reached</c>, each with its own remedy.
|
||||
/// </exception>
|
||||
Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The typed client for one DodoSSH server.
|
||||
/// </summary>
|
||||
@@ -275,7 +291,7 @@ public interface ISyncApi
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi, IRelayApi
|
||||
{
|
||||
private const string MetaPath = "/api/v1/meta";
|
||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
DodoSshJsonContext.Default.DodoSshConfiguration,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Never cached, and never fetched ahead of need: a ticket is spent by the socket it opens and is
|
||||
/// worthless thirty seconds after issue, so the only right moment to ask for one is the connect that
|
||||
/// will use it.
|
||||
/// </remarks>
|
||||
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
RelayProtocol.TicketsPath,
|
||||
JsonContent.Create(new RelayTicketRequest(hostId, PortForwardId: null), DodoSshJsonContext.Default.RelayTicketRequest),
|
||||
DodoSshJsonContext.Default.RelayTicketResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the caller's profile, unlock material and reachable vaults.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a window on screen first, and composes the application behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var splash = new SplashWindow();
|
||||
|
||||
desktop.MainWindow = splash;
|
||||
|
||||
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
() => ComposeBehind(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||
/// only window there is would end the process instead of handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||
/// arrives instead is the sentence and a way to close it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ComposeBehind(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
SplashWindow splash)
|
||||
{
|
||||
try
|
||||
{
|
||||
Compose(desktop);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
splash.CouldNotStart(exception.Message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
desktop.MainWindow?.Show();
|
||||
splash.Close();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||
@@ -137,12 +203,44 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
var others = SingleInstance.Listen(
|
||||
paths,
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
// discarding the task here is safe rather than merely convenient.
|
||||
_ = viewModel.StartAsync(CancellationToken.None);
|
||||
|
||||
WireShutdown(desktop, viewModel, workspace, caches);
|
||||
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the window somebody has just asked for by launching the application again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||
/// </remarks>
|
||||
private static void BringToFront(Window? window)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState is WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||
@@ -271,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -290,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||
// that found the name still taken would be a second instance refusing itself.
|
||||
await others.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
caches.Dispose();
|
||||
|
||||
desktop.Shutdown();
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
||||
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||
/// what makes the exclusion invisible rather than mysterious.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SingleInstance
|
||||
{
|
||||
/// <summary>The one thing a second launch has to say.</summary>
|
||||
/// <remarks>
|
||||
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||
/// is checked rather than trusted.
|
||||
/// </remarks>
|
||||
private const string ComeForward = "dodossh.come-forward";
|
||||
|
||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||
/// <remarks>
|
||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||
/// cannot be talked to.
|
||||
/// </remarks>
|
||||
private const int AnswerTimeoutMilliseconds = 500;
|
||||
|
||||
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||
/// and has not reached its pipe yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||
/// launch is gone before any of this matters.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>How long to wait between attempts within that window.</summary>
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||
/// </returns>
|
||||
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||
/// <param name="patience">
|
||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
paths.EnsureCreated();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||
// follows will fail on its own terms and say something better than this could.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
|
||||
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||
// makes of them.
|
||||
var attemptsLeft = (int)Math.Ceiling(
|
||||
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||
{
|
||||
return taken;
|
||||
}
|
||||
|
||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||
// round trip on a local pipe rather than the whole window below.
|
||||
if (AskTheRunningOneToComeForward(paths))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attemptsLeft-- <= 0)
|
||||
{
|
||||
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||
// worth corrupting it for.
|
||||
return null;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||
/// accept completes. A caller that touches a window has to marshal.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The profile this instance claimed.</param>
|
||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(comeForward);
|
||||
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||
/// <returns>Whether anything was listening.</returns>
|
||||
/// <remarks>
|
||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||
/// and proves itself, and this is the part with a protocol in it.
|
||||
/// </remarks>
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
PipeNameFor(paths),
|
||||
PipeDirection.Out);
|
||||
|
||||
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
writer.WriteLine(ComeForward);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||
// "no".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||
/// <remarks>
|
||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||
/// there and sometimes locked has one fewer state.
|
||||
/// </remarks>
|
||||
private static IDisposable? TryTake(string lockFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(
|
||||
lockFile,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Somebody else holds it. The one expected failure, and the whole point.
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||
/// </remarks>
|
||||
private static string PipeNameFor(ClientPaths paths)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
|
||||
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||
private sealed class NoClaim : IDisposable
|
||||
{
|
||||
internal static NoClaim Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing was taken.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The accept loop that answers refused launches.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
||||
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
||||
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
||||
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
||||
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
||||
/// so a second launch that connected while the first was being served has its connection dropped on the
|
||||
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
||||
/// launch was told it had rung the doorbell and the window never came forward.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
||||
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
||||
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
||||
/// there is never a moment with nothing listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
||||
/// the stream it was given, and the stream is now the server that has to survive into the next
|
||||
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class Doorbell : IAsyncDisposable
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly Action comeForward;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
internal Doorbell(string name, Action comeForward)
|
||||
{
|
||||
this.name = name;
|
||||
this.comeForward = comeForward;
|
||||
}
|
||||
|
||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (loop is { } running)
|
||||
{
|
||||
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||
// would otherwise race its own successor for the name.
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
name,
|
||||
PipeDirection.In,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||
{
|
||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
{
|
||||
comeForward();
|
||||
}
|
||||
}
|
||||
|
||||
HangUp(server);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
||||
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
||||
// this class exists to avoid.
|
||||
HangUp(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||
/// <remarks>
|
||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
||||
/// find. Throwing out of the error path would take the loop with it.
|
||||
/// </remarks>
|
||||
private static void HangUp(NamedPipeServerStream server)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (server.IsConnected)
|
||||
{
|
||||
server.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already disconnected. Nothing to end.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,27 @@ internal static class Program
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
/*
|
||||
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||
this process rather than of the application.
|
||||
|
||||
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||
|
||||
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,13 +111,33 @@
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<!--
|
||||
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||
connection leaves this card holding the only copy on screen.
|
||||
|
||||
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||
be taken and the second cannot is a worse answer than either.
|
||||
|
||||
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||
-->
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -636,16 +636,15 @@
|
||||
|
||||
<!--
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
|
||||
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
|
||||
and still the reason this box says what it says.
|
||||
title beside it rather than under it. The "(not wired up yet)" this title carried while the
|
||||
client had no path to the relay is gone, because it has one: a relayed host is connected
|
||||
through a ticket, a WebSocket and a loopback bridge. See docs/reaching-a-host-you-cannot-dial.md.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
|
||||
<TextBlock Text="Connect through the server relay" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
@@ -655,19 +654,13 @@
|
||||
to resolve the target, or it becomes an authenticated open proxy into the operator's
|
||||
network.
|
||||
|
||||
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||
concession in the design — the address in plaintext columns — and delivered a
|
||||
connection that failed exactly as it had before, with nothing saying why.
|
||||
|
||||
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||
be able to lose it, and a disabled control would trap the concession on. See
|
||||
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||
removable.
|
||||
It also says the two things a user would otherwise find out by failing: the server,
|
||||
not this machine, has to be able to reach the address — the relay is no help against a
|
||||
deployment outside the target's network — and a relayed host does not fall back to
|
||||
dialling directly, so it connects only while the server is reachable.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||
Text="The server dials this host on your behalf, for a machine this computer cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in plain text; everything else about the host stays encrypted. A relayed host connects only while you are online." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="DodoSSH.Client.App.Views.SplashCard">
|
||||
|
||||
<!--
|
||||
What is on screen while the application composes itself.
|
||||
|
||||
── WHY THIS IS ITS OWN CONTROL ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
The same reason UpdateBanner is: a Window cannot be laid out by the headless suite when it contains a
|
||||
WebView, and while this one does not, a UserControl is what LayoutHarness can host and measure. See
|
||||
SplashCardTests. It also keeps the window itself down to a frame and a size.
|
||||
|
||||
── AND WHY THERE IS NO VIEW MODEL ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
There is nothing to observe. Composition is one synchronous block — see DodoSshApp.ComposeBehind — so
|
||||
this control has exactly two states, both of them set by the one caller: starting, and could not. Two
|
||||
methods on the code-behind is the whole of it, and a view model would be an observable object with a
|
||||
lifetime and a test of its own standing in for two assignments.
|
||||
-->
|
||||
<Border Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1">
|
||||
|
||||
<StackPanel Margin="28" Spacing="12" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Classes="heading" Text="DodoSSH" />
|
||||
|
||||
<TextBlock x:Name="Line" Classes="hint" Text="Starting…" />
|
||||
|
||||
<!--
|
||||
── AND NO PROGRESS BAR, WHICH IS A REMOVAL RATHER THAN AN OMISSION ─────────────────────────────────
|
||||
|
||||
There was an indeterminate one here, and on screen it did not move. The reason is the same fact this
|
||||
whole window exists for: composition is a synchronous block, and DodoSshApp posts it to the very
|
||||
dispatcher that would have to animate the bar. Background priority buys the splash its first paint
|
||||
and nothing after it — once composition starts, the UI thread is inside it until the main window is
|
||||
ready, and an indeterminate bar's animation is driven by that thread's clock. So it painted one
|
||||
frame and froze.
|
||||
|
||||
A frozen indeterminate bar does not read as "indeterminate". It reads as a determinate bar stuck at
|
||||
zero, which is the one thing worse than no bar at all: it says the work has stalled at the exact
|
||||
moment the application is busiest. Nothing here can honestly be measured either — a SQLite open, a
|
||||
loopback bind, a TPM probe and an install-layout read report no progress between them.
|
||||
|
||||
So the line above is the whole of it. "Starting…" is true for as long as it is on screen, it needs
|
||||
no clock to stay true, and the window closing is what says it finished.
|
||||
-->
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>What is on screen while the application composes itself.</summary>
|
||||
/// <remarks>
|
||||
/// The two methods below are the whole interface, and both are called by
|
||||
/// <c>DodoSshApp.ComposeBehind</c> and by nothing else. See the control's own markup for why there is no
|
||||
/// view model behind it.
|
||||
/// </remarks>
|
||||
internal sealed partial class SplashCard : UserControl
|
||||
{
|
||||
public SplashCard() => InitializeComponent();
|
||||
|
||||
/// <summary>Says what is happening, while it still is.</summary>
|
||||
internal void Say(string what) => Line.Text = what;
|
||||
|
||||
/// <summary>
|
||||
/// Says why nothing is going to happen, and stops pretending otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The line is the whole message now that the progress bar has gone — see the markup for why it went.
|
||||
/// It used to have to stop the bar as well: a composition that threw leaves this window as the only one
|
||||
/// there is, with no main window to hand over to, so anything still claiming to be working would be
|
||||
/// doing it over a process that has stopped. One assignment says it instead, and there is nothing left
|
||||
/// on this card that could contradict it.
|
||||
/// </remarks>
|
||||
internal void CouldNotStart(string why) => Line.Text = why;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SplashWindow"
|
||||
Title="DodoSSH"
|
||||
Icon="/Assets/dodossh.ico"
|
||||
Width="420"
|
||||
MinHeight="160"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource Chrome}"
|
||||
WindowDecorations="None">
|
||||
|
||||
<!--
|
||||
── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
The main window cannot be shown until it has a view model, and the view model cannot exist until
|
||||
composition has opened the SQLite cache, bound the terminal's loopback socket, asked the platform about
|
||||
a TPM and read the install layout off disk. Until this window existed, all of that happened with nothing
|
||||
whatever on screen: the icon had been double-clicked and the desktop looked unchanged.
|
||||
|
||||
The main window's own IsStarting card already narrates what happens *after* it appears — see
|
||||
MainWindow.axaml — and this is the part before that, which no arrangement of the main window can cover.
|
||||
|
||||
── NOT TOPMOST, AND NO TASKBAR SUPPRESSION ────────────────────────────────────────────────────────────
|
||||
|
||||
Both are the usual splash-screen habits and both are wrong here. Topmost means a start that stalls sits
|
||||
over whatever the user gave up and went back to; hiding the taskbar entry means the one place they would
|
||||
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
|
||||
one.
|
||||
|
||||
WindowDecorations="None" rather than the BorderOnly the main window asks for: this cannot be resized, has
|
||||
no titlebar of its own to drag by, and lives for about a second. (The main window still says
|
||||
SystemDecorations, which Avalonia 12 has deprecated in favour of this property; that one is not this
|
||||
change's to move.) What replaces the frame is the card's
|
||||
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
|
||||
|
||||
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
|
||||
|
||||
A fixed height is right for the one line this normally shows and wrong for the one thing that can replace
|
||||
it: CouldNotStart carries an exception's own message, whose length nothing here decides. Inside a fixed
|
||||
height a long one would wrap and be clipped, at exactly the moment the text is the only thing this window
|
||||
is for. So the height comes from the content, and MinHeight keeps the ordinary one line from looking
|
||||
cramped in a box sized for it alone.
|
||||
-->
|
||||
<views:SplashCard x:Name="Card" />
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,56 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The window that is up while the application composes itself.</summary>
|
||||
/// <remarks>
|
||||
/// A frame around <see cref="SplashCard"/> and nothing else. Its whole lifetime is one call to
|
||||
/// <c>DodoSshApp.ComposeBehind</c>: shown by the desktop lifetime as the main window, replaced by the real
|
||||
/// one, closed. The exception is a composition that fails, which is the only case this window outlives.
|
||||
/// </remarks>
|
||||
internal sealed partial class SplashWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether Escape may close this.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// False while composition is running, and that is not caution for its own sake: this window has no
|
||||
/// close button, so Escape is the only key it could offer — and closing it mid-composition would leave a
|
||||
/// process that has opened the cache and bound a socket with no window and no way to reach either. Once
|
||||
/// there is nothing left to wait for, it is the only way out and has to work.
|
||||
/// </remarks>
|
||||
private bool escapable;
|
||||
|
||||
public SplashWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (escapable && e.Key == Key.Escape)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Says what is happening, while it still is.</summary>
|
||||
internal void Say(string what) => Card.Say(what);
|
||||
|
||||
/// <summary>
|
||||
/// Says why nothing is going to happen, and becomes the last window there is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The way out is named in the message rather than drawn as a button. A failure here is a broken
|
||||
/// installation — a cache that cannot be opened, a port that cannot be bound — so what this window is
|
||||
/// for at that point is carrying one sentence to whoever has to act on it, and a control that has to be
|
||||
/// laid out and measured to say "close me" earns less than the words do.
|
||||
/// </remarks>
|
||||
internal void CouldNotStart(string why)
|
||||
{
|
||||
escapable = true;
|
||||
|
||||
Card.CouldNotStart($"DodoSSH could not start: {why} Press Escape to close.");
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,26 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
/// </remarks>
|
||||
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
||||
|
||||
/// <summary>
|
||||
/// The file one desktop instance holds open for as long as it is running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named here rather than in the head that uses it, because every other name in this directory is named
|
||||
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
|
||||
/// Android head has no use for it: the platform allows one process per package and needs no help.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
|
||||
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
|
||||
/// release build do not exclude each other and two people signed into one machine each get their own
|
||||
/// instance — neither of which a global name would give without repeating the same two decisions in
|
||||
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
|
||||
/// handle, which the operating system releases when the process ends however it ends.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Session.Tests" />
|
||||
<!-- The shell suite drives a real ServerRelay over an in-process socket through its test constructor. -->
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -161,6 +161,15 @@ public interface IVaultServer : IDisposable
|
||||
/// </remarks>
|
||||
IVaultEventStream Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reaches a host through the server, for a host set to connect that way.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never null, for the reason <see cref="Events"/> is not: a server without a relay supplies
|
||||
/// <see cref="UnavailableServerRelay"/>, which refuses in words. See ADR 0004.
|
||||
/// </remarks>
|
||||
IServerRelay Relay { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
@@ -226,6 +235,12 @@ public sealed class ServerConnection : IVaultServer
|
||||
Events = meta.Features.Contains(VaultEvents.Feature, StringComparer.Ordinal)
|
||||
? new VaultEventStream(serverUrl, tokens, clock)
|
||||
: IdleVaultEventStream.Instance;
|
||||
|
||||
// Decided the same way and for the same reason. Configuration.Relay says the same thing twice;
|
||||
// meta is what the server says it does, and is what Events is decided from.
|
||||
Relay = meta.Features.Contains(RelayProtocol.Feature, StringComparer.Ordinal)
|
||||
? new ServerRelay(api)
|
||||
: UnavailableServerRelay.Instance;
|
||||
}
|
||||
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
@@ -261,6 +276,9 @@ public sealed class ServerConnection : IVaultServer
|
||||
/// <inheritdoc />
|
||||
public IVaultEventStream Events { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IServerRelay Relay { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Reaches a host through the server, for a host this machine cannot dial itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Always present on <see cref="IVaultServer"/>, like <see cref="IVaultServer.Events"/>: a server that does
|
||||
/// not relay supplies <see cref="UnavailableServerRelay"/>, whose answer is a sentence saying so. That keeps
|
||||
/// the connect path on one shape, and it means the refusal a user reads is the same whether the server said
|
||||
/// no before being asked or when it was.
|
||||
/// </remarks>
|
||||
public interface IServerRelay
|
||||
{
|
||||
/// <summary>Whether this server advertises a relay at all.</summary>
|
||||
bool IsOffered { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Opens a relay to one host, ready for a connection to be made through it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
|
||||
/// use it and never ahead of one.
|
||||
/// </remarks>
|
||||
/// <exception cref="ServerRelayException">The relay could not be opened; the message says why.</exception>
|
||||
Task<RelayRoute> OpenAsync(Guid hostId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open relay, one connection long.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Handing <see cref="Proxy"/> to a connection hands the bridge over with it, so the connection's own
|
||||
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
||||
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||
/// </remarks>
|
||||
public sealed class RelayRoute : IAsyncDisposable
|
||||
{
|
||||
private readonly RelayBridge bridge;
|
||||
|
||||
internal RelayRoute(RelayBridge bridge) => this.bridge = bridge;
|
||||
|
||||
/// <summary>The loopback proxy to connect through, which owns the relay from then on.</summary>
|
||||
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// What the relay said when it closed, in words a user can act on; null when it has not closed, or
|
||||
/// closed with nothing to say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For a connect that failed. SSH.NET reports a relay that hung up as a stream that ended — which reads
|
||||
/// exactly like the direct connection that failed and sent the user to the relay — so the relay's own
|
||||
/// reason, when it gave one, replaces it.
|
||||
/// </remarks>
|
||||
public string? Failure => bridge.CloseStatus is { } status ? Describe((int)status) : null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||
|
||||
private static string? Describe(int code) => code switch
|
||||
{
|
||||
RelayProtocol.TargetUnreachableCloseCode =>
|
||||
"The server relay could not reach this host. The server dials it on your behalf, so the "
|
||||
+ "address has to be one the server can reach — check it from the server's side.",
|
||||
|
||||
RelayProtocol.TooManySessionsCloseCode =>
|
||||
"The server relay has as many sessions open as it allows. Close one and try again.",
|
||||
|
||||
(int)WebSocketCloseStatus.EndpointUnavailable =>
|
||||
"The server relay is restarting. Try again in a moment.",
|
||||
|
||||
RelayProtocol.IdleTimeoutCloseCode => "The server relay closed the connection for being idle.",
|
||||
|
||||
RelayProtocol.DurationLimitCloseCode =>
|
||||
"The server relay closed the connection: it reached the longest session the server allows.",
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A relay that could not be opened, with the reason in its message.</summary>
|
||||
/// <remarks>
|
||||
/// Its own type so the connect path can tell a relay refusal from a connection failure. The first has an
|
||||
/// exact reason already, and running the reachability probe over it would dial the direct address — the one
|
||||
/// the user chose the relay because they cannot reach — and append a finding about the wrong path.
|
||||
/// </remarks>
|
||||
public sealed class ServerRelayException : Exception
|
||||
{
|
||||
/// <summary>A refusal with nothing underneath it.</summary>
|
||||
public ServerRelayException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A refusal and what caused it.</summary>
|
||||
public ServerRelayException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The relay of a server that does not offer one.</summary>
|
||||
public sealed class UnavailableServerRelay : IServerRelay
|
||||
{
|
||||
private UnavailableServerRelay()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>The one instance.</summary>
|
||||
public static UnavailableServerRelay Instance { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffered => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RelayRoute> OpenAsync(Guid hostId, CancellationToken cancellationToken) =>
|
||||
Task.FromException<RelayRoute>(new ServerRelayException(ServerRelay.NotOffered));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relay of a server that offers one: a ticket from the API, a socket, and a bridge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See ADR 0004 for the protocol and <c>docs/reaching-a-host-you-cannot-dial.md</c> for why the bridge
|
||||
/// speaks SOCKS5. Every way this can fail is turned into a sentence here, because the codes are the
|
||||
/// server's and the one place that should know what each means is the one that asked.
|
||||
/// </remarks>
|
||||
public sealed class ServerRelay : IServerRelay
|
||||
{
|
||||
/// <summary>What a user is told when the server they are signed in to does not relay.</summary>
|
||||
internal const string NotOffered =
|
||||
"This host is set to connect through the server relay, and this server does not offer one. "
|
||||
+ "Untick “Connect through the server relay” in the host's settings to dial it directly.";
|
||||
|
||||
private readonly IRelayApi api;
|
||||
private readonly Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect;
|
||||
|
||||
/// <summary>A relay reached through this API client.</summary>
|
||||
public ServerRelay(IRelayApi api)
|
||||
: this(api, DialAsync)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A relay whose socket is opened by <paramref name="connect"/>, for tests.</summary>
|
||||
internal ServerRelay(
|
||||
IRelayApi api,
|
||||
Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect)
|
||||
{
|
||||
this.api = api;
|
||||
this.connect = connect;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffered => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RelayRoute> OpenAsync(Guid hostId, CancellationToken cancellationToken)
|
||||
{
|
||||
RelayTicketResponse ticket;
|
||||
|
||||
try
|
||||
{
|
||||
ticket = await api.IssueRelayTicketAsync(hostId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
throw new ServerRelayException(Describe(exception), exception);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new ServerRelayException(
|
||||
$"The server could not be reached to open the relay: {exception.Message}",
|
||||
exception);
|
||||
}
|
||||
|
||||
// The ticket as a second subprotocol, which is how ADR 0004 carries it: a header every WebSocket
|
||||
// client can set. The server echoes the first alone.
|
||||
var socket = await connect(
|
||||
ticket.WebSocketUrl,
|
||||
[RelayProtocol.SubProtocol, RelayProtocol.TicketProtocolPrefix + ticket.Ticket],
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new RelayRoute(RelayBridge.Start(socket));
|
||||
}
|
||||
|
||||
private static string Describe(DodoSshApiException exception) => exception.Code switch
|
||||
{
|
||||
ProblemCodes.RelayUnavailable => NotOffered,
|
||||
|
||||
// Deliberately unspecific on the server's side, so that it cannot be used to map the operator's
|
||||
// network. The likeliest causes from here are the two this machine can check.
|
||||
ProblemCodes.RelayTargetRejected =>
|
||||
"The server refused to relay to this host. Check that the change turning the relay on has "
|
||||
+ "synchronised, and that the address is one the server is allowed to reach; the server's log "
|
||||
+ "records which.",
|
||||
|
||||
ProblemCodes.RelayLimitReached =>
|
||||
"You have as many relay sessions open as the server allows. Close one and try again.",
|
||||
|
||||
_ => $"The server would not open the relay: {exception.Message}",
|
||||
};
|
||||
|
||||
/// <summary>Opens the relay socket, and translates a refused upgrade into its reason.</summary>
|
||||
/// <remarks>
|
||||
/// The status of a refused upgrade is collected, which a constrained client could not do and this one
|
||||
/// can: a 401 is a ticket the relay would not take — spent, expired, or issued by a node whose key ring
|
||||
/// this one does not share — and asking again is the remedy.
|
||||
/// </remarks>
|
||||
private static async Task<WebSocket> DialAsync(
|
||||
Uri url,
|
||||
IReadOnlyList<string> protocols,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new ClientWebSocket();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var protocol in protocols)
|
||||
{
|
||||
socket.Options.AddSubProtocol(protocol);
|
||||
}
|
||||
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
|
||||
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
var status = socket.HttpStatusCode;
|
||||
socket.Dispose();
|
||||
|
||||
throw new ServerRelayException(
|
||||
status == HttpStatusCode.Unauthorized
|
||||
? "The server relay refused its ticket. Try connecting again."
|
||||
: $"The server relay could not be reached: {exception.Message}",
|
||||
exception);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!string.Equals(socket.SubProtocol, RelayProtocol.SubProtocol, StringComparison.Ordinal))
|
||||
{
|
||||
socket.Abort();
|
||||
socket.Dispose();
|
||||
|
||||
throw new ServerRelayException(
|
||||
"The server relay answered in a protocol this app does not speak. Update the app or the server.");
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Func<Task<string?>>? readClipboard;
|
||||
|
||||
/// <summary>
|
||||
/// What explains a connection that would not reach its host, handed to every <see cref="VaultViewModel"/>
|
||||
/// this shell builds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held rather than passed straight through, because the vault is not built here: it is built on unlock,
|
||||
/// which happens once per session and possibly more than once. Null means the standard probe, which is
|
||||
/// what both heads use — a test passes one to keep a failed connection from putting a real DNS lookup and
|
||||
/// a real socket in the middle of a unit suite.
|
||||
/// </remarks>
|
||||
private readonly ISshReachabilityProbe? reachability;
|
||||
|
||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||
private readonly Action<Action> post;
|
||||
|
||||
@@ -556,8 +568,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = null,
|
||||
Func<string, Task>? openInEditor = null,
|
||||
Func<string, Action, IDisposable>? watchFile = null)
|
||||
Func<string, Action, IDisposable>? watchFile = null,
|
||||
ISshReachabilityProbe? reachability = null)
|
||||
{
|
||||
this.reachability = reachability;
|
||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||
|
||||
this.paths = paths;
|
||||
@@ -2871,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog,
|
||||
visibility);
|
||||
visibility,
|
||||
reachability);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -3746,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
AdoptTab(tab);
|
||||
@@ -3810,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
HostId = e.HostId,
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
@@ -4021,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
||||
/// The host this tab was opened against, or null when the keychain no longer holds it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock.
|
||||
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
|
||||
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
|
||||
/// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
|
||||
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
|
||||
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
|
||||
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id is the item's own
|
||||
/// sync identity rather than a reference to a row object, so a vault that is locked and opened again
|
||||
/// rebuilds its list around the same ids and a tab from before the lock finds its host in it. A host
|
||||
/// renamed since the tab opened is found too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||
@@ -4036,10 +4061,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||
tab is null || Vault is not { } vault
|
||||
tab?.HostId is not { } hostId || Vault is not { } vault
|
||||
? null
|
||||
: vault.Hosts.FirstOrDefault(
|
||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
||||
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected tab has stopped and there is something to dial again.
|
||||
@@ -4367,10 +4391,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
||||
/// wrong than a dot that goes dark when the vault is reopened.
|
||||
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
|
||||
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
|
||||
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
|
||||
/// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
|
||||
/// offers, which would then be another host's folders.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
|
||||
/// correct: there is no row here that it is a session on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||
@@ -4393,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
foreach (var host in vault.Hosts)
|
||||
{
|
||||
host.IsConnected = Tabs.Any(
|
||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
|
||||
|
||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
|
||||
{
|
||||
connectedActiveHost = host;
|
||||
}
|
||||
@@ -4534,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
||||
/// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
|
||||
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
|
||||
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (HostFor(SelectedTab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4623,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void PinFolderFromSidebar()
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4659,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| tab is null
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (HostFor(tab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
|
||||
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
|
||||
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
|
||||
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
|
||||
/// the one place being approximately right is dialling a stranger. See
|
||||
/// <c>MainWindowViewModel.HostFor</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
|
||||
/// an index into the list on screen — see <c>VaultItem<TSecret>.EntityId</c> — so a vault that is
|
||||
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
|
||||
/// finds its host again. A rename finds it too, which a name match by definition cannot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
|
||||
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal Guid? HostId { get; init; }
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
internal string Address { get; }
|
||||
|
||||
|
||||
@@ -1795,25 +1795,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
ISftpSession opened;
|
||||
|
||||
try
|
||||
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
||||
is not { } opened)
|
||||
{
|
||||
opened = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact, decided here rather than inherited from a terminal. File transfer is
|
||||
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
||||
// same pin a shell would then find.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
return;
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. Nothing was connected.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1831,6 +1815,55 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the session on a host, through the relay when the host uses it, or says why not.
|
||||
/// </summary>
|
||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
||||
VaultViewModel open,
|
||||
HostRowViewModel row,
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RelayRoute? relay = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Through the relay for a host set to use it, exactly as a shell to it would be. The
|
||||
// session owns the relay from here, on success and on failure alike.
|
||||
relay = row.Host.RelayEnabled
|
||||
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
|
||||
return await sftp
|
||||
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (relay?.Failure is { } failure
|
||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||
{
|
||||
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
|
||||
Status = failure;
|
||||
return null;
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact, decided here rather than inherited from a terminal. File transfer is
|
||||
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
||||
// same pin a shell would then find.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
return null;
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. Nothing was connected.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that a host connection just succeeded: the address, the negotiated facts, and the ticket the
|
||||
/// disconnect log closes out later.
|
||||
|
||||
@@ -947,6 +947,7 @@ internal static class ItemBadge
|
||||
/// <param name="identityLabel">
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||
/// </param>
|
||||
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||||
internal sealed class TerminalSessionEventArgs(
|
||||
Guid attemptId,
|
||||
uint sessionId,
|
||||
@@ -954,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
|
||||
string address,
|
||||
string cipher,
|
||||
string hostKeyAlgorithm,
|
||||
string? identityLabel) : EventArgs
|
||||
string? identityLabel,
|
||||
Guid? hostId) : EventArgs
|
||||
{
|
||||
/// <summary>Which attempt this session came out of.</summary>
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||
@@ -971,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
|
||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||
|
||||
internal string? IdentityLabel { get; } = identityLabel;
|
||||
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
|
||||
internal Guid? HostId { get; } = hostId;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this will be logged in as, and where.</param>
|
||||
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
|
||||
/// <remarks>
|
||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||||
/// has no vault to read it from.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
||||
internal sealed class ConnectionAttemptEventArgs(
|
||||
Guid attemptId,
|
||||
string label,
|
||||
string address,
|
||||
Guid? hostId) : EventArgs
|
||||
{
|
||||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||
/// <remarks>
|
||||
@@ -995,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
|
||||
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
|
||||
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
|
||||
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
|
||||
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
|
||||
/// see <c>TerminalTabViewModel.HostId</c>.
|
||||
/// </remarks>
|
||||
internal Guid? HostId { get; } = hostId;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has got as far as a named step.</summary>
|
||||
@@ -1445,8 +1468,18 @@ internal sealed partial class VaultViewModel(
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
VaultVisibility? visibility = null,
|
||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
|
||||
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
|
||||
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
|
||||
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
|
||||
/// </remarks>
|
||||
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -11233,7 +11266,52 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||||
{
|
||||
public void Report(SshConnectionPhase value) => report(value);
|
||||
private int furthest = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The furthest phase reported, or null if the handshake never reported one at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
|
||||
/// only for a connection that stopped while reaching, and this is the record of how far it got.
|
||||
/// Nothing else asks — the card is driven by the events, not by this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
|
||||
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
|
||||
/// raises host key verification on its own message thread, which is where
|
||||
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
|
||||
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
|
||||
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
|
||||
/// language says so, and a cached read here would probe a host that had already offered a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
|
||||
/// maximum rather than the latest is what keeps that an observation rather than something this type
|
||||
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
|
||||
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public SshConnectionPhase? Furthest =>
|
||||
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
|
||||
|
||||
public void Report(SshConnectionPhase value)
|
||||
{
|
||||
var reported = (int)value;
|
||||
|
||||
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
|
||||
// race it loses is two phases reported at the same instant from two threads, which this
|
||||
// handshake does not do — it is one connection making one call at a time — and the cost of
|
||||
// losing it would be one phase forgotten rather than a wrong answer.
|
||||
if (reported > Volatile.Read(ref furthest))
|
||||
{
|
||||
Volatile.Write(ref furthest, reported);
|
||||
}
|
||||
|
||||
report(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||
@@ -11271,7 +11349,8 @@ internal sealed partial class VaultViewModel(
|
||||
var attempt = new ConnectionAttemptEventArgs(
|
||||
Guid.CreateVersion7(),
|
||||
target.Label,
|
||||
Dialled(target, authentication));
|
||||
Dialled(target, authentication),
|
||||
target.HostId);
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {target.Label}…";
|
||||
@@ -11506,9 +11585,13 @@ internal sealed partial class VaultViewModel(
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Built here rather than inside the call, because the catch blocks below read it: how far the
|
||||
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
|
||||
var reporter = ReporterFor(attempt);
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
@@ -11534,10 +11617,90 @@ internal sealed partial class VaultViewModel(
|
||||
catch (Exception exception)
|
||||
{
|
||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||||
Abandon(attempt, exception.Message);
|
||||
|
||||
Abandon(
|
||||
attempt,
|
||||
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What to tell the user about a connection that failed, having gone and looked when the message alone
|
||||
/// does not say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
|
||||
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
|
||||
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
|
||||
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
|
||||
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
|
||||
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
|
||||
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
|
||||
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
|
||||
/// remote would not take is not a reachability question, and probing the port would answer one nobody
|
||||
/// asked while telling somebody their network is fine when they already know.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
|
||||
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
|
||||
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
|
||||
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
|
||||
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
|
||||
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
|
||||
/// guesses; see the enum.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
|
||||
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
|
||||
/// against, and would lose the one case where the exception is already specific.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
|
||||
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
|
||||
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
|
||||
/// this ran is not waiting for the answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<string> ExplainAsync(
|
||||
Exception exception,
|
||||
ConnectionTarget target,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
||||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
||||
// they cannot use — and report on that instead.
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = await Reachability
|
||||
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
|
||||
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
|
||||
// from changing the text of every failure that goes past it.
|
||||
return report.Summary is { Length: > 0 } finding
|
||||
? $"{exception.Message} {finding}"
|
||||
: exception.Message;
|
||||
}
|
||||
catch (Exception probe) when (probe is not OutOfMemoryException)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="DefaultReachability" />
|
||||
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
|
||||
|
||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11603,6 +11766,7 @@ internal sealed partial class VaultViewModel(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Not reported before the await: the tab is constructed with this step already running — see
|
||||
@@ -11615,9 +11779,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||
@@ -11647,7 +11809,8 @@ internal sealed partial class VaultViewModel(
|
||||
Dialled(target, authentication),
|
||||
facts?.Cipher ?? string.Empty,
|
||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||
authentication.IdentityLabel));
|
||||
authentication.IdentityLabel,
|
||||
target.HostId));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
@@ -11661,6 +11824,90 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a relay to a host set to connect through the server, or says why there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>There is no falling back to a direct connection.</b> A user who ticked the relay did so because
|
||||
/// the direct path does not work, or goes somewhere they do not mean — and dialling it anyway on a
|
||||
/// failure would be the old defect in reverse: the box would say one thing and the connection do
|
||||
/// another. So a relay that cannot be opened is the failure, stated as such.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server is resolved the way a synchronisation resolves it, which comes back online quietly when a
|
||||
/// sign-in is remembered. Offline is its own sentence, because it is the one case the user can fix from
|
||||
/// this machine without touching the host.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
||||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
throw new ServerRelayException(
|
||||
$"{label} connects through the server relay, and this machine is not connected to the "
|
||||
+ "server right now. Try again once it is back online.");
|
||||
}
|
||||
|
||||
return await server.Relay.OpenAsync(hostId, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
||||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
||||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||||
/// bridge listening until its own timeout; disposing twice is safe.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<uint> OpenThroughAsync(
|
||||
ConnectionTarget target,
|
||||
SshConnectionRequest request,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||||
var relay = target.Relayed
|
||||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
return await workspace
|
||||
.OpenSessionAsync(
|
||||
relay is null ? request : request with { Proxy = relay.Proxy },
|
||||
TerminalSize.Default,
|
||||
reporter,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (relay is not null)
|
||||
{
|
||||
await relay.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
if (relay.Failure is { } failure
|
||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||
{
|
||||
throw new ServerRelayException(failure, exception);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the password that just worked into a keychain credential bound to this host.
|
||||
/// </summary>
|
||||
@@ -11879,6 +12126,15 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is reached through the server relay rather than dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
||||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
||||
/// </remarks>
|
||||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,6 +34,7 @@ const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
const SERVER_FONT_SIZE = 7;
|
||||
const SERVER_CLIPBOARD = 8;
|
||||
const SERVER_OUTPUT_DROPPED = 9;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -534,6 +535,24 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_OUTPUT_DROPPED: {
|
||||
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||
|
||||
/*
|
||||
The host could not keep the whole of a flood, and this is the line saying where the gap is. It
|
||||
goes in exactly here, between the bytes either side of it — the socket keeps order within a
|
||||
session, so nothing has to be done to place it.
|
||||
|
||||
Written without an acknowledgement, deliberately. Credit describes what the remote sent and what
|
||||
this page has drawn of it; these bytes came from the host itself, and acknowledging them would
|
||||
hand back credit for output that was never reserved against the window in the first place. The
|
||||
payload arrives already styled and already terminated by a reset — see the host's own remark for
|
||||
why the reset comes first as well.
|
||||
*/
|
||||
session.term.write(payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_ACTIVATED: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// A loopback SOCKS5 proxy that carries exactly one connection over one relay WebSocket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SSH.NET cannot be handed a stream, only an address — so the relay, which is a WebSocket, is reached by
|
||||
/// giving SSH.NET an address that leads into one. This listens on <c>127.0.0.1</c> at a port the system
|
||||
/// chooses, answers one SOCKS5 CONNECT, and from then on moves bytes: what SSH.NET writes goes out as binary
|
||||
/// messages, and what the relay sends comes back as the stream SSH.NET reads. That is ADR 0004's loopback
|
||||
/// bridge, and step 3 of <c>docs/reaching-a-host-you-cannot-dial.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>SOCKS5 rather than a plain pipe, so the host stays the host.</b> The connection made through this is
|
||||
/// told the target's real name and port, and pins its host key under them; the CONNECT carrying that name
|
||||
/// arrives here and is answered without being acted on, because where the bytes go was decided by the
|
||||
/// server when it issued the ticket, and nothing this side says can change it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three properties are the security of this, and each is held by a test. It binds loopback and nothing
|
||||
/// else — asserted on the bound address, because a bridge on every interface would be a proxy into the
|
||||
/// relay for anyone on the network and nothing would report it. It accepts once and stops listening, so a
|
||||
/// second process on this machine cannot take a turn. And it gives up if nobody arrives within
|
||||
/// <see cref="AcceptTimeout"/>, so a connect that failed before dialling cannot leave it waiting.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No operation on the WebSocket is cancelled.</b> Cancelling one aborts a .NET WebSocket, and an aborted
|
||||
/// one never reads the close frame that says why the relay ended — which is the one thing the user needs to
|
||||
/// be told when the server could not reach the host. The bridge stops a direction by closing, and waits a
|
||||
/// bounded time for the other side to agree, as the relay's own endpoint does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RelayBridge : IAsyncDisposable
|
||||
{
|
||||
/// <summary>How long the bridge waits for its one connection before closing the relay unused.</summary>
|
||||
/// <remarks>
|
||||
/// The connection is made the moment the bridge exists, so this bounds a caller that failed between the
|
||||
/// two. Longer than any SSH connect timeout, so it never ends a connect that is merely slow.
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan AcceptTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private const int BufferBytes = 32 * 1024;
|
||||
|
||||
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly TcpListener listener;
|
||||
private readonly WebSocket socket;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly Task running;
|
||||
private int disposed;
|
||||
|
||||
private RelayBridge(WebSocket socket, TimeSpan acceptTimeout)
|
||||
{
|
||||
this.socket = socket;
|
||||
|
||||
// The address rather than "localhost", so there is no resolver and no hosts file between this and
|
||||
// the interface it means. See the remarks on SshLoopbackProxy.
|
||||
listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start(backlog: 1);
|
||||
|
||||
Endpoint = (IPEndPoint)listener.LocalEndpoint;
|
||||
running = RunAsync(acceptTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Where the bridge is listening. Loopback, always.</summary>
|
||||
public IPEndPoint Endpoint { get; }
|
||||
|
||||
/// <summary>The proxy to hand the connection, which then owns this bridge.</summary>
|
||||
public SshLoopbackProxy Proxy => new(Endpoint.Port, this);
|
||||
|
||||
/// <summary>
|
||||
/// How the relay closed the socket, once it has; null while it is open or when it vanished without
|
||||
/// saying.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read after a connect through the bridge fails. The relay says in a close code what went wrong on its
|
||||
/// side — the server could not reach the host, or has too many sessions open — and that is the sentence
|
||||
/// the user needs in place of the transport error SSH.NET reports for a stream that ended.
|
||||
/// </remarks>
|
||||
public WebSocketCloseStatus? CloseStatus { get; private set; }
|
||||
|
||||
/// <summary>The relay's own words about why it closed, alongside <see cref="CloseStatus"/>.</summary>
|
||||
public string? CloseDescription { get; private set; }
|
||||
|
||||
/// <summary>Completes when the bridge has finished: its one connection is over, or nobody came.</summary>
|
||||
public Task Completion => running;
|
||||
|
||||
/// <summary>Starts a bridge over an open relay socket, which it then owns.</summary>
|
||||
public static RelayBridge Start(WebSocket socket) => Start(socket, AcceptTimeout);
|
||||
|
||||
/// <summary>Starts a bridge with a shorter wait for its connection, for tests.</summary>
|
||||
internal static RelayBridge Start(WebSocket socket, TimeSpan acceptTimeout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(socket);
|
||||
|
||||
return new RelayBridge(socket, acceptTimeout);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Idempotent, because two owners may reach it: the connection that was handed the proxy, and a caller
|
||||
/// tidying up after a connect that never got that far.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await stopping.CancelAsync().ConfigureAwait(false);
|
||||
listener.Stop();
|
||||
|
||||
if (!await SettlesAsync(running, CloseTimeout * 2).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
await SettlesAsync(running, CloseTimeout).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
socket.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(TimeSpan acceptTimeout)
|
||||
{
|
||||
TcpClient? client = null;
|
||||
|
||||
try
|
||||
{
|
||||
using (var accepting = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token))
|
||||
{
|
||||
accepting.CancelAfter(acceptTimeout);
|
||||
client = await listener.AcceptTcpClientAsync(accepting.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Once. A second connection to this port is somebody else's, and there is nothing for it here.
|
||||
listener.Stop();
|
||||
client.NoDelay = true;
|
||||
|
||||
var stream = client.GetStream();
|
||||
|
||||
if (await Socks5.AcceptConnectAsync(stream, stopping.Token).ConfigureAwait(false))
|
||||
{
|
||||
await PipeAsync(stream).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or SocketException or IOException or ObjectDisposedException)
|
||||
{
|
||||
// Nobody came, the bridge was disposed, or the connection went away mid-handshake. The relay is
|
||||
// closed below either way.
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
client?.Dispose();
|
||||
await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves bytes both ways until either side ends, then lets the other finish.</summary>
|
||||
private async Task PipeAsync(NetworkStream stream)
|
||||
{
|
||||
var upstream = TcpToRelayAsync(stream);
|
||||
var downstream = RelayToTcpAsync(stream);
|
||||
|
||||
var first = await Task.WhenAny(upstream, downstream).ConfigureAwait(false);
|
||||
|
||||
if (first == upstream)
|
||||
{
|
||||
// SSH.NET closed its end. Say so to the relay, then give it a moment to answer, which is what
|
||||
// ends the receive in progress without aborting it.
|
||||
await CloseSocketAsync(WebSocketCloseStatus.NormalClosure).ConfigureAwait(false);
|
||||
|
||||
if (!await SettlesAsync(downstream, CloseTimeout).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The relay ended. Closing the TCP side is how SSH.NET hears it, as the end of its stream.
|
||||
stream.Socket.Shutdown(SocketShutdown.Both);
|
||||
await SettlesAsync(upstream, CloseTimeout).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TcpToRelayAsync(NetworkStream stream)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
int read;
|
||||
|
||||
while ((read = await stream.ReadAsync(buffer.AsMemory(0, BufferBytes), stopping.Token).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
// Not cancellable, deliberately. See the remarks on this class.
|
||||
await socket
|
||||
.SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, endOfMessage: true, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// Either end went away. PipeAsync decides what that means.
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RelayToTcpAsync(NetworkStream stream)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
// Not cancellable, deliberately. See the remarks on this class.
|
||||
var received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
CloseStatus = socket.CloseStatus;
|
||||
CloseDescription = socket.CloseStatusDescription;
|
||||
return;
|
||||
}
|
||||
|
||||
await stream.WriteAsync(buffer.AsMemory(0, received.Count), stopping.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or IOException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// Either end went away. PipeAsync decides what that means.
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends a close, if one can still be sent, bounded so a vanished peer cannot hold it.</summary>
|
||||
private async Task CloseSocketAsync(WebSocketCloseStatus status)
|
||||
{
|
||||
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(CloseTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.CloseOutputAsync(status, string.Empty, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// Gone already.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> SettlesAsync(Task task, TimeSpan within)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.WaitAsync(within).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The server half of SOCKS5 (RFC 1928), as much of it as SSH.NET speaks: no authentication, CONNECT.
|
||||
/// </summary>
|
||||
private static class Socks5
|
||||
{
|
||||
private const byte Version = 5;
|
||||
private const byte NoAuthentication = 0;
|
||||
private const byte NoAcceptableMethod = 0xFF;
|
||||
private const byte Connect = 1;
|
||||
private const byte Succeeded = 0;
|
||||
private const byte CommandNotSupported = 7;
|
||||
private const byte AddressTypeNotSupported = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Answers the greeting and one CONNECT, whatever it names.
|
||||
/// </summary>
|
||||
/// <returns>Whether the stream is now a tunnel; false when the client asked for something else.</returns>
|
||||
/// <remarks>
|
||||
/// The target in the request is read and discarded. It is the host the connection is <em>about</em>,
|
||||
/// which is why SSH.NET sends it, and it is not where the bytes go, which the relay decided. The bound
|
||||
/// address in the reply is all zeroes: nothing on the far side of a relay has an address the client
|
||||
/// could use, and SSH.NET does not read it.
|
||||
/// </remarks>
|
||||
internal static async Task<bool> AcceptConnectAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = await ReadAsync(stream, 2, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (header[0] != Version)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var methods = await ReadAsync(stream, header[1], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!methods.Contains(NoAuthentication))
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { Version, NoAcceptableMethod }, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
await stream.WriteAsync(new byte[] { Version, NoAuthentication }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var request = await ReadAsync(stream, 4, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request[0] != Version || request[1] != Connect)
|
||||
{
|
||||
await ReplyAsync(stream, CommandNotSupported, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var addressLength = request[3] switch
|
||||
{
|
||||
1 => 4,
|
||||
4 => 16,
|
||||
3 => (await ReadAsync(stream, 1, cancellationToken).ConfigureAwait(false))[0],
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (addressLength < 0)
|
||||
{
|
||||
await ReplyAsync(stream, AddressTypeNotSupported, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The address, then the port. Neither is used; see the remarks above.
|
||||
await ReadAsync(stream, addressLength + 2, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await ReplyAsync(stream, Succeeded, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Task ReplyAsync(Stream stream, byte status, CancellationToken cancellationToken) =>
|
||||
stream.WriteAsync(new byte[] { Version, status, 0, 1, 0, 0, 0, 0, 0, 0 }, cancellationToken).AsTask();
|
||||
|
||||
private static async Task<byte[]> ReadAsync(Stream stream, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
await stream.ReadExactlyAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,19 @@ public sealed record SshPrivateKeyCredential(byte[] PrivateKeyPem, string? Passp
|
||||
/// stand-in. See the gate in <c>SshNetConnectionFactory</c>, which pins what this request names.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing in this assembly opens one. The proxy is somebody else's — a forward on a bastion connection,
|
||||
/// or the relay bridge — and its lifetime belongs to whoever opened it, which must outlast the connection
|
||||
/// made through it. See <c>docs/reaching-a-host-you-cannot-dial.md</c>.
|
||||
/// <b>Whoever opens a proxy may hand it to the connection made through it.</b> A proxy with no
|
||||
/// <see cref="Lifetime"/> belongs to its opener, which must keep it listening for as long as the connection
|
||||
/// lives. One with a lifetime is given away: the connection disposes it after itself, and a connect that
|
||||
/// fails disposes it before throwing — so a proxy opened for one connection, which is what the relay bridge
|
||||
/// is, cannot outlive it on any of the paths a session ends by. See
|
||||
/// <c>docs/reaching-a-host-you-cannot-dial.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SshLoopbackProxy(int Port);
|
||||
/// <param name="Port">The loopback port the proxy listens on.</param>
|
||||
/// <param name="Lifetime">
|
||||
/// What keeps the proxy listening, when the connection is to own it; null when its opener does.
|
||||
/// </param>
|
||||
public sealed record SshLoopbackProxy(int Port, IAsyncDisposable? Lifetime = null);
|
||||
|
||||
/// <summary>Everything needed to reach one host.</summary>
|
||||
/// <param name="Host">Hostname or address.</param>
|
||||
|
||||
@@ -75,12 +75,22 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
|
||||
var lifetime = request.Proxy?.Lifetime;
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!, lifetime);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DisposeAsync(lifetime).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -95,6 +105,40 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var lifetime = request.Proxy?.Lifetime;
|
||||
|
||||
try
|
||||
{
|
||||
return await OpenSftpCoreAsync(request, lifetime, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DisposeAsync(lifetime).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes a proxy the connection was given, when the connection it was given for did not happen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every failure path, including the ones before a socket exists — an unreadable key throws from
|
||||
/// <see cref="BuildConnectionInfo"/> — because a relay bridge left listening after a failed connect is a
|
||||
/// loopback SOCKS proxy with an open relay session behind it, and nothing would ever close it.
|
||||
/// </remarks>
|
||||
private static async ValueTask DisposeAsync(IAsyncDisposable? lifetime)
|
||||
{
|
||||
if (lifetime is not null)
|
||||
{
|
||||
await lifetime.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ISftpSession> OpenSftpCoreAsync(
|
||||
SshConnectionRequest request,
|
||||
IAsyncDisposable? lifetime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||
@@ -125,7 +169,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetSftpSession(client, gate.Presented!, home);
|
||||
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -326,7 +370,15 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
}
|
||||
|
||||
/// <summary>An SSH.NET-backed connection.</summary>
|
||||
internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : ISshConnection
|
||||
/// <remarks>
|
||||
/// Owns the proxy it was reached through when it was given one, and disposes it after the client — the
|
||||
/// order that lets SSH.NET close its end of the loopback socket before the thing on the other end goes.
|
||||
/// </remarks>
|
||||
internal sealed class SshNetConnection(
|
||||
SshClient client,
|
||||
HostKeyPresentation hostKey,
|
||||
IAsyncDisposable? proxyLifetime = null)
|
||||
: ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => client.IsConnected;
|
||||
@@ -366,10 +418,14 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
client.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
|
||||
if (proxyLifetime is not null)
|
||||
{
|
||||
await proxyLifetime.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,23 @@ namespace DodoSSH.Client.Ssh;
|
||||
internal sealed class SshNetSftpSession : ISftpSession
|
||||
{
|
||||
private readonly SftpClient client;
|
||||
private readonly IAsyncDisposable? proxyLifetime;
|
||||
|
||||
private string? transportFailure;
|
||||
private int announced;
|
||||
|
||||
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
/// <param name="client">The connected client.</param>
|
||||
/// <param name="hostKey">What the server presented.</param>
|
||||
/// <param name="homeDirectory">Where the session starts.</param>
|
||||
/// <param name="proxyLifetime">The proxy this session was reached through, when it owns it.</param>
|
||||
internal SshNetSftpSession(
|
||||
SftpClient client,
|
||||
HostKeyPresentation hostKey,
|
||||
string homeDirectory,
|
||||
IAsyncDisposable? proxyLifetime = null)
|
||||
{
|
||||
this.client = client;
|
||||
this.proxyLifetime = proxyLifetime;
|
||||
|
||||
HostKey = hostKey;
|
||||
HomeDirectory = homeDirectory;
|
||||
@@ -243,7 +253,7 @@ internal sealed class SshNetSftpSession : ISftpSession
|
||||
|
||||
client.Dispose();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
return proxyLifetime?.DisposeAsync() ?? ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// What one look at an endpoint found, once a connection to it had already failed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Four, because they are the four a user would do something different about.</b> A name that does not
|
||||
/// resolve is a typo or a VPN that is down; a port that refuses is a machine that is up with no <c>sshd</c>
|
||||
/// on it; a port that says nothing at all is a firewall or a machine that is off; and a port that opens is
|
||||
/// the one that matters most, because it means the network is fine and the SSH handshake is what failed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here is inferred from the exception that started it.</b> Each value is what a socket actually
|
||||
/// did, measured at the moment it was asked, which is what makes it safe for a screen to state as fact —
|
||||
/// the same rule <see cref="SshConnectionPhase"/> is written to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum SshReach
|
||||
{
|
||||
/// <summary>The name does not resolve on this machine, so there was no address to dial.</summary>
|
||||
Unresolved = 0,
|
||||
|
||||
/// <summary>It resolved, and the port sent nothing back before the probe gave up.</summary>
|
||||
NoAnswer = 1,
|
||||
|
||||
/// <summary>It resolved, and the host said at once that nothing is listening there.</summary>
|
||||
Refused = 2,
|
||||
|
||||
/// <summary>It resolved, and something accepted a plain TCP connection on that port.</summary>
|
||||
Open = 3,
|
||||
}
|
||||
|
||||
/// <summary>What a probe of one endpoint found, and the sentence that says so.</summary>
|
||||
/// <param name="Reach">Which of the four it was.</param>
|
||||
/// <param name="Host">The name or address that was probed, as the connection named it.</param>
|
||||
/// <param name="Port">The port that was probed.</param>
|
||||
/// <param name="Address">
|
||||
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
|
||||
/// <para>
|
||||
/// One rather than all of them. A name with several addresses is dialled at whichever the stack picks
|
||||
/// first, and naming the one that was tried is a fact; listing the rest would suggest they were.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="Elapsed">How long the probe took, which is the evidence behind <see cref="SshReach.NoAnswer"/>.</param>
|
||||
/// <param name="Summary">The finding in plain words, ready to put in front of somebody.</param>
|
||||
public sealed record SshReachabilityReport(
|
||||
SshReach Reach,
|
||||
string Host,
|
||||
int Port,
|
||||
string? Address,
|
||||
TimeSpan Elapsed,
|
||||
string Summary);
|
||||
|
||||
/// <summary>Looks at an endpoint that would not connect, and reports what is actually wrong with it.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="ISshConnectionFactory"/> rather than a method on it, because it is not a way of
|
||||
/// connecting: it runs after one has failed, it authenticates nothing, and it must stay usable from a caller
|
||||
/// that has no credential to hand — which the failure path is, by the time it needs this.
|
||||
/// </remarks>
|
||||
public interface ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
|
||||
/// </summary>
|
||||
/// <param name="host">The hostname or address the failed connection named.</param>
|
||||
/// <param name="port">The port it named.</param>
|
||||
/// <param name="cancellationToken">Abandons the probe.</param>
|
||||
/// <remarks>
|
||||
/// Never throws for a host that is simply broken — being unreachable is the answer, not an error. A
|
||||
/// caller is already in a failure path and has nothing useful to do with a second exception.
|
||||
/// </remarks>
|
||||
ValueTask<SshReachabilityReport> ProbeAsync(string host, int port, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Why this exists at all: two unrelated failures arrive as the same sentence.</b> SSH.NET reports both
|
||||
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
|
||||
/// <c>SshOperationTimeoutException</c> with the message "Connection has timed out." Those are not the same
|
||||
/// problem and do not have the same fix — one is a network or a firewall, the other is an <c>sshd</c> that
|
||||
/// is wedged or a port that is not SSH at all — and nothing in the exception tells them apart. A socket does,
|
||||
/// in about as long as it takes to open one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain TCP connect and a name lookup, and deliberately nothing else.</b> No ping, because ICMP is
|
||||
/// filtered on most of the networks this product is pointed at and an unanswered ping would be evidence of
|
||||
/// nothing. No scan of neighbouring ports: the question is what happened to the connection the user asked
|
||||
/// for, and probing ports nobody asked about is the kind of thing that gets a client flagged by an intrusion
|
||||
/// detector on the very network somebody is trying to work on. No SSH banner read either — that is a second
|
||||
/// handshake against a server that has already failed one, and the open socket has answered the question.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The whole probe is bounded, and the bound is short.</b> By the time this runs the user has already
|
||||
/// waited out a connect timeout, so the budget here is a few seconds spent buying an answer rather than
|
||||
/// another wait to sit through. Exceeding it is itself a finding — nothing came back — so the deadline
|
||||
/// produces a result rather than an error.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SshReachabilityProbe : ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the name lookup and the connection attempt get between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A budget rather than two timeouts, so a slow resolver cannot spend the socket's share and leave the
|
||||
/// interesting half of the probe unrun. It is well under the fifteen this client allows a real connection
|
||||
/// — see <c>SshNetConnectionFactory</c> — because a machine that answers at all answers a bare TCP
|
||||
/// connect in a fraction of what an SSH handshake takes, and everything past that point is waiting to
|
||||
/// confirm silence somebody has already waited out once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Six rather than two, and the floor under it is not a guess.</b> A refusal is not instant on
|
||||
/// Windows: a closed port on loopback took ~2s to come back as <c>WSAECONNREFUSED</c> when this was
|
||||
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
|
||||
/// <see cref="SshReach.Refused"/> — a machine that is up — into <see cref="SshReach.NoAnswer"/>, which
|
||||
/// this client would then explain as a firewall. The budget must clear the platform's SYN-retry time
|
||||
/// with room to spare, and a couple of seconds of headroom is what the extra four buy. Shortening this
|
||||
/// without re-measuring that floor reintroduces a wrong answer rather than a slow one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
|
||||
|
||||
private readonly TimeSpan budget;
|
||||
|
||||
/// <summary>Uses the standard <see cref="Budget"/>.</summary>
|
||||
public SshReachabilityProbe()
|
||||
: this(Budget)
|
||||
{
|
||||
}
|
||||
|
||||
/// <param name="budget">
|
||||
/// How long the whole probe gets. A test uses this to keep a deliberately silent endpoint from costing
|
||||
/// the suite six seconds; nothing in the application passes one.
|
||||
/// </param>
|
||||
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<SshReachabilityReport> ProbeAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
var address = await ResolveAsync(host, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
if (address is null)
|
||||
{
|
||||
return Report(SshReach.Unresolved, host, port, address: null, clock.Elapsed);
|
||||
}
|
||||
|
||||
var reach = await ConnectAsync(address, port, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
return Report(reach, host, port, address.ToString(), clock.Elapsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The address the connection would have been made to, or null if the name does not resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An address typed in rather than a name costs no lookup: <see cref="IPAddress.TryParse(string, out IPAddress)"/>
|
||||
/// settles it here, where <see cref="Dns"/> would go and ask the resolver about a string it is going to
|
||||
/// hand straight back. That matters for the report as much as for the speed — a literal that "resolved"
|
||||
/// would be a fact this probe invented about a machine it never asked anybody about.
|
||||
/// </remarks>
|
||||
private static async ValueTask<IPAddress?> ResolveAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
return literal;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// First rather than a preference between v4 and v6: this is reporting on the connection that
|
||||
// failed, and the stack picked from this same list in this same order when it made it.
|
||||
return addresses.Length == 0 ? null : addresses[0];
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// A resolver that refuses and a resolver that never answers are the same finding here: there is
|
||||
// no address, so there was nothing to dial. Which of the two it was is a DNS question, and this
|
||||
// probe would be guessing at the answer.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The socket is opened and dropped without a byte being sent. A TCP handshake is the whole question —
|
||||
/// whether anything is listening — and writing to a server that is about to be told nothing is wanted
|
||||
/// would leave a connection attempt in its log that the user did not make.
|
||||
/// </remarks>
|
||||
private static async ValueTask<SshReach> ConnectAsync(
|
||||
IPAddress address,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return SshReach.Open;
|
||||
}
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionRefused)
|
||||
{
|
||||
return SshReach.Refused;
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// Everything else the stack can say here — unreachable, timed out, the deadline above — comes to
|
||||
// the same account of it: the port never answered. Naming the OS error instead would be trading a
|
||||
// sentence somebody can act on for one they would have to look up.
|
||||
return SshReach.NoAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
private static SshReachabilityReport Report(
|
||||
SshReach reach,
|
||||
string host,
|
||||
int port,
|
||||
string? address,
|
||||
TimeSpan elapsed)
|
||||
=> new(reach, host, port, address, elapsed, Describe(reach, host, port, address, elapsed));
|
||||
|
||||
/// <summary>
|
||||
/// The finding as a sentence, saying what was measured and then what it means.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measurement first in every case, on the rule the rest of this product's failure text follows: the
|
||||
/// fact is what the user can check and act on, and the interpretation after it is this client's reading
|
||||
/// of that fact. Each reading is hedged exactly as far as it deserves — a refusal has one common cause
|
||||
/// and names it, silence has three and lists them, and an open port names the class of problem rather
|
||||
/// than pretending to know which member of it this is.
|
||||
/// </remarks>
|
||||
private static string Describe(SshReach reach, string host, int port, string? address, TimeSpan elapsed)
|
||||
{
|
||||
var seconds = elapsed.TotalSeconds.ToString("0.#", CultureInfo.CurrentCulture);
|
||||
var where = address is null || string.Equals(address, host, StringComparison.OrdinalIgnoreCase)
|
||||
? host
|
||||
: $"{host} ({address})";
|
||||
|
||||
return reach switch
|
||||
{
|
||||
SshReach.Unresolved =>
|
||||
$"The name {host} does not resolve on this machine, so nothing was ever dialled. Check the "
|
||||
+ "spelling, or whether the VPN or DNS server this name depends on is up.",
|
||||
|
||||
SshReach.Refused =>
|
||||
$"{where} is reachable and refused port {port} immediately: the machine is up and nothing is "
|
||||
+ "listening there. Either sshd is not running, or this is the wrong port.",
|
||||
|
||||
SshReach.NoAnswer =>
|
||||
$"{where} sent nothing back on port {port} within {seconds}s. A machine that was up and had "
|
||||
+ "nothing listening would have refused at once, so this is a firewall dropping the "
|
||||
+ "packets, a machine that is off, or the wrong address.",
|
||||
|
||||
_ =>
|
||||
$"Port {port} on {where} accepts connections, so the network and the machine are fine and "
|
||||
+ "something is listening. The SSH handshake is what did not finish: an sshd that is "
|
||||
+ "overloaded or wedged, a middlebox holding the connection open, or a service on this "
|
||||
+ "port that is not SSH.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// Credit-based flow control over one terminal session's output.
|
||||
/// Credit-based flow control over what has been sent to the renderer and not yet drawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is what makes <c>yes</c> survivable. A terminal emulator renders at 60 Hz at best, while a
|
||||
/// remote process can produce output as fast as the network allows. Without a limit the difference
|
||||
/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
|
||||
/// application's memory grows until it dies.
|
||||
/// A terminal emulator renders at 60 Hz at best, while a remote process can produce output as fast as
|
||||
/// the network allows. The difference has to accumulate somewhere, and the one place it must never
|
||||
/// accumulate is inside the WebView: xterm queues whatever it is written and nothing on this side can
|
||||
/// see how deep that queue is, so a page told to draw faster than it can grows until the tab dies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
|
||||
/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
|
||||
/// credit reaches zero the pump <b>stops reading the SSH channel</b>. That closes SSH's own receive
|
||||
/// window, which makes the remote <c>sshd</c> block on write, which propagates the backpressure all
|
||||
/// the way to the process producing the output. Nothing buffers without bound because nothing is read
|
||||
/// that cannot be delivered.
|
||||
/// credit reaches zero the pump sends nothing more until some comes back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound
|
||||
/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
|
||||
/// in-flight cost at a quarter of a megabyte.
|
||||
/// ◆ <b>What this does not do is bound what the remote has sent</b>, and it used to be written as
|
||||
/// though it did — the claim was that a pump which stops reading closes SSH's own receive window and
|
||||
/// blocks the remote <c>sshd</c>. It does not, because SSH.NET re-opens that window as bytes arrive
|
||||
/// rather than as they are consumed. Holding output back therefore never reached the remote at all;
|
||||
/// it only chose which buffer the flood landed in, and the one it landed in had no ceiling. The bound
|
||||
/// that was missing now lives in <see cref="OutputBacklog"/>, which is also where the evidence for
|
||||
/// this paragraph is written down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound what
|
||||
/// the page is holding. 256 KiB is roughly a screenful of dense output many times over, and it caps a
|
||||
/// session's in-flight cost at a quarter of a megabyte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CreditWindow
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// One session's bounded queue of output waiting for the renderer, oldest first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is where a session's memory is actually bounded, and it exists because the obvious place
|
||||
/// cannot do it.</b> The design used to be that <see cref="CreditWindow"/> bounded everything: the pump
|
||||
/// stopped reading the SSH channel when the renderer fell behind, SSH's own receive window closed, and the
|
||||
/// remote blocked on write. The middle step is not true of the library this client uses.
|
||||
/// <c>Renci.SshNet.Channels.Channel.OnData</c> calls its window adjustment <em>before</em> raising
|
||||
/// <c>DataReceived</c> and re-opens the channel window as soon as it drops below one packet, and
|
||||
/// <c>ShellStream</c>'s own handler copies the bytes into a growable buffer without blocking and without a
|
||||
/// ceiling. So a reader that stops reading does not slow the remote down by one byte; it only decides
|
||||
/// whose buffer the flood accumulates in — and SSH.NET's grows by doubling and does not give the memory
|
||||
/// back until the stream is disposed. A remote running <c>yes</c> took the process with it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the pump now reads continuously, which is what keeps SSH.NET's buffer empty, and the backlog it
|
||||
/// reads into is this one, with a hard capacity. When the renderer cannot keep up the <em>oldest</em>
|
||||
/// bytes go rather than the newest, because the newest are the ones somebody is waiting to see: the tail
|
||||
/// of a flood is the prompt that comes back after it. Dropping is announced rather than silent — see
|
||||
/// <see cref="Take"/> — and it is the honest end of the trade, since output arriving faster than the
|
||||
/// terminal can draw it was going to scroll off a 5,000-line scrollback regardless.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The credit window is still here and still does its own job: it bounds what is in flight <em>to the
|
||||
/// renderer</em>, which is the buffer inside the WebView that nothing on this side can see. What changed
|
||||
/// is that it no longer pretends to bound what is in flight from the remote.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutputBacklog
|
||||
{
|
||||
/// <summary>
|
||||
/// Default capacity: 4 MiB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sixteen times the credit window, so nothing an ordinary burst produces is ever dropped — a
|
||||
/// screenful of dense output is kilobytes, and a build that scrolls for a second is well under this.
|
||||
/// It is a per-session cost, and it is the whole of what a runaway remote can take, which is the
|
||||
/// number worth choosing against: twenty tabs all flooding at once is 80 MiB and survivable, where the
|
||||
/// same twenty unbounded was whatever the machine had.
|
||||
/// </remarks>
|
||||
public const int DefaultCapacityBytes = 4 * 1024 * 1024;
|
||||
|
||||
private readonly Lock gate = new();
|
||||
private readonly Queue<byte[]> segments = new();
|
||||
private readonly int capacityBytes;
|
||||
|
||||
/// <summary>Signalled when output arrives at an empty backlog, and when it is completed.</summary>
|
||||
private TaskCompletionSource available = CreateSignal();
|
||||
|
||||
/// <summary>How much of the head segment has already been taken.</summary>
|
||||
private int head;
|
||||
|
||||
private int buffered;
|
||||
private long droppedSinceTake;
|
||||
private long droppedTotal;
|
||||
private bool completed;
|
||||
|
||||
/// <param name="capacityBytes">How many unsent bytes to hold before dropping the oldest.</param>
|
||||
public OutputBacklog(int capacityBytes = DefaultCapacityBytes)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacityBytes, 1);
|
||||
|
||||
this.capacityBytes = capacityBytes;
|
||||
}
|
||||
|
||||
/// <summary>How many unsent bytes are held.</summary>
|
||||
public int Buffered
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return buffered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many bytes this backlog has discarded over its whole life.</summary>
|
||||
public long DroppedTotal
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return droppedTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether the producer has said there will be no more output.</summary>
|
||||
public bool IsCompleted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds output, dropping the oldest already held if that puts the backlog over capacity.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes ownership of <paramref name="segment"/>; the caller must not write to it afterwards.
|
||||
/// Appending to a completed backlog does nothing rather than throwing, because a producer finding out
|
||||
/// it has been shut down is a race rather than a mistake.
|
||||
/// </remarks>
|
||||
/// <param name="segment">Bytes read from the remote, in order.</param>
|
||||
public void Append(byte[] segment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(segment);
|
||||
|
||||
if (segment.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var wasEmpty = buffered == 0;
|
||||
|
||||
segments.Enqueue(segment);
|
||||
buffered += segment.Length;
|
||||
|
||||
// Never the segment just added, however large it is. A read bigger than the whole capacity is
|
||||
// possible if somebody configures one, and a backlog that answered every take with nothing
|
||||
// would stall the session rather than bound it.
|
||||
while (buffered > capacityBytes && segments.Count > 1)
|
||||
{
|
||||
var oldest = segments.Dequeue();
|
||||
var discarded = oldest.Length - head;
|
||||
|
||||
head = 0;
|
||||
buffered -= discarded;
|
||||
droppedSinceTake += discarded;
|
||||
droppedTotal += discarded;
|
||||
}
|
||||
|
||||
// Only on the transition, because a waiter that found bytes here never captured the signal in
|
||||
// the first place — see WaitForOutputAsync, which checks under this same lock. Signalling on
|
||||
// every append would allocate a TaskCompletionSource per read of a session doing megabytes a
|
||||
// second, for a waiter that is not there.
|
||||
if (wasEmpty)
|
||||
{
|
||||
Signal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the oldest output held into <paramref name="destination"/>, as much of it as will fit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Into a caller's span rather than into an array of its own, because this is the hot path: the pump
|
||||
/// writes straight into the frame it is about to send, so a terminal showing a gigabyte costs one copy
|
||||
/// of it here rather than two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="dropped"/> is what makes a drop visible instead of silent. It counts the bytes
|
||||
/// discarded since the previous take, which is exactly the gap sitting immediately before the bytes
|
||||
/// being handed over now. The caller writes a line saying so, and it has to — the alternative is a
|
||||
/// terminal quietly showing a different transcript from the one the remote sent. It is reported even
|
||||
/// when nothing is taken, so a drop at the very end of a session is still announced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="destination">Where the bytes go, oldest first.</param>
|
||||
/// <param name="dropped">Bytes discarded since the previous take, and zero on the ordinary path.</param>
|
||||
/// <returns>How many bytes were written, which is zero when nothing is held.</returns>
|
||||
public int TakeInto(Span<byte> destination, out long dropped)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
dropped = droppedSinceTake;
|
||||
droppedSinceTake = 0;
|
||||
|
||||
var take = Math.Min(destination.Length, buffered);
|
||||
var offset = 0;
|
||||
|
||||
while (offset < take)
|
||||
{
|
||||
var oldest = segments.Peek();
|
||||
var fromThis = Math.Min(oldest.Length - head, take - offset);
|
||||
|
||||
oldest.AsSpan(head, fromThis).CopyTo(destination[offset..]);
|
||||
|
||||
offset += fromThis;
|
||||
head += fromThis;
|
||||
buffered -= fromThis;
|
||||
|
||||
if (head == oldest.Length)
|
||||
{
|
||||
_ = segments.Dequeue();
|
||||
head = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return take;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Says there will be no more output, releasing anything waiting for some.</summary>
|
||||
public void Complete()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
completed = true;
|
||||
Signal();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits until there is output to take, or until there will never be any.</summary>
|
||||
/// <param name="cancellationToken">Abandons the wait.</param>
|
||||
/// <returns><see langword="false"/> once the backlog is completed and empty.</returns>
|
||||
public async ValueTask<bool> WaitForOutputAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task signal;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (buffered > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
signal = available.Task;
|
||||
}
|
||||
|
||||
await signal.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Completed inside the lock so a waiter cannot miss the transition, and replaced there too so the
|
||||
/// next wait is on a signal that has not already fired. Continuations run asynchronously, so nothing
|
||||
/// resumes while the lock is held.
|
||||
/// </remarks>
|
||||
private void Signal()
|
||||
{
|
||||
_ = available.TrySetResult();
|
||||
available = CreateSignal();
|
||||
}
|
||||
|
||||
private static TaskCompletionSource CreateSignal() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
@@ -120,6 +120,32 @@ public enum TerminalServerOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Clipboard = 8,
|
||||
|
||||
/// <summary>
|
||||
/// A line for the pane saying that output was dropped. Payload is UTF-8, already styled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written into the pane exactly where it arrives, which is where the gap is — the socket keeps order
|
||||
/// within a session, so this frame lands between the bytes before the drop and the bytes after it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Its own opcode rather than a few bytes spliced into an <see cref="Output"/> frame</b>, which is
|
||||
/// what it was first, and the reason is that the two are not the same kind of thing. Output is what the
|
||||
/// remote said, and this is what this application says about it. Keeping them apart means the host's
|
||||
/// own sentence is never mistaken for the transcript — by anything reading a frame, and in particular
|
||||
/// by the arithmetic that has to hold if dropping is to stay honest: every byte read from the remote is
|
||||
/// either in an <see cref="Output"/> payload or counted in <c>TerminalSessionPump.BytesDropped</c>, and
|
||||
/// a notice inside the output stream would put this process's words on the left of that equation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not acknowledged, and so not part of the credit window: credit accounts for what the remote sent
|
||||
/// and what the renderer has drawn of it. A notice is a few dozen bytes the host chose to add, and
|
||||
/// billing them to a window that is meant to describe the remote would make the number mean two
|
||||
/// things.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
OutputDropped = 9,
|
||||
}
|
||||
|
||||
/// <summary>Frames the renderer sends to the host.</summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
@@ -49,6 +49,16 @@ public sealed class TerminalPumpOptions
|
||||
|
||||
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
||||
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
|
||||
|
||||
/// <summary>
|
||||
/// How much unsent output to hold for a renderer that is behind, before dropping the oldest.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the number that bounds a session's memory, and <see cref="OutputBacklog"/> says at length
|
||||
/// why it rather than <see cref="WindowBytes"/> is the one that does. Larger drops less and shows a
|
||||
/// flood's tail later; smaller drops sooner and keeps the terminal closer to live.
|
||||
/// </remarks>
|
||||
public int BacklogBytes { get; init; } = OutputBacklog.DefaultCapacityBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,27 +66,45 @@ public sealed class TerminalPumpOptions
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Credit is reserved before reading, never after. That ordering is the whole design: because the pump
|
||||
/// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit
|
||||
/// window rather than by how fast the remote can talk. Reserving after reading would leave an
|
||||
/// unbounded queue between the socket and the screen, which is the failure mode this exists to
|
||||
/// prevent.
|
||||
/// ◆ <b>The pump reads the shell channel continuously and never stops, which is deliberate rather than
|
||||
/// careless.</b> It used to stop as soon as the renderer fell behind, on the reasoning that a reader
|
||||
/// which does not read closes SSH's receive window, blocks the remote <c>sshd</c> on write, and blocks
|
||||
/// the process producing the output in turn — backpressure all the way to the source, with no custom
|
||||
/// protocol. That reasoning is wrong for this client's SSH library, and wrong in the direction that costs
|
||||
/// memory: SSH.NET re-opens the channel window when bytes <em>arrive</em> rather than when they are
|
||||
/// consumed, and keeps everything it has been handed in a growable array it does not give back. Not
|
||||
/// reading slowed nothing down; it only moved the flood into a buffer with no ceiling.
|
||||
/// <see cref="OutputBacklog"/> carries the full account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote
|
||||
/// <c>sshd</c> blocks on write, and the process producing output blocks in turn — backpressure all the
|
||||
/// way to the source, with no custom protocol.
|
||||
/// So there are two bounds now, each enforced where it can be. The backlog bounds what is waiting to be
|
||||
/// sent, by dropping its oldest bytes and saying so. The credit window bounds what has been sent and not
|
||||
/// yet drawn — the buffer inside the WebView that nothing on this side can measure — and the flush loop
|
||||
/// reserves against it before every frame, holding off while there is none. Reading is no longer part of
|
||||
/// either, because reading was never what the remote was waiting on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The most output one frame carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the 85,000-byte threshold at which the runtime allocates on the large object heap, and that is
|
||||
/// the whole of why the number is here: a frame is allocated per send, and a session doing megabytes a
|
||||
/// second would otherwise put a quarter-megabyte array on the LOH sixty times a second, which is not
|
||||
/// compacted and is collected only with a generation 2 pass. Below the threshold the same traffic is
|
||||
/// generation 0 garbage that costs nothing to collect. It is a ceiling rather than a target — a quiet
|
||||
/// terminal sends whatever it has, which is a few bytes.
|
||||
/// </remarks>
|
||||
private const int MaximumFrameBytes = 64 * 1024;
|
||||
|
||||
private readonly uint sessionId;
|
||||
private readonly ISshShellSession session;
|
||||
private readonly ITerminalTransport transport;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly TerminalPumpOptions options;
|
||||
private readonly Channel<byte[]> pending = Channel.CreateUnbounded<byte[]>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
private readonly OutputBacklog backlog;
|
||||
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
private int disposed;
|
||||
@@ -104,6 +132,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
this.options = options ?? new TerminalPumpOptions();
|
||||
|
||||
Credits = new CreditWindow(this.options.WindowBytes);
|
||||
backlog = new OutputBacklog(this.options.BacklogBytes);
|
||||
}
|
||||
|
||||
/// <summary>This session's flow-control window.</summary>
|
||||
@@ -112,6 +141,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
|
||||
public long BytesRead { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes this session discarded because the renderer could not keep up with the remote.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zero for every ordinary session, and the number to look at first when somebody reports missing
|
||||
/// output. The user is told as it happens — a dropped run puts a line in the pane rather than a gap —
|
||||
/// so this is for a test or a report rather than for the interface.
|
||||
/// </remarks>
|
||||
public long BytesDropped => backlog.DroppedTotal;
|
||||
|
||||
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||
public int BytesPending => backlog.Buffered;
|
||||
|
||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||
public long FramesSent { get; private set; }
|
||||
|
||||
@@ -152,31 +194,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
var reader = ReadLoopAsync(linked.Token);
|
||||
var flusher = FlushLoopAsync(linked.Token);
|
||||
|
||||
TerminalSessionOutcome outcome;
|
||||
try
|
||||
{
|
||||
await reader.ConfigureAwait(false);
|
||||
|
||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||
outcome = session.TransportFailure is { Length: > 0 } failure
|
||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||
outcome = new TerminalSessionOutcome(
|
||||
SshSessionEnd.ConnectionLost,
|
||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||
}
|
||||
var outcome = await ReadUntilItEndsAsync(reader).ConfigureAwait(false);
|
||||
|
||||
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
||||
// workspace's own announcement — cannot see the default.
|
||||
@@ -185,7 +203,17 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
||||
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||
// crash.
|
||||
pending.Writer.TryComplete();
|
||||
backlog.Complete();
|
||||
|
||||
// ◆ AND RELEASE THE FLUSHER IF IT IS PARKED ON CREDIT, WHICH IS NOT THE SAME SIGNAL. Completing
|
||||
// the backlog wakes anything waiting for output; it says nothing to a flush loop that has output
|
||||
// and is waiting for the renderer to make room for it. Against a renderer that has stopped
|
||||
// acknowledging — the page's process killed, the window gone — that acknowledgement never comes,
|
||||
// and without this the await below would never return: the shell would have ended, the tab would
|
||||
// never be told, and the session would never be torn down. Resetting rather than waiting is
|
||||
// right because the window describes a renderer's remaining room and there is nothing left to
|
||||
// protect it from: what follows is one backlog's worth of tail and then the session is over.
|
||||
Credits.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -203,6 +231,42 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the read loop to finish, and says which of the three ways it did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="RunAsync"/> for length rather than for reuse, and it is the half of that
|
||||
/// method with no side effects: three branches, one answer, nothing touched.
|
||||
/// </remarks>
|
||||
/// <param name="reader">The read loop's task.</param>
|
||||
private async Task<TerminalSessionOutcome> ReadUntilItEndsAsync(Task reader)
|
||||
{
|
||||
try
|
||||
{
|
||||
await reader.ConfigureAwait(false);
|
||||
|
||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||
return session.TransportFailure is { Length: > 0 } failure
|
||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||
return new TerminalSessionOutcome(
|
||||
SshSessionEnd.ConnectionLost,
|
||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards keystrokes to the remote.</summary>
|
||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||
session.WriteAsync(data, cancellationToken);
|
||||
@@ -227,44 +291,37 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
// Unblocks anything waiting on credit that will now never be acknowledged.
|
||||
Credits.Reset();
|
||||
|
||||
pending.Writer.TryComplete();
|
||||
backlog.Complete();
|
||||
lifetime.Dispose();
|
||||
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// No credit is consulted here, and the class remark says at length why: the remote is not waiting on
|
||||
/// this loop, so pausing it buys nothing and costs a buffer inside SSH.NET that grows without bound.
|
||||
/// What this loop owes is to keep that buffer empty, which means reading as fast as the channel
|
||||
/// delivers and putting the result somewhere with a ceiling.
|
||||
/// </remarks>
|
||||
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (true)
|
||||
{
|
||||
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Thrown rather than tested in the loop condition, and the difference is the whole of how
|
||||
// this session's ending is described. RunAsync reads a read loop that RETURNS as
|
||||
// the remote having closed the channel, and one that throws OperationCanceledException as
|
||||
// this client having ended it. A condition on the token would leave by the first door for
|
||||
// a reason that belongs to the second, and a tab the user closed would report itself as a
|
||||
// shell that exited on its own.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var granted = Credits.TryReserve(options.ReadBufferBytes);
|
||||
if (granted == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await session
|
||||
.ReadAsync(buffer.AsMemory(0, granted), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Credits.Return(granted);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Hand back what was reserved but not used, so a short read does not permanently
|
||||
// shrink the window.
|
||||
Credits.Return(granted - read);
|
||||
var read = await session
|
||||
.ReadAsync(buffer.AsMemory(0, options.ReadBufferBytes), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
@@ -272,7 +329,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
}
|
||||
|
||||
BytesRead += read;
|
||||
await pending.Writer.WriteAsync(buffer[..read], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Copied out of the rented buffer, because the backlog holds it until the renderer has
|
||||
// room and the next read is already about to overwrite this one.
|
||||
backlog.Append(buffer[..read]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -283,65 +343,160 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
|
||||
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var segments = new List<byte[]>();
|
||||
|
||||
while (await pending.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
while (await backlog.WaitForOutputAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!pending.Reader.TryRead(out var first))
|
||||
if (backlog.IsCompleted)
|
||||
{
|
||||
// The reader has finished and what is left is the tail, which goes out regardless of
|
||||
// credit. Leaving by this door rather than going round again is load-bearing: with the
|
||||
// backlog complete, SendWhatCreditAllowsAsync declines to wait for an acknowledgement
|
||||
// that may never come, so a renderer that has stopped would put this loop into a spin —
|
||||
// output to send, no credit to send it with, and a wait for more output that returns at
|
||||
// once because there is some.
|
||||
break;
|
||||
}
|
||||
|
||||
// Coalesce for one frame's worth of time before the first send, so a burst arriving at an idle
|
||||
// terminal becomes a single write. Under sustained output the loop below never comes back
|
||||
// here, and does not need to: credit is what paces it then, and a frame is as large as the
|
||||
// renderer has room for.
|
||||
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await SendWhatCreditAllowsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await DrainAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Sends until the backlog is empty or the renderer has no room left.</summary>
|
||||
private async Task SendWhatCreditAllowsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var wanted = Math.Min(backlog.Buffered, MaximumFrameBytes);
|
||||
if (wanted == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var granted = Credits.TryReserve(wanted);
|
||||
|
||||
if (granted == 0)
|
||||
{
|
||||
// The renderer is behind. Nothing more goes out until it acknowledges, and meanwhile the
|
||||
// backlog holds what it can and drops its oldest for what it cannot — which is the whole
|
||||
// of how this session's memory stays bounded.
|
||||
if (backlog.IsCompleted)
|
||||
{
|
||||
// Except at the end, where waiting on an acknowledgement that may never come would
|
||||
// hang the session's teardown behind a renderer that has already gone.
|
||||
return;
|
||||
}
|
||||
|
||||
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Clear();
|
||||
segments.Add(first);
|
||||
var sent = await SendFromBacklogAsync(granted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Coalesce for one frame's worth of time, then send everything at once. Whatever the
|
||||
// remote produced in that window becomes a single write to the terminal.
|
||||
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (pending.Reader.TryRead(out var more))
|
||||
if (granted > sent)
|
||||
{
|
||||
segments.Add(more);
|
||||
// Whatever was reserved and not used, so a backlog that shrank between the reservation
|
||||
// and the take does not permanently narrow the window. Guarded rather than called with a
|
||||
// zero, because returning credit wakes every waiter and replaces the signal they were
|
||||
// waiting on, and this is the hot path.
|
||||
Credits.Return(granted - sent);
|
||||
}
|
||||
|
||||
await SendOutputAsync(segments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// The channel completed. Anything the reader wrote before finishing still has to go out.
|
||||
segments.Clear();
|
||||
while (pending.Reader.TryRead(out var trailing))
|
||||
{
|
||||
segments.Add(trailing);
|
||||
}
|
||||
|
||||
if (segments.Count > 0)
|
||||
{
|
||||
await SendOutputAsync(segments, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SendOutputAsync(List<byte[]> segments, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Puts the last of the output out, regardless of credit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The session is over by the time this runs, so there is no renderer left to protect from a flood —
|
||||
/// and what remains is bounded by the backlog's own capacity rather than by anything the remote can
|
||||
/// still do. Waiting on credit here instead would mean a shell that exited against a renderer which
|
||||
/// had gone never finished tearing down.
|
||||
/// </remarks>
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var segment in segments)
|
||||
while (await SendFromBacklogAsync(MaximumFrameBytes, CancellationToken.None)
|
||||
.ConfigureAwait(false) > 0)
|
||||
{
|
||||
total += segment.Length;
|
||||
// Until it is empty.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves up to <paramref name="maxBytes"/> out of the backlog and onto the wire.
|
||||
/// </summary>
|
||||
/// <returns>How many bytes of output were sent, which is zero once the backlog is empty.</returns>
|
||||
private async ValueTask<int> SendFromBacklogAsync(int maxBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
// Taken straight into the frame, header space and all. A second buffer here would be an
|
||||
// allocation and a copy of every byte the terminal ever shows.
|
||||
var frame = new byte[TerminalFrame.HeaderLength + maxBytes];
|
||||
var taken = backlog.TakeInto(frame.AsSpan(TerminalFrame.HeaderLength), out var dropped);
|
||||
|
||||
if (dropped > 0)
|
||||
{
|
||||
// Sent immediately before the bytes it explains, and under its own opcode — see
|
||||
// TerminalServerOpcode.OutputDropped for why this is not simply spliced into the output.
|
||||
await SendAsync(TerminalServerOpcode.OutputDropped, DroppedNotice(dropped), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (taken == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var frame = new byte[TerminalFrame.HeaderLength + total];
|
||||
TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default);
|
||||
|
||||
var offset = TerminalFrame.HeaderLength;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
segment.CopyTo(frame, offset);
|
||||
offset += segment.Length;
|
||||
}
|
||||
|
||||
FramesSent++;
|
||||
await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transport
|
||||
.SendAsync(frame.AsMemory(0, TerminalFrame.HeaderLength + taken), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return taken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The line a user sees where output was dropped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It opens with a reset rather than the colour, and that is the load-bearing part: the bytes were cut
|
||||
/// at an arbitrary offset, so whatever escape sequence the remote was in the middle of has been left
|
||||
/// half-said. Resetting first means the notice reads as a notice and the output after it starts from a
|
||||
/// known state, instead of inheriting a colour or a mode from a sequence nobody finished.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Dim grey, matching the renderer's own banners for a session that closed and a view that
|
||||
/// reconnected — this is the same kind of thing, a sentence from the application rather than from the
|
||||
/// remote, and it should not read like part of the transcript.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[] DroppedNotice(long dropped) =>
|
||||
Encoding.UTF8.GetBytes(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"\u001b[0m\r\n\u001b[38;5;244m── {Sized(dropped)} of output was dropped; "
|
||||
+ $"the terminal could not keep up ──\u001b[0m\r\n"));
|
||||
|
||||
/// <remarks>
|
||||
/// One decimal place from a megabyte up, because the number is an order of magnitude rather than an
|
||||
/// accounting: what a person needs from it is whether they lost a screenful or a session's worth.
|
||||
/// </remarks>
|
||||
private static string Sized(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes} bytes"),
|
||||
< 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / 1024.0:0.#} KB"),
|
||||
< 1024L * 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024):0.#} MB"),
|
||||
_ => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024 * 1024):0.#} GB"),
|
||||
};
|
||||
|
||||
private async ValueTask SendAsync(
|
||||
TerminalServerOpcode opcode,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
|
||||
@@ -209,11 +209,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
||||
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
||||
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
||||
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
||||
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes the
|
||||
/// channel on its own — <see cref="ReleaseAsync"/> gives back what that session was holding but leaves
|
||||
/// the entry, so the tab can go on saying it ended — and a session whose shell exited half an hour ago
|
||||
/// is therefore still in the dictionary. A completed <c>Run</c> task is what "the shell is gone"
|
||||
/// actually looks like: the pump's loops have finished and it has already sent <c>SessionClosed</c> to
|
||||
/// the renderer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
||||
@@ -642,6 +643,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
||||
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session whose shell has <em>ended</em> is ignored the same way, and has to be: the tab is still on
|
||||
/// screen and still selectable, so an accessory key pressed at one is as ordinary as a key pressed at a
|
||||
/// tab that has closed. It is also the one of the two that would throw rather than fail quietly, since
|
||||
/// <see cref="ReleaseAsync"/> disposes the shell channel the moment the remote hangs up. Liveness
|
||||
/// rather than mere presence, which is the same question <see cref="IsSessionLive"/> answers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">The terminal to type into.</param>
|
||||
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
||||
@@ -655,7 +663,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
|
||||
pump = sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||
? session.Pump
|
||||
: null;
|
||||
}
|
||||
|
||||
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
||||
@@ -848,14 +858,79 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
|
||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||
// the application is closing.
|
||||
// the application is closing, and everything inside a finally is time the run task has not
|
||||
// completed yet, which is time LiveSessionCount still counts this session as running.
|
||||
//
|
||||
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
||||
// AnnounceEndedAsync for what was wrong with that.
|
||||
// Neither SessionEnded nor the release of what the session held is done from here, and both
|
||||
// used to be candidates for it — see AnnounceEndedAsync, which is where they ended up and why.
|
||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives back everything a session that ended on its own was still holding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Nothing used to do this, and a shell that exited went on costing what a running one costs.</b>
|
||||
/// The entry stayed in <see cref="sessions"/> until the user closed the tab, and with it an undisposed
|
||||
/// <see cref="ISshConnection"/> — an authenticated socket to the remote, its cipher state, and a
|
||||
/// keep-alive still putting a packet on the wire every thirty seconds — plus the pump and, underneath
|
||||
/// it, SSH.NET's read buffer at whatever size the session's heaviest moment had grown it to. A tab
|
||||
/// somebody typed <c>exit</c> into an hour ago and left open was indistinguishable from a live one in
|
||||
/// everything except what it drew, and a working day of those is the memory a user actually notices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The dictionary entry stays even so, and that is not an oversight.</b>
|
||||
/// <see cref="LiveSessionCount"/>, <see cref="IsSessionLive"/> and
|
||||
/// <see cref="AnnounceEndedAsync"/>'s containment check are all written against it — a session that
|
||||
/// ended still has to be able to say so, and a tab that shows ENDED and offers a reconnect is reading
|
||||
/// exactly that. What goes is everything the entry was holding that costs anything; what is left is
|
||||
/// three references and a completed task.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipped entirely when the entry has already been removed, which is precisely the deliberate path:
|
||||
/// <see cref="CloseSessionAsync"/> takes the entry out before it disposes anything, so the two can
|
||||
/// never both be tearing the same session down. That is also what makes this a no-op during
|
||||
/// <see cref="DisposeAsync"/>, which closes every session by that route before anything here could run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure is swallowed. The caller is a fire-and-forget continuation with nobody to hand a fault
|
||||
/// to, and the shell it would be about is already gone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ReleaseAsync(uint sessionId)
|
||||
{
|
||||
LiveSession? session;
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
if (!sessions.TryGetValue(sessionId, out session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The connection goes even if the pump's disposal throws, for the reason CloseSessionAsync
|
||||
// gives at the same pair of calls: an undisposed ISshConnection is a live authenticated
|
||||
// channel this process has forgotten about.
|
||||
try
|
||||
{
|
||||
await session.Pump.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best-effort. The shell is already gone; nothing a caller could do with this.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
||||
/// <param name="pump">
|
||||
@@ -898,12 +973,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
if (announce)
|
||||
if (!announce)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read after the await, which is the only point at which the pump has settled it — see
|
||||
// TerminalSessionPump.Outcome.
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// After the announcement rather than before it, and in a finally so a handler that throws
|
||||
// cannot leave a connection open. Tearing down an SSH connection is a socket closing and a
|
||||
// library shutting down its listener; doing that first would put it between the shell ending
|
||||
// and the tab being told, which is latency on the one thing the user is watching for.
|
||||
await ReleaseAsync(sessionId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The address as dialled, for the log.</summary>
|
||||
|
||||
@@ -76,6 +76,17 @@ public static class ProblemCodes
|
||||
/// </remarks>
|
||||
public const string EventsUnavailable = "events-unavailable";
|
||||
|
||||
/// <summary>
|
||||
/// This deployment does not relay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own code rather than <see cref="RelayTargetRejected"/>, because it is the one relay refusal
|
||||
/// whose remedy is not about the host: nothing the user changes on this machine will make it work
|
||||
/// here, and saying so is not an oracle for anything. <c>GET /api/v1/meta</c> says the same thing
|
||||
/// without asking.
|
||||
/// </remarks>
|
||||
public const string RelayUnavailable = "relay-unavailable";
|
||||
|
||||
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
|
||||
public const string RelayTargetRejected = "relay-target-rejected";
|
||||
|
||||
|
||||
@@ -966,3 +966,14 @@ static DodoSSH.Contracts.VaultKeyWrap.operator !=(DodoSSH.Contracts.VaultKeyWrap
|
||||
static DodoSSH.Contracts.VaultKeyWrap.operator ==(DodoSSH.Contracts.VaultKeyWrap? left, DodoSSH.Contracts.VaultKeyWrap? right) -> bool
|
||||
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int
|
||||
const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.IdleTimeoutCloseCode = 4408 -> int
|
||||
const DodoSSH.Contracts.RelayProtocol.SubProtocol = "dodossh.relay.v1" -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.TargetUnreachableCloseCode = 4502 -> int
|
||||
const DodoSSH.Contracts.RelayProtocol.TicketProtocolPrefix = "ticket." -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.TicketsPath = "/api/v1/relay/tickets" -> string!
|
||||
const DodoSSH.Contracts.RelayProtocol.TooManySessionsCloseCode = 4429 -> int
|
||||
DodoSSH.Contracts.RelayProtocol
|
||||
@@ -1,5 +1,78 @@
|
||||
namespace DodoSSH.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// The relay's routes, subprotocol and close codes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two steps, per ADR 0004. <see cref="TicketsPath"/> is an ordinary authenticated request that decides
|
||||
/// everything — which host, which addresses, whether the caller may — and answers with a ticket.
|
||||
/// <see cref="ConnectPath"/> is a WebSocket that carries no API authority at all: it accepts the ticket
|
||||
/// and nothing else, and pipes raw bytes to the addresses the ticket names. There is no framing of the
|
||||
/// relay's own inside the socket. SSH is already a byte stream, so every binary message is simply the
|
||||
/// next bytes of it, in order, and message boundaries mean nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A refusal the client could not have avoided by asking differently — a limit, a target that did not
|
||||
/// answer — is sent as a close code after the upgrade rather than as an HTTP status before it, for the
|
||||
/// same reason as <see cref="VaultEvents"/>: a constrained WebSocket client cannot read the status of a
|
||||
/// failed upgrade, and these are exactly the cases where the user needs to be told something specific.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RelayProtocol
|
||||
{
|
||||
/// <summary>The <c>/api/v1/meta</c> feature flag advertising that this server relays at all.</summary>
|
||||
public const string Feature = "relay";
|
||||
|
||||
/// <summary>Where a ticket is asked for, with a <see cref="RelayTicketRequest"/>.</summary>
|
||||
public const string TicketsPath = "/api/v1/relay/tickets";
|
||||
|
||||
/// <summary>The path the relay socket is served from.</summary>
|
||||
/// <remarks>
|
||||
/// A client dials <see cref="RelayTicketResponse.WebSocketUrl"/> rather than building this itself,
|
||||
/// because a deployment may publish the relay under a different host than the API.
|
||||
/// </remarks>
|
||||
public const string ConnectPath = "/api/v1/relay/connect";
|
||||
|
||||
/// <summary>The only subprotocol this version speaks.</summary>
|
||||
public const string SubProtocol = "dodossh.relay.v1";
|
||||
|
||||
/// <summary>
|
||||
/// The prefix of the second subprotocol an upgrade offers, which carries the ticket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The header rather than the query string, because a URL is what proxies, access logs and traces
|
||||
/// keep. The server echoes <see cref="SubProtocol"/> alone and never the ticket.
|
||||
/// </remarks>
|
||||
public const string TicketProtocolPrefix = "ticket.";
|
||||
|
||||
/// <summary>
|
||||
/// Close code for a caller, or a node, already holding as many relay sessions as it may.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same number as <see cref="VaultEvents.TooManyConnectionsCloseCode"/>, deliberately, for the
|
||||
/// same meaning. Retrying at once is what caused it.
|
||||
/// </remarks>
|
||||
public const int TooManySessionsCloseCode = 4429;
|
||||
|
||||
/// <summary>
|
||||
/// Close code for a target that did not accept the connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one failure a user needs told in so many words, because from the laptop it looks exactly like
|
||||
/// the direct connection that failed and sent them to the relay in the first place. It says that the
|
||||
/// <em>server</em> could not reach the machine, which is a different problem with a different owner.
|
||||
/// </remarks>
|
||||
public const int TargetUnreachableCloseCode = 4502;
|
||||
|
||||
/// <summary>Close code for a session that carried no bytes for the idle timeout.</summary>
|
||||
public const int IdleTimeoutCloseCode = 4408;
|
||||
|
||||
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
|
||||
public const int DurationLimitCloseCode = 4409;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A request for permission to relay to a host.
|
||||
/// </summary>
|
||||
@@ -11,8 +84,10 @@ namespace DodoSSH.Contracts;
|
||||
/// </remarks>
|
||||
/// <param name="HostId">The host to reach.</param>
|
||||
/// <param name="PortForwardId">
|
||||
/// A configured port forward to use instead of the host's own SSH port. Resolved server-side the
|
||||
/// same way.
|
||||
/// A configured port forward to use instead of the host's own SSH port. Reserved, and refused when
|
||||
/// set: a port forward lives inside the host's encrypted payload, so the server has no plaintext port
|
||||
/// to resolve it to, and taking one from the client is the open proxy this record exists to prevent.
|
||||
/// Serving it needs a plaintext concession of its own, argued the way ADR 0004 argues the host's.
|
||||
/// </param>
|
||||
public sealed record RelayTicketRequest(
|
||||
Guid HostId,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace DodoSSH.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A relay ticket that has been redeemed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The replay guard, and the only state the relay shares between nodes — see ADR 0004. A ticket is
|
||||
/// verified without the database; what the database adds is that it is verified <em>once</em>, by a
|
||||
/// unique insert on <see cref="TicketId"/> that a second redemption loses whichever node it reaches.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A row is worth keeping only while its ticket could still verify, so <see cref="ExpiresAtUtc"/> is
|
||||
/// stored to let redemption sweep the rows that can no longer matter. Nothing else reads this table,
|
||||
/// and it carries nothing about the target: the audit record is <see cref="RelaySession"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RelayTicketUse
|
||||
{
|
||||
/// <summary>The ticket's own id. Primary key, and the whole of the guard.</summary>
|
||||
public Guid TicketId { get; set; }
|
||||
|
||||
/// <summary>When the ticket stops verifying, after which this row guards nothing.</summary>
|
||||
public DateTimeOffset ExpiresAtUtc { get; set; }
|
||||
|
||||
/// <summary>When it was redeemed.</summary>
|
||||
public DateTimeOffset UsedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The audit record of one relay session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What ADR 0004 says remains auditable in relay mode, and nothing more: who, to where, for how long,
|
||||
/// how many bytes each way and why it ended. The relay forwards SSH ciphertext, so there is no content
|
||||
/// to record and no column that pretends otherwise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written twice — once when the target answers, once when the session ends — each time from a scope
|
||||
/// opened for that write alone. A session lasts hours and a scope held across it is a pooled database
|
||||
/// connection held across it, which is the failure ADR 0004 records.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No foreign keys to the host or the vault, deliberately. An audit row has to outlive what it is
|
||||
/// about: a host deleted a minute after a session is exactly the host an operator will want the
|
||||
/// session for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RelaySession
|
||||
{
|
||||
/// <summary>The session id, minted with the ticket and returned to the client. Primary key.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Who opened it.</summary>
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>The host it reached.</summary>
|
||||
public Guid HostId { get; set; }
|
||||
|
||||
/// <summary>The vault the host was in when the ticket was issued.</summary>
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>The hostname as the host row carried it at issue.</summary>
|
||||
public string TargetHost { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The address actually dialled, which is the one that answered.</summary>
|
||||
/// <remarks>Null only for a session whose target never answered.</remarks>
|
||||
public string? TargetAddress { get; set; }
|
||||
|
||||
/// <summary>The port dialled.</summary>
|
||||
public int TargetPort { get; set; }
|
||||
|
||||
/// <summary>The client's address as this node saw it, for the audit ADR 0004 promises.</summary>
|
||||
public string? ClientAddress { get; set; }
|
||||
|
||||
/// <summary>When the socket was accepted.</summary>
|
||||
public DateTimeOffset StartedAtUtc { get; set; }
|
||||
|
||||
/// <summary>When it closed, or null while it is open — or if the node died under it.</summary>
|
||||
public DateTimeOffset? EndedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Bytes forwarded from the client to the target.</summary>
|
||||
public long BytesClientToTarget { get; set; }
|
||||
|
||||
/// <summary>Bytes forwarded from the target to the client.</summary>
|
||||
public long BytesTargetToClient { get; set; }
|
||||
|
||||
/// <summary>Why it ended. <see cref="RelayCloseReason.Unspecified"/> while open.</summary>
|
||||
public RelayCloseReason CloseReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Why a relay session ended.</summary>
|
||||
/// <remarks>
|
||||
/// Numbered as <c>RelaySessionCloseReason</c> on the wire is, and held to it by a test rather than by
|
||||
/// hope: the endpoint converts between the two by value.
|
||||
/// </remarks>
|
||||
public enum RelayCloseReason
|
||||
{
|
||||
/// <summary>Not a legal value for a closed session; what an open one carries.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>The client closed the connection.</summary>
|
||||
ClientClosed = 1,
|
||||
|
||||
/// <summary>The target closed the connection.</summary>
|
||||
TargetClosed = 2,
|
||||
|
||||
/// <summary>No traffic within the idle timeout.</summary>
|
||||
IdleTimeout = 3,
|
||||
|
||||
/// <summary>The maximum session duration was reached.</summary>
|
||||
DurationLimit = 4,
|
||||
|
||||
/// <summary>The server is shutting down and drained the session.</summary>
|
||||
ServerShutdown = 5,
|
||||
|
||||
/// <summary>The connection to the target failed.</summary>
|
||||
TargetUnreachable = 6,
|
||||
|
||||
/// <summary>An error ended the session.</summary>
|
||||
Error = 7,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DodoSSH.Infrastructure.Configurations;
|
||||
|
||||
/// <summary>Maps <see cref="RelayTicketUse"/>.</summary>
|
||||
public sealed class RelayTicketUseConfiguration : IEntityTypeConfiguration<RelayTicketUse>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<RelayTicketUse> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("relay_ticket_use");
|
||||
|
||||
// The key is the guard. A second redemption of the same ticket is a unique violation, on whichever
|
||||
// node it reaches, and nothing weaker — a lookup followed by an insert — would be one.
|
||||
builder.HasKey(u => u.TicketId);
|
||||
builder.Property(u => u.TicketId).ValueGeneratedNever();
|
||||
|
||||
// For the sweep, which deletes by expiry.
|
||||
builder.HasIndex(u => u.ExpiresAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="RelaySession"/>.</summary>
|
||||
public sealed class RelaySessionConfiguration : IEntityTypeConfiguration<RelaySession>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<RelaySession> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("relay_session");
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// Minted with the ticket and handed to the client before the row exists, so the client can name
|
||||
// the session it opened.
|
||||
builder.Property(s => s.Id).ValueGeneratedNever();
|
||||
|
||||
builder.Property(s => s.TargetHost).HasMaxLength(255).IsRequired();
|
||||
|
||||
// An address's text form: 45 characters is the longest IPv6 spelling with an embedded IPv4.
|
||||
builder.Property(s => s.TargetAddress).HasMaxLength(45);
|
||||
builder.Property(s => s.ClientAddress).HasMaxLength(45);
|
||||
|
||||
builder.Property(s => s.CloseReason).HasConversion<int>();
|
||||
|
||||
builder.HasIndex(s => new { s.UserId, s.StartedAtUtc });
|
||||
builder.HasIndex(s => new { s.HostId, s.StartedAtUtc });
|
||||
|
||||
builder.ToTable(t => t.HasCheckConstraint(
|
||||
"ck_relay_session_port_range",
|
||||
"target_port BETWEEN 1 AND 65535"));
|
||||
|
||||
builder.ToTable(t => t.HasCheckConstraint(
|
||||
"ck_relay_session_bytes",
|
||||
"bytes_client_to_target >= 0 AND bytes_target_to_client >= 0"));
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,12 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
/// <summary>Applied-operation receipts, for exactly-once retries.</summary>
|
||||
public DbSet<SyncOperationReceipt> SyncOperationReceipts => Set<SyncOperationReceipt>();
|
||||
|
||||
/// <summary>Redeemed relay tickets: the replay guard ADR 0004 shares between nodes.</summary>
|
||||
public DbSet<RelayTicketUse> RelayTicketUses => Set<RelayTicketUse>();
|
||||
|
||||
/// <summary>The relay's audit record, one row per session.</summary>
|
||||
public DbSet<RelaySession> RelaySessions => Set<RelaySession>();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
+1907
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the relay's two tables: the replay guard for its tickets, and its audit record.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both are new and empty, so this is additive and safe to roll back. Neither table references a
|
||||
/// host or a vault by foreign key — an audit row has to outlive what it is about — and neither holds
|
||||
/// anything the host row did not already hold in plaintext for a relay-enabled host. See ADR 0004.
|
||||
/// </remarks>
|
||||
public partial class AddRelaySessions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "relay_session",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
host_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
target_host = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
target_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||
target_port = table.Column<int>(type: "integer", nullable: false),
|
||||
client_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||
started_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ended_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
bytes_client_to_target = table.Column<long>(type: "bigint", nullable: false),
|
||||
bytes_target_to_client = table.Column<long>(type: "bigint", nullable: false),
|
||||
close_reason = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_relay_session", x => x.id);
|
||||
table.CheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0");
|
||||
table.CheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "relay_ticket_use",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
ticket_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
used_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_relay_ticket_use", x => x.ticket_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_session_host_id_started_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_session",
|
||||
columns: new[] { "host_id", "started_at_utc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_session_user_id_started_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_session",
|
||||
columns: new[] { "user_id", "started_at_utc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_ticket_use_expires_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_ticket_use",
|
||||
column: "expires_at_utc");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "relay_session",
|
||||
schema: "dodo");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "relay_ticket_use",
|
||||
schema: "dodo");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,104 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.RelaySession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("BytesClientToTarget")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("bytes_client_to_target");
|
||||
|
||||
b.Property<long>("BytesTargetToClient")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("bytes_target_to_client");
|
||||
|
||||
b.Property<string>("ClientAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("client_address");
|
||||
|
||||
b.Property<int>("CloseReason")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("close_reason");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at_utc");
|
||||
|
||||
b.Property<Guid>("HostId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("host_id");
|
||||
|
||||
b.Property<DateTimeOffset>("StartedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at_utc");
|
||||
|
||||
b.Property<string>("TargetAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("target_address");
|
||||
|
||||
b.Property<string>("TargetHost")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("target_host");
|
||||
|
||||
b.Property<int>("TargetPort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("target_port");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_relay_session");
|
||||
|
||||
b.HasIndex("HostId", "StartedAtUtc")
|
||||
.HasDatabaseName("ix_relay_session_host_id_started_at_utc");
|
||||
|
||||
b.HasIndex("UserId", "StartedAtUtc")
|
||||
.HasDatabaseName("ix_relay_session_user_id_started_at_utc");
|
||||
|
||||
b.ToTable("relay_session", "dodo", t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0");
|
||||
|
||||
t.HasCheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.RelayTicketUse", b =>
|
||||
{
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("ticket_id");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at_utc");
|
||||
|
||||
b.Property<DateTimeOffset>("UsedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("used_at_utc");
|
||||
|
||||
b.HasKey("TicketId")
|
||||
.HasName("pk_relay_ticket_use");
|
||||
|
||||
b.HasIndex("ExpiresAtUtc")
|
||||
.HasDatabaseName("ix_relay_ticket_use_expires_at_utc");
|
||||
|
||||
b.ToTable("relay_ticket_use", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.WebSockets;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -70,7 +72,17 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
// The stub serves plaintext HTTP on a loopback port.
|
||||
["DODOSSH_Oidc__RequireHttpsMetadata"] = "false",
|
||||
|
||||
["DODOSSH_Relay__Enabled"] = "false",
|
||||
// On, so the relay's endpoints can be tested at all. Nothing dials a real network: the seam
|
||||
// below answers every resolve and connect, and the address policy still judges what it says.
|
||||
["DODOSSH_Relay__Enabled"] = "true",
|
||||
["DODOSSH_Relay__WebSocketUrl"] = "ws://localhost" + RelayProtocol.ConnectPath,
|
||||
|
||||
// Small, so a test can reach the cap with two sockets rather than ten.
|
||||
["DODOSSH_Relay__MaxConcurrentSessionsPerUser"] = "2",
|
||||
|
||||
// Short enough that a test can watch a session time out, long enough that no test which is
|
||||
// moving bytes comes near it.
|
||||
["DODOSSH_Relay__IdleTimeout"] = "00:00:03",
|
||||
|
||||
// Fixed so cursors stay valid for the lifetime of the test host.
|
||||
["DODOSSH_Sync__CursorSigningKey"] = Convert.ToBase64String(new byte[32]),
|
||||
@@ -80,8 +92,20 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every address a test machine can reach by itself is loopback, which the relay refuses outright, so
|
||||
/// a relay test needs a network that pretends. This one maps the addresses a test names onto loopback
|
||||
/// listeners the test owns, and remembers what it was asked to dial.
|
||||
/// </remarks>
|
||||
internal FakeRelayNetwork RelayNetwork { get; } = new();
|
||||
|
||||
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
|
||||
/// <remarks>
|
||||
/// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
|
||||
|
||||
@@ -62,6 +62,13 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
// the handshake, unlike the relay's ticket. See ADR 0012.
|
||||
"GET /api/v1/events name=VaultEvents tags=Events policies=Enrolled anon=False",
|
||||
|
||||
// The relay's two halves, and the split between them is the design. The ticket is Enrolled, as sync
|
||||
// is, and every decision is made there with the full access model; the socket is anonymous because
|
||||
// the ticket it carries is the whole of its authorization — single-use, thirty seconds, one host —
|
||||
// and a constrained client cannot put a bearer token on an upgrade. See ADR 0004.
|
||||
"POST /api/v1/relay/tickets name=IssueRelayTicket tags=Relay policies=Enrolled anon=False",
|
||||
"GET /api/v1/relay/connect name=RelayConnect tags=Relay policies= anon=True",
|
||||
|
||||
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
|
||||
// nothing to wrap and no signature to attribute it with. There is no search here — see
|
||||
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A resolver and a dialler that reach only what a test has arranged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Names resolve to what <see cref="Resolve"/> says, and address literals to themselves, as the real
|
||||
/// resolver does. An address dials the loopback endpoint <see cref="Route"/> mapped it to; anything
|
||||
/// else is refused the way a closed port is, so a test that forgot to arrange a target sees the relay's
|
||||
/// real "unreachable" path rather than a hang.
|
||||
/// </remarks>
|
||||
internal sealed class FakeRelayNetwork : IRelayNetwork
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IPAddress[]> names = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<IPEndPoint, IPEndPoint> routes = new();
|
||||
private readonly ConcurrentQueue<IPEndPoint> dialled = new();
|
||||
|
||||
/// <summary>Every endpoint the relay asked to connect to, in order.</summary>
|
||||
internal IReadOnlyCollection<IPEndPoint> Dialled => dialled;
|
||||
|
||||
internal void Resolve(string name, params IPAddress[] addresses) => names[name] = addresses;
|
||||
|
||||
internal void Route(IPEndPoint target, IPEndPoint loopback) => routes[target] = loopback;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
return Task.FromResult<IPAddress[]>([literal]);
|
||||
}
|
||||
|
||||
return names.TryGetValue(host, out var addresses)
|
||||
? Task.FromResult(addresses)
|
||||
: Task.FromException<IPAddress[]>(new SocketException((int)SocketError.HostNotFound));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = new IPEndPoint(address, port);
|
||||
dialled.Enqueue(endpoint);
|
||||
|
||||
if (!routes.TryGetValue(endpoint, out var loopback))
|
||||
{
|
||||
throw new SocketException((int)SocketError.ConnectionRefused);
|
||||
}
|
||||
|
||||
var client = new TcpClient();
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(loopback, cancellationToken);
|
||||
|
||||
return client.GetStream();
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using DodoSSH.Api.Setup;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ADR 0004 §3, address by address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The refused list is tested against the most permissive configuration this server accepts — every
|
||||
/// network allowed, private ranges on — because "no configuration escape" is a claim about exactly that
|
||||
/// case. A test that ran it only under the defaults would pass for a policy an operator could open up.
|
||||
/// </remarks>
|
||||
public sealed class RelayAddressPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("127.255.255.254")]
|
||||
[InlineData("0.0.0.0")]
|
||||
[InlineData("0.1.2.3")]
|
||||
[InlineData("169.254.169.254")]
|
||||
[InlineData("169.254.0.1")]
|
||||
[InlineData("224.0.0.251")]
|
||||
[InlineData("255.255.255.255")]
|
||||
[InlineData("100.100.100.200")]
|
||||
[InlineData("::")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("fe80::1")]
|
||||
[InlineData("ff02::1")]
|
||||
[InlineData("fd00:ec2::254")]
|
||||
|
||||
// The same addresses, spelled as IPv6 — the bypass ADR 0004 names, and its NAT64 cousin.
|
||||
[InlineData("::ffff:127.0.0.1")]
|
||||
[InlineData("::ffff:169.254.169.254")]
|
||||
[InlineData("64:ff9b::a9fe:a9fe")]
|
||||
[InlineData("64:ff9b::7f00:1")]
|
||||
[InlineData("::127.0.0.1")]
|
||||
public void TheRefusedList_HasNoConfigurationEscape(string address)
|
||||
{
|
||||
var wideOpen = new RelayOptions { AllowPrivateNetworks = true };
|
||||
wideOpen.AllowedNetworks.Add("0.0.0.0/0");
|
||||
wideOpen.AllowedNetworks.Add("::/0");
|
||||
|
||||
new RelayAddressPolicy(wideOpen).Permits(IPAddress.Parse(address)).ShouldBeFalse();
|
||||
new RelayAddressPolicy(new RelayOptions()).Permits(IPAddress.Parse(address)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALinkLocalAddressWithAScope_IsStillLinkLocal()
|
||||
{
|
||||
var scoped = IPAddress.Parse("fe80::1%7");
|
||||
|
||||
new RelayAddressPolicy(new RelayOptions()).Permits(scoped).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.0.0.5")]
|
||||
[InlineData("172.16.4.4")]
|
||||
[InlineData("192.168.1.10")]
|
||||
[InlineData("100.101.102.103")] // A Tailnet address: carrier-grade NAT, beside the refused metadata one.
|
||||
[InlineData("fd12:3456::1")]
|
||||
[InlineData("::ffff:10.0.0.5")]
|
||||
public void PrivateNetworks_ArePermittedByDefault_AndRefusedWhenSwitchedOff(string address)
|
||||
{
|
||||
new RelayAddressPolicy(new RelayOptions()).Permits(IPAddress.Parse(address)).ShouldBeTrue();
|
||||
new RelayAddressPolicy(new RelayOptions { AllowPrivateNetworks = false })
|
||||
.Permits(IPAddress.Parse(address))
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("203.0.113.10")]
|
||||
[InlineData("2001:db8::10")]
|
||||
public void PublicAddresses_ArePermitted(string address) =>
|
||||
new RelayAddressPolicy(new RelayOptions { AllowPrivateNetworks = false })
|
||||
.Permits(IPAddress.Parse(address))
|
||||
.ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public void AnAllowList_IsTheWholeAnswer_EvenWithPrivateNetworksOff()
|
||||
{
|
||||
var options = new RelayOptions { AllowPrivateNetworks = false };
|
||||
options.AllowedNetworks.Add("10.20.0.0/16");
|
||||
|
||||
var policy = new RelayAddressPolicy(options);
|
||||
|
||||
policy.Permits(IPAddress.Parse("10.20.3.4")).ShouldBeTrue();
|
||||
policy.Permits(IPAddress.Parse("10.21.3.4")).ShouldBeFalse();
|
||||
policy.Permits(IPAddress.Parse("203.0.113.10")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADenial_IsAHoleInAnAllowance()
|
||||
{
|
||||
var options = new RelayOptions();
|
||||
options.AllowedNetworks.Add("10.20.0.0/16");
|
||||
options.DeniedNetworks.Add("10.20.5.0/24");
|
||||
|
||||
var policy = new RelayAddressPolicy(options);
|
||||
|
||||
policy.Permits(IPAddress.Parse("10.20.4.1")).ShouldBeTrue();
|
||||
policy.Permits(IPAddress.Parse("10.20.5.1")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(25, false)]
|
||||
[InlineData(465, false)]
|
||||
[InlineData(587, false)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(65536, false)]
|
||||
[InlineData(22, true)]
|
||||
[InlineData(5432, true)]
|
||||
public void Ports_RefuseSmtpAndNothingElse(int port, bool permitted) =>
|
||||
RelayAddressPolicy.PermitsPort(port).ShouldBe(permitted);
|
||||
|
||||
[Theory]
|
||||
[InlineData("10.0.0.0/8", true)]
|
||||
[InlineData("fd00::/8", true)]
|
||||
[InlineData("10.0.0.1/8", false)]
|
||||
[InlineData("10.0.0.0", false)]
|
||||
[InlineData("ten dot oh", false)]
|
||||
public void NetworksAreReadStrictly(string entry, bool readable) =>
|
||||
RelayAddressPolicy.AreNetworks([entry]).ShouldBe(readable);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The relay, over a real socket, through the real ticket, against a target the test owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The network is the only thing replaced — see <see cref="FakeRelayNetwork"/> — and it is replaced
|
||||
/// <em>under</em> the address policy, so every address these tests arrange is still judged by the real
|
||||
/// rules. That is what lets the refusals here be meaningful: a loopback target is refused because the
|
||||
/// policy refused it, not because the fake had nothing to route it to, and the tests say which by
|
||||
/// asserting that nothing was dialled.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The refusals share one answer on purpose, and a test holds them to it. A distinct response for "that
|
||||
/// name resolves internally, but no" is a scanner for the operator's DNS in the hands of anyone who can
|
||||
/// edit a host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class RelayEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 28, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
/// <summary>How long a test waits for a frame. Generous, because it bounds a broken build, not a measurement.</summary>
|
||||
private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// ---- The ticket ----
|
||||
|
||||
[Fact]
|
||||
public async Task ATicket_NamesTheSocketAndExpiresWithinThirtySeconds()
|
||||
{
|
||||
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.1", 22);
|
||||
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
ticket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
|
||||
ticket.WebSocketUrl.AbsolutePath.ShouldBe(RelayProtocol.ConnectPath);
|
||||
ticket.ExpiresAt.ShouldBeLessThanOrEqualTo(TimeProvider.System.GetUtcNow().AddSeconds(31));
|
||||
|
||||
// It has to stand in a Sec-WebSocket-Protocol list, so it must be an HTTP token: no padding, no
|
||||
// slash, no plus — which is what base64url is for.
|
||||
ticket.Ticket.ShouldAllBe(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryTargetRefusal_IsTheSameAnswer()
|
||||
{
|
||||
var (subject, relayOff) = await SeedHostAsync(relay: null);
|
||||
var (_, others) = await SeedRelayHostAsync("10.40.0.2", 22);
|
||||
var (_, loopback) = await SeedRelayHostAsync("127.0.0.1", 22, subject);
|
||||
var (_, smtp) = await SeedRelayHostAsync("10.40.0.3", 25, subject);
|
||||
var (_, unresolvable) = await SeedRelayHostAsync("nowhere.invalid", 22, subject);
|
||||
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
var answers = new List<JsonProblem>();
|
||||
|
||||
foreach (var hostId in new[] { relayOff, others, loopback, smtp, unresolvable, Guid.CreateVersion7() })
|
||||
{
|
||||
var response = await client.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
answers.Add((await response.Content.ReadProblemAsync()).ShouldNotBeNull());
|
||||
}
|
||||
|
||||
answers.ShouldAllBe(problem => problem.Code == ProblemCodes.RelayTargetRejected);
|
||||
answers.Select(problem => problem.Detail).Distinct(StringComparer.Ordinal).Count().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("::ffff:127.0.0.1")]
|
||||
[InlineData("169.254.169.254")]
|
||||
[InlineData("::ffff:169.254.169.254")]
|
||||
[InlineData("0.0.0.0")]
|
||||
public async Task AnAddressThePolicyRefuses_IsNeverDialled(string address)
|
||||
{
|
||||
var (subject, hostId) = await SeedRelayHostAsync(address, 22);
|
||||
var dialledBefore = fixture.RelayNetwork.Dialled.Count;
|
||||
|
||||
var response = await fixture.CreateClientFor(subject)
|
||||
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
fixture.RelayNetwork.Dialled.Count.ShouldBe(dialledBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANameResolvingToABadAndAGoodAddress_IsDialledOnlyOnTheGoodOne()
|
||||
{
|
||||
// The rebinding shape: one answer the policy likes and one it must never reach. The ticket carries
|
||||
// only what passed, so the socket has nothing else it could try.
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var good = new IPEndPoint(IPAddress.Parse("10.40.1.1"), 22);
|
||||
|
||||
fixture.RelayNetwork.Resolve("mixed.test", IPAddress.Parse("169.254.169.254"), good.Address);
|
||||
fixture.RelayNetwork.Route(good, target.Endpoint);
|
||||
|
||||
var (subject, hostId) = await SeedRelayHostAsync("mixed.test", 22);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await ConnectAsync(await IssueAsync(subject, hostId), timeout.Token);
|
||||
|
||||
await RoundTripAsync(socket, "hello", timeout.Token);
|
||||
|
||||
fixture.RelayNetwork.Dialled.ShouldNotContain(endpoint => endpoint.Address.Equals(IPAddress.Parse("169.254.169.254")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APortForward_IsRefused()
|
||||
{
|
||||
// Its port lives in ciphertext, so honouring one would mean taking the port from the client.
|
||||
var (subject, hostId) = await SeedRelayHostAsync("10.40.0.4", 22);
|
||||
|
||||
var response = await fixture.CreateClientFor(subject)
|
||||
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, Guid.CreateVersion7()));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayTargetRejected);
|
||||
}
|
||||
|
||||
// ---- The socket ----
|
||||
|
||||
[Fact]
|
||||
public async Task BytesGoBothWays_AndTheSessionIsAudited()
|
||||
{
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.2.1");
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
|
||||
using (var socket = await ConnectAsync(ticket, timeout.Token))
|
||||
{
|
||||
socket.SubProtocol.ShouldBe(RelayProtocol.SubProtocol);
|
||||
|
||||
await RoundTripAsync(socket, "SSH-2.0-test", timeout.Token);
|
||||
await RoundTripAsync(socket, "a second message", timeout.Token);
|
||||
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, timeout.Token);
|
||||
}
|
||||
|
||||
var session = await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token);
|
||||
|
||||
session.HostId.ShouldBe(hostId);
|
||||
session.TargetAddress.ShouldBe("10.40.2.1");
|
||||
session.TargetPort.ShouldBe(22);
|
||||
session.CloseReason.ShouldBe(RelayCloseReason.ClientClosed);
|
||||
|
||||
var sent = Encoding.UTF8.GetByteCount("SSH-2.0-test") + Encoding.UTF8.GetByteCount("a second message");
|
||||
session.BytesClientToTarget.ShouldBe(sent);
|
||||
session.BytesTargetToClient.ShouldBe(sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATicket_IsSpentByItsFirstUse()
|
||||
{
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.3.1");
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var first = await ConnectAsync(ticket, timeout.Token);
|
||||
|
||||
var replay = await Should.ThrowAsync<InvalidOperationException>(ConnectAsync(ticket, timeout.Token));
|
||||
|
||||
replay.Message.ShouldContain("401");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-a-ticket")]
|
||||
public async Task AnUpgradeWithoutAGenuineTicket_IsRefused(string? ticket)
|
||||
{
|
||||
var client = fixture.Server.CreateWebSocketClient();
|
||||
client.SubProtocols.Add(RelayProtocol.SubProtocol);
|
||||
|
||||
if (ticket is not null)
|
||||
{
|
||||
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket);
|
||||
}
|
||||
|
||||
var refused = await Should.ThrowAsync<InvalidOperationException>(client.ConnectAsync(
|
||||
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
refused.Message.ShouldContain("401");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlainGet_SaysItIsAWebSocket()
|
||||
{
|
||||
var response = await fixture.CreateClient().GetAsync(
|
||||
new Uri(RelayProtocol.ConnectPath, UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await response.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.MalformedRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATargetThatDoesNotAnswer_ClosesWithItsOwnCode()
|
||||
{
|
||||
// Routed nowhere, so the fake refuses the connect the way a closed port does. The user has to be
|
||||
// told the *server* could not reach the machine, which is not what a direct failure means.
|
||||
var (subject, hostId) = await SeedRelayHostAsync("10.40.4.1", 22);
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await ConnectAsync(ticket, timeout.Token);
|
||||
|
||||
await ReadUntilCloseAsync(socket, timeout.Token);
|
||||
|
||||
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TargetUnreachableCloseCode);
|
||||
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
||||
.ShouldBe(RelayCloseReason.TargetUnreachable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATargetThatHangsUp_EndsTheSession()
|
||||
{
|
||||
using var target = LoopbackTarget.SayThenHangUp("bye");
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.5.1");
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await ConnectAsync(ticket, timeout.Token);
|
||||
|
||||
(await ReadTextAsync(socket, timeout.Token)).ShouldBe("bye");
|
||||
await ReadUntilCloseAsync(socket, timeout.Token);
|
||||
|
||||
socket.CloseStatus.ShouldBe(WebSocketCloseStatus.NormalClosure);
|
||||
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
||||
.ShouldBe(RelayCloseReason.TargetClosed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnIdleSession_IsClosedWithTheIdleCode()
|
||||
{
|
||||
// The close code has to arrive, not merely the close. Cancelling a pending receive aborts a .NET
|
||||
// WebSocket, and an aborted one sends nothing — so a relay that stopped its reader by cancelling
|
||||
// it would pass a test that only checked the socket ended.
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.6.1");
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await ConnectAsync(ticket, timeout.Token);
|
||||
|
||||
await ReadUntilCloseAsync(socket, timeout.Token);
|
||||
|
||||
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.IdleTimeoutCloseCode);
|
||||
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
||||
.ShouldBe(RelayCloseReason.IdleTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
|
||||
{
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.7.1");
|
||||
|
||||
// Three tickets while nothing is open, so the third reaches the socket and is refused there —
|
||||
// the authoritative check, which the ticket endpoint's is only an early warning of.
|
||||
var tickets = new[]
|
||||
{
|
||||
await IssueAsync(subject, hostId),
|
||||
await IssueAsync(subject, hostId),
|
||||
await IssueAsync(subject, hostId),
|
||||
};
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var first = await ConnectAsync(tickets[0], timeout.Token);
|
||||
using var second = await ConnectAsync(tickets[1], timeout.Token);
|
||||
|
||||
await RoundTripAsync(first, "one", timeout.Token);
|
||||
await RoundTripAsync(second, "two", timeout.Token);
|
||||
|
||||
var early = await fixture.CreateClientFor(subject)
|
||||
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
||||
|
||||
early.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
(await early.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.RelayLimitReached);
|
||||
|
||||
using var third = await ConnectAsync(tickets[2], timeout.Token);
|
||||
await ReadUntilCloseAsync(third, timeout.Token);
|
||||
|
||||
third.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.TooManySessionsCloseCode);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationTokenSource Timeout()
|
||||
{
|
||||
var source = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
source.CancelAfter(FrameTimeout);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static string NewSubject() => $"relay-{Guid.CreateVersion7():N}";
|
||||
|
||||
private async Task<RelayTicketResponse> IssueAsync(string subject, Guid hostId)
|
||||
{
|
||||
var response = await fixture.CreateClientFor(subject)
|
||||
.PostContractAsync(RelayProtocol.TicketsPath, new RelayTicketRequest(hostId, null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
return (await response.Content.ReadContractAsync<RelayTicketResponse>()).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private Task<WebSocket> ConnectAsync(RelayTicketResponse ticket, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = fixture.Server.CreateWebSocketClient();
|
||||
client.SubProtocols.Add(RelayProtocol.SubProtocol);
|
||||
client.SubProtocols.Add(RelayProtocol.TicketProtocolPrefix + ticket.Ticket);
|
||||
|
||||
return client.ConnectAsync(
|
||||
new Uri(fixture.Server.BaseAddress, RelayProtocol.ConnectPath.TrimStart('/')),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RoundTripAsync(WebSocket socket, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
await socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Binary, true, cancellationToken);
|
||||
|
||||
(await ReadTextAsync(socket, cancellationToken, Encoding.UTF8.GetByteCount(text))).ShouldBe(text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads until at least <paramref name="expected"/> bytes have arrived, because message boundaries
|
||||
/// carry no meaning on the relay and an echo may come back split.
|
||||
/// </summary>
|
||||
private static async Task<string> ReadTextAsync(
|
||||
WebSocket socket,
|
||||
CancellationToken cancellationToken,
|
||||
int expected = 1)
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
var received = 0;
|
||||
|
||||
while (received < expected)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer.AsMemory(received), cancellationToken);
|
||||
|
||||
result.MessageType.ShouldBe(WebSocketMessageType.Binary);
|
||||
received += result.Count;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer, 0, received);
|
||||
}
|
||||
|
||||
private static async Task ReadUntilCloseAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the audit row's second write, which lands after the close frame has gone out.
|
||||
/// </summary>
|
||||
private async Task<RelaySession> WaitForClosedAuditAsync(Guid sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await using (var scope = fixture.CreateScope())
|
||||
{
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
var session = await database.RelaySessions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
||||
|
||||
if (session?.EndedAtUtc is not null)
|
||||
{
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string Subject, Guid HostId)> SeedRoutedHostAsync(LoopbackTarget target, string address)
|
||||
{
|
||||
fixture.RelayNetwork.Route(new IPEndPoint(IPAddress.Parse(address), 22), target.Endpoint);
|
||||
|
||||
return await SeedRelayHostAsync(address, 22);
|
||||
}
|
||||
|
||||
private Task<(string Subject, Guid HostId)> SeedRelayHostAsync(
|
||||
string hostname,
|
||||
int port,
|
||||
string? subject = null) =>
|
||||
SeedHostAsync((hostname, port), subject);
|
||||
|
||||
/// <summary>A user, their personal vault, and one host in it — relayed when an address is given.</summary>
|
||||
private async Task<(string Subject, Guid HostId)> SeedHostAsync(
|
||||
(string Hostname, int Port)? relay,
|
||||
string? subject = null)
|
||||
{
|
||||
await using var scope = fixture.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
var user = await FindOrAddUserAsync(database, subject);
|
||||
|
||||
var vault = await database.Vaults.FirstOrDefaultAsync(
|
||||
v => v.OwnerUserId == user.Id,
|
||||
TestContext.Current.CancellationToken)
|
||||
?? database.Vaults.Add(new Vault
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Name = "Personal",
|
||||
OwnerKind = VaultOwnerKind.Personal,
|
||||
OwnerUserId = user.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
}).Entity;
|
||||
|
||||
var host = new SshHost
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
Payload = [1, 2, 3],
|
||||
KeyGeneration = 1,
|
||||
PayloadAadVersion = 1,
|
||||
RelayEnabled = relay is not null,
|
||||
Hostname = relay?.Hostname,
|
||||
Port = relay?.Port,
|
||||
Version = 1,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
CreatedByUserId = user.Id,
|
||||
UpdatedByUserId = user.Id,
|
||||
};
|
||||
|
||||
database.Hosts.Add(host);
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
return (user.Subject, host.Id);
|
||||
}
|
||||
|
||||
private async Task<UserAccount> FindOrAddUserAsync(DodoDbContext database, string? subject)
|
||||
{
|
||||
if (subject is not null
|
||||
&& await database.Users.SingleOrDefaultAsync(
|
||||
u => u.Subject == subject,
|
||||
TestContext.Current.CancellationToken) is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Issuer = fixture.IdentityProvider.Authority,
|
||||
Subject = NewSubject(),
|
||||
Status = UserStatus.Active,
|
||||
CreatedAtUtc = Now,
|
||||
UpdatedAtUtc = Now,
|
||||
};
|
||||
|
||||
database.Users.Add(user);
|
||||
database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>A TCP server on loopback, standing in for the machine the relay reaches.</summary>
|
||||
private sealed class LoopbackTarget : IDisposable
|
||||
{
|
||||
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
|
||||
private LoopbackTarget(Func<NetworkStream, CancellationToken, Task> serve)
|
||||
{
|
||||
listener.Start();
|
||||
_ = AcceptAsync(serve);
|
||||
}
|
||||
|
||||
internal IPEndPoint Endpoint => (IPEndPoint)listener.LocalEndpoint;
|
||||
|
||||
/// <summary>Writes back everything it reads, on every connection.</summary>
|
||||
internal static LoopbackTarget Echo() => new(static async (stream, token) =>
|
||||
{
|
||||
var buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
|
||||
while ((read = await stream.ReadAsync(buffer, token)) > 0)
|
||||
{
|
||||
await stream.WriteAsync(buffer.AsMemory(0, read), token);
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Writes one line and closes, as a server does that refuses the client.</summary>
|
||||
internal static LoopbackTarget SayThenHangUp(string text) => new(async (stream, token) =>
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), token));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
stopping.Cancel();
|
||||
listener.Stop();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcceptAsync(Func<NetworkStream, CancellationToken, Task> serve)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(stopping.Token);
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
try
|
||||
{
|
||||
await serve(client.GetStream(), stopping.Token);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// The relay hung up, or the test is over.
|
||||
}
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// Stopped.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,4 +73,20 @@ public sealed class TeamEnumAlignmentTests
|
||||
((int)PermissionFlags.Read).ShouldBe(1 << 0);
|
||||
((int)PermissionFlags.Write).ShouldBe(1 << 1);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Not a team enum, and here because this is where the wire-against-domain pairs live. The relay
|
||||
/// writes its audit row's reason by value, so a renumbering would file every idle timeout as
|
||||
/// something else.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void EveryWireRelayCloseReason_HasADomainReasonWithTheSameValue()
|
||||
{
|
||||
Enum.GetValues<RelaySessionCloseReason>().Length.ShouldBe(Enum.GetValues<RelayCloseReason>().Length);
|
||||
|
||||
foreach (var reason in Enum.GetValues<RelaySessionCloseReason>())
|
||||
{
|
||||
((int)Enum.Parse<RelayCloseReason>(reason.ToString())).ShouldBe((int)reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,43 @@ public sealed class DodoSshApiClientTests : IDisposable
|
||||
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARelayTicket_IsAskedForByHostAlone()
|
||||
{
|
||||
// The request names a host and nothing else. An address in it would be the open proxy ADR 0004
|
||||
// exists to prevent, and the server would be right to ignore it — so it is not sent.
|
||||
var hostId = Guid.CreateVersion7();
|
||||
var issued = new RelayTicketResponse(
|
||||
"dGlja2V0",
|
||||
new Uri("wss://relay.example" + RelayProtocol.ConnectPath),
|
||||
RelayProtocol.SubProtocol,
|
||||
Guid.CreateVersion7(),
|
||||
new DateTimeOffset(2026, 9, 28, 12, 0, 30, TimeSpan.Zero));
|
||||
|
||||
server.StubRelayTicket(issued);
|
||||
|
||||
var ticket = await Client().IssueRelayTicketAsync(hostId, TestContext.Current.CancellationToken);
|
||||
|
||||
ticket.ShouldBe(issued);
|
||||
server.LastAuthorization(RelayProtocol.TicketsPath).ShouldBe("Bearer test-access-token");
|
||||
|
||||
var body = System.Text.Json.Nodes.JsonNode.Parse(server.LastBody(RelayProtocol.TicketsPath))!.AsObject();
|
||||
body["hostId"]!.GetValue<Guid>().ShouldBe(hostId);
|
||||
body.Where(property => property.Key is not ("hostId" or "portForwardId")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedRelayTicket_CarriesItsCode()
|
||||
{
|
||||
server.StubProblem(
|
||||
RelayProtocol.TicketsPath, "POST", 403, ProblemCodes.RelayTargetRejected, "The relay will not connect.");
|
||||
|
||||
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
|
||||
await Client().IssueRelayTicketAsync(Guid.CreateVersion7(), TestContext.Current.CancellationToken));
|
||||
|
||||
exception.Code.ShouldBe(ProblemCodes.RelayTargetRejected);
|
||||
}
|
||||
|
||||
private DodoSshApiClient Client() => new(http, new StubTokenProvider());
|
||||
|
||||
private static MeResponse UnenrolledMe() =>
|
||||
|
||||
@@ -51,6 +51,10 @@ internal sealed class StubServer : IDisposable
|
||||
response, DodoSshJsonContext.Default.SyncPullResponse));
|
||||
|
||||
/// <summary>Stubs an RFC 9457 problem response.</summary>
|
||||
internal void StubRelayTicket(RelayTicketResponse response) =>
|
||||
StubJson(RelayProtocol.TicketsPath, "POST", 200, JsonSerializer.Serialize(
|
||||
response, DodoSshJsonContext.Default.RelayTicketResponse));
|
||||
|
||||
internal void StubProblem(string path, string method, int statusCode, string code, string detail)
|
||||
{
|
||||
var problem = new JsonObject
|
||||
|
||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
internal const double HostDrawerWidth = 320;
|
||||
|
||||
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Its height is deliberately absent from this list, because the window has none to state: it is
|
||||
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
|
||||
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
|
||||
/// <c>SplashCardTests</c> pins all three against the XAML.
|
||||
/// </remarks>
|
||||
internal const double SplashWidth = 420;
|
||||
|
||||
/// <inheritdoc cref="SplashWidth" />
|
||||
internal const double SplashMinimumHeight = 160;
|
||||
|
||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
@@ -942,6 +942,49 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The card is the one place the shell says what it is dialling <em>before</em> anything answers, and on a
|
||||
/// refusal it is the only copy of that on screen — so both lines are there to be taken away, and a
|
||||
/// <c>TextBlock</c> in Avalonia is a drawing of a string rather than a string. Same change, same argument,
|
||||
/// as the session column's own address; see <c>SessionSidebarTests</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted on values long enough to be trimmed, which is the half that could quietly break: an ellipsis
|
||||
/// is a drawing and not a shorter string, so select-all has to reach the whole name and the whole address
|
||||
/// rather than the part that happened to fit inside the card's 460 pixels. Values that fit would pass
|
||||
/// whether that were true or not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectingCardsNameAndAddressSelectAndCopyWhole()
|
||||
{
|
||||
const string label = "customer-production-database-01.eu-west-1.internal.example.company";
|
||||
const string address = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
||||
|
||||
await OnTheConnectingCardAsync(
|
||||
card =>
|
||||
{
|
||||
// Found by their text rather than by position: the order is the visual tree's, and a third
|
||||
// selectable block is already in there — the refusal reason, hidden while this tab is only
|
||||
// connecting. Single rather than First, so a duplicate would be a question rather than a
|
||||
// silent pick.
|
||||
var takeable = card.GetVisualDescendants().OfType<SelectableTextBlock>().ToList();
|
||||
|
||||
var name = takeable.Single(block => block.Text == label);
|
||||
var endpoint = takeable.Single(block => block.Text == address);
|
||||
|
||||
name.SelectAll();
|
||||
name.SelectedText.ShouldBe(label, "the ellipsis is a drawing, not a shorter string");
|
||||
name.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||
|
||||
endpoint.SelectAll();
|
||||
endpoint.SelectedText.ShouldBe(address, "the ellipsis is a drawing, not a shorter string");
|
||||
endpoint.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||
},
|
||||
new TerminalTabViewModel(label, address));
|
||||
}
|
||||
|
||||
// ---- The logs screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the connecting card out and hands it over, rather than measuring it.</summary>
|
||||
/// <remarks>
|
||||
/// The same rectangle <see cref="MeasureConnectingAsync"/> uses and for the same reasons — see its
|
||||
/// remark. Separate because the claim is about a control's own state rather than about where anything was
|
||||
/// arranged, and threading an assertion over the card through a method whose parameter is a fault list
|
||||
/// would make both callers harder to read than two methods are.
|
||||
/// </remarks>
|
||||
private Task OnTheConnectingCardAsync(Action<ConnectingCard> body, TerminalTabViewModel tab) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.State = ShellState.Unlocked;
|
||||
shell.Tabs.Clear();
|
||||
shell.Tabs.Add(tab);
|
||||
shell.SelectedTab = tab;
|
||||
|
||||
var card = new ConnectingCard { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card,
|
||||
LayoutHarness.SessionScreenWidth(sidebarVisible: true),
|
||||
LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
body(card);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Avalonia.Controls;
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The splash, and the one thing about it that can go wrong.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
|
||||
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
|
||||
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
|
||||
/// height it would wrap and be clipped at exactly the wrong moment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So what is measured here is that the message grows the card, and that the window is declared in a way
|
||||
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
|
||||
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
|
||||
/// heights instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SplashCardTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
|
||||
private const string LongFailure =
|
||||
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
|
||||
+ "because it is being used by another process, and this build cannot open a second copy of it.";
|
||||
|
||||
/// <remarks>
|
||||
/// Pins the two constants and the mechanism against the XAML, the way
|
||||
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
|
||||
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
|
||||
/// suite's other test would go on passing, because the card would still be measuring the taller size it
|
||||
/// was never given.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
|
||||
try
|
||||
{
|
||||
window.Width.ShouldBe(LayoutHarness.SplashWidth);
|
||||
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
|
||||
window.SizeToContent.ShouldBe(
|
||||
SizeToContent.Height,
|
||||
"a fixed height would clip a long failure message");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
|
||||
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
|
||||
/// that never applies to anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
|
||||
LayoutHarness.SplashMinimumHeight,
|
||||
"the splash opens at its minimum and the one line it starts with has to fit"),
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
|
||||
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
|
||||
/// thing the splash exists to carry at that point would be the part nobody could read.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var starting = MessageHeight(card => { });
|
||||
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
|
||||
|
||||
failed.ShouldBeGreaterThan(
|
||||
starting,
|
||||
"the message wraps, so the card has to want the height the window then gives it");
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one card wants to be, at the splash's width, after the caller has had its say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hosted in a window before being measured, because the heading and the hint take their size and their
|
||||
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
|
||||
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
|
||||
/// gives it and what the host window's own fixed height would otherwise clamp away.
|
||||
/// </remarks>
|
||||
private static double MessageHeight(Action<SplashCard> say)
|
||||
{
|
||||
var card = new SplashCard();
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
say(card);
|
||||
|
||||
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
|
||||
|
||||
return card.DesiredSize.Height;
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
||||
/// </remarks>
|
||||
public IVaultEventStream Events => IdleVaultEventStream.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IServerRelay Relay => UnavailableServerRelay.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => new();
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Runs against each shell request before it succeeds or fails, when set.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// How a test plays the part of SSH.NET arriving at a proxy: the relay tests use it to speak SOCKS to the
|
||||
/// bridge the request names, so that what the relay then says reaches the bridge the way it would in a
|
||||
/// real connect, before <see cref="Failure"/> is thrown.
|
||||
/// </remarks>
|
||||
internal Func<SshConnectionRequest, Task>? BeforeConnect { get; set; }
|
||||
|
||||
/// <summary>Every shell this factory has opened, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
|
||||
@@ -45,6 +55,31 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// </remarks>
|
||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The furthest phase a failing attempt reports before <see cref="Failure"/> is thrown, or null for one
|
||||
/// that never reports any.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>It is what decides whether the shell goes and probes the endpoint.</b> A connection that stopped
|
||||
/// while reaching is worth asking a socket about; one that got a host key has plainly reached the machine,
|
||||
/// and one that never reported a phase never dialled at all. See <c>VaultViewModel.ExplainAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="SshConnectionPhase.Reaching"/>, which is what this fake reported
|
||||
/// unconditionally before the field existed, so every test written against it goes on meaning what it did.
|
||||
/// Null models the real factory throwing out of <c>BuildConnectionInfo</c> — a private key that will not
|
||||
/// parse — which happens before it reports anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal SshConnectionPhase? FailAfter { get; set; } = SshConnectionPhase.Reaching;
|
||||
|
||||
/// <summary>Every phase up to and including <paramref name="furthest"/>, in the order they happen.</summary>
|
||||
private static IEnumerable<SshConnectionPhase> PhasesUpTo(SshConnectionPhase? furthest) =>
|
||||
furthest is { } limit
|
||||
? Enum.GetValues<SshConnectionPhase>().Where(phase => phase <= limit)
|
||||
: [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -56,13 +91,23 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
||||
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
||||
// show it stuck on a named step rather than on none.
|
||||
progress?.Report(SshConnectionPhase.Reaching);
|
||||
//
|
||||
// How far a failing attempt gets is FailAfter's to say; a succeeding one is always reaching by now.
|
||||
foreach (var phase in Failure is null ? [SshConnectionPhase.Reaching] : PhasesUpTo(FailAfter))
|
||||
{
|
||||
progress?.Report(phase);
|
||||
}
|
||||
|
||||
if (Gate is { } gate)
|
||||
{
|
||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (BeforeConnect is { } before)
|
||||
{
|
||||
await before(request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (Failure is { } failure)
|
||||
{
|
||||
throw failure;
|
||||
@@ -419,3 +464,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
||||
closed.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reachability probe that answers from a script instead of from the network.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Every shell in this suite is given one, and that is not only about speed.</b> The real probe resolves
|
||||
/// a name and opens a socket — see <see cref="SshReachabilityProbe"/> — so a fixture without this would put a
|
||||
/// live DNS lookup for <c>db.internal</c> and a live TCP connect in the middle of a unit suite, on every test
|
||||
/// where a connection fails. That is slow, it needs a network, and it is worse than either: <c>db.internal</c>
|
||||
/// resolves on any machine whose resolver has a search domain that makes it resolve, so the sentence the
|
||||
/// assertions read would depend on whose network the suite was run from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Summary"/> defaults to empty, which is what keeps the tests that predate the probe asserting
|
||||
/// the exact message they always did: an empty finding appends nothing, so those cases go on describing a
|
||||
/// failure and nothing else. A test that is about the diagnosis sets it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class FakeReachabilityProbe : ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>What every probe comes back with. Empty adds nothing to the failure it explains.</summary>
|
||||
internal string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>What it was asked about, in order, so a test can assert it was not asked at all.</summary>
|
||||
internal List<string> Probed { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<SshReachabilityReport> ProbeAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Probed.Add($"{host}:{port}");
|
||||
|
||||
return ValueTask.FromResult(
|
||||
new SshReachabilityReport(SshReach.Open, host, port, host, TimeSpan.Zero, Summary));
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,12 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
||||
/// <inheritdoc />
|
||||
public IVaultEventStream Events => Notices;
|
||||
|
||||
/// <summary>
|
||||
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
|
||||
/// server that does not advertise the feature supplies.
|
||||
/// </summary>
|
||||
public IServerRelay Relay { get; set; } = UnavailableServerRelay.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncOptions SyncOptions => SyncOptions.Default;
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A host set to connect through the server relay, from the Connect button to the request that reaches SSH.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The relay here is the real <see cref="ServerRelay"/> — ticket, subprotocols and bridge — over an
|
||||
/// in-process WebSocket pair, with only the API call and the far end of the socket stood in for. What these
|
||||
/// hold is the promise the checkbox makes and used not to keep: ticking it changes how the host is reached,
|
||||
/// and a relay that cannot be used is a stated failure rather than a quiet direct dial.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The bridge's own behaviour against a real sshd is <c>RelayBridgeTests</c>, and the server's is
|
||||
/// <c>RelayEndpointTests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ARelayedHost_IsDialledThroughTheBridge_AndStillAsItself()
|
||||
{
|
||||
var tickets = new FakeRelayApi();
|
||||
server.Relay = new ServerRelay(tickets, ConnectPairedAsync);
|
||||
|
||||
var vault = await ReadyToConnectRelayedAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
tickets.HostIds.ShouldHaveSingleItem().ShouldBe(vault.SelectedHost.ShouldNotBeNull().EntityId);
|
||||
|
||||
// Through a loopback proxy — and still named as the host, because the name is what the host key is
|
||||
// pinned under. The bridge carries the name in its CONNECT; it does not replace it.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
request.Host.ShouldBe("db.internal");
|
||||
request.Port.ShouldBe(22);
|
||||
|
||||
// The ticket travels as the second subprotocol and never as anything else.
|
||||
offeredProtocols.ShouldBe([RelayProtocol.SubProtocol, RelayProtocol.TicketProtocolPrefix + FakeRelayApi.Ticket]);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARelayedHost_OnAServerWithoutARelay_IsRefusedWithoutDialling()
|
||||
{
|
||||
// The fake server's default: a server that does not advertise the feature.
|
||||
var vault = await ReadyToConnectRelayedAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// No fallback to the direct address. The user ticked the relay because that path does not work, or
|
||||
// goes somewhere they do not mean.
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("does not offer one");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ProblemCodes.RelayTargetRejected, "refused to relay")]
|
||||
[InlineData(ProblemCodes.RelayLimitReached, "as many relay sessions")]
|
||||
[InlineData(ProblemCodes.RelayUnavailable, "does not offer one")]
|
||||
public async Task ARefusedTicket_IsExplained_AndNothingIsDialled(string code, string explanation)
|
||||
{
|
||||
server.Relay = new ServerRelay(
|
||||
new FakeRelayApi { Refusal = new DodoSshApiException(HttpStatusCode.Forbidden, code, "no") },
|
||||
ConnectPairedAsync);
|
||||
|
||||
var vault = await ReadyToConnectRelayedAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARelayThatCouldNotReachTheHost_SaysSo_InsteadOfTheTransportError()
|
||||
{
|
||||
// What the server does when it cannot dial the host: accept the socket, then close it with 4502.
|
||||
server.Relay = new ServerRelay(new FakeRelayApi(), async (url, protocols, cancellationToken) =>
|
||||
{
|
||||
var socket = await ConnectPairedAsync(url, protocols, cancellationToken);
|
||||
await relayEnd!.CloseOutputAsync(
|
||||
(WebSocketCloseStatus)RelayProtocol.TargetUnreachableCloseCode,
|
||||
"unreachable",
|
||||
cancellationToken);
|
||||
|
||||
return socket;
|
||||
});
|
||||
|
||||
// SSH.NET arriving at the bridge and then losing its stream, which is how a relay that hung up looks
|
||||
// from inside the connect.
|
||||
ssh.BeforeConnect = request => ThroughSocksToEndOfStreamAsync(request.Proxy.ShouldNotBeNull().Port);
|
||||
ssh.Failure = new InvalidOperationException("An established connection was aborted by the server.");
|
||||
|
||||
var vault = await ReadyToConnectRelayedAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("could not reach this host");
|
||||
vault.Status.ShouldNotContain("aborted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARelayedHostsFileTransfer_GoesThroughTheRelayToo()
|
||||
{
|
||||
// Its own connection, so it needs its own relay — or the transfers screen would fail for exactly the
|
||||
// hosts the relay exists for.
|
||||
server.Relay = new ServerRelay(new FakeRelayApi(), ConnectPairedAsync);
|
||||
|
||||
var vault = await ReadyToConnectRelayedAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var request = ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>The protocols the last relay socket was opened offering.</summary>
|
||||
private IReadOnlyList<string>? offeredProtocols;
|
||||
|
||||
/// <summary>The far end of the last relay socket, which a test may close as the server would.</summary>
|
||||
private WebSocket? relayEnd;
|
||||
|
||||
private async Task<VaultViewModel> ReadyToConnectRelayedAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "behind-the-server";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
// A relayed host needs a port of its own; the server stores it and dials it.
|
||||
vault.EditorPort = 22;
|
||||
vault.EditorRelayEnabled = true;
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(row => row.Host.RelayEnabled);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
/// <summary>Opens both ends of a WebSocket over loopback, and keeps the far one.</summary>
|
||||
private async Task<WebSocket> ConnectPairedAsync(
|
||||
Uri url,
|
||||
IReadOnlyList<string> protocols,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
url.ShouldBe(FakeRelayApi.SocketUrl);
|
||||
offeredProtocols = protocols;
|
||||
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var dialling = new TcpClient();
|
||||
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken);
|
||||
var accepted = await listener.AcceptTcpClientAsync(cancellationToken);
|
||||
await connecting;
|
||||
|
||||
relayEnd = WebSocket.CreateFromStream(
|
||||
accepted.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero });
|
||||
|
||||
return WebSocket.CreateFromStream(
|
||||
dialling.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which
|
||||
/// it does once the relay has closed, so the relay's reason is recorded by the time this returns.
|
||||
/// </summary>
|
||||
private static async Task ThroughSocksToEndOfStreamAsync(int port)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port, Token);
|
||||
|
||||
var stream = client.GetStream();
|
||||
var name = Encoding.ASCII.GetBytes("db.internal");
|
||||
|
||||
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
||||
await stream.ReadExactlyAsync(new byte[2], Token);
|
||||
|
||||
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
|
||||
await stream.ReadExactlyAsync(new byte[10], Token);
|
||||
|
||||
var rest = new byte[256];
|
||||
while (await stream.ReadAsync(rest, Token) > 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ticket half of the API, answering or refusing as a test arranges.</summary>
|
||||
private sealed class FakeRelayApi : IRelayApi
|
||||
{
|
||||
internal const string Ticket = "dGlja2V0";
|
||||
|
||||
internal static readonly Uri SocketUrl = new("ws://relay.test" + RelayProtocol.ConnectPath);
|
||||
|
||||
internal List<Guid> HostIds { get; } = [];
|
||||
|
||||
internal DodoSshApiException? Refusal { get; init; }
|
||||
|
||||
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken)
|
||||
{
|
||||
HostIds.Add(hostId);
|
||||
|
||||
return Refusal is { } refusal
|
||||
? Task.FromException<RelayTicketResponse>(refusal)
|
||||
: Task.FromResult(new RelayTicketResponse(
|
||||
Ticket,
|
||||
SocketUrl,
|
||||
RelayProtocol.SubProtocol,
|
||||
Guid.CreateVersion7(),
|
||||
TimeProvider.System.GetUtcNow().AddSeconds(30)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,15 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// </remarks>
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
/// <summary>
|
||||
/// The scripted answer behind every "why did that not connect" in this file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Silent by default — see <see cref="FakeReachabilityProbe.Summary"/> — so the cases written before the
|
||||
/// diagnosis existed go on asserting the exact failure message they always did.
|
||||
/// </remarks>
|
||||
private readonly FakeReachabilityProbe reachability = new();
|
||||
|
||||
private int signInAttempts;
|
||||
|
||||
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
||||
@@ -212,7 +221,12 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
{
|
||||
save = onChanged;
|
||||
return new StopWatching(() => save = null);
|
||||
});
|
||||
},
|
||||
|
||||
// Scripted rather than real, so that a failed connection in any test in this file does not go
|
||||
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||
// the wall clock.
|
||||
reachability: reachability);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -2085,6 +2099,82 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
"the session that was replaced should have been released with its pane");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ THE ONE THIS EXISTS FOR: a reconnect that dialled a different machine of the same name. Nothing in
|
||||
/// this application stops two hosts being called the same thing — a team's vault beside a personal one
|
||||
/// makes it ordinary, and "prod-db" is what a database server gets called in both — and a tab used to
|
||||
/// find its host by that name. A dropped session on one machine then came back as a live shell on the
|
||||
/// other, with the tab, the address and the status bar all still naming the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted on the address actually dialled rather than on anything the tab says about itself. The two
|
||||
/// rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written
|
||||
/// against the label would have passed on the day the wrong machine was being opened.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ReconnectingATabWhoseHostHasANamesake_DialsTheMachineTheTabWasOpenedOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
// A second host under the name the fixture's own already has, on a different machine.
|
||||
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||
|
||||
// Both bound to the key, so neither is diverted to the password sheet: what this asserts is which of
|
||||
// the two gets dialled, not whether either can be. Re-found between the two, because saving a host
|
||||
// rebuilds the list.
|
||||
await BindKeyAsync(vault, Machine(vault, "db.internal"), vault.Keys[0].EntityId);
|
||||
await BindKeyAsync(vault, Machine(vault, "db-replica.internal"), vault.Keys[0].EntityId);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(Machine(vault, "db-replica.internal"));
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
ssh.Requests.ShouldHaveSingleItem().Host.ShouldBe("db-replica.internal");
|
||||
|
||||
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
shell.CanReconnectSelectedTab.ShouldBeTrue("the machine this tab was opened on is still there");
|
||||
|
||||
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||
|
||||
tab.IsLive.ShouldBeTrue(tab.Status);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(2);
|
||||
ssh.Requests[1].Host.ShouldBe(
|
||||
"db-replica.internal",
|
||||
"a reconnect dials the machine the tab was opened on, not the one that shares its name");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same mix-up as the test above, where it is read rather than dialled. A green dot is the host list
|
||||
/// answering "am I on this machine", and the name two rows share is not an answer to it — a dot on the
|
||||
/// machine nobody has a shell on claims a session that does not exist, and takes QUICK ACCESS's pins with
|
||||
/// it, since the sidebar offers the pinned folders of whichever row that same match found.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingToOneOfTwoHostsOfTheSameName_LightsThatOneAlone()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.SelectedHost = Machine(vault, "db-replica.internal");
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue();
|
||||
|
||||
Machine(vault, "db-replica.internal").IsConnected.ShouldBeTrue();
|
||||
Machine(vault, "db.internal").IsConnected.ShouldBeFalse(
|
||||
"nobody has a shell on this machine; only its name was connected to");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
||||
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
||||
@@ -2516,6 +2606,84 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The reason this exists: "Connection has timed out." is what SSH.NET says for two different
|
||||
/// faults.</b> A machine that never answered and a machine that answered TCP and then never spoke SSH are
|
||||
/// the same five words and nothing else, and they are not the same problem. So a failure that stopped
|
||||
/// while reaching now goes and looks, and what it finds is added to what was said.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The finding is appended rather than substituted, which is the half worth asserting: the exception is
|
||||
/// the fact and the probe is a second measurement, and a test that only checked for the new sentence
|
||||
/// would pass against an implementation that had thrown the original away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectionThatCouldNotReachItsHost_SaysWhatIsActuallyWrongWithIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Failure = new InvalidOperationException("Connection has timed out.");
|
||||
reachability.Summary = "Port 22 on db.internal accepts connections.";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
|
||||
tab.Status.ShouldBe("Connection has timed out. Port 22 on db.internal accepts connections.");
|
||||
reachability.Probed.ShouldHaveSingleItem().ShouldBe("db.internal:22");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Past <see cref="SshConnectionPhase.Reaching"/> the machine has offered a host key, so it is plainly
|
||||
/// reachable and there is nothing a socket could add. A refused password that came back with "the network
|
||||
/// is fine and something is listening" bolted on would be true, useless, and read as though the client
|
||||
/// had not understood the question.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectionRefusedAfterTheHostKey_IsNotProbed()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.FailAfter = SshConnectionPhase.Authenticating;
|
||||
ssh.Failure = new InvalidOperationException("Permission denied (publickey,password).");
|
||||
reachability.Summary = "this must not be appended";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("Permission denied (publickey,password).");
|
||||
reachability.Probed.ShouldBeEmpty("the host key proved the machine was reached");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ <b>The other end of the window, and the one that would state something false.</b> A failure with no
|
||||
/// phase reported at all never dialled — the real factory throws out of <c>BuildConnectionInfo</c> on a
|
||||
/// private key it cannot parse, before the socket is opened. Probing there finds a perfectly healthy port
|
||||
/// and appends "the SSH handshake is what did not finish" to a message about an unreadable key, which is
|
||||
/// a confident wrong answer replacing an accurate one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AFailureBeforeAnythingWasDialled_IsNotProbed()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.FailAfter = null;
|
||||
ssh.Failure = new InvalidOperationException("The private key could not be read.");
|
||||
reachability.Summary = "this must not be appended";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("The private key could not be read.");
|
||||
reachability.Probed.ShouldBeEmpty("nothing had been dialled, so there was nothing to ask about");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||
@@ -10285,11 +10453,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
||||
private static async Task AddHostAsync(
|
||||
VaultViewModel vault,
|
||||
string label,
|
||||
string hostname = "db.internal")
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorHostname = hostname;
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
@@ -10313,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>The host row for a given machine, for the tests whose rows deliberately share a name.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Host"/> cannot be asked when two rows are called the same thing, which is the whole
|
||||
/// situation those tests set up. Re-found rather than held, because saving any host rebuilds the list.
|
||||
/// </remarks>
|
||||
private static HostRowViewModel Machine(VaultViewModel vault, string hostname) =>
|
||||
vault.Hosts.Single(row => string.Equals(row.Host.Hostname, hostname, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and what the second launch does instead of starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
|
||||
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
|
||||
/// handle and a pipe name, and a fake of either would be a test of the fake.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
|
||||
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
|
||||
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
|
||||
/// line.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
|
||||
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
|
||||
/// DodoSSH the developer happens to have running.
|
||||
/// </remarks>
|
||||
private readonly ClientPaths paths =
|
||||
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
|
||||
|
||||
/// <summary>
|
||||
/// No patience at all, which is what every test here wants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
|
||||
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
|
||||
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Impatient = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// How long a doorbell is given to ring before the test says it never will.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A bound at all, which is the lesson rather than the number.</b> These two awaits were written
|
||||
/// against <c>TestContext.Current.CancellationToken</c> alone, which carries no timeout — so the run
|
||||
/// where a ring was genuinely lost did not fail, it <em>hung</em>, and took the whole CI job with it for
|
||||
/// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of
|
||||
/// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the
|
||||
/// build.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(paths.DataDirectory))
|
||||
{
|
||||
Directory.Delete(paths.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFirstLaunchClaimsTheProfile()
|
||||
{
|
||||
using var claim = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
claim.ShouldNotBeNull();
|
||||
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
|
||||
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ASecondLaunchIsRefused()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
|
||||
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
|
||||
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
|
||||
{
|
||||
var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
first.Dispose();
|
||||
|
||||
using var second = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
second.ShouldNotBeNull("the lock went with the handle");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
|
||||
/// nothing has been told the application is broken, so the running instance is asked to come forward.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var asked = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
|
||||
|
||||
// Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly,
|
||||
// because the test's own token is not — see LongEnoughForAPipe.
|
||||
await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell
|
||||
/// getting that wrong on Linux and only on Linux. A fresh <c>NamedPipeServerStream</c> per connection
|
||||
/// closes the listening socket between callers there, so the second ring was accepted by nobody while
|
||||
/// the launch that made it was told it had succeeded. See <c>SingleInstance.Doorbell</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that
|
||||
/// waited for the first to be answered before making the second would pass against the broken version.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDoorbellAnswersMoreThanOnce()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var rings = 0;
|
||||
var twice = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() =>
|
||||
{
|
||||
if (Interlocked.Increment(ref rings) == 2)
|
||||
{
|
||||
twice.TrySetResult();
|
||||
}
|
||||
});
|
||||
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
|
||||
await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// What the give-up path turns on. A lock held by something that is not listening — a process still
|
||||
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
|
||||
/// lock again rather than concluding anything from the silence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AskingWithNobodyListening_SaysSo()
|
||||
{
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
|
||||
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
|
||||
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TwoProfilesDoNotExcludeEachOther()
|
||||
{
|
||||
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
|
||||
|
||||
try
|
||||
{
|
||||
using var release = SingleInstance.Claim(paths, Impatient);
|
||||
using var nightly = SingleInstance.Claim(other, Impatient);
|
||||
|
||||
release.ShouldNotBeNull();
|
||||
nightly.ShouldNotBeNull("a second profile is a second application");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(other.DataDirectory))
|
||||
{
|
||||
Directory.Delete(other.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,84 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
||||
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remote flooding a renderer that never draws anything is bounded, and the pump keeps reading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ THIS IS THE TEST THE OLD DESIGN WOULD FAIL, AND IT NEEDS A REAL SSH.NET UNDERNEATH IT TO SAY SO.
|
||||
/// The pump used to stop reading once a renderer had a credit window's worth of unrendered output, on
|
||||
/// the reasoning that SSH's own receive window would then close and block the remote. It does not:
|
||||
/// <c>Renci.SshNet.Channels.Channel.OnData</c> adjusts the window as bytes <em>arrive</em>, before it
|
||||
/// hands them on, so the remote goes on sending at full speed and <c>ShellStream</c> copies every byte
|
||||
/// into a buffer that doubles and is never given back. Against this test, <c>yes</c> would have been
|
||||
/// read at <c>BytesRead == WindowBytes</c> exactly and then stopped for ever while the process grew.
|
||||
/// A fake shell cannot show that, because a fake hands over bytes when it is asked and a real channel
|
||||
/// hands them over when they arrive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is in two halves and needs both. Reading keeps going, which is what keeps SSH.NET's
|
||||
/// buffer drained; and what this side holds stays inside the backlog, which is what keeps the drained
|
||||
/// bytes from simply piling up here instead. Either alone is satisfied by a design that leaks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog()
|
||||
{
|
||||
const int Window = 64 * 1024;
|
||||
const int Backlog = 512 * 1024;
|
||||
|
||||
await using var connection = await ConnectTrustedAsync();
|
||||
|
||||
var shell = await connection.OpenShellAsync(
|
||||
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||
|
||||
// Deliberately never acknowledges: this is a renderer that has stopped drawing, which is the whole
|
||||
// condition under examination. CountingTransport only acknowledges when it is given a pump.
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = new TerminalSessionPump(
|
||||
1,
|
||||
shell,
|
||||
transport,
|
||||
TimeProvider.System,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
FlushInterval = TimeSpan.FromMilliseconds(20),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await pump.WriteInputAsync("yes dodossh\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Long enough that a pump which had stopped at the window would plainly have stopped, and that one
|
||||
// which had not would have read many times the backlog's capacity.
|
||||
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(10);
|
||||
|
||||
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead < Backlog * 4L)
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||
Backlog,
|
||||
"output is accumulating on this side of the socket, which is the growth the backlog bounds");
|
||||
}
|
||||
|
||||
pump.BytesRead.ShouldBeGreaterThan(
|
||||
Backlog * 4L,
|
||||
"the pump stopped reading, so the flood is piling up inside SSH.NET where nothing bounds it");
|
||||
|
||||
pump.BytesDropped.ShouldBeGreaterThan(
|
||||
0,
|
||||
"nothing was dropped, so something between here and the screen is still holding all of it");
|
||||
|
||||
await pump.DisposeAsync();
|
||||
|
||||
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
||||
/// <remarks>
|
||||
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The relay bridge: a real SSH connection, through a real SOCKS5 handshake, over a real WebSocket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The relay server is stood in for by the other end of an in-process WebSocket pair that pipes to the
|
||||
/// fixture's sshd — which is the whole of what the server's socket does once a ticket is spent, so the
|
||||
/// bytes on the wire here are the bytes a relayed session carries. What is not here is the ticket, which
|
||||
/// is the server's half and is tested there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three of these are the security properties the bridge has to keep, from
|
||||
/// <c>docs/reaching-a-host-you-cannot-dial.md</c>: it binds loopback only, it accepts once, and a
|
||||
/// connection that goes away takes the bridge — and so the relay session — with it. The failure each
|
||||
/// guards against is silent: an open proxy, or a relay session left running behind a closed tab.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(SshCollection.Name)]
|
||||
public sealed class RelayBridgeTests(SshServerFixture fixture)
|
||||
{
|
||||
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task TheBridge_ListensOnLoopbackOnly()
|
||||
{
|
||||
var (client, _) = await PairAsync();
|
||||
await using var bridge = RelayBridge.Start(client);
|
||||
|
||||
// Asserted on the address rather than trusted to the constructor: a bridge on every interface
|
||||
// would be a proxy into the relay for anyone on the network, and nothing would report it.
|
||||
bridge.Endpoint.Address.ShouldBe(IPAddress.Loopback);
|
||||
bridge.Proxy.Port.ShouldBe(bridge.Endpoint.Port);
|
||||
bridge.Proxy.Lifetime.ShouldBeSameAs(bridge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AShell_ThroughTheBridge_ReachesTheHost_AndGoesWithTheConnection()
|
||||
{
|
||||
var knownHosts = await TrustedStoreAsync();
|
||||
var (client, server) = await PairAsync();
|
||||
var relaying = RelayToSshdAsync(server);
|
||||
|
||||
var bridge = RelayBridge.Start(client);
|
||||
|
||||
// The host as the request names it, so the pin is the host's — the bridge is a SOCKS proxy, and
|
||||
// the CONNECT carries this name through it rather than 127.0.0.1.
|
||||
var connection = await new SshNetConnectionFactory(knownHosts)
|
||||
.ConnectAsync(Request(bridge.Proxy), progress: null, Token);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue();
|
||||
connection.HostKey.Host.ShouldBe(fixture.Host);
|
||||
|
||||
// The connection owns the bridge. Closing the one is what closes the other, and the relay hears it
|
||||
// as a close rather than as a socket that stopped answering.
|
||||
await connection.DisposeAsync();
|
||||
|
||||
await bridge.Completion.WaitAsync(Settles, Token);
|
||||
(await relaying.WaitAsync(Settles, Token)).ShouldBe(WebSocketMessageType.Close);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheBridge_AcceptsOnce()
|
||||
{
|
||||
var (client, _) = await PairAsync();
|
||||
await using var bridge = RelayBridge.Start(client);
|
||||
|
||||
using var first = new TcpClient();
|
||||
await first.ConnectAsync(bridge.Endpoint, Token);
|
||||
|
||||
// A greeting answered means the first connection has been accepted, which is when the listener
|
||||
// stops — so what the second attempt meets is a closed port, not a race with the backlog.
|
||||
var stream = first.GetStream();
|
||||
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
||||
var reply = new byte[2];
|
||||
await stream.ReadExactlyAsync(reply, Token);
|
||||
reply.ShouldBe(new byte[] { 5, 0 });
|
||||
|
||||
using var second = new TcpClient();
|
||||
await Should.ThrowAsync<SocketException>(second.ConnectAsync(bridge.Endpoint, Token).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARelayThatClosesWithAReason_FailsTheConnect_AndKeepsTheReason()
|
||||
{
|
||||
// What the server does when it cannot reach the host: accept the socket, then close it with 4502.
|
||||
var (client, server) = await PairAsync();
|
||||
await server.CloseOutputAsync((WebSocketCloseStatus)4502, "unreachable", Token);
|
||||
|
||||
var bridge = RelayBridge.Start(client);
|
||||
|
||||
var failure = await Should.ThrowAsync<Exception>(async () =>
|
||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
|
||||
.ConnectAsync(Request(bridge.Proxy), progress: null, Token));
|
||||
|
||||
// A connection error, not a question about a host key nobody presented.
|
||||
failure.ShouldNotBeOfType<SshHostKeyUnknownException>();
|
||||
|
||||
bridge.CloseStatus.ShouldBe((WebSocketCloseStatus)4502);
|
||||
|
||||
// And the failed connect disposed the bridge it was handed, rather than leaving it listening.
|
||||
await bridge.Completion.WaitAsync(Settles, Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NobodyArriving_ClosesTheRelay()
|
||||
{
|
||||
var (client, server) = await PairAsync();
|
||||
await using var bridge = RelayBridge.Start(client, TimeSpan.FromMilliseconds(200));
|
||||
|
||||
await bridge.Completion.WaitAsync(Settles, Token);
|
||||
|
||||
var received = await server.ReceiveAsync(new byte[16], Token);
|
||||
received.MessageType.ShouldBe(WebSocketMessageType.Close);
|
||||
}
|
||||
|
||||
/// <summary>Two ends of one WebSocket, over a loopback TCP connection this test owns.</summary>
|
||||
private static async Task<(WebSocket Client, WebSocket Server)> PairAsync()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var dialling = new TcpClient();
|
||||
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, Token);
|
||||
var accepted = await listener.AcceptTcpClientAsync(Token);
|
||||
await connecting;
|
||||
|
||||
var client = WebSocket.CreateFromStream(
|
||||
dialling.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero });
|
||||
|
||||
var server = WebSocket.CreateFromStream(
|
||||
accepted.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero });
|
||||
|
||||
return (client, server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relay's half: bytes from the socket to sshd and back, until one side ends.
|
||||
/// </summary>
|
||||
/// <returns>The type of the last message the relay received — a close, when the client said goodbye.</returns>
|
||||
private Task<WebSocketMessageType> RelayToSshdAsync(WebSocket server) => Task.Run(async () =>
|
||||
{
|
||||
using var target = new TcpClient();
|
||||
await target.ConnectAsync(fixture.Host, fixture.Port, Token);
|
||||
var stream = target.GetStream();
|
||||
|
||||
var down = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[32 * 1024];
|
||||
int read;
|
||||
|
||||
try
|
||||
{
|
||||
while ((read = await stream.ReadAsync(buffer, Token)) > 0)
|
||||
{
|
||||
await server.SendAsync(buffer.AsMemory(0, read), WebSocketMessageType.Binary, true, Token);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// The test's connection is over.
|
||||
}
|
||||
});
|
||||
|
||||
var inbound = new byte[32 * 1024];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var received = await server.ReceiveAsync(inbound.AsMemory(), Token);
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await server.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, Token);
|
||||
target.Close();
|
||||
await down;
|
||||
|
||||
return received.MessageType;
|
||||
}
|
||||
|
||||
await stream.WriteAsync(inbound.AsMemory(0, received.Count), Token);
|
||||
}
|
||||
});
|
||||
|
||||
private SshConnectionRequest Request(SshLoopbackProxy proxy) =>
|
||||
new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
SshServerFixture.Username,
|
||||
new SshPasswordCredential(SshServerFixture.Password),
|
||||
ConnectTimeout: null,
|
||||
proxy);
|
||||
|
||||
/// <summary>A store that already trusts the container's host key, learned directly.</summary>
|
||||
private async Task<InMemoryKnownHostStore> TrustedStoreAsync()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await new SshNetConnectionFactory(knownHosts).ConnectAsync(
|
||||
new SshConnectionRequest(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
SshServerFixture.Username,
|
||||
new SshPasswordCredential(SshServerFixture.Password)),
|
||||
progress: null,
|
||||
Token));
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
return knownHosts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The probe that runs after a connection has failed, and the four answers it can come back with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Real sockets on loopback rather than an abstraction over them, because the thing under test <em>is</em>
|
||||
/// what a socket does: the whole reason this type exists is that a refusal and a silence are
|
||||
/// indistinguishable in the exception SSH.NET throws and completely distinguishable to a TCP connect. A fake
|
||||
/// socket would be this suite asserting that the mapping table maps, and would have passed just as happily
|
||||
/// against the bug that prompted the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No container and no sshd. Nothing here speaks SSH — the probe deliberately does not — so the fixture is a
|
||||
/// listener that accepts, a port nothing is on, and a name that does not resolve.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SshReachabilityProbeTests
|
||||
{
|
||||
/// <remarks>
|
||||
/// Short, because two of these cases prove a negative by waiting. The production budget is six seconds —
|
||||
/// see <see cref="SshReachabilityProbe.Budget"/> — and nothing about the branch under test changes with
|
||||
/// the number, so the suite buys the same coverage for a fraction of the wall clock.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ShortBudget = TimeSpan.FromSeconds(2);
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <remarks>
|
||||
/// The case the whole type was written for. SSH.NET reports this and <see cref="NothingListening"/> with
|
||||
/// the identical "Connection has timed out." — so if this comes back as anything but
|
||||
/// <see cref="SshReach.Open"/>, the probe is adding nothing the exception did not already say.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APortThatAcceptsIsOpen_WhichIsWhatTellsAWedgedSshdFromABrokenNetwork()
|
||||
{
|
||||
using var listener = Accepting();
|
||||
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Open);
|
||||
report.Address.ShouldBe("127.0.0.1");
|
||||
report.Summary.ShouldContain("handshake");
|
||||
|
||||
// The half a user acts on: nothing here may suggest the network is at fault, because it demonstrably
|
||||
// is not — a socket just opened over it.
|
||||
report.Summary.ShouldNotContain("firewall");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A port with nothing on it. The distinction from <see cref="NothingAnswers"/> is the entire value of
|
||||
/// the finding: refused means the machine is up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The full budget, and this test is why the production one is six seconds.</b> A refusal is not
|
||||
/// immediate on Windows — the stack retries the SYN, and a closed loopback port took ~2s to answer when
|
||||
/// this was written. Run against <see cref="ShortBudget"/> this case comes back
|
||||
/// <see cref="SshReach.NoAnswer"/>, which is the probe telling somebody a machine that is up and
|
||||
/// refusing is behind a firewall. Left at the real budget on purpose: it is the one case whose
|
||||
/// correctness depends on the number, so it is the one that should fail if the number is lowered.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingListening()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(SshReachabilityProbe.Budget)
|
||||
.ProbeAsync("127.0.0.1", UnusedPort(), Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Refused);
|
||||
report.Summary.ShouldContain("sshd is not running");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 192.0.2.1 is TEST-NET-1 from RFC 5737: reserved for documentation, routed nowhere, and guaranteed not
|
||||
/// to be some real machine that answers and makes this flap. The packets go out and nothing comes back,
|
||||
/// which is exactly the shape of a firewall that drops rather than rejects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The elapsed assertion is the one that matters beside the enum. Silence is only evidence if the probe
|
||||
/// actually waited for it, and a bug that returned <see cref="SshReach.NoAnswer"/> instantly — a socket
|
||||
/// erroring for some unrelated reason — would otherwise pass this while telling the user something false
|
||||
/// about their firewall.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingAnswers()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(ShortBudget).ProbeAsync("192.0.2.1", 22, Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.NoAnswer);
|
||||
report.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(1));
|
||||
report.Summary.ShouldContain("firewall");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <c>.invalid</c> is reserved by RFC 2606 precisely so that a test may rely on it never resolving, on
|
||||
/// any network, including one whose resolver answers every NXDOMAIN with a search page.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANameThatDoesNotResolve()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("dodossh-no-such-host.invalid", 22, Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Unresolved);
|
||||
report.Address.ShouldBeNull("there is no address, and inventing one would be the whole failure");
|
||||
report.Summary.ShouldContain("does not resolve");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An address typed in is not looked up, and the report must not read as though it had been. The
|
||||
/// assertion is on the sentence rather than on a call count: what would be wrong is the user being shown
|
||||
/// "127.0.0.1 (127.0.0.1)", which is what a probe that resolved a literal and then printed both produces.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressIsNotPrintedTwice()
|
||||
{
|
||||
using var listener = Accepting();
|
||||
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||
|
||||
report.Summary.ShouldNotContain("(127.0.0.1)");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The caller is in a failure path with nothing to do with a second exception — see
|
||||
/// <see cref="ISshReachabilityProbe.ProbeAsync"/> — so every branch above returns a report. This is the
|
||||
/// one input that is a caller's mistake rather than a broken host, and it is the one that still throws.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnEmptyHostIsTheCallersBug_NotAFinding()
|
||||
{
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await new SshReachabilityProbe(ShortBudget).ProbeAsync(" ", 22, Token));
|
||||
}
|
||||
|
||||
private static TcpListener Accepting()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>A port that was free a moment ago, which is the closest thing to a guarantee there is.</summary>
|
||||
/// <remarks>
|
||||
/// Bound and released rather than picked out of the air: the OS hands out a port it knows is unused, and
|
||||
/// the window in which something else could take it is far smaller than the odds of a hard-coded number
|
||||
/// colliding with whatever the machine running this happens to have open.
|
||||
/// </remarks>
|
||||
private static int UnusedPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var port = PortOf(listener);
|
||||
listener.Stop();
|
||||
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
private readonly Lock gate = new();
|
||||
|
||||
private readonly bool blockReads;
|
||||
private readonly TimeSpan? pacePerRead;
|
||||
|
||||
private long remaining;
|
||||
private byte pattern;
|
||||
@@ -26,10 +27,26 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||
/// <c>TerminalWorkspaceTests</c>.
|
||||
/// </param>
|
||||
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
|
||||
/// <param name="pacePerRead">
|
||||
/// How long each read takes, or null for one that returns as fast as the CPU can fill a buffer.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default producer emits at memory bandwidth, which is faster than any network and faster than
|
||||
/// the pump can frame and send — useful for the throughput harness, and useless for asking whether a
|
||||
/// terminal that <em>can</em> keep up loses anything, because nothing can keep up with it. A pace makes
|
||||
/// the fake a link instead of a memcpy: 8 KiB every millisecond is 8 MB/s, which is a fast remote
|
||||
/// rather than an impossible one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal FakeShellSession(
|
||||
long bytesToProduce = 0,
|
||||
bool blockReads = false,
|
||||
TimeSpan? pacePerRead = null)
|
||||
{
|
||||
remaining = bytesToProduce;
|
||||
this.blockReads = blockReads;
|
||||
this.pacePerRead = pacePerRead;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -81,6 +98,11 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (pacePerRead is { } pace)
|
||||
{
|
||||
await Task.Delay(pace, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -212,6 +234,40 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts output without keeping it, and acknowledges it as a renderer that is keeping up would.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="RecordingTransport"/>'s cheap sibling, and the throughput harness needs it to be the one
|
||||
/// measuring rather than the one measured: recording copies every frame into a list that grows to the size
|
||||
/// of the whole run, which at the rates that harness drives makes the <em>test double</em> the slowest thing
|
||||
/// in the pipeline. A harness whose consumer cannot keep up is measuring itself.
|
||||
/// </remarks>
|
||||
internal sealed class CountingTransport : ITerminalTransport
|
||||
{
|
||||
private long outputBytes;
|
||||
|
||||
/// <summary>Set to acknowledge every output frame immediately, as a keeping-up renderer would.</summary>
|
||||
internal TerminalSessionPump? AutoAcknowledge { get; set; }
|
||||
|
||||
/// <summary>Payload bytes of every output frame sent so far.</summary>
|
||||
internal long OutputBytes => Interlocked.Read(ref outputBytes);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|
||||
&& opcode == (byte)TerminalServerOpcode.Output)
|
||||
{
|
||||
_ = Interlocked.Add(ref outputBytes, payload.Length);
|
||||
|
||||
AutoAcknowledge?.Acknowledge((uint)payload.Length);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
||||
internal sealed class RecordingTransport : ITerminalTransport
|
||||
{
|
||||
@@ -270,6 +326,23 @@ internal sealed class RecordingTransport : ITerminalTransport
|
||||
return [.. output];
|
||||
}
|
||||
|
||||
/// <summary>Concatenated payloads of every frame of one opcode.</summary>
|
||||
internal byte[] PayloadsOf(TerminalServerOpcode opcode)
|
||||
{
|
||||
var payloads = new List<byte>();
|
||||
|
||||
foreach (var frame in Frames)
|
||||
{
|
||||
if (TerminalFrame.TryRead(frame, out var actual, out _, out var payload)
|
||||
&& actual == (byte)opcode)
|
||||
{
|
||||
payloads.AddRange(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. payloads];
|
||||
}
|
||||
|
||||
/// <summary>Counts frames of one opcode.</summary>
|
||||
internal int CountOf(TerminalServerOpcode opcode) =>
|
||||
Frames.Count(frame =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
@@ -84,13 +85,22 @@ public sealed class TerminalSessionPumpTests
|
||||
await run;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A renderer that has stopped acknowledging does not stop the pump reading, and does not grow it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THIS TEST REPLACES ONE THAT ASSERTED THE OPPOSITE, and the reversal is the point rather than a
|
||||
/// relaxation. The old contract was that the pump reads exactly one credit window and then stops,
|
||||
/// which was believed to close SSH's receive window and block the remote. It does not — see
|
||||
/// <see cref="OutputBacklog"/> — so all the old behaviour bought was an unbounded buffer inside
|
||||
/// SSH.NET instead of a bounded one here. What has to be true now is both halves at once: reading
|
||||
/// continues, so nothing piles up underneath, <em>and</em> what is held stays inside the backlog.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenCreditRunsOut_ThePumpStopsReading()
|
||||
public async Task WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine()
|
||||
{
|
||||
// The property the whole design rests on. With no acknowledgement the pump must read exactly
|
||||
// the window and then stop, so nothing accumulates between the socket and the screen. Left
|
||||
// unbounded, a remote running `yes` grows the client's memory until it dies.
|
||||
const int Window = 4 * 1024;
|
||||
const int Backlog = 64 * 1024;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
@@ -104,19 +114,32 @@ public sealed class TerminalSessionPumpTests
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesRead.ShouldBe(
|
||||
Window,
|
||||
"The pump read past its credit window, so output accumulates without bound.");
|
||||
var readByNow = pump.BytesRead;
|
||||
|
||||
// Still stalled a moment later, rather than merely slow.
|
||||
readByNow.ShouldBeGreaterThan(
|
||||
Window,
|
||||
"The pump stopped reading, which leaves the flood accumulating inside SSH.NET instead.");
|
||||
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||
Backlog,
|
||||
"The backlog grew past its capacity, which is the unbounded growth it exists to prevent.");
|
||||
|
||||
pump.BytesDropped.ShouldBeGreaterThan(
|
||||
0,
|
||||
"Nothing was dropped, so something is still holding what the renderer never took.");
|
||||
|
||||
// Still reading a moment later, rather than having quietly stalled once the backlog filled.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
pump.BytesRead.ShouldBe(Window);
|
||||
|
||||
pump.BytesRead.ShouldBeGreaterThan(readByNow);
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(Backlog);
|
||||
|
||||
pump.Credits.Available.ShouldBe(0);
|
||||
|
||||
@@ -125,10 +148,13 @@ public sealed class TerminalSessionPumpTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgingCredit_LetsThePumpResume()
|
||||
public async Task AcknowledgingCredit_LetsMoreOutputThrough()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// Endless, so the backlog never completes while this is being measured: once it does, the tail
|
||||
// goes out regardless of credit and there is nothing for the window to hold back. Drops along the
|
||||
// way change which bytes are sent, never how many — and the notice they raise is its own opcode.
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
@@ -145,13 +171,16 @@ public sealed class TerminalSessionPumpTests
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
pump.BytesRead.ShouldBe(Window);
|
||||
|
||||
// One window's worth has gone out and stopped there, because nothing has been rendered.
|
||||
var sentBeforeAcknowledgement = transport.OutputBytes().Length;
|
||||
sentBeforeAcknowledgement.ShouldBe(Window);
|
||||
|
||||
pump.Acknowledge(2048);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesRead.ShouldBe(Window + 2048);
|
||||
transport.OutputBytes().Length.ShouldBe(sentBeforeAcknowledgement + 2048);
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
@@ -161,9 +190,10 @@ public sealed class TerminalSessionPumpTests
|
||||
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
||||
{
|
||||
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
|
||||
// not be able to talk the host into an unbounded read.
|
||||
// not be able to talk the host into filling its own buffer without limit.
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// Endless for the reason the test above gives: a backlog that completes stops the window mattering.
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
@@ -185,7 +215,8 @@ public sealed class TerminalSessionPumpTests
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
// One further window's worth at most, never everything the endless producer could offer.
|
||||
pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2);
|
||||
transport.OutputBytes().Length.ShouldBeLessThanOrEqualTo(Window * 2);
|
||||
pump.Credits.Outstanding.ShouldBeLessThanOrEqualTo(Window);
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
@@ -344,7 +375,11 @@ public sealed class TerminalSessionPumpTests
|
||||
const int Window = CreditWindow.DefaultWindowBytes;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: Target);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
// Counting rather than recording, for the reason CountingTransport carries: a consumer that
|
||||
// copies every frame into a 32 MB list is the slowest thing in this pipeline, and a harness
|
||||
// whose consumer cannot keep up measures the harness.
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
@@ -369,7 +404,15 @@ public sealed class TerminalSessionPumpTests
|
||||
var peakOutstanding = await sampler;
|
||||
|
||||
pump.BytesRead.ShouldBe(Target);
|
||||
transport.OutputBytes().Length.ShouldBe((int)Target);
|
||||
|
||||
// ◆ Every byte read either reached the renderer or was counted as dropped, and none of it went
|
||||
// anywhere else. That is the invariant worth asserting here rather than "nothing was dropped":
|
||||
// this producer emits at memory bandwidth with no network under it, which is faster than any
|
||||
// real remote and faster than the pump can frame and send, so drops are the honest outcome and
|
||||
// the harness would be lying to forbid them. What must never happen is a byte that is neither
|
||||
// delivered nor accounted for — see NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote for
|
||||
// the case where the producer is one a network could actually deliver.
|
||||
(transport.OutputBytes + pump.BytesDropped).ShouldBe(Target);
|
||||
|
||||
var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
@@ -387,6 +430,142 @@ public sealed class TerminalSessionPumpTests
|
||||
$"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A renderer that keeps up loses nothing, however much the remote sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other half of the trade the backlog makes, and the half a user actually lives in: dropping is
|
||||
/// for a terminal that cannot keep up, and a terminal that can must show every byte. The producer here
|
||||
/// is sized for a real link rather than for memory bandwidth — several times the backlog, delivered in
|
||||
/// pieces a network could carry — so a drop would mean the pump losing output it had room for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote()
|
||||
{
|
||||
const int Backlog = 256 * 1024;
|
||||
const long Target = 512L * 1024;
|
||||
|
||||
// 8 KiB a millisecond — 8 MB/s, a fast remote rather than an impossible one, and thirty-two
|
||||
// times the backlog's capacity per second so the pump has no room to be merely lucky.
|
||||
await using var session = new FakeShellSession(
|
||||
bytesToProduce: Target,
|
||||
pacePerRead: TimeSpan.FromMilliseconds(1));
|
||||
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 8 * 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(1),
|
||||
WindowBytes = CreditWindow.DefaultWindowBytes,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
transport.AutoAcknowledge = pump;
|
||||
|
||||
await pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesDropped.ShouldBe(0);
|
||||
transport.OutputBytes.ShouldBe(Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shell that ends against a renderer which stopped acknowledging still finishes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THE HANG THIS DESIGN MAKES POSSIBLE, AND THE TEST THAT SAYS IT DOES NOT HAPPEN. The flush loop
|
||||
/// waits on credit when the renderer is behind, and completing the backlog is a different signal from
|
||||
/// returning credit — so a run whose reader finished while the flusher was parked on an
|
||||
/// acknowledgement that will never come would wait for ever. Nothing above would notice: the shell has
|
||||
/// ended, but the tab is told by <c>SessionEnded</c>, which fires off the run task completing, and the
|
||||
/// connection is given back at the same moment. A session in that state is invisible and permanent.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenTheRemoteCloses_TheRunEndsEvenIfTheRendererNeverAcknowledged()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// More than the window, so the flusher has certainly run out of credit and parked by the time the
|
||||
// producer reaches the end of what it has to give.
|
||||
await using var session = new FakeShellSession(bytesToProduce: Window * 4);
|
||||
|
||||
// No AutoAcknowledge, ever: this renderer's process died holding the socket.
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
|
||||
pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||
|
||||
// And the tail went out rather than being abandoned with the flusher: the last thing a remote
|
||||
// writes is usually why it ended.
|
||||
transport.OutputBytes().Length.ShouldBe(Window * 4);
|
||||
transport.CountOf(TerminalServerOpcode.SessionClosed).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drop is announced in the pane rather than leaving a silent gap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The thing that makes dropping defensible at all. A terminal quietly showing a different transcript
|
||||
/// from the one the remote sent is worse than one that says it could not keep up, because the second
|
||||
/// can be believed. Its own opcode, so the sentence is never counted as something the remote said —
|
||||
/// see <see cref="TerminalServerOpcode.OutputDropped"/>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenOutputIsDropped_TheUserIsToldInThePane()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
const int Backlog = 16 * 1024;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
// Enough credit for one more frame, which is what carries the notice out.
|
||||
pump.Acknowledge((uint)Window);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.CountOf(TerminalServerOpcode.OutputDropped).ShouldBeGreaterThan(0);
|
||||
|
||||
var notice = Encoding.UTF8.GetString(transport.PayloadsOf(TerminalServerOpcode.OutputDropped));
|
||||
|
||||
notice.ShouldContain("of output was dropped");
|
||||
notice.ShouldStartWith("\u001b[0m");
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
}
|
||||
|
||||
/// <summary>Watches in-flight bytes for the duration of a run, returning the highest seen.</summary>
|
||||
private static async Task<int> SamplePeakOutstandingAsync(
|
||||
TerminalSessionPump pump,
|
||||
|
||||
@@ -27,6 +27,12 @@ public sealed class TerminalWorkspaceTests
|
||||
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
||||
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// How long <see cref="ConnectRendererAsync"/> waits for the attach's replay before calling it a
|
||||
/// failure. Generous, because it bounds a hang rather than timing anything.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ReplayTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
||||
{
|
||||
@@ -83,9 +89,9 @@ public sealed class TerminalWorkspaceTests
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
|
||||
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
|
||||
/// is finished — so counting entries would report a shell that exited as still running. A user
|
||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote closes
|
||||
/// the channel by itself — what it held is given back, but the entry stays so the tab can go on saying
|
||||
/// the session ended — so counting entries would report a shell that exited as still running. A user
|
||||
/// deciding whether it is safe to walk away is the person that lie is told to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -645,6 +651,86 @@ public sealed class TerminalWorkspaceTests
|
||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shell that ended on its own gives its connection back at once, without waiting for the tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THE HALF THAT WAS MISSING. Closing a tab disposed the connection and always had; a remote hanging
|
||||
/// up did not, so a session that ended went on holding an authenticated socket, a keep-alive putting a
|
||||
/// packet on the wire every thirty seconds, and SSH.NET's read buffer at whatever size the session's
|
||||
/// busiest moment had grown it to — for as long as the user left the tab open, which for a tab showing
|
||||
/// the last thing a job said is indefinitely.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab()
|
||||
{
|
||||
// A shell with no output to give: its first read returns 0, which is the remote closing the
|
||||
// channel, so the pump finishes with nobody having asked it to.
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => connections.Connections.TrueForAll(connection => connection.IsDisposed));
|
||||
|
||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||
|
||||
connection.IsConnected.ShouldBeFalse();
|
||||
connection.Shell.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||
|
||||
// The entry itself stays, and has to: the tab is still on screen, still says it ended, and still
|
||||
// offers a reconnect — all of which read this workspace rather than the tab strip's own memory.
|
||||
workspace.CreditsFor(sessionId).ShouldNotBeNull();
|
||||
workspace.IsSessionLive(sessionId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The consequence of the release above, and the reason <c>SendInputAsync</c> asks about liveness
|
||||
/// rather than presence: the shell channel is disposed the moment the remote hangs up, so an accessory
|
||||
/// key pressed at a tab that ended reaches a disposed stream. It has to be as ordinary as pressing one
|
||||
/// at a tab that has closed, which is to say nothing happens.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SendingInputToASessionWhoseRemoteHasExited_IsIgnored()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing a tab over a session that already released itself must still work, because that is the
|
||||
/// ordinary way a dead tab goes away. Everything the close does is idempotent by construction — the
|
||||
/// entry is removed under the lock, and both disposals are guarded — and this is the test that says so
|
||||
/// rather than leaving it to be inferred from three separate classes.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingATabOverASessionThatAlreadyEnded_IsStillSafe()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||
|
||||
await Should.NotThrowAsync(() => workspace.CloseSessionAsync(sessionId));
|
||||
|
||||
workspace.CreditsFor(sessionId).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <param name="connections">
|
||||
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
||||
/// take a fake anyway, because reaching a real host from here would make this a network test.
|
||||
@@ -693,11 +779,20 @@ public sealed class TerminalWorkspaceTests
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
||||
/// returns immediately without proving anything about the second socket. That is enough here and is
|
||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
||||
/// returns immediately without proving anything about the second socket — and even for the first it
|
||||
/// answers too early for what the callers here need. It completes when the socket is attached, which
|
||||
/// is before <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> has run: that is fired and
|
||||
/// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the
|
||||
/// test open a session in the meantime. The replay then finds that session live and sends it a second,
|
||||
/// replay-flagged <c>SessionOpened</c> — harmless to the page, which treats it as idempotent, but it
|
||||
/// lands in the middle of the frames the test is counting on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ SO WAIT FOR THE REPLAY TO BE OVER, NOT ONLY FOR THE ATTACH. <c>RendererReattached</c> is raised
|
||||
/// as the last thing the replay does, so a socket this returns has had every frame the attach itself
|
||||
/// causes, and nothing but what the test does next can put another on it. Subscribed before the
|
||||
/// upgrade, because the replay for a socket that attaches quickly can be over before this method gets
|
||||
/// to look.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
@@ -715,17 +810,29 @@ public sealed class TerminalWorkspaceTests
|
||||
"Origin",
|
||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
||||
|
||||
var replayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnReattached(object? sender, EventArgs e) => replayed.TrySetResult();
|
||||
|
||||
workspace.RendererReattached += OnReattached;
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
workspace.RendererReattached -= OnReattached;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user