Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20f7cae04 | ||
|
|
54bc142dce | ||
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 | ||
|
|
c859705b83 | ||
|
|
ed936380b3 | ||
|
|
c270f3fe39 | ||
|
|
56c19b006d | ||
|
|
1908189100 | ||
|
|
2810f7c12b | ||
|
|
c0dd5a147e | ||
|
|
c0396b4f4d |
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/
|
||||
@@ -69,7 +69,8 @@ src/
|
||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
|
||||
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
|
||||
Cloud project over its API
|
||||
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||
@@ -872,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.
|
||||
@@ -0,0 +1,246 @@
|
||||
# ADR 0015 — The Android terminal's input path leaves the WebView
|
||||
|
||||
- Status: proposed — **Stage 1 built, unverified on a device**
|
||||
- Date: 2026-08-25
|
||||
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
|
||||
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
|
||||
|
||||
## Context
|
||||
|
||||
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
|
||||
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
|
||||
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
|
||||
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
|
||||
|
||||
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
|
||||
the act of putting characters into a shell. That narrows the problem usefully, because the input path
|
||||
and the render path are separable, and only one of them is wrong.
|
||||
|
||||
### Three things sit between a finger and a byte, and the middle one is the fault
|
||||
|
||||
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
|
||||
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
|
||||
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
|
||||
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
|
||||
to the next main-looper message so the platform has taken its turn first. It works. It is also a
|
||||
counter-move in a fight that only exists because the thing holding the input connection is not the
|
||||
thing the framework thinks is focused.
|
||||
|
||||
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
|
||||
worth checking rather than assuming, and the check inverted the obvious conclusion.
|
||||
|
||||
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
|
||||
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
|
||||
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
|
||||
|
||||
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
|
||||
|
||||
```
|
||||
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
```
|
||||
|
||||
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
|
||||
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
|
||||
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
|
||||
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
|
||||
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
|
||||
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
|
||||
a `<textarea>` produces the visible-password variation.
|
||||
|
||||
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
|
||||
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
|
||||
held or repeated.
|
||||
|
||||
### The finding that decides this ADR
|
||||
|
||||
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
|
||||
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
|
||||
|
||||
```csharp
|
||||
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||
{
|
||||
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||
}
|
||||
```
|
||||
|
||||
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
|
||||
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
|
||||
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
|
||||
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
|
||||
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
|
||||
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
|
||||
|
||||
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
|
||||
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
|
||||
WebView, and never will be. That asymmetry is the whole decision.
|
||||
|
||||
## Decision
|
||||
|
||||
### Avalonia owns the keystrokes. The WebView renders and nothing else.
|
||||
|
||||
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
|
||||
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
|
||||
and keeps it for as long as that surface is up.
|
||||
|
||||
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
|
||||
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
|
||||
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
|
||||
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
|
||||
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
|
||||
- Committed text and key events are encoded to bytes and written to the session through the same path
|
||||
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
|
||||
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
|
||||
at attach.
|
||||
|
||||
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
|
||||
one side is a focus candidate. Its remarks should move into the new control as the record of why the
|
||||
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
|
||||
|
||||
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
|
||||
call, and Avalonia still offers no way to close one.
|
||||
|
||||
### Not: patching xterm.js's textarea
|
||||
|
||||
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
|
||||
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
|
||||
WebView, so the effort buys nothing.
|
||||
|
||||
### Not: a native Android `View`, Termux-shaped, today
|
||||
|
||||
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
|
||||
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
|
||||
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
|
||||
against each other, for a defect that is entirely in the input path. That is a large bill for the
|
||||
wrong problem.
|
||||
|
||||
It also does not become unavailable later. Stage 3 keeps it on the table.
|
||||
|
||||
### Not: replacing xterm.js now
|
||||
|
||||
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
|
||||
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
|
||||
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
|
||||
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
|
||||
half attributable when the result is judged on a device.
|
||||
|
||||
## Staging
|
||||
|
||||
### Stage 1 — the input surface · **built**
|
||||
|
||||
The decision above, and nothing else. This is the change that answers the complaint, and it should be
|
||||
judged on a device on its own before anything else is built on it.
|
||||
|
||||
What landed:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
|
||||
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
|
||||
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
|
||||
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
|
||||
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
|
||||
|
||||
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
|
||||
software keyboard. It never did before — everything typed went straight into the page and never passed
|
||||
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
|
||||
from the accessory row. Check 11.10d covers it.
|
||||
|
||||
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
|
||||
first of all, because it is the falsification test at the end of this document.
|
||||
|
||||
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
|
||||
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
|
||||
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
|
||||
> not `Multiline`, to `IME_ACTION_DONE`:
|
||||
>
|
||||
> ```csharp
|
||||
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
|
||||
> ```
|
||||
>
|
||||
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
|
||||
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
|
||||
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
|
||||
> action for the IME to perform and no reason for it to hide.
|
||||
>
|
||||
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
|
||||
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
|
||||
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
|
||||
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
|
||||
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
|
||||
>
|
||||
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
|
||||
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
|
||||
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
|
||||
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
|
||||
> `"\r\n"` does not run its line twice.
|
||||
|
||||
### Stage 2 — the keys around it
|
||||
|
||||
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
|
||||
without it:
|
||||
|
||||
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
|
||||
tap.
|
||||
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
|
||||
right that latching is what phone SSH clients do; held becomes possible when one control sees both
|
||||
the modifier press and the following keystroke.
|
||||
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
|
||||
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
|
||||
|
||||
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
|
||||
change its volume with a shell open is a worse phone.
|
||||
|
||||
### Stage 3 — the renderer, deliberately unsettled
|
||||
|
||||
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
|
||||
survival across renderer death, scroll feel — then the renderer is the next question, and the
|
||||
candidates are known:
|
||||
|
||||
| Option | What it is | Cost |
|
||||
| --- | --- | --- |
|
||||
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
|
||||
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
|
||||
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
|
||||
|
||||
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
|
||||
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
|
||||
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
|
||||
|
||||
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
|
||||
would need, unchanged. That is the reason for splitting them.
|
||||
|
||||
## Consequences
|
||||
|
||||
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
|
||||
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
|
||||
That is the difference between typing at a shell and fighting a phone.
|
||||
|
||||
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
|
||||
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
|
||||
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
|
||||
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
|
||||
prose keyboard back — not a middle setting, because there isn't one.
|
||||
|
||||
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
|
||||
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
|
||||
unassigned and this ADR does not fix it. Stage 3 territory.
|
||||
|
||||
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
|
||||
banner, still Stage 3 territory.
|
||||
|
||||
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
|
||||
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
|
||||
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
|
||||
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
|
||||
keyboard still reaches the shell.
|
||||
|
||||
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
|
||||
through Avalonia. Behaviour should be identical and probably will not be on the first try.
|
||||
|
||||
## What would falsify this
|
||||
|
||||
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
|
||||
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
|
||||
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
|
||||
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
|
||||
attached property on a focusable control and a look at the keyboard.
|
||||
@@ -269,6 +269,12 @@ accessory key row for this. That is UI work, not porting.
|
||||
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
||||
> terminal will actually read it, not buried in a log.
|
||||
|
||||
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
|
||||
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
|
||||
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
|
||||
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
|
||||
> Avalonia and leaving the renderer exactly as this section describes it.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken
|
||||
@@ -749,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
|
||||
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
||||
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
||||
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
||||
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
|
||||
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
|
||||
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
|
||||
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
|
||||
table of servers, and a phone version of that is a different screen rather than the same one narrower.
|
||||
Said on the Preferences screen rather than left to be discovered.
|
||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||
confirmed to exist before anything is written to solve it.
|
||||
|
||||
@@ -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. |
|
||||
@@ -741,3 +741,27 @@ to a 34-pixel rail carrying the chevron that brings it back — a rail rather th
|
||||
that vanishes without trace is one people report as lost. The choice is written through to
|
||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||
|
||||
## v5c-5 — one door to settings, not three
|
||||
|
||||
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
|
||||
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
|
||||
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
|
||||
settings page too.
|
||||
|
||||
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
|
||||
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
|
||||
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
|
||||
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
|
||||
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
|
||||
you want is a question that has a rail of its own once you are there.
|
||||
|
||||
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
|
||||
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
|
||||
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
|
||||
application rather than a tidied menu.
|
||||
|
||||
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
|
||||
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
|
||||
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
|
||||
besides settings, and neither is a page of the settings mode.
|
||||
+413
-26
@@ -118,15 +118,17 @@ view models — this check is the version with a real connection on the end of i
|
||||
|
||||
### 1.8 Entering and leaving settings mode
|
||||
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
||||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings** — which is the menu's
|
||||
only way in now; the Vaults and Preferences rows beside it were folded back into it, because all three
|
||||
opened this same mode and differed only in which page they landed on.
|
||||
|
||||
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
|
||||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
||||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
||||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
||||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
||||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
||||
**Pass:** it swaps the whole window's chrome — the titlebar becomes the 53px "Back to application" bar, the
|
||||
ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the update banner (if either
|
||||
was showing) are both gone. It lands on **General**, with SettingsNav's own list beside it —
|
||||
General/Vaults/Account under SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below
|
||||
both. Click every one of the seven rows in turn: each swaps the content column without leaving settings mode
|
||||
or touching the titlebar, and Vaults and Preferences are among them — which is the whole reason the popover
|
||||
no longer carries its own rows for those two.
|
||||
|
||||
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
||||
application** — or, without touching anything else, press Esc.
|
||||
@@ -134,7 +136,8 @@ application** — or, without touching anything else, press Esc.
|
||||
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
||||
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
||||
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
||||
**first** entered does.
|
||||
**first** entered does. The other doors into this mode — the palette, and the Preferences page's own two
|
||||
importer rows — behave the same way, so the popover having one row rather than three changes nothing here.
|
||||
|
||||
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
||||
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
||||
@@ -165,7 +168,9 @@ ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An
|
||||
comes up over settings mode the same way it would over any other screen.
|
||||
|
||||
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
||||
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
|
||||
bound to the same side of `IsImporterOpen` — which is the flag now, rather than `IsImportOpen`, because
|
||||
there are two importers over that page and the bar behaves identically for both. SettingsNav lighting a
|
||||
different row while an importer is up is
|
||||
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
||||
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
||||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||||
@@ -197,6 +202,126 @@ 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
|
||||
output with the left button, then **right-click inside the selection**.
|
||||
|
||||
**Pass:** the highlight disappears at once, **no menu appears anywhere**, and the text is on the clipboard —
|
||||
paste it into Notepad, or into the connect box, to be sure. The pane still takes typing straight afterwards.
|
||||
|
||||
Now **right-click again with nothing selected.**
|
||||
|
||||
**Pass:** the clipboard's text appears at the prompt as one insertion and **Enter is not pressed** — it sits
|
||||
there waiting. On bash it will be highlighted in reverse video until the next keystroke, which is right: that
|
||||
is bash marking a bracketed paste, and it is how you can see the markers arrived.
|
||||
|
||||
**And the multi-line case, which is the one worth the most care.** Copy three separate lines out of a text
|
||||
editor — `cd /tmp`, `pwd`, `ls` — and right-click in the terminal.
|
||||
|
||||
**Pass:** all three lines land at the prompt as one thing being edited and **nothing runs** until Enter is
|
||||
pressed. Three commands executing the moment you right-click is the failure this is looking for.
|
||||
|
||||
**On a Mac, one more:** right-click over a word with nothing selected and confirm it pastes rather than
|
||||
selecting the word under the pointer. xterm.js defaults `rightClickSelectsWord` to true on macOS alone;
|
||||
`terminal.js` turns it off, and this is the one platform where forgetting to would show.
|
||||
|
||||
**What the tests already own.** `RendererClipboardTests` runs `terminal.js` itself and proves which frame a
|
||||
right-click sends, which session it is addressed to, that the selection is dropped after a copy and that the
|
||||
answer comes back through `paste()` rather than `input()`. `ShellFlowTests` proves the round trip through the
|
||||
host's own clipboard. What is left here is the WebView: that a real `contextmenu` event reaches a listener
|
||||
registered in the capture phase, that no native menu is drawn once it is prevented, and that xterm's real
|
||||
`paste()` brackets the text — none of which a Jint engine can answer.
|
||||
|
||||
**Failure means:**
|
||||
|
||||
- **A context menu appears.** The event is not being prevented, or not being seen. `handleRightClick` takes
|
||||
it in the capture phase on the pane; a listener registered without the third argument to
|
||||
`addEventListener` would still prevent the menu, so a menu means the listener is not attached at all.
|
||||
- **A permission prompt appears** — *"…wants to see text and images copied to the clipboard"*. Somebody has
|
||||
routed the read through `navigator.clipboard.readText()`, which is exactly what the `ClipboardPaste`
|
||||
opcode exists to avoid. The clipboard is the host's.
|
||||
- **A drag started exactly where you last right-clicked selects nothing.** xterm's own `contextmenu`
|
||||
handler ran: it parks its invisible helper textarea under the pointer, 20 pixels square at z-index 1000,
|
||||
to serve the native menu. The page stops the event as well as preventing it, and `stopPropagation` is the
|
||||
half that was left out. Subtle enough to be worth looking for on purpose rather than waiting to notice.
|
||||
- **Three commands run on a multi-line paste.** The text went through `input()` rather than `paste()`, so
|
||||
bracketed paste never happened and the shell read each newline as "run this". That is the whole reason the
|
||||
`Clipboard` opcode is separate from `Paste`; see `TerminalFrame.cs`.
|
||||
- **Nothing happens at all, in either direction.** The head is not passing a clipboard. Both pass one — see
|
||||
`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
|
||||
@@ -360,6 +485,74 @@ machine. That is the ordinary case rather than an error.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Importing from Hetzner Cloud
|
||||
|
||||
The reader has its own suite over the shapes the API returns, and the end-to-end path is covered by
|
||||
`ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked` against a stubbed transport. What no
|
||||
test in this repository does is talk to Hetzner — there is no account, no token and no network in the
|
||||
ordinary suite — so everything below needs a real project.
|
||||
|
||||
### 2.11a A read-only token lists your project
|
||||
|
||||
Make a token in the Hetzner console under **Security → API tokens** with **Read** permission only. Settings
|
||||
→ Preferences → **OPEN WIZARD**, paste it, press **READ THE PROJECT**.
|
||||
|
||||
**Pass:** every server in that project is listed, with the address, the plan and the location the Hetzner
|
||||
console shows for it. The token box is **empty** afterwards — it is used for one request and dropped — and
|
||||
the Hosts screen is unchanged, because reading writes nothing.
|
||||
|
||||
**What to look for specifically:**
|
||||
- A project with more than fifty servers must list all of them. Fifty is Hetzner's page size, and a list
|
||||
that stops there exactly is the paginator not being followed.
|
||||
- A server with no public IPv4 should show the first address of its `/64` with a warning chip saying so.
|
||||
Check that address against what the server actually answers on — the convention is Hetzner's own images,
|
||||
not a fact the API states, and a machine whose operator moved it elsewhere in the `/64` will not answer.
|
||||
- A server the console shows as **off** should still be listed, with `off` in the STATE column.
|
||||
|
||||
**Failure means:** the importer and the console disagree about what the project holds, which produces
|
||||
bookmarks that dial the wrong machine. Worse than an import that refused.
|
||||
|
||||
### 2.11b A wrong token says so, and says where tokens come from
|
||||
|
||||
Paste anything that is not a valid token — a truncated one is the realistic case, since the console shows
|
||||
the value once.
|
||||
|
||||
**Pass:** the wizard stays on the token step, the message names the Hetzner console and the Security → API
|
||||
tokens page, and the box is cleared for a fresh paste. Nothing has been stored.
|
||||
|
||||
Now try a token from a **different** project.
|
||||
|
||||
**Pass:** it lists that project instead. A token belongs to one project and there is no API call that will
|
||||
say which — an account with several projects runs this once per project, which the note above the box says.
|
||||
|
||||
### 2.11c The private-network choice does not fall back
|
||||
|
||||
With a project holding at least one server on a private network and one on none, press **PRIVATE NETWORK**.
|
||||
|
||||
**Pass:** the server with no private network shows an em dash for its address, a chip reading *no address of
|
||||
that kind*, and a tickbox that cannot be ticked — including by the tick-all box in the header. The other
|
||||
one shows its `10.x` address.
|
||||
|
||||
**Failure means:** a public address quietly stored under a choice that asked to stay off the internet. This
|
||||
is the one asymmetry in how addresses resolve and it is deliberate; v4 and v6 fall back to each other,
|
||||
private does not.
|
||||
|
||||
### 2.11d Imported hosts are correct, and ask for a password
|
||||
|
||||
Import a couple, then open one on the Hosts screen.
|
||||
|
||||
**Pass:** the address, port and login user are what the wizard showed, and the notes carry the server id,
|
||||
the plan, the location and the image — and **not** the power state, which would be stale the moment somebody
|
||||
pressed a button in the console. Connecting asks for a **password**: no key came with these hosts, because
|
||||
Hetzner has never held the private half of one. Bind it to a key in your keychain and it connects.
|
||||
|
||||
Now go back to the list and import the same project again.
|
||||
|
||||
**Pass:** the ones already stored are marked *already here* and arrive unticked. Importing one twice is
|
||||
allowed and takes a click.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Drag and drop on the SFTP page
|
||||
|
||||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||||
@@ -1687,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.
|
||||
@@ -1840,12 +2054,22 @@ Now tap **Tab** on the accessory row, or **A+**, and type again.
|
||||
keyboard.
|
||||
|
||||
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
||||
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
|
||||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
||||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
||||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
||||
focus on tap, which takes it off `TerminalInputSurface`. The row goes on working, because its keys are
|
||||
pressed rather than typed into, so the symptom is a terminal that answers the buttons and ignores the
|
||||
keyboard: it reads as the session having died. See `Focusable = false` in `TerminalScreen.axaml.cs`.
|
||||
|
||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||
**Also worth typing while a hardware keyboard is attached**, because that path moved under
|
||||
[ADR 0015](adr/0015-android-terminal-input.md) and is the one most likely to have been broken by the move:
|
||||
it used to arrive through the `WebView`'s own key handling and now arrives through Avalonia's. Confirm that
|
||||
Ctrl-C interrupts **and leaves no stray `c` at the prompt** — Avalonia raises the letter's own text event
|
||||
whether or not the key event was handled, so a doubled character here means `controlByteAlreadySent` is not
|
||||
doing its job — that the arrow keys reach shell history, that Backspace deletes one character, and that Tab
|
||||
completes a path. **Forward-delete is a known, deliberate loss** — the Delete key sends a backspace,
|
||||
because Avalonia turns an IME's `deleteSurroundingText` into a `KEYCODE_FORWARD_DEL` and a broken backspace
|
||||
on every phone is worse than a missing forward-delete on the few devices with the key. See
|
||||
`TerminalInputSurface.TryTranslate`.
|
||||
|
||||
Worth doing on the software keyboard too, where the old fault showed as the keyboard closing on the first
|
||||
tap of an arrow key.
|
||||
|
||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||
@@ -1853,18 +2077,98 @@ tap of an arrow key.
|
||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same height — and everything typed after the
|
||||
tap still reaches the terminal. The accessory row stays visible above the keyboard throughout. There should
|
||||
be **no blink at all** now: under [ADR 0015](adr/0015-android-terminal-input.md) the input target is an
|
||||
Avalonia control, so the focus the platform hands to Avalonia's own view after every dispatched touch is
|
||||
already the focus the keyboard wants, and there is nothing to hand back.
|
||||
|
||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
**Failure means:** the buttons on that row have become focusable again, taking Avalonia's focus off
|
||||
`TerminalInputSurface`. The old failure — a keyboard that swapped layouts and parked itself over the row —
|
||||
belonged to the arrangement where the `WebView` held the input connection and is not reachable from here;
|
||||
if it reappears, the renderer has taken focus back and `RendererFocus.Suppress` did not run. See check
|
||||
11.10c.
|
||||
|
||||
### 11.10b The keyboard over a terminal is a terminal's keyboard · **the important one for ADR 0015**
|
||||
|
||||
Open a shell and look at the keyboard, without typing anything yet. Then type a few words of ordinary
|
||||
English — `ls`, `cd etc`, `sudo apt update`.
|
||||
|
||||
**Pass:** there is **no suggestion strip** above the keys, nothing is autocapitalised at the start of a line,
|
||||
no word is autocorrected, and no predictive text appears ahead of the cursor. Backspace deletes exactly one
|
||||
character per press. Rotating to landscape does not replace the terminal with a full-screen text editor.
|
||||
|
||||
**Failure means:** the premise of ADR 0015 is wrong and everything staged on it needs rethinking, so find
|
||||
out *which* half failed before concluding anything. Either `TextInputOptions.ShowSuggestions` is not
|
||||
reaching Avalonia's `AndroidInputMethod` — the guard there is narrow and needs `InputType` to be exactly
|
||||
`TYPE_CLASS_TEXT`, so anything that sets `ContentType`, `Multiline` or `AutoCapitalization` on
|
||||
`TerminalInputSurface` or an ancestor silently disables it, `inherits: true` being the trap — or the
|
||||
keyboard is ignoring `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD`, which would mean no Avalonia-side fix exists
|
||||
and the honest next step is a native input connection. Worth repeating on a second keyboard (Gboard and one
|
||||
other) before deciding, because this is a claim about keyboards rather than about Android.
|
||||
|
||||
### 11.10c The renderer cannot take the keyboard, and a tap still brings it back
|
||||
|
||||
With a shell open, put the keyboard away with the system back gesture. Then tap the terminal. Separately:
|
||||
with the keyboard up, drag the terminal to scroll its scrollback.
|
||||
|
||||
**Pass:** the tap brings the keyboard straight back. The drag scrolls the scrollback and does **not** raise
|
||||
the keyboard. At no point does a text caret or a selection handle appear inside the terminal.
|
||||
|
||||
**Failure means:** if the tap does nothing, `RendererFocus.Suppress` never found the `WebView` — it is
|
||||
called from two places, the posted `EnvironmentRequested` handler and the `IsTerminalShowing` transition,
|
||||
and a miss in both leaves no tap listener. If the drag raises the keyboard, the touch-slop test is not
|
||||
working. If a caret appears, the `WebView` is still focusable and the page has taken the input connection
|
||||
back, which is the whole failure ADR 0015 exists to prevent.
|
||||
|
||||
### 11.10d The Ctrl latch reaches the software keyboard
|
||||
|
||||
With a shell open and something running that can be interrupted — `sleep 60`, or `top` — tap **ctrl** on the
|
||||
accessory row and then press **c** on the software keyboard.
|
||||
|
||||
**Pass:** the process is interrupted, and the ctrl key returns to its unlatched colour.
|
||||
|
||||
**Failure means:** typed characters are not going through `TerminalScreen.SendAsync`. Note that this check
|
||||
could not pass *before* ADR 0015 and is not a regression test but a new capability: the latch used to apply
|
||||
only to the accessory row's own keys, because everything typed went straight into the page and never passed
|
||||
the code that holds the latch.
|
||||
|
||||
### 11.10e Enter runs the command and the keyboard stays where it is
|
||||
|
||||
With a shell open and the software keyboard up, type `pwd` and press Enter. Then type another command.
|
||||
|
||||
**Pass:** the command runs and **the keyboard is still up**, unmoved, ready for the next one. The Enter key
|
||||
reads as a return arrow rather than as `DONE` or a tick. Ten commands in a row cost ten Enters and no taps
|
||||
to bring the keyboard back.
|
||||
|
||||
**Failure means:** `TextInputOptions.ReturnKeyType` is not `Return` on `TerminalInputSurface`, so Avalonia
|
||||
has mapped the field's default to `IME_ACTION_DONE` and the keyboard is doing exactly what Done means. This
|
||||
was the first defect the first device run found. **Do not fix it with `Multiline = true`** — that reaches
|
||||
the same `IME_ACTION_NONE` by a route that ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which fails the
|
||||
`ShowSuggestions` guard and hands the suggestion strip back; check 11.10b would then start failing and the
|
||||
cause would look unrelated. See the correction in [ADR 0015](adr/0015-android-terminal-input.md).
|
||||
|
||||
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
|
||||
|
||||
@@ -1922,6 +2226,26 @@ without either end noticing, which a suspended renderer can leave behind — sho
|
||||
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
|
||||
liveness probe rather than a faster retry.
|
||||
|
||||
### 11.13 The right button on a phone with a pointer · **needs a mouse, a trackpad or a dock**
|
||||
|
||||
The same page as 1.11 runs on this head, so the gesture is here too — but only for a device that has a
|
||||
secondary click at all. Attach a mouse or use a keyboard case's trackpad, or dock the phone, open a shell,
|
||||
select some output and right-click it; then right-click again with nothing selected.
|
||||
|
||||
**Pass:** exactly as 1.11 — the selection drops with no menu, and the second click pastes.
|
||||
|
||||
**Unverified, and that is why this check exists.** Nothing here has been run against Android's WebView with
|
||||
a pointer attached, so whether it delivers a secondary click as a DOM `contextmenu` event at all is an open
|
||||
question. `ClipboardReader` is wired on this head regardless, because the alternative is a head where the
|
||||
paste half silently does nothing and nobody finds out.
|
||||
|
||||
**Failure means:** if the copy works and the paste does not, the clipboard read is being refused — Android
|
||||
returns nothing from the clipboard to an app that is not the foreground one, and a WebView taking the
|
||||
secondary click may be leaving Avalonia's window in a state where `TopLevel` has none. If neither works,
|
||||
Android is not delivering the click as a `contextmenu` event, and the gesture needs a native listener on the
|
||||
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||
has no right button to miss.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -2565,6 +2889,32 @@ having been broken, and the fallback is to move the offer into the titlebar inst
|
||||
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||||
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||||
|
||||
### 16.7a Two releases published while it is running cost one update · **needs two releases**
|
||||
|
||||
The nightly channel is the easier place to do this — every push to `main` publishes one — but it works the
|
||||
same way on the release channel with two tags. With the client running and the banner already up for one
|
||||
build, publish a second. Then press **RESTART NOW** on the banner.
|
||||
|
||||
**Pass:** one download and one restart, landing on the **second** build. The banner says
|
||||
*"A newer build appeared. Fetching it before restarting"* with a percentage while it does, and it stays on
|
||||
screen throughout rather than vanishing. Starting up afterwards offers nothing, because there is nothing
|
||||
left to offer.
|
||||
|
||||
**And the timer's half, if you have six hours or a debugger.** Leave the client running past a check
|
||||
interval with a build already fetched and a newer one published. The banner's version changes on its own,
|
||||
and LATER having been pressed does not stop it coming back — a different version is a different question.
|
||||
|
||||
**Failure means:** two downloads and two restarts is the regression this check exists for, and it was the
|
||||
shipped behaviour: the background pass skipped itself entirely once anything was fetched, so a running
|
||||
client was pinned to whichever build it first saw and installed that, then found the next one at the launch
|
||||
straight afterwards. On a rolling channel — where the release is deleted and remade on every push, so the
|
||||
feed holds exactly one build — that was the normal case rather than a corner.
|
||||
|
||||
A banner that disappears when RESTART NOW is pressed is `IsRestarting` not reaching it; everything the strip
|
||||
draws keys off `IsReady`, and the last look moves the state off that. A restart that does not happen at all
|
||||
when the forge is unreachable is the last look having become a gate: it is a courtesy, and the build already
|
||||
on disk is still newer than the one running.
|
||||
|
||||
### 16.8 The first connect after an update is not a cold start
|
||||
|
||||
Immediately after 16.7, connect to a host.
|
||||
@@ -2610,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;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
var shell = new PhoneShell();
|
||||
|
||||
var viewModel = ComposeShell(
|
||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
connections,
|
||||
ClipboardWriter(shell),
|
||||
ClipboardReader(shell));
|
||||
|
||||
shell.DataContext = viewModel;
|
||||
|
||||
@@ -176,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Reads this phone's clipboard.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same
|
||||
/// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click.
|
||||
/// Whether Android's WebView delivers one as a DOM <c>contextmenu</c> event is unverified — see
|
||||
/// <c>docs/manual-checks.md</c> 11.13 — and leaving this out in the meantime would make the paste half
|
||||
/// the one thing that worked on one head and silently did nothing on the other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
|
||||
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
|
||||
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
/// <remarks>
|
||||
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
||||
/// the platform graph, and below it is the shell every head shares.
|
||||
@@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
SshNetConnectionFactory connections,
|
||||
Func<string, Task> copyToClipboard)
|
||||
Func<string, Task> copyToClipboard,
|
||||
Func<Task<string?>> readClipboard)
|
||||
{
|
||||
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
||||
// key. A straight implementation of the interface the session layer has always taken.
|
||||
@@ -236,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
deviceName: PhoneEnvironment.DeviceName,
|
||||
|
||||
copyToClipboard: copyToClipboard,
|
||||
readClipboard: readClipboard,
|
||||
|
||||
updates: updates);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||
/// back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
|
||||
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
|
||||
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
|
||||
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
|
||||
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
|
||||
/// ends by removing one of the contestants rather than by refereeing it faster.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
|
||||
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
|
||||
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
|
||||
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
|
||||
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
|
||||
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
|
||||
/// keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
|
||||
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
|
||||
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
|
||||
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tap is distinguished from a scroll the way every Android view does it, by
|
||||
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
|
||||
/// keyboard over the very output being scrolled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
|
||||
/// <c>WebView</c> until the terminal surface has been shown once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class RendererFocus
|
||||
{
|
||||
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
|
||||
/// <remarks>
|
||||
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
|
||||
/// hierarchy, and the activity behind it, alive for the life of the process.
|
||||
/// </remarks>
|
||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||
/// taps it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||
/// </remarks>
|
||||
public static void Suppress(Action onTapped)
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Focusable = false;
|
||||
webView.FocusableInTouchMode = false;
|
||||
webView.ClearFocus();
|
||||
|
||||
WatchForTaps(webView, onTapped);
|
||||
|
||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tap is a press and a release that did not travel further than the platform's own
|
||||
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||
/// </remarks>
|
||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||
{
|
||||
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
var downX = 0f;
|
||||
var downY = 0f;
|
||||
var moved = false;
|
||||
|
||||
webView.Touch += (_, e) =>
|
||||
{
|
||||
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||
e.Handled = false;
|
||||
|
||||
if (e.Event is not { } motion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion.Action)
|
||||
{
|
||||
case MotionEventActions.Down:
|
||||
downX = motion.GetX();
|
||||
downY = motion.GetY();
|
||||
moved = false;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Move:
|
||||
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Up when !moved:
|
||||
onTapped();
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
||||
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
||||
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
||||
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
||||
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
||||
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
||||
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
||||
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
||||
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
||||
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
||||
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
||||
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
||||
/// about to be made stale by the very mechanism this exists to counter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
||||
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
||||
/// control does not expose its platform child and this application only ever has the one WebView — the
|
||||
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
|
||||
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
|
||||
/// surface has never been shown, and nothing to do in either case.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TerminalFocus
|
||||
{
|
||||
public static void Return()
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Post(() =>
|
||||
{
|
||||
if (!webView.IsFocused)
|
||||
{
|
||||
webView.RequestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static View? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -163,6 +163,8 @@
|
||||
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||
</StackPanel>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>
|
||||
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
|
||||
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
|
||||
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
|
||||
/// kind it was handed.
|
||||
/// </remarks>
|
||||
/// <param name="bytes">The encoded keystroke.</param>
|
||||
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
|
||||
{
|
||||
public byte[] Bytes { get; } = bytes;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.TextInput;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The terminal's keyboard, as an Avalonia control that draws nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
|
||||
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
|
||||
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
|
||||
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
|
||||
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
|
||||
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
|
||||
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
|
||||
/// effect, because they are advisory and Gboard ignores them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
|
||||
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
|
||||
/// <code>
|
||||
/// if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||
/// </code>
|
||||
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact
|
||||
/// pair Termux's own <c>TerminalView</c> sets, and the reason typing in Termux feels like a terminal. The
|
||||
/// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
|
||||
/// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
|
||||
/// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
|
||||
/// rather than merely left.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two options are set, and the second one is not optional either.</b> The default
|
||||
/// <c>ReturnKeyType</c> maps to <c>IME_ACTION_DONE</c> on a non-multiline field, which closed the keyboard
|
||||
/// on every Enter — that is, on every command. <c>ReturnKeyType.Return</c> is <c>IME_ACTION_NONE</c> and
|
||||
/// touches nothing else. The constructor carries the full argument, including why the obvious alternative
|
||||
/// of <c>Multiline</c> cannot be used without giving the suggestion strip back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
|
||||
/// target does not need to cover the terminal — and must not, because the renderer is a native child view
|
||||
/// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable
|
||||
/// to receive the touches the <c>WebView</c> consumes first, and in the way of the connect page that shares
|
||||
/// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and
|
||||
/// is the whole of what an input target has to be. Taps on the terminal itself are noticed by
|
||||
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> reports no surrounding text and no preedit,
|
||||
/// because a terminal has no document for an input method to edit — the remote holds the line and the only
|
||||
/// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is
|
||||
/// turned into bytes and forwarded the instant it arrives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TerminalInputSurface : Control
|
||||
{
|
||||
/// <summary>
|
||||
/// Bytes the shell should receive, already encoded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
|
||||
/// the view model. <c>TerminalScreen.SendAsync</c> owns both and applies the latch to anything one byte
|
||||
/// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor
|
||||
/// key's escape sequence, no.
|
||||
/// </remarks>
|
||||
internal event EventHandler<TerminalInputEventArgs>? Input;
|
||||
|
||||
private readonly TerminalImeClient client;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the text event about to arrive has already been sent as a control byte.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
|
||||
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
|
||||
/// event for any key whose <c>UnicodeChar</c> is 32 or above — it reads the handled flag from the first
|
||||
/// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard
|
||||
/// produces both a <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
|
||||
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A plain field rather than anything cleverer because the two events are raised one after the other in
|
||||
/// the same dispatch, and it is cleared at the top of every <see cref="OnKeyDown"/> so a chord that
|
||||
/// somehow produced no text cannot swallow the next thing typed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
|
||||
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool controlByteAlreadySent;
|
||||
|
||||
public TerminalInputSurface()
|
||||
{
|
||||
client = new TerminalImeClient(this);
|
||||
|
||||
Focusable = true;
|
||||
|
||||
// Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a
|
||||
// zero-area control is a shape no layout pass has a reason to keep.
|
||||
Width = 1;
|
||||
Height = 1;
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left;
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
|
||||
IsHitTestVisible = false;
|
||||
|
||||
// The one line that fixes the keyboard. See the remarks for why almost nothing else is set.
|
||||
TextInputOptions.SetShowSuggestions(this, false);
|
||||
|
||||
// ◆ AND THE ONE THAT KEEPS THE KEYBOARD UP WHEN A COMMAND IS RUN.
|
||||
//
|
||||
// Avalonia maps ReturnKeyType.Default, on a field that is not Multiline, to IME_ACTION_DONE — and
|
||||
// Done means done: the keyboard performs the action and dismisses itself. Every Enter therefore
|
||||
// closed the keyboard, which on a terminal is every command.
|
||||
//
|
||||
// Return is the member that maps to IME_ACTION_NONE, leaving the key as a plain newline with no
|
||||
// action attached and no reason for the IME to hide. Its whole switch arm is
|
||||
// `returnKeyType - 1 => 0 => 1073741824`, which is 0x40000000, IME_ACTION_NONE.
|
||||
//
|
||||
// Multiline=true reaches the same IME_ACTION_NONE through the default arm and is the wrong way to
|
||||
// get there: it also ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves InputType off 1 and
|
||||
// silently fails the ShowSuggestions guard above. The two options cannot both be had that way — one
|
||||
// trades the suggestion strip back for a working Enter. ReturnKeyType costs nothing and is the
|
||||
// reason this file sets it rather than Multiline.
|
||||
TextInputOptions.SetReturnKeyType(this, TextInputReturnKeyType.Return);
|
||||
|
||||
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes focus and asks for the keyboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The request is made even when focus was already here, which is the case that matters: the system back
|
||||
/// gesture puts the keyboard away without moving focus, so the way back has to be an activation request
|
||||
/// rather than a focus change that would be a no-op. <c>AndroidInputMethod</c> answers
|
||||
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
|
||||
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
|
||||
/// belt and braces rather than two mechanisms disagreeing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void Acquire()
|
||||
{
|
||||
Focus();
|
||||
client.RequestKeyboard();
|
||||
}
|
||||
|
||||
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
|
||||
e.Client = client;
|
||||
|
||||
/// <remarks>
|
||||
/// Everything the IME commits and everything a hardware keyboard types that is not in
|
||||
/// <see cref="TryTranslate"/>'s table. UTF-8 because that is what the pty is told the session is, and
|
||||
/// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote.
|
||||
/// </remarks>
|
||||
protected override void OnTextInput(TextInputEventArgs e)
|
||||
{
|
||||
if (controlByteAlreadySent)
|
||||
{
|
||||
controlByteAlreadySent = false;
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(e.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Newlines arrive as carriage returns, whichever way they came. A keyboard with no editor action
|
||||
// attached may commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a paste carries
|
||||
// whatever the clipboard held — but a terminal's line terminator from a keyboard is CR, and a shell
|
||||
// handed LF for Enter is a shell that looks like it ignored the key. Normalised rather than special
|
||||
// -cased so that a pasted "\r\n" does not run the line twice.
|
||||
var text = e.Text.ReplaceLineEndings("\r");
|
||||
|
||||
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(text)));
|
||||
e.Handled = true;
|
||||
|
||||
base.OnTextInput(e);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
|
||||
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can
|
||||
/// hold a modifier and this is the only place that sees the chord.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
controlByteAlreadySent = false;
|
||||
|
||||
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord))
|
||||
{
|
||||
Input?.Invoke(this, new TerminalInputEventArgs(chord));
|
||||
|
||||
// The letter's own text event is still coming; see the field's remarks.
|
||||
controlByteAlreadySent = true;
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryTranslate(e.Key, out var bytes))
|
||||
{
|
||||
Input?.Invoke(this, new TerminalInputEventArgs(bytes));
|
||||
e.Handled = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
|
||||
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
|
||||
/// </remarks>
|
||||
private static bool TryControlChord(Key key, out byte[] bytes)
|
||||
{
|
||||
if (key is >= Key.A and <= Key.Z)
|
||||
{
|
||||
bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = [];
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The keys a terminal expects as escape sequences rather than as text.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning —
|
||||
/// as the accessory row's table in <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
|
||||
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
|
||||
/// application-cursor mode.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
|
||||
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
|
||||
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
|
||||
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
|
||||
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> would be correct for a hardware forward-delete and would
|
||||
/// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by
|
||||
/// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal
|
||||
/// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryTranslate(Key key, out byte[] bytes)
|
||||
{
|
||||
bytes = key switch
|
||||
{
|
||||
Key.Enter => [0x0D],
|
||||
Key.Tab => [0x09],
|
||||
Key.Escape => [0x1B],
|
||||
Key.Back or Key.Delete => [0x7F],
|
||||
|
||||
Key.Up => [0x1B, (byte)'[', (byte)'A'],
|
||||
Key.Down => [0x1B, (byte)'[', (byte)'B'],
|
||||
Key.Right => [0x1B, (byte)'[', (byte)'C'],
|
||||
Key.Left => [0x1B, (byte)'[', (byte)'D'],
|
||||
|
||||
Key.Home => [0x1B, (byte)'[', (byte)'H'],
|
||||
Key.End => [0x1B, (byte)'[', (byte)'F'],
|
||||
Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'],
|
||||
Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'],
|
||||
Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'],
|
||||
|
||||
_ => [],
|
||||
};
|
||||
|
||||
return bytes.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What Android's input method is told about the text it is editing, which is that there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
|
||||
/// <see cref="SurroundingText"/> is empty, because they are the truth: the line being edited lives on
|
||||
/// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise
|
||||
/// would invite the IME to reason about text it cannot see and to issue edits against positions that
|
||||
/// mean nothing here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> writes it before every
|
||||
/// replacement, so it is stored and ignored rather than refused — a property that threw would take the
|
||||
/// input connection down with it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="CursorRectangle"/> is empty for the same reason the control is 1×1: it positions an IME's
|
||||
/// composition popup, and a field that never composes has no popup to place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient
|
||||
{
|
||||
public override Visual TextViewVisual => visual;
|
||||
|
||||
public override bool SupportsPreedit => false;
|
||||
|
||||
public override bool SupportsSurroundingText => false;
|
||||
|
||||
public override string SurroundingText => string.Empty;
|
||||
|
||||
public override Rect CursorRectangle => default;
|
||||
|
||||
public override TextSelection Selection { get; set; }
|
||||
|
||||
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
|
||||
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
|
||||
}
|
||||
}
|
||||
@@ -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}" />
|
||||
@@ -471,6 +471,29 @@
|
||||
design's #171A26. See WebAssets/terminal.css.
|
||||
-->
|
||||
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
============ the keyboard ============
|
||||
|
||||
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
|
||||
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
|
||||
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
|
||||
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
|
||||
control can, on the version already pinned here.
|
||||
|
||||
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
|
||||
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
|
||||
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
|
||||
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
|
||||
RendererFocus, where they actually land.
|
||||
|
||||
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
|
||||
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
|
||||
session is open. Left always-visible it would be a stray target on a screen with a real text box on
|
||||
it, offering a keyboard configured for a shell to somebody typing a hostname.
|
||||
-->
|
||||
<views:TerminalInputSurface x:Name="Input"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ accessory keys ============ -->
|
||||
@@ -482,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 ============ -->
|
||||
<!--
|
||||
@@ -539,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>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -37,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),
|
||||
@@ -46,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),
|
||||
];
|
||||
|
||||
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
private Button? controlKey;
|
||||
|
||||
private readonly TerminalInputSurface input;
|
||||
|
||||
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||
private MainWindowViewModel? subscribed;
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
|
||||
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
|
||||
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
|
||||
/// </remarks>
|
||||
private bool terminalWasShowing;
|
||||
|
||||
public TerminalScreen()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
BuildAccessoryRow();
|
||||
|
||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||
|
||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||
|
||||
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
|
||||
renderer.EnvironmentRequested += (sender, e) =>
|
||||
{
|
||||
OnRendererEnvironmentRequested(sender, e);
|
||||
|
||||
// The precise moment the platform WebView is being built, which is the earliest one at which
|
||||
// there is a native view to take the keyboard away from — and the one that fires again if
|
||||
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
|
||||
// child is not in the hierarchy yet while its environment is still being asked for.
|
||||
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
|
||||
};
|
||||
|
||||
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (subscribed is { } previous)
|
||||
{
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
subscribed = null;
|
||||
}
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
renderer.Source = shell.TerminalPageUrl;
|
||||
|
||||
shell.PropertyChanged += OnShellPropertyChanged;
|
||||
|
||||
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
|
||||
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
subscribed = shell;
|
||||
|
||||
terminalWasShowing = shell.IsTerminalShowing;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||
/// </summary>
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
|
||||
sender is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var showing = shell.IsTerminalShowing;
|
||||
|
||||
if (showing == terminalWasShowing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
terminalWasShowing = showing;
|
||||
|
||||
if (!showing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
|
||||
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
|
||||
// path above: whichever runs first configures the view and the other finds it already done.
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
AdoptRenderer();
|
||||
input.Acquire();
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard off the renderer and puts the taps on it to use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||
/// the tap has to be reported rather than left to the page.
|
||||
/// </remarks>
|
||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||
|
||||
/// <summary>
|
||||
/// Puts the keyboard away on the way to the connect menu.
|
||||
/// </summary>
|
||||
@@ -137,18 +259,26 @@ 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
|
||||
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||
// 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.
|
||||
|
||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||
}
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
/// <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);
|
||||
|
||||
@@ -171,49 +301,31 @@ 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.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||
// connection is never torn down and nothing has to be handed back.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
//
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||
// to be posted rather than done inline.
|
||||
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
||||
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
||||
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
@@ -228,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
|
||||
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Reads this machine's clipboard.</summary>
|
||||
/// <remarks>
|
||||
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||
/// </remarks>
|
||||
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
||||
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||
/// <remarks>
|
||||
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||
@@ -126,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>
|
||||
@@ -169,6 +278,7 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
watchFile: WatchFile);
|
||||
@@ -259,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -278,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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
private UpdateInfo? found;
|
||||
|
||||
/// <summary>
|
||||
/// The last thing actually fetched, kept separately from the last thing found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
||||
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
||||
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
||||
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
||||
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
||||
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
||||
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo? fetched;
|
||||
|
||||
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -321,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
|
||||
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
||||
// so the adaptation happens here rather than leaking the older shape into the view models.
|
||||
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
|
||||
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
|
||||
/// the one this channel can still apply. See <see cref="fetched"/>.
|
||||
/// </remarks>
|
||||
private async Task DownloadAndRememberAsync(
|
||||
UpdateInfo info,
|
||||
IProgress<int> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await manager
|
||||
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
fetched = info;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -335,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
||||
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
||||
/// some safe-looking default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
||||
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo Matched(AvailableUpdate update)
|
||||
{
|
||||
if (found is not { } info
|
||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
||||
if (Names(fetched, update))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
return fetched!;
|
||||
}
|
||||
|
||||
return info;
|
||||
if (Names(found, update))
|
||||
{
|
||||
return found!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found or fetched by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
}
|
||||
|
||||
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
||||
info is not null
|
||||
&& string.Equals(
|
||||
info.TargetFullRelease.Version.ToString(),
|
||||
update.Version,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:import="using:DodoSSH.Client.Import"
|
||||
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
|
||||
x:DataType="vm:HetznerImportViewModel">
|
||||
|
||||
<!--
|
||||
Importing the servers in a Hetzner Cloud project.
|
||||
|
||||
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
|
||||
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
|
||||
rail. See MainWindowViewModel.IsHetznerImportOpen.
|
||||
|
||||
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
|
||||
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
|
||||
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
|
||||
then it makes a request over the internet. Those are two separate moments and the screen is honest about
|
||||
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
|
||||
Both of the first two write nothing.
|
||||
|
||||
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
|
||||
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
|
||||
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
|
||||
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
|
||||
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
|
||||
are not covered by any of this.
|
||||
|
||||
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
|
||||
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
|
||||
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
|
||||
of this sentence: somebody who is not told will conclude the import was broken.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
|
||||
<Style Selector="Border.importrow:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new">
|
||||
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.new > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists">
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.exists > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn">
|
||||
<Setter Property="Background" Value="{StaticResource WarnWash}" />
|
||||
</Style>
|
||||
<Style Selector="Border.meaningchip.warn > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<!-- ============ HEADER ============ -->
|
||||
<StackPanel Grid.Row="0" Spacing="10">
|
||||
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Panel Grid.Row="2" Margin="0,18,0,0">
|
||||
|
||||
<!-- ============ STEP 1 · THE TOKEN ============ -->
|
||||
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="{Binding TokenNote}" />
|
||||
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="64 characters, from Security → API tokens">
|
||||
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
|
||||
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
|
||||
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
|
||||
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
What every imported host gets: which address, which login user, which port. One set of answers for
|
||||
the whole import rather than one per row, because an estate is reached the same way throughout —
|
||||
a per-server dropdown would be forty answers to one question.
|
||||
-->
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<!--
|
||||
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
|
||||
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
|
||||
the tag picker's control, which is already "one of these is on" drawn as a chip.
|
||||
-->
|
||||
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
|
||||
Classes.worn="{Binding UsesPublicIPv4}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
|
||||
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
|
||||
Classes.worn="{Binding UsesPublicIPv6}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
|
||||
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
|
||||
Classes.worn="{Binding UsesPrivate}"
|
||||
Command="{Binding UseAddressCommand}"
|
||||
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" Margin="14,0,0,0" />
|
||||
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
|
||||
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
|
||||
ToolTip.Tip="The SSH port every imported host gets." />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
|
||||
decide on somebody's behalf: the active vault, and nothing.
|
||||
|
||||
A project belongs to a team more often than to a person, so the vault is asked rather than
|
||||
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
|
||||
this is its own selection rather than the keychain screen's standing preference. It is hidden
|
||||
at one vault, which is a question with one answer.
|
||||
|
||||
The key is the half Hetzner cannot supply and the keychain usually already has: the public
|
||||
half went to Hetzner when the servers were made, the private half never did. Pointing forty
|
||||
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
|
||||
picker is hidden entirely on a keychain with no keys in it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
|
||||
SelectedItem="{Binding SelectedVault}"
|
||||
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasKeyChoice}">
|
||||
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
|
||||
SelectedItem="{Binding SelectedKey}"
|
||||
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HetznerKeyChoice">
|
||||
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
|
||||
Text="{Binding AddressNote}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
|
||||
Foreground="{StaticResource WarnText}"
|
||||
Text="That is not a port. Give a number between 1 and 65535." />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- What the project could not be made to say, at project level. -->
|
||||
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<!-- ============ THE TABLE ============ -->
|
||||
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
|
||||
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
|
||||
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
|
||||
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
|
||||
ToolTip.Tip="Tick or untick every row this address choice can reach">
|
||||
<Panel Width="18" Height="18">
|
||||
<Border CornerRadius="5" Background="{StaticResource Accent}"
|
||||
IsVisible="{Binding AllTicked}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||
Foreground="White" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
|
||||
IsVisible="{Binding !AllTicked}" />
|
||||
</Panel>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="14,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
|
||||
LetterSpacing="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||
<Border Classes="importrow" CornerRadius="8">
|
||||
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
|
||||
<!--
|
||||
Disabled where the address choice cannot reach this server, which is the private
|
||||
case: a server on no private network is not importable and must not be silently
|
||||
given a public address instead.
|
||||
-->
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
|
||||
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Classes.new="{Binding IsMeaningNew}"
|
||||
Classes.exists="{Binding IsMeaningExisting}"
|
||||
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ FOOTER ============ -->
|
||||
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
|
||||
|
||||
<!--
|
||||
Said before the button. Whether a credential comes with the host is the difference between a
|
||||
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
|
||||
supply one.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding AuthenticationNote}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
|
||||
Text="{Binding SelectionSummary}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
|
||||
Command="{Binding RestartCommand}"
|
||||
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
|
||||
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences. Nothing is stored." />
|
||||
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
|
||||
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
|
||||
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="{Binding AuthenticationNote}" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
|
||||
Margin="0,16,0,0">
|
||||
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
|
||||
ToolTip.Tip="Back to the token box, for another project." />
|
||||
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
|
||||
Command="{Binding ReviewCommand}"
|
||||
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
|
||||
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
|
||||
ToolTip.Tip="Back to Preferences." />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Importing hosts from a Hetzner Cloud project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
|
||||
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
|
||||
/// reasons, including having no keyboard target of its own to hand back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
|
||||
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
|
||||
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
|
||||
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
|
||||
/// rather than in a routed-event handler on a control.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HetznerImportScreen : UserControl
|
||||
{
|
||||
public HetznerImportScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -457,11 +457,12 @@
|
||||
something that refuses it, leaving the window with nothing focused and the keystrokes going
|
||||
nowhere.
|
||||
|
||||
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
|
||||
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
|
||||
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
|
||||
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
|
||||
feature invisible until it is used.
|
||||
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
|
||||
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
|
||||
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
|
||||
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
|
||||
which is what makes the feature invisible until it is used. A session holding one vault draws no
|
||||
vault headings either, for the same reason and by the same rule.
|
||||
-->
|
||||
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -472,6 +473,27 @@
|
||||
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<!--
|
||||
============ THE VAULT HEADING ============
|
||||
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
|
||||
row existed the board drew only the inner two — every group of every vault in one run,
|
||||
told apart by a small grey vault name beside each heading. Two vaults each holding a
|
||||
"production" were two headings with the same word on them.
|
||||
|
||||
Drawn on the first section of each vault and nowhere else — see
|
||||
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
|
||||
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
|
||||
to its right is the whole of the emphasis: this level is a divider with a name on it,
|
||||
not a heading competing with the group headings under it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Background="{StaticResource BorderSubtle}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ THE SECTION HEADING ============
|
||||
"{label} · {count}", the vault name beside it when more than one vault is in play, a
|
||||
@@ -569,6 +591,18 @@
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
|
||||
|
||||
<!--
|
||||
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
|
||||
one machine used to mean opening its pane first and then finding the menu inside
|
||||
it, while moving six was a right click away — the one-host case was the harder of
|
||||
the two, which is backwards. The command raises the drawer itself (see
|
||||
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
|
||||
else, and an entry that armed a panel nobody could see would be a menu item that
|
||||
did nothing.
|
||||
-->
|
||||
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
|
||||
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
|
||||
@@ -600,16 +634,26 @@
|
||||
The full address is on the tooltip and nowhere else on the card — the design's own
|
||||
rule, kept: a card read while scanning a section is read for its name and whether
|
||||
it is live, and an address is what somebody reads once they have found the machine,
|
||||
which the tooltip and the drawer both still say.
|
||||
which the tooltip and the drawer both still say. The name is on the tooltip too,
|
||||
and that is not duplication of the line six pixels above it: 214 pixels of card
|
||||
trims it, and the tooltip is where the untrimmed one lives. See
|
||||
HostRowViewModel.Tooltip.
|
||||
|
||||
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
|
||||
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
|
||||
card wearing four tags grows a second line rather than clipping the fourth one — a
|
||||
silent truncation is exactly the kind of thing the honesty rule in
|
||||
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
|
||||
|
||||
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
|
||||
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
|
||||
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
|
||||
every card on a board that is scrolled. It is still a floor with room in it: the
|
||||
number is deliberately above what the content measures, so the common card is a
|
||||
uniform height rather than each one shrink-wrapping its own last line.
|
||||
-->
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Panel>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
|
||||
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
if (control)
|
||||
{
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
|
||||
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
|
||||
vault.ExtendHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
Board.Focus();
|
||||
|
||||
@@ -252,13 +252,23 @@
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
|
||||
mode's own default landing page; Vaults and Preferences open directly on the page they name,
|
||||
which is also what anything that used to navigate to ShellScreen.Vaults or
|
||||
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
||||
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
||||
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
||||
◆ ONE ROW, WHERE v5c PUT THREE.
|
||||
|
||||
Settings, Vaults and Preferences all opened the same full-window settings mode — see
|
||||
MainWindowViewModel.EnterSettings and SettingsView.axaml — differing only in which of its
|
||||
seven pages they landed on. Two of the three were therefore deep links into a place the third
|
||||
one opens, and SettingsNav is already on screen the whole time listing every page including
|
||||
those two, so the shortcut saved one click and cost a menu that read as though it offered
|
||||
three destinations.
|
||||
|
||||
It was three rows because settings mode had only just been built out of what used to be two
|
||||
bare ShellScreens: "Settings" and "Preferences" were the same click before that wave, and
|
||||
splitting them was the fix for a real thing. Folding the other two back in is not a return to
|
||||
that state — Vaults and Preferences are pages now, reached from the rail inside settings, and
|
||||
what this menu is for is getting there at all.
|
||||
|
||||
What still lands on a page rather than on General is Logout below, and it has to: its
|
||||
confirmation card is drawn inline on the Account page and nowhere else.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
@@ -268,29 +278,6 @@
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
||||
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
||||
destinations drew two of them in the colour this window uses for something switched off. The
|
||||
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
||||
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -59,28 +59,6 @@ internal sealed partial class NavRail : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens settings mode on its Preferences page.</summary>
|
||||
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosePopover();
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens settings mode on its Vaults page.</summary>
|
||||
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosePopover();
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
||||
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
||||
Text="Every six hours while DodoSSH is running, starting at launch. It keeps checking while the keychain is locked — where builds come from has nothing to do with your vault — and while an update is already waiting, so what is offered is the newest build rather than the first one this run happened to see." />
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
||||
IsChecked="{Binding Updates.IsAutomatic}"
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
|
||||
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
|
||||
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
|
||||
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
|
||||
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||
Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
|
||||
confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||
|
||||
Two importer rows now rather than the one the old screen had: ~/.ssh/config, and a Hetzner Cloud project.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
@@ -95,7 +97,7 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingsrow last">
|
||||
<Border Classes="settingsrow">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
||||
@@ -108,6 +110,24 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The second importer, beside the first rather than folded into it: they read different things
|
||||
through different code, and one row that asked "from where?" would put a question in front of
|
||||
the far more common answer. See HetznerImportScreen.axaml.
|
||||
-->
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Import from Hetzner Cloud" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Lists the servers in one Hetzner Cloud project and imports the ones you tick. Needs a read-only API token, which is used for a single request and never stored; a token belongs to one project, so an account with several runs this once each. No key can come with them — Hetzner has never held the private half of one — so imported hosts ask for a password until you bind them to a key in your keychain. Dedicated servers behind Hetzner's Robot are not covered." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN WIZARD"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.HetznerImport}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
bars needs its own window behaviour a shared base would have to be pulled apart first.
|
||||
|
||||
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
|
||||
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
|
||||
MainWindowViewModel.IsImporterOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||
place of "Back to application" while an importer is up, and CloseImportCommand closes it without leaving
|
||||
settings mode, unlike LeaveSettingsCommand.
|
||||
|
||||
IsImporterOpen rather than IsImportOpen because there are two importers now — the ssh_config one and the
|
||||
Hetzner wizard — and this bar behaves identically for both: same label, same destination, same command.
|
||||
-->
|
||||
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
@@ -28,7 +31,7 @@
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !IsImportOpen}"
|
||||
IsVisible="{Binding !IsImporterOpen}"
|
||||
Command="{Binding LeaveSettingsCommand}"
|
||||
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
@@ -40,7 +43,7 @@
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsImportOpen}"
|
||||
IsVisible="{Binding IsImporterOpen}"
|
||||
Command="{Binding CloseImportCommand}"
|
||||
ToolTip.Tip="Back to preferences, without leaving Settings">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
|
||||
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
|
||||
same split MainWindow.axaml drew before the importer moved in here.
|
||||
|
||||
HetznerImportScreen joined it on identical terms. Two flags rather than one "which importer" value,
|
||||
because they are two independent facts about the content column and every path that opens either closes
|
||||
the other; what the Preferences page below asks is IsSettingsPreferencesContentShowing, which is written
|
||||
against both.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||
@@ -59,6 +64,11 @@
|
||||
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- The Hetzner wizard, on exactly the same terms. See MainWindowViewModel.IsHetznerImportOpen. -->
|
||||
<Panel IsVisible="{Binding IsHetznerImportOpen}">
|
||||
<views:HetznerImportScreen DataContext="{Binding HetznerImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Reading an OpenSSH client configuration and turning it into hosts this application can store.
|
||||
Turning somewhere an estate is already described into hosts this application can store. Two sources:
|
||||
an OpenSSH client configuration on this machine, and a Hetzner Cloud project over its API.
|
||||
|
||||
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
|
||||
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
|
||||
different concern with different dependencies, and keeping them apart is what lets the whole of the
|
||||
parsing be tested with no Avalonia, no SQLite and no disk.
|
||||
|
||||
The Hetzner reader is here rather than in a project of its own because it is the same concern arriving
|
||||
from somewhere else: a candidate list, warnings about what could not be represented, and a HostSecret at
|
||||
the end of it. It brings no package reference with it — HttpClient and System.Text.Json are the shared
|
||||
framework — and it stays testable on the same terms, against a stubbed HttpMessageHandler rather than
|
||||
against strings.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Hetzner Cloud answered, and the answer was not a list of servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One exception type with a sentence already fit to show somebody, rather than a status code every caller
|
||||
/// would have to translate for itself. The status is kept beside it because "401" and "503" are different
|
||||
/// problems with different next steps — one is the token, the other is nothing the user can do — and the
|
||||
/// screen distinguishes them by <see cref="IsTokenProblem"/> rather than by matching on prose.
|
||||
/// </remarks>
|
||||
public sealed class HetznerCloudException : Exception
|
||||
{
|
||||
/// <summary>Creates a failure that has no underlying exception — a refusal Hetzner spelled out.</summary>
|
||||
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
|
||||
: this(statusCode, code, message, innerException: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a failure wrapping the one the transport or the parser threw.</summary>
|
||||
public HetznerCloudException(
|
||||
HttpStatusCode? statusCode,
|
||||
string? code,
|
||||
string message,
|
||||
Exception? innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>What Hetzner returned, or null where the request never got an answer at all.</summary>
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
|
||||
/// <summary>Hetzner's own machine-readable error code, where the body carried one.</summary>
|
||||
public string? Code { get; }
|
||||
|
||||
/// <summary>Whether the token is the thing to go and fix.</summary>
|
||||
/// <remarks>
|
||||
/// The wizard's first step is where a token gets typed, so a failure that is about the token belongs
|
||||
/// back on that step and a failure that is about Hetzner's weather does not — sending somebody back to
|
||||
/// re-type a token that was fine, because a datacentre had a bad minute, teaches them the token is
|
||||
/// wrong.
|
||||
/// </remarks>
|
||||
public bool IsTokenProblem =>
|
||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the servers in one Hetzner Cloud project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One token is one project.</b> Hetzner issues Cloud API tokens per project and the API offers no way
|
||||
/// to ask which project a token belongs to, or to list the others — so this reads exactly the project whose
|
||||
/// token it was handed, and the screen says as much rather than implying it found everything the account
|
||||
/// owns. Somebody with four projects runs this four times.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Read is all it does.</b> Only <c>GET /v1/servers</c> is ever called, which is why the screen asks for
|
||||
/// a read-only token: a Read & Write token would work identically here and would be a credential capable
|
||||
/// of deleting the estate, pasted into a text box for no gain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The token is never stored.</b> It lives in the view model's field for as long as the wizard is open
|
||||
/// and goes no further — not into the vault, not into settings, not into a log. The next import asks again,
|
||||
/// which is the same reasoning <c>ImportViewModel.ImportsKeys</c> gives for not remembering its tick.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Dedicated servers are not here.</b> Hetzner's Robot — the dedicated-server and Storage Box side of the
|
||||
/// business — is a different API behind different credentials, and nothing in this file touches it. An
|
||||
/// account with both gets its cloud servers from this wizard and adds the rest by hand.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HetznerCloudClient
|
||||
{
|
||||
/// <summary>The Cloud API, and the one address in this file.</summary>
|
||||
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
|
||||
|
||||
/// <summary>What Hetzner will return per page, which is also its maximum.</summary>
|
||||
private const int PerPage = 50;
|
||||
|
||||
/// <summary>
|
||||
/// How many pages this will follow before giving up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backstop against a paginator that never says "no more pages" rather than a limit anybody's estate
|
||||
/// will meet: at fifty a page this is five thousand servers, and the warning it emits on the way out
|
||||
/// says the list is short rather than letting it look complete.
|
||||
/// </remarks>
|
||||
private const int MaxPages = 100;
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
/// <summary>Creates a reader over its own transport, which is how the application builds one.</summary>
|
||||
public HetznerCloudClient()
|
||||
: this(new HttpClient { BaseAddress = CloudApi })
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a reader with its transport supplied, which is how the tests reach it.</summary>
|
||||
/// <remarks>
|
||||
/// The base address is set here when the caller left it unset, so a stubbed <c>HttpMessageHandler</c>
|
||||
/// can be handed over on its own without every test repeating the one URL this class knows.
|
||||
/// </remarks>
|
||||
public HetznerCloudClient(HttpClient http)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(http);
|
||||
|
||||
http.BaseAddress ??= CloudApi;
|
||||
|
||||
this.http = http;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every server the token's project holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pages are followed rather than assumed away: twenty-six servers in a project is enough to meet the
|
||||
/// second page, and an importer that quietly showed the first fifty would offer a list that looks
|
||||
/// complete and is not.
|
||||
/// </remarks>
|
||||
/// <exception cref="HetznerCloudException">
|
||||
/// The token was refused, the project could not be read, or Hetzner could not be reached. Every failure
|
||||
/// arrives as this, with a sentence already fit to put on the screen.
|
||||
/// </exception>
|
||||
public async Task<HetznerImport> ListServersAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
var bearer = Sanitise(token);
|
||||
|
||||
var servers = new List<HetznerServer>();
|
||||
var warnings = new List<string>();
|
||||
var page = 1;
|
||||
|
||||
while (page <= MaxPages)
|
||||
{
|
||||
var read = await ReadPageAsync(bearer, page, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var server in read.Servers ?? [])
|
||||
{
|
||||
servers.Add(Convert(server, warnings));
|
||||
}
|
||||
|
||||
if (read.Meta?.Pagination?.NextPage is not { } next || next <= page)
|
||||
{
|
||||
return new HetznerImport(servers, warnings);
|
||||
}
|
||||
|
||||
page = next;
|
||||
}
|
||||
|
||||
var counted = servers.Count.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
warnings.Add(
|
||||
$"Stopped after {MaxPages.ToString(CultureInfo.InvariantCulture)} pages. This list is the first "
|
||||
+ counted + " servers in the project and not all of them.");
|
||||
|
||||
return new HetznerImport(servers, warnings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the pasted token and refuses one that could not be a header value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A token copied out of the Hetzner console arrives with a newline behind it often enough to be worth
|
||||
/// handling, and a header value carrying one throws a <see cref="FormatException"/> from inside
|
||||
/// <see cref="HttpClient"/> — a stack trace where the answer is "we trimmed it for you". Anything left
|
||||
/// after the trim that still cannot be a header is refused here, with a sentence, rather than there.
|
||||
/// </remarks>
|
||||
private static string Sanitise(string token)
|
||||
{
|
||||
var trimmed = token?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
throw new HetznerCloudException(null, null, "Paste a Hetzner Cloud API token first.");
|
||||
}
|
||||
|
||||
foreach (var character in trimmed)
|
||||
{
|
||||
if (char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
{
|
||||
throw new HetznerCloudException(
|
||||
null,
|
||||
null,
|
||||
"That does not look like a Hetzner Cloud API token — it has a space or a line break in "
|
||||
+ "the middle of it. A token is one unbroken run of 64 letters and digits.");
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// <summary>Fetches and parses one page, turning every way it can fail into one exception type.</summary>
|
||||
private async Task<HetznerServersPage> ReadPageAsync(
|
||||
string token,
|
||||
int page,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"servers?page={page}&per_page={PerPage}"));
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException failure)
|
||||
{
|
||||
throw new HetznerCloudException(
|
||||
null,
|
||||
null,
|
||||
$"Could not reach {CloudApi.Host}: {failure.Message}",
|
||||
failure);
|
||||
}
|
||||
catch (TaskCanceledException failure) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new HetznerCloudException(
|
||||
null,
|
||||
null,
|
||||
$"{CloudApi.Host} did not answer in time.",
|
||||
failure);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw await FailureAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content
|
||||
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerServersPage, cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? new HetznerServersPage(null, null);
|
||||
}
|
||||
catch (JsonException failure)
|
||||
{
|
||||
throw new HetznerCloudException(
|
||||
response.StatusCode,
|
||||
null,
|
||||
$"{CloudApi.Host} answered with something that is not a server list. That usually means "
|
||||
+ "a proxy answered instead of Hetzner.",
|
||||
failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a refusal into the sentence the screen shows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hetzner's own message is kept and something actionable is put in front of it, because "unable to
|
||||
/// authenticate" on its own does not tell somebody where tokens come from — and the token step is
|
||||
/// exactly where they will be standing when they read it.
|
||||
/// </remarks>
|
||||
private static async Task<HetznerCloudException> FailureAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? code = null;
|
||||
string? detail = null;
|
||||
|
||||
try
|
||||
{
|
||||
var body = await response.Content
|
||||
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerErrorEnvelope, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
code = body?.Error?.Code;
|
||||
detail = body?.Error?.Message;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not Hetzner's error shape. The status still says what happened, which is what the switch below
|
||||
// is written against — a proxy's HTML 502 must not lose the 502 while being parsed.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// A content type that is not JSON at all, for the same reason.
|
||||
}
|
||||
|
||||
var said = detail is { Length: > 0 } ? $" Hetzner said: {detail}" : string.Empty;
|
||||
var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var summary = response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized =>
|
||||
"Hetzner did not accept that token. Cloud API tokens are made per project, in the Hetzner "
|
||||
+ "console under Security → API tokens, and the value is shown only once — so a token "
|
||||
+ "that was written down half-copied fails exactly like this.",
|
||||
HttpStatusCode.Forbidden =>
|
||||
"That token is not allowed to read this project's servers. A read-only token is enough; a "
|
||||
+ "token scoped to something narrower is not.",
|
||||
HttpStatusCode.TooManyRequests =>
|
||||
"Hetzner is rate-limiting this token. Give it a minute and start the scan again.",
|
||||
HttpStatusCode.NotFound =>
|
||||
$"{CloudApi.Host} has no server list at that address, which should not happen and probably "
|
||||
+ "means something between here and Hetzner answered instead.",
|
||||
_ when (int)response.StatusCode >= 500 =>
|
||||
$"Hetzner Cloud returned {status}. That is a fault at their end rather than anything about "
|
||||
+ "the token.",
|
||||
_ => $"Hetzner Cloud returned {status}.",
|
||||
};
|
||||
|
||||
return new HetznerCloudException(response.StatusCode, code, summary + said);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns one server as the API describes it into one the import can offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything the API leaves out is tolerated rather than fatal, and only the name is worth a warning: a
|
||||
/// server with no plan or no image loses a phrase from a note, and a server with no name would arrive as
|
||||
/// a bookmark called nothing.
|
||||
/// </remarks>
|
||||
private static HetznerServer Convert(HetznerServerDto server, List<string> warnings)
|
||||
{
|
||||
var name = server.Name;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = string.Create(CultureInfo.InvariantCulture, $"server-{server.Id}");
|
||||
|
||||
warnings.Add($"A server had no name in the project, so it is offered as {name}.");
|
||||
}
|
||||
|
||||
var privateAddresses = (server.PrivateNet ?? [])
|
||||
.Select(network => network.Ip)
|
||||
.Where(ip => !string.IsNullOrWhiteSpace(ip))
|
||||
.Select(ip => ip!)
|
||||
.ToList();
|
||||
|
||||
var labels = (server.Labels ?? new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.Select(label => label.Value is { Length: > 0 }
|
||||
? $"{label.Key}={label.Value}"
|
||||
: label.Key)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new HetznerServer(
|
||||
server.Id,
|
||||
name,
|
||||
server.Status is { Length: > 0 } status ? status : "unknown",
|
||||
Blank(server.PublicNet?.IPv4?.Ip),
|
||||
Blank(server.PublicNet?.IPv6?.Ip),
|
||||
privateAddresses,
|
||||
Blank(server.ServerType?.Name),
|
||||
Where(server.Datacenter),
|
||||
Blank(server.Image?.Name),
|
||||
labels);
|
||||
}
|
||||
|
||||
/// <summary>Where a server runs, as a person would say it — or null where the API said nothing useful.</summary>
|
||||
private static string? Where(HetznerDatacenterDto? datacenter)
|
||||
{
|
||||
if (datacenter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var location = datacenter.Location;
|
||||
var region = Blank(location?.Name) ?? Blank(datacenter.Name);
|
||||
|
||||
var place = (Blank(location?.City), Blank(location?.Country)) switch
|
||||
{
|
||||
({ } city, { } country) => $"{city}, {country}",
|
||||
({ } city, null) => city,
|
||||
(null, { } country) => country,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return (region, place) switch
|
||||
{
|
||||
({ } where, { } which) => $"{where} · {which}",
|
||||
({ } where, null) => where,
|
||||
(null, { } which) => which,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Folds an absent value and a present-but-empty one together, which JSON does not.</summary>
|
||||
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
/// <summary>One page of <c>GET /v1/servers</c>.</summary>
|
||||
internal sealed record HetznerServersPage(
|
||||
[property: JsonPropertyName("servers")] IReadOnlyList<HetznerServerDto>? Servers,
|
||||
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
|
||||
|
||||
/// <summary>The envelope every Hetzner list response carries its paging in.</summary>
|
||||
internal sealed record HetznerMetaDto(
|
||||
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
|
||||
|
||||
/// <summary>Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.</summary>
|
||||
internal sealed record HetznerPaginationDto(
|
||||
[property: JsonPropertyName("next_page")] int? NextPage);
|
||||
|
||||
/// <summary>One server, as the API describes it. Every field is optional because the API's are.</summary>
|
||||
internal sealed record HetznerServerDto(
|
||||
[property: JsonPropertyName("id")] long Id,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("status")] string? Status,
|
||||
[property: JsonPropertyName("public_net")] HetznerPublicNetDto? PublicNet,
|
||||
[property: JsonPropertyName("private_net")] IReadOnlyList<HetznerPrivateNetDto>? PrivateNet,
|
||||
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
|
||||
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
|
||||
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
|
||||
[property: JsonPropertyName("labels")] IReadOnlyDictionary<string, string>? Labels);
|
||||
|
||||
/// <summary>A server's public addresses. Either half can be absent on a server that has only the other.</summary>
|
||||
internal sealed record HetznerPublicNetDto(
|
||||
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
|
||||
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
|
||||
|
||||
/// <summary>
|
||||
/// One public address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The v6 case puts a <em>network</em> in this field rather than an address — see
|
||||
/// <see cref="HetznerServer.FirstIPv6Address"/>, which is where that is dealt with rather than papered over
|
||||
/// here.
|
||||
/// </remarks>
|
||||
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
|
||||
|
||||
/// <summary>A server's attachment to one private network.</summary>
|
||||
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
|
||||
|
||||
/// <summary>The shape shared by the server type and the image, of which only the name is read.</summary>
|
||||
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
|
||||
|
||||
/// <summary>Where a server runs.</summary>
|
||||
internal sealed record HetznerDatacenterDto(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
|
||||
|
||||
/// <inheritdoc cref="HetznerDatacenterDto" />
|
||||
internal sealed record HetznerLocationDto(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("city")] string? City,
|
||||
[property: JsonPropertyName("country")] string? Country);
|
||||
|
||||
/// <summary>How Hetzner wraps a refusal.</summary>
|
||||
internal sealed record HetznerErrorEnvelope(
|
||||
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
|
||||
|
||||
/// <inheritdoc cref="HetznerErrorEnvelope" />
|
||||
internal sealed record HetznerErrorDto(
|
||||
[property: JsonPropertyName("code")] string? Code,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// The serializer for everything above.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source-generated rather than reflective, for the reason every other JSON context in this repository is:
|
||||
/// the Android head publishes trimmed, and a reflective deserializer in an assembly the phone links is a
|
||||
/// trim warning at best and a runtime failure at worst.
|
||||
/// </remarks>
|
||||
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(HetznerServersPage))]
|
||||
[JsonSerializable(typeof(HetznerErrorEnvelope))]
|
||||
internal sealed partial class HetznerJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Which of a Hetzner server's addresses an import should dial.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One choice for the whole import rather than one per row, for the reason the ssh_config screen's key tick
|
||||
/// is one control: an estate is reached the same way throughout — everything over the public internet, or
|
||||
/// everything across the private network from a machine already inside it — and a per-server dropdown would
|
||||
/// be forty answers to one question.
|
||||
/// </remarks>
|
||||
public enum HetznerAddressKind
|
||||
{
|
||||
/// <summary>The public IPv4 address, which is how all but the cheapest Hetzner servers are reached.</summary>
|
||||
PublicIPv4 = 0,
|
||||
|
||||
/// <summary>The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all.</summary>
|
||||
PublicIPv6 = 1,
|
||||
|
||||
/// <summary>The address on the first private network the server is attached to.</summary>
|
||||
Private = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An address an import settled on, and what it had to assume to get there.
|
||||
/// </summary>
|
||||
/// <param name="Value">What the host will dial.</param>
|
||||
/// <param name="Warning">
|
||||
/// What is not simply a fact the API stated, or null where it is. Every one of these is a place the import
|
||||
/// is deciding something on somebody's behalf, and a bookmark that dials the wrong machine is worse than one
|
||||
/// that was never offered — so they are shown beside the row rather than folded away.
|
||||
/// </param>
|
||||
public sealed record HetznerAddress(string Value, string? Warning);
|
||||
|
||||
/// <summary>
|
||||
/// One server a Hetzner Cloud project holds, as the import sees it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deliberately not a <see cref="HostSecret"/>, on exactly the reasoning <see cref="ImportedHost"/> gives:
|
||||
/// this is a candidate nobody has agreed to import, and it carries facts a stored host has no field for —
|
||||
/// the server's own id, what it is running, where it runs, and which of its several addresses were on offer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>There is no key here and there cannot be.</b> The Cloud API will name the SSH keys a project holds and
|
||||
/// hand over their public halves; the private halves have never been in Hetzner's hands. So an imported host
|
||||
/// authenticates with a key that was already in this keychain or it asks for a password, and the import
|
||||
/// screen says so rather than leaving somebody to find out at connect time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Id">The server's id in the project, which is the one name of it that never changes.</param>
|
||||
/// <param name="Name">What the project calls it, which is what the user will recognise.</param>
|
||||
/// <param name="Status">
|
||||
/// <c>running</c>, <c>off</c>, <c>initializing</c> and the rest. Shown on the row and deliberately not
|
||||
/// stored: it stays true for as long as it takes somebody to press a button in the Hetzner console.
|
||||
/// </param>
|
||||
/// <param name="PublicIPv4">The public IPv4 address, or null on a server that has none.</param>
|
||||
/// <param name="PublicIPv6Network">
|
||||
/// The public IPv6 <em>network</em> — Hetzner routes a whole /64 and the API reports the prefix, not an
|
||||
/// address. See <see cref="FirstIPv6Address"/> for what this import does with that.
|
||||
/// </param>
|
||||
/// <param name="PrivateAddresses">The addresses it has on private networks, in the order the API gave them.</param>
|
||||
/// <param name="ServerType">The plan — <c>cx22</c>, <c>ccx13</c> — or null where the API did not say.</param>
|
||||
/// <param name="Location">Where it runs, as a person would say it: <c>fsn1 · Falkenstein, DE</c>.</param>
|
||||
/// <param name="Image">What it was built from, which is the best available hint at which user logs in.</param>
|
||||
/// <param name="Labels">The project's own labels, as <c>key=value</c>, sorted.</param>
|
||||
public sealed record HetznerServer(
|
||||
long Id,
|
||||
string Name,
|
||||
string Status,
|
||||
string? PublicIPv4,
|
||||
string? PublicIPv6Network,
|
||||
IReadOnlyList<string> PrivateAddresses,
|
||||
string? ServerType,
|
||||
string? Location,
|
||||
string? Image,
|
||||
IReadOnlyList<string> Labels)
|
||||
{
|
||||
/// <summary>Whether the API gave this server a public IPv4 address at all.</summary>
|
||||
/// <remarks>
|
||||
/// Not a formality. Hetzner bills IPv4 separately now, and a project built since then can be entirely
|
||||
/// IPv6-only — which is exactly the case an import that assumed a v4 would get silently wrong.
|
||||
/// </remarks>
|
||||
public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 };
|
||||
|
||||
/// <summary>
|
||||
/// The address inside the routed /64 that a stock Hetzner image actually answers on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a convention and not a fact the API stated</b>, which is why every use of it carries a
|
||||
/// warning. What the API reports is <c>2a01:4f8:1c17:d00::/64</c> — a network, which nothing can dial.
|
||||
/// The address Hetzner's own images configure is the first one in it, and the console shows the same
|
||||
/// thing; a server whose operator moved it elsewhere in the /64 will not answer here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Computed by setting the low byte rather than by string surgery on the prefix, so a network the API
|
||||
/// spells some other way still yields an address rather than a malformed string.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? FirstIPv6Address
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PublicIPv6Network is not { Length: > 0 } network)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slash = network.IndexOf('/', StringComparison.Ordinal);
|
||||
var prefix = slash < 0 ? network : network[..slash];
|
||||
|
||||
if (!IPAddress.TryParse(prefix, out var parsed)
|
||||
|| parsed.AddressFamily != AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytes = parsed.GetAddressBytes();
|
||||
bytes[^1] = 1;
|
||||
|
||||
return new IPAddress(bytes).ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles on the address this server would be dialled at, or null where the choice has none to offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two public choices fall back to each other and the private one does not, and that asymmetry is
|
||||
/// deliberate. Falling back between v4 and v6 reaches the same machine over the same internet, so the
|
||||
/// worst case is a route somebody did not pick; falling back from a private address to a public one
|
||||
/// would put an address on the open internet into a bookmark that was asked to stay off it. So a server
|
||||
/// with no private address is simply not importable under that choice, and the screen says which.
|
||||
/// </remarks>
|
||||
public HetznerAddress? Resolve(HetznerAddressKind kind)
|
||||
{
|
||||
var six = FirstIPv6Address;
|
||||
|
||||
return kind switch
|
||||
{
|
||||
HetznerAddressKind.PublicIPv4 when HasPublicIPv4 => new(PublicIPv4!, null),
|
||||
HetznerAddressKind.PublicIPv4 when six is not null => new(
|
||||
six,
|
||||
$"No public IPv4. This is the first address of {PublicIPv6Network}, which is where Hetzner's "
|
||||
+ "own images put the server — the API reports the network, not an address."),
|
||||
HetznerAddressKind.PublicIPv6 when six is not null => new(
|
||||
six,
|
||||
$"The API reports {PublicIPv6Network} rather than an address. This is the first address in "
|
||||
+ "it, which is where Hetzner's own images put the server."),
|
||||
HetznerAddressKind.PublicIPv6 when HasPublicIPv4 => new(
|
||||
PublicIPv4!,
|
||||
"No public IPv6 on this server, so this is its IPv4 address."),
|
||||
HetznerAddressKind.Private when PrivateAddresses.Count > 1 => new(
|
||||
PrivateAddresses[0],
|
||||
$"On {PrivateAddresses.Count} private networks. This is the first the API listed."),
|
||||
HetznerAddressKind.Private when PrivateAddresses.Count == 1 => new(PrivateAddresses[0], null),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Turns this into the host that would be stored.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing here authenticates.</b> There is no key to bring — see the remark on this record — so the
|
||||
/// host arrives bound to nothing and asks for a password until somebody points it at a key already in
|
||||
/// the keychain. That is the same shape an <c>ssh_config</c> import has with its key tick off, and it is
|
||||
/// stated on the screen rather than discovered at connect time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What goes in the notes is what does not go stale.</b> The id, the plan, the location and the image
|
||||
/// survive; the power state does not, so it is a column on the import screen and never a note. A note
|
||||
/// saying "running" about a machine somebody switched off last March is worse than no note.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="address">The address to dial, from <see cref="Resolve"/>.</param>
|
||||
/// <param name="username">The login user, or null and empty for none.</param>
|
||||
/// <param name="port">The SSH port.</param>
|
||||
public HostSecret ToSecret(HetznerAddress address, string? username, int port)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(address);
|
||||
|
||||
return new HostSecret
|
||||
{
|
||||
Label = Name,
|
||||
Hostname = address.Value,
|
||||
Port = port,
|
||||
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(),
|
||||
Notes = Describe(address),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>What the note says about where this came from, plus whatever the address had to assume.</summary>
|
||||
private string Describe(HetznerAddress address)
|
||||
{
|
||||
var facts = new List<string> { string.Create(CultureInfo.InvariantCulture, $"server {Id}") };
|
||||
|
||||
if (ServerType is { Length: > 0 } plan)
|
||||
{
|
||||
facts.Add(plan);
|
||||
}
|
||||
|
||||
if (Location is { Length: > 0 } where)
|
||||
{
|
||||
facts.Add(where);
|
||||
}
|
||||
|
||||
if (Image is { Length: > 0 } image)
|
||||
{
|
||||
facts.Add($"image {image}");
|
||||
}
|
||||
|
||||
if (Labels.Count > 0)
|
||||
{
|
||||
facts.Add($"labels {string.Join(", ", Labels)}");
|
||||
}
|
||||
|
||||
var sentence = $"Imported from Hetzner Cloud: {string.Join(", ", facts)}.";
|
||||
|
||||
return address.Warning is { Length: > 0 } warning ? $"{sentence} {warning}" : sentence;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not.
|
||||
/// </summary>
|
||||
/// <param name="Servers">The importable candidates, in the order the API returned them.</param>
|
||||
/// <param name="Warnings">
|
||||
/// What the read could not represent, at project level — a page that came back malformed, a server the API
|
||||
/// described without a name. The counterpart of <see cref="SshConfigImport.Warnings"/>.
|
||||
/// </param>
|
||||
public sealed record HetznerImport(IReadOnlyList<HetznerServer> Servers, IReadOnlyList<string> Warnings);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Import;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Which of the wizard's three steps is on screen.</summary>
|
||||
/// <remarks>
|
||||
/// A wizard rather than one screen, unlike <see cref="ImportViewModel"/>, and the reason is what each step
|
||||
/// costs. Reading an <c>ssh_config</c> is free and local, so that screen scans the moment it opens and shows
|
||||
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
|
||||
/// and it then makes a request over the internet. Those are two different moments, and a screen that mixed
|
||||
/// the token box in with the list would ask for the credential again every time somebody came back to look
|
||||
/// at the list.
|
||||
/// </remarks>
|
||||
internal enum HetznerImportStep
|
||||
{
|
||||
/// <summary>Paste a token. Nothing has been sent yet.</summary>
|
||||
Token = 0,
|
||||
|
||||
/// <summary>The project's servers, and what to do with them. Nothing has been stored yet.</summary>
|
||||
Choose = 1,
|
||||
|
||||
/// <summary>What was stored.</summary>
|
||||
Done = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Hetzner server, as a row somebody decides about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The address is not a property of the server but of the server <em>and the choice above the list</em>, so
|
||||
/// it is pushed down by <see cref="Refresh"/> rather than computed here — the same shape
|
||||
/// <see cref="ImportRowViewModel.ImportsItsKey"/> has, and for the same reason: a control that decides one
|
||||
/// thing for forty rows belongs above the forty rows.
|
||||
/// </remarks>
|
||||
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
|
||||
{
|
||||
private HetznerAddress? resolved;
|
||||
|
||||
internal HetznerServer Server => server;
|
||||
|
||||
internal string Name => server.Name;
|
||||
|
||||
/// <summary>Whether it is running, off, or somewhere in between — a fact of the moment, never stored.</summary>
|
||||
internal string Status => server.Status;
|
||||
|
||||
/// <summary>The plan and where it runs, as one cell.</summary>
|
||||
internal string Kind => (server.ServerType, server.Location) switch
|
||||
{
|
||||
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
|
||||
({ Length: > 0 } plan, _) => plan,
|
||||
(_, { Length: > 0 } place) => place,
|
||||
_ => "—",
|
||||
};
|
||||
|
||||
/// <summary>What this row would dial, or an em dash where the address choice leaves it nothing.</summary>
|
||||
internal string Address => resolved?.Value ?? "—";
|
||||
|
||||
/// <summary>Whether the address choice above the list can reach this server at all.</summary>
|
||||
/// <remarks>
|
||||
/// False is the private-network case: a server on no private network has no private address, and this
|
||||
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
|
||||
/// an import that was asked to stay off the internet from putting a public address in the keychain.
|
||||
/// </remarks>
|
||||
internal bool CanImport => resolved is not null;
|
||||
|
||||
/// <summary>Whether the keychain already has a host pointing where this one would.</summary>
|
||||
internal bool AlreadyPresent { get; private set; }
|
||||
|
||||
/// <summary>What had to be assumed to reach that address, if anything.</summary>
|
||||
internal string Warning => resolved?.Warning ?? string.Empty;
|
||||
|
||||
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
|
||||
|
||||
/// <summary>
|
||||
/// The row's own chip: the single most actionable thing true about it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
|
||||
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
|
||||
/// here", which is only about whether it is worth ticking. Deliberately parallel to
|
||||
/// <see cref="ImportRowViewModel.Meaning"/> — the same three states plus the one that screen cannot
|
||||
/// have, because a resolved <c>ssh_config</c> entry always has a hostname.
|
||||
/// </remarks>
|
||||
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
|
||||
{
|
||||
(false, _, _) => "no address of that kind",
|
||||
(_, true, _) => Warning,
|
||||
(_, _, true) => "already here",
|
||||
_ => "new host",
|
||||
};
|
||||
|
||||
internal bool IsMeaningWarned => !CanImport || HasWarning;
|
||||
|
||||
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
|
||||
|
||||
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
|
||||
|
||||
/// <summary>The address, resolved, or null where there is none of the chosen kind.</summary>
|
||||
internal HetznerAddress? Resolved => resolved;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Re-resolves this row against the address choice above the list.
|
||||
/// </summary>
|
||||
/// <param name="kind">Which address the import is dialling.</param>
|
||||
/// <param name="isAlreadyPresent">Whether the keychain already holds a host at a given address.</param>
|
||||
/// <param name="resetSelection">
|
||||
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
|
||||
/// question "which of these do you want" is being put again — and false when only the address choice
|
||||
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
|
||||
/// A row that has lost its address is unticked either way; there is nothing to import.
|
||||
/// </param>
|
||||
internal void Refresh(
|
||||
HetznerAddressKind kind,
|
||||
Func<HetznerAddress, bool> isAlreadyPresent,
|
||||
bool resetSelection)
|
||||
{
|
||||
resolved = server.Resolve(kind);
|
||||
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
|
||||
|
||||
if (resetSelection)
|
||||
{
|
||||
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
|
||||
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
|
||||
// want — but it should take a click rather than be the default.
|
||||
IsSelected = CanImport && !AlreadyPresent;
|
||||
}
|
||||
else if (!CanImport)
|
||||
{
|
||||
IsSelected = false;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(Address));
|
||||
OnPropertyChanged(nameof(CanImport));
|
||||
OnPropertyChanged(nameof(AlreadyPresent));
|
||||
OnPropertyChanged(nameof(Warning));
|
||||
OnPropertyChanged(nameof(HasWarning));
|
||||
OnPropertyChanged(nameof(Meaning));
|
||||
OnPropertyChanged(nameof(IsMeaningWarned));
|
||||
OnPropertyChanged(nameof(IsMeaningExisting));
|
||||
OnPropertyChanged(nameof(IsMeaningNew));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the import's key picker: a key already in the keychain, or none at all.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The key, or null for "no key".</param>
|
||||
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
|
||||
/// <param name="Label">What the picker shows.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
|
||||
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
|
||||
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
|
||||
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
|
||||
/// is no file at all, only a key the keychain has had all along.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
|
||||
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
|
||||
/// same thing — and on this screen the second is a promise about how forty machines will behave.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
|
||||
{
|
||||
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
|
||||
/// <remarks>
|
||||
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
|
||||
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
|
||||
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
|
||||
/// typed. Chosen, it is one answer to one question the person running the import already knows.
|
||||
/// </remarks>
|
||||
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importing the servers in a Hetzner Cloud project.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Three steps, and the first two write nothing.</b> Step one takes a token and sends one request; step
|
||||
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
|
||||
/// the same argument <see cref="ImportViewModel"/> makes — a project with forty servers is forty questions,
|
||||
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
|
||||
/// import has and that one does not: the credential. It has to be fetched from another company's console
|
||||
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The token is never stored.</b> It lives in <see cref="Token"/> while the wizard is open and is cleared
|
||||
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
|
||||
/// again. That is the same reasoning <see cref="ImportViewModel.ImportsKeys"/> gives for not remembering its
|
||||
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
|
||||
/// stored credential capable of listing somebody's whole estate.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing imported here authenticates.</b> Hetzner has never held the private half of an SSH key, so
|
||||
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
|
||||
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
|
||||
/// time — see <see cref="AuthenticationNote"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HetznerImportViewModel(
|
||||
VaultViewModel vault,
|
||||
HetznerCloudClient hetzner,
|
||||
Action? onCancel = null) : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The user a stock Hetzner Linux image logs in as.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
|
||||
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
|
||||
/// estate at once, which is why the box is above the list rather than on each row.
|
||||
/// </remarks>
|
||||
internal const string DefaultUsername = "root";
|
||||
|
||||
internal ObservableCollection<HetznerRowViewModel> Rows { get; } = [];
|
||||
|
||||
/// <summary>What the project could not be made to say, at project level.</summary>
|
||||
internal ObservableCollection<string> Warnings { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HetznerImportStep step = HetznerImportStep.Token;
|
||||
|
||||
/// <summary>
|
||||
/// The Cloud API token, held only for as long as the wizard needs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
|
||||
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
|
||||
/// that outlives the process. <see cref="ScanAsync"/> clears it as soon as the read is finished.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string token = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
/// <summary>The login user every imported host gets.</summary>
|
||||
[ObservableProperty]
|
||||
private string username = DefaultUsername;
|
||||
|
||||
/// <summary>
|
||||
/// The SSH port, as typed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
|
||||
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
|
||||
/// <see cref="Port"/>, which is also what <see cref="IsPortValid"/> reports on.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private const int HetznerPort = 22;
|
||||
|
||||
[ObservableProperty]
|
||||
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
|
||||
|
||||
internal bool IsTokenStep => Step is HetznerImportStep.Token;
|
||||
|
||||
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
|
||||
|
||||
internal bool IsDoneStep => Step is HetznerImportStep.Done;
|
||||
|
||||
/// <summary>"Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.</summary>
|
||||
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
|
||||
|
||||
/// <summary>What this step is for, in the header, beside <see cref="StepLabel"/>.</summary>
|
||||
internal string StepTitle => Step switch
|
||||
{
|
||||
HetznerImportStep.Token => "Connect to a project",
|
||||
HetznerImportStep.Choose => "Choose what to import",
|
||||
_ => "Imported",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Which of the three address chips above the list is lit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
|
||||
/// control is a chip that runs <see cref="UseAddressCommand"/> — see <c>HetznerImportScreen.axaml</c>
|
||||
/// for why chips rather than radio buttons, which this application has no style for.
|
||||
/// </remarks>
|
||||
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
|
||||
|
||||
/// <inheritdoc cref="UsesPublicIPv4" />
|
||||
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
|
||||
|
||||
/// <inheritdoc cref="UsesPublicIPv4" />
|
||||
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
|
||||
|
||||
/// <summary>What the chosen address means for the list, said once above it rather than per row.</summary>
|
||||
internal string AddressNote => AddressKind switch
|
||||
{
|
||||
HetznerAddressKind.PublicIPv4 =>
|
||||
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
|
||||
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
|
||||
HetznerAddressKind.PublicIPv6 =>
|
||||
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
|
||||
+ "than an address, so this is the first address in it — where Hetzner's own images put "
|
||||
+ "the server, and not something the API states.",
|
||||
_ =>
|
||||
"The address on the server's own private network, which only reaches it from inside that "
|
||||
+ "network. Servers on no private network are not importable under this choice, and this "
|
||||
+ "will never quietly fall back to a public address.",
|
||||
};
|
||||
|
||||
internal bool HasRows => Rows.Count > 0;
|
||||
|
||||
internal bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
/// <summary>The port every imported host gets, or null where what is typed is not one.</summary>
|
||||
internal int? Port =>
|
||||
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|
||||
&& port is >= 1 and <= 65535
|
||||
? port
|
||||
: null;
|
||||
|
||||
internal bool IsPortValid => Port is not null;
|
||||
|
||||
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
|
||||
|
||||
/// <summary>How many rows the address choice can actually reach.</summary>
|
||||
internal int ImportableCount => Rows.Count(row => row.CanImport);
|
||||
|
||||
/// <summary>Whether every importable row is ticked — what the header's tick-all box shows.</summary>
|
||||
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
|
||||
|
||||
/// <summary>Whether IMPORT would do anything.</summary>
|
||||
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
|
||||
/// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
|
||||
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
|
||||
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
|
||||
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
|
||||
/// items moved one at a time afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
|
||||
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
|
||||
/// preference about where new items go, and this is a field of the import in front of you. Filled from
|
||||
/// the same list, so it offers what every other "file this into" control offers — vaults this session
|
||||
/// can both read and write.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedVault;
|
||||
|
||||
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||||
/// <remarks>
|
||||
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
|
||||
/// offering one option is a question with no answer.
|
||||
/// </remarks>
|
||||
internal bool HasVaultChoice => VaultChoices.Count > 1;
|
||||
|
||||
/// <summary>The vault the import writes to, or null for the active one.</summary>
|
||||
internal Guid? TargetVaultId => SelectedVault?.VaultId;
|
||||
|
||||
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
|
||||
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
|
||||
|
||||
/// <summary>
|
||||
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
|
||||
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
|
||||
/// already in the keychain is almost always the key that opens them, because it is the one whose public
|
||||
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
|
||||
/// estate; without it, it was forty visits to forty host editors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
|
||||
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
|
||||
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
|
||||
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
|
||||
|
||||
/// <summary>Whether the keychain has a key to offer at all.</summary>
|
||||
/// <remarks>
|
||||
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
|
||||
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
|
||||
/// would do something.
|
||||
/// </remarks>
|
||||
internal bool HasKeyChoice => KeyChoices.Count > 1;
|
||||
|
||||
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
|
||||
internal Guid? BoundKeyId => SelectedKey?.KeyId;
|
||||
|
||||
/// <summary>How many ticked, out of how many, and where they land.</summary>
|
||||
internal string SelectionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var noun = Rows.Count == 1 ? "server" : "servers";
|
||||
|
||||
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sentence that says how an imported host will authenticate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
|
||||
/// for its own version: whether the credential comes with the host is the difference between a bookmark
|
||||
/// that connects and one that asks for a password, and somebody who is not told will conclude the
|
||||
/// import was broken.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
|
||||
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
|
||||
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string AuthenticationNote => SelectedKey?.KeyId is null
|
||||
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
|
||||
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
|
||||
+ "one — from the host's own editor, or in bulk from the Hosts screen."
|
||||
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
|
||||
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
|
||||
+ $"itself is not copied and not read.{StrandedKeyNote}";
|
||||
|
||||
/// <summary>
|
||||
/// What is said when the chosen key sits in a different vault from the hosts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
|
||||
/// because it is being done to a whole estate at once: a binding resolves across every vault
|
||||
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
|
||||
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
|
||||
/// hosts in three vaults is what keys are for.
|
||||
/// </remarks>
|
||||
private string StrandedKeyNote =>
|
||||
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
|
||||
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
|
||||
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
|
||||
+ "see hosts they cannot authenticate."
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>What the token step says about the token it is asking for.</summary>
|
||||
/// <remarks>
|
||||
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
|
||||
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
|
||||
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
|
||||
/// say so would look like it had lost most of the estate.
|
||||
/// </remarks>
|
||||
internal static string TokenNote =>
|
||||
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
|
||||
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
|
||||
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
|
||||
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
|
||||
+ "covered here.";
|
||||
|
||||
/// <summary>Reads the project. Writes nothing, and forgets the token on the way out.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ClearRows();
|
||||
RefreshChoices();
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
|
||||
|
||||
if (Rows.Count == 0)
|
||||
{
|
||||
// Deliberately not an error, and deliberately not a step forward: an empty project is a
|
||||
// real answer to a correct token, and a list screen with nothing on it says less than this.
|
||||
Status = "That project has no servers in it.";
|
||||
return;
|
||||
}
|
||||
|
||||
Status = Rows.Count == 1
|
||||
? "Found 1 server. Nothing is stored until you press the button below."
|
||||
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
|
||||
|
||||
Step = HetznerImportStep.Choose;
|
||||
}
|
||||
catch (HetznerCloudException failure)
|
||||
{
|
||||
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
|
||||
// for why a fault at Hetzner's end is worded differently from a refused token even though both
|
||||
// land here: one of them is something the person reading this can act on.
|
||||
Status = failure.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleared whatever happened, including on the way to a list somebody is about to import from:
|
||||
// the read is over and the token has no further use. A failed attempt clears it too, so a
|
||||
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
|
||||
// against a credential sitting in a text box for the rest of the session.
|
||||
Token = string.Empty;
|
||||
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the ticked servers as hosts.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ImportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Port is not { } port)
|
||||
{
|
||||
Status = "That is not a port. Give a number between 1 and 65535.";
|
||||
return;
|
||||
}
|
||||
|
||||
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
|
||||
|
||||
if (chosen.Count == 0)
|
||||
{
|
||||
Status = "Nothing is ticked.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
|
||||
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
|
||||
// leaves the host bound to nothing, which is what it always was.
|
||||
var requests = chosen
|
||||
.Select(row => new ImportedHostRequest(
|
||||
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
|
||||
Key: null,
|
||||
KeyPath: null))
|
||||
.ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
|
||||
// which is what makes coming back to the list and importing the rest of the project harmless
|
||||
// and visible rather than harmless and confusing.
|
||||
Refresh(resetSelection: true);
|
||||
|
||||
Status = WhatWasStored(outcome.Hosts);
|
||||
|
||||
Step = HetznerImportStep.Done;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Back to the token step, with the list dropped.</summary>
|
||||
/// <remarks>
|
||||
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
|
||||
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
|
||||
/// means to have produced them would be showing a list it cannot stand behind.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Restart()
|
||||
{
|
||||
ClearRows();
|
||||
Status = string.Empty;
|
||||
Step = HetznerImportStep.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Back from the last step to the list, with what was just stored marked as already here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Restart"/> this keeps the rows, and it can: they were read before the token was
|
||||
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
|
||||
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
|
||||
/// otherwise.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Review()
|
||||
{
|
||||
if (Rows.Count > 0)
|
||||
{
|
||||
Step = HetznerImportStep.Choose;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Points the whole import at one of the three kinds of address.</summary>
|
||||
[RelayCommand]
|
||||
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
|
||||
|
||||
/// <summary>Ticks or unticks every importable row at once.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleAll()
|
||||
{
|
||||
var importable = Rows.Where(row => row.CanImport).ToList();
|
||||
var target = SelectedCount < importable.Count;
|
||||
|
||||
foreach (var row in importable)
|
||||
{
|
||||
row.IsSelected = target;
|
||||
}
|
||||
|
||||
RaiseListState();
|
||||
}
|
||||
|
||||
/// <summary>The footer's Cancel button: back to the Preferences page, nothing stored.</summary>
|
||||
/// <remarks>
|
||||
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the reasoning
|
||||
/// <see cref="ImportViewModel"/>'s own cancel gives: this type has no business knowing settings mode
|
||||
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
|
||||
/// no-op rather than a crash.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Cancel() => onCancel?.Invoke();
|
||||
|
||||
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
|
||||
/// <remarks>
|
||||
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
|
||||
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
|
||||
/// standing on. It names the vault and the key because those are the two answers that were chosen above
|
||||
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
|
||||
/// there is a key is what they would otherwise find out at the next connection.
|
||||
/// </remarks>
|
||||
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
|
||||
{
|
||||
(true, true) =>
|
||||
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
|
||||
(true, false) =>
|
||||
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
|
||||
+ "you bind it to a key.",
|
||||
(false, true) =>
|
||||
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
|
||||
+ $"'{SelectedKey!.Label}'.",
|
||||
_ =>
|
||||
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
|
||||
+ "password until you bind them to a key.",
|
||||
};
|
||||
|
||||
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
|
||||
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
|
||||
/// them and presses the button.
|
||||
/// </remarks>
|
||||
private void Fill(HetznerImport import)
|
||||
{
|
||||
foreach (var server in import.Servers)
|
||||
{
|
||||
var row = new HetznerRowViewModel(server);
|
||||
|
||||
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
|
||||
// the item template, because a template binding cannot reach the list it is inside without a
|
||||
// parent binding — and the count is on the parent by definition.
|
||||
row.PropertyChanged += OnRowChanged;
|
||||
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
|
||||
Refresh(resetSelection: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
|
||||
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
|
||||
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
|
||||
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
|
||||
/// the import would not actually write to. Anything that has since gone falls back to the default —
|
||||
/// the active vault, and no key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshChoices()
|
||||
{
|
||||
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
|
||||
var keyId = SelectedKey?.KeyId;
|
||||
|
||||
VaultChoices.Clear();
|
||||
|
||||
foreach (var choice in vault.TargetVaults)
|
||||
{
|
||||
VaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
|
||||
?? VaultChoices.FirstOrDefault();
|
||||
|
||||
KeyChoices.Clear();
|
||||
KeyChoices.Add(HetznerKeyChoice.None);
|
||||
|
||||
foreach (var key in vault.Keys)
|
||||
{
|
||||
// The vault name where there is more than one to be in, exactly as the key list itself prints
|
||||
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
|
||||
// has to say which. HasVaultBadge is that same test, already answered.
|
||||
KeyChoices.Add(new HetznerKeyChoice(
|
||||
key.EntityId,
|
||||
key.VaultId,
|
||||
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
|
||||
}
|
||||
|
||||
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
|
||||
|
||||
OnPropertyChanged(nameof(HasVaultChoice));
|
||||
OnPropertyChanged(nameof(HasKeyChoice));
|
||||
}
|
||||
|
||||
/// <summary>Drops the list, unsubscribing from what it held.</summary>
|
||||
/// <remarks>
|
||||
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
|
||||
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
|
||||
/// </remarks>
|
||||
private void ClearRows()
|
||||
{
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
row.PropertyChanged -= OnRowChanged;
|
||||
}
|
||||
|
||||
Rows.Clear();
|
||||
Warnings.Clear();
|
||||
RaiseListState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
|
||||
/// started, which already ends in <see cref="RaiseListState"/>.
|
||||
/// </remarks>
|
||||
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
|
||||
{
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-resolves every row against the current address choice.</summary>
|
||||
private void Refresh(bool resetSelection)
|
||||
{
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
|
||||
}
|
||||
|
||||
RaiseListState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the keychain already holds a host that would dial where this one does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matched on address, port and user rather than on name, for the reason
|
||||
/// <see cref="ImportViewModel.IsAlreadyPresent"/> gives at more length: a machine bookmarked under
|
||||
/// another name is the same machine, and matching on the label would offer a duplicate of something
|
||||
/// already stored. Compared against the <em>resolved</em> host so that a stored host inheriting its port
|
||||
/// or user from a group still matches.
|
||||
/// </remarks>
|
||||
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
|
||||
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
|
||||
&& existing.Resolved.Port.Value == Port
|
||||
&& string.Equals(
|
||||
existing.Resolved.Username.Value,
|
||||
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
partial void OnStepChanged(HetznerImportStep value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsTokenStep));
|
||||
OnPropertyChanged(nameof(IsChooseStep));
|
||||
OnPropertyChanged(nameof(IsDoneStep));
|
||||
OnPropertyChanged(nameof(StepLabel));
|
||||
OnPropertyChanged(nameof(StepTitle));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The vault is named in three places on this step — the summary line, the note under it and the
|
||||
/// sentence about a key left in another vault — and none of them is a control the picker owns.
|
||||
/// </remarks>
|
||||
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(VaultName));
|
||||
OnPropertyChanged(nameof(SelectionSummary));
|
||||
OnPropertyChanged(nameof(AuthenticationNote));
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnSelectedVaultChanged" />
|
||||
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
|
||||
OnPropertyChanged(nameof(AuthenticationNote));
|
||||
|
||||
partial void OnAddressKindChanged(HetznerAddressKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(UsesPublicIPv4));
|
||||
OnPropertyChanged(nameof(UsesPublicIPv6));
|
||||
OnPropertyChanged(nameof(UsesPrivate));
|
||||
OnPropertyChanged(nameof(AddressNote));
|
||||
|
||||
Refresh(resetSelection: false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The user and the port are half of what "already here" is decided on, so changing either has to
|
||||
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
|
||||
/// would now store is different.
|
||||
/// </remarks>
|
||||
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
|
||||
|
||||
/// <inheritdoc cref="OnUsernameChanged" />
|
||||
partial void OnPortTextChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPortValid));
|
||||
|
||||
Refresh(resetSelection: false);
|
||||
}
|
||||
|
||||
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
|
||||
|
||||
private void RaiseListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRows));
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportableCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
OnPropertyChanged(nameof(AllTicked));
|
||||
OnPropertyChanged(nameof(CanImport));
|
||||
OnPropertyChanged(nameof(SelectionSummary));
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
|
||||
|
||||
var requests = chosen.Select(BuildRequest).ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
||||
var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||
|
||||
@@ -182,6 +182,17 @@ internal enum ShellScreen
|
||||
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
|
||||
/// </remarks>
|
||||
Buckets = 10,
|
||||
|
||||
/// <summary>Importing hosts from a Hetzner Cloud project.</summary>
|
||||
/// <remarks>
|
||||
/// Reached from preferences and not from the nav rail, exactly as <see cref="Import"/> is, and for the
|
||||
/// same reason: it is a task done once rather than a place to be. A member of its own rather than a mode
|
||||
/// of <see cref="Import"/>, because the two read different things through different code and draw
|
||||
/// different screens — a single "import" destination that remembered which kind you last opened would be
|
||||
/// a destination whose meaning depended on history, which is the argument <see cref="Buckets"/> already
|
||||
/// makes for not sharing a member with <see cref="Transfers"/>.
|
||||
/// </remarks>
|
||||
HetznerImport = 11,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -328,6 +339,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected.
|
||||
/// The renderer cannot read a clipboard — <see cref="TerminalClientOpcode.ClipboardPaste"/> says why —
|
||||
/// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now
|
||||
/// no head at all: it stays nullable because this class has to be constructible without a visual, which
|
||||
/// is what every test that drives it does.
|
||||
/// </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;
|
||||
|
||||
@@ -493,6 +525,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||
/// </param>
|
||||
/// <param name="readClipboard">
|
||||
/// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click
|
||||
/// paste and nothing else — see the field it is kept in.
|
||||
/// </param>
|
||||
/// <param name="openInEditor">
|
||||
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
||||
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
||||
@@ -527,12 +563,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
Func<Task<string?>>? readClipboard = null,
|
||||
string? deviceName = null,
|
||||
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;
|
||||
@@ -545,6 +584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
this.readClipboard = readClipboard;
|
||||
|
||||
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
|
||||
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
|
||||
@@ -575,6 +615,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
|
||||
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
|
||||
this.workspace.RendererReattached += OnRendererReattached;
|
||||
|
||||
settings = new ClientSettingsStore(paths);
|
||||
@@ -726,6 +768,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
||||
|
||||
/// <summary>
|
||||
/// A right-click over a selection: the page has sent the text, and this puts it on the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled for the reason <see cref="OnFontSizeStepRequested"/> is — this arrives on the data plane's
|
||||
/// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a
|
||||
/// <c>TopLevel</c>, and a <c>TopLevel</c> is only safe to touch from the thread that owns the window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Through <see cref="post"/> rather than <c>Dispatcher.UIThread</c> directly, unlike the handlers
|
||||
/// beside it: those predate the delegate and are the ones this shell's suite records as out of its
|
||||
/// reach. There is no reason to add another.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) =>
|
||||
post(() => _ = CopyFromTerminalAsync(e.Text));
|
||||
|
||||
/// <summary>
|
||||
/// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked.
|
||||
/// </summary>
|
||||
/// <remarks>Marshalled for the reasons <see cref="OnClipboardCopyRequested"/> gives.</remarks>
|
||||
private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) =>
|
||||
post(() => _ = PasteIntoTerminalAsync(e.SessionId));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons
|
||||
/// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere
|
||||
/// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none
|
||||
/// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch
|
||||
/// no shipped head reaches would be the wrong trade, and both heads wire a clipboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared
|
||||
/// machine resource — on Windows another process holding it open is an ordinary, transient failure — and
|
||||
/// a copy that did not happen is worth exactly one more right-click.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task CopyFromTerminalAsync(string text)
|
||||
{
|
||||
if (copyToClipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await copyToClipboard(text).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Deliberately silent; see the remark for both halves of why. Nothing is left half done — the
|
||||
// clipboard either took the text or did not.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand
|
||||
/// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact
|
||||
/// about the machine rather than about this terminal, and the honest answer to it is that the
|
||||
/// right-click did nothing — which is what every other terminal does with an empty clipboard too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The answer for a session whose shell has ended is discarded for the reason
|
||||
/// <see cref="TerminalWorkspace.SendClipboardAsync"/> gives for refusing it: nothing visible would
|
||||
/// happen either way, and there is nowhere on this screen to say so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A throw is swallowed, as in <see cref="CopyFromTerminalAsync"/> and for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task PasteIntoTerminalAsync(uint sessionId)
|
||||
{
|
||||
if (readClipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = await readClipboard().ConfigureAwait(true);
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await workspace
|
||||
.SendClipboardAsync(sessionId, text, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Deliberately silent; see the remark. The read and the send are both inside it, because
|
||||
// whichever of the two failed leaves the same nothing on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
||||
@@ -913,6 +1055,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private ImportViewModel? importScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private HetznerImportViewModel? hetznerImportScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private SnippetsViewModel? snippetsScreen;
|
||||
@@ -1414,6 +1560,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is ShellScreen.HetznerImport)
|
||||
{
|
||||
OpenHetznerImport();
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
|
||||
// the way in — the caller named a destination, and that destination wins over "go back".
|
||||
ActiveSettingsPage = null;
|
||||
@@ -1476,10 +1628,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private bool isImportOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
|
||||
/// Whether the Hetzner wizard, rather than the ssh_config importer, is the one drawn over Preferences.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A second flag rather than a second value of one, because they are two independent facts about the
|
||||
/// content column and a single "which importer" enum would have to carry a "neither" member that
|
||||
/// <see cref="IsImportOpen"/> already expresses. What keeps them from both being true is that every
|
||||
/// path that opens one closes the other — see <see cref="OpenImport"/> and
|
||||
/// <see cref="OpenHetznerImport"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isHetznerImportOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether either importer is up, which is the one thing the titlebar's back button cares about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both back out to the same place by the same command, so the bar asks this rather than asking twice.
|
||||
/// See <c>SettingsTitleBar.axaml</c>.
|
||||
/// </remarks>
|
||||
internal bool IsImporterOpen => IsImportOpen || IsHetznerImportOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the Preferences page itself, rather than an importer drawn over it, is what settings mode's
|
||||
/// content column shows.
|
||||
/// </summary>
|
||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
|
||||
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImporterOpen;
|
||||
|
||||
partial void OnActiveSettingsPageChanged(SettingsPage? value)
|
||||
{
|
||||
@@ -1494,8 +1668,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||
}
|
||||
|
||||
partial void OnIsImportOpenChanged(bool value) =>
|
||||
partial void OnIsImportOpenChanged(bool value) => RaiseImporterState();
|
||||
|
||||
/// <inheritdoc cref="OnIsImportOpenChanged" />
|
||||
partial void OnIsHetznerImportOpenChanged(bool value) => RaiseImporterState();
|
||||
|
||||
private void RaiseImporterState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsImporterOpen));
|
||||
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
|
||||
}
|
||||
|
||||
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
|
||||
/// <remarks>
|
||||
@@ -1518,6 +1700,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
ActiveSettingsPage = page;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
|
||||
Screen = page switch
|
||||
{
|
||||
@@ -1539,9 +1722,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IsImportOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
|
||||
/// <summary>
|
||||
/// Opens the Hetzner wizard over the Preferences page — the Preferences row's own button, and
|
||||
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.HetznerImport"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reopened from the first step each time rather than resumed where it was left, and that is a decision
|
||||
/// rather than an omission: the wizard's first step holds a credential it deliberately does not keep, so
|
||||
/// a "resumed" second step would be a list nothing on screen could refresh. See
|
||||
/// <see cref="HetznerImportViewModel"/>.
|
||||
/// </remarks>
|
||||
private void OpenHetznerImport()
|
||||
{
|
||||
EnterSettings(SettingsPage.Preferences);
|
||||
|
||||
HetznerImportScreen?.RestartCommand.Execute(null);
|
||||
|
||||
IsHetznerImportOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>"Back to preferences": closes whichever importer is up, without leaving settings mode.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseImport() => IsImportOpen = false;
|
||||
private void CloseImport()
|
||||
{
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
}
|
||||
|
||||
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
|
||||
[RelayCommand]
|
||||
@@ -1554,6 +1760,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
ActiveSettingsPage = null;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
Screen = settingsReturnScreen;
|
||||
Surface = settingsReturnSurface;
|
||||
}
|
||||
@@ -2678,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
|
||||
@@ -3203,6 +3411,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
|
||||
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
|
||||
workspace.RendererReattached -= OnRendererReattached;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
@@ -3434,6 +3644,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
? null
|
||||
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
|
||||
|
||||
// Its own transport, built once with the vault rather than per scan: an HttpClient is meant to be
|
||||
// long-lived, and this one goes exactly one place — see HetznerCloudClient.CloudApi.
|
||||
HetznerImportScreen = newValue is null
|
||||
? null
|
||||
: new HetznerImportViewModel(newValue, new HetznerCloudClient(), CloseImport);
|
||||
|
||||
SnippetsScreen?.Detach();
|
||||
SnippetsScreen = newValue is null
|
||||
? null
|
||||
@@ -3545,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);
|
||||
@@ -3609,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),
|
||||
@@ -3820,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
|
||||
@@ -3835,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.
|
||||
@@ -4166,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
|
||||
@@ -4192,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;
|
||||
}
|
||||
@@ -4333,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;
|
||||
}
|
||||
@@ -4422,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;
|
||||
}
|
||||
@@ -4458,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.
|
||||
|
||||
@@ -74,6 +74,22 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ten seconds. It bounds one HTTPS request for a small JSON document, which ordinarily answers in well
|
||||
/// under one — and the case it exists for is not a slow answer but no answer at all: a captive portal or
|
||||
/// a firewall that drops packets rather than refusing them, where the underlying handler's own timeout is
|
||||
/// a hundred seconds. A refused connection fails in milliseconds and never reaches this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bounded because somebody is waiting on it with a finger on RESTART NOW, which is the whole difference
|
||||
/// from the timer's own pass. Giving up is not a failure here: the build already on disk is still newer
|
||||
/// than the one running, so the restart goes ahead with it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly IUpdateChannel updates;
|
||||
private readonly ClientSettingsStore settings;
|
||||
private readonly TimeProvider clock;
|
||||
@@ -143,6 +159,18 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
[ObservableProperty]
|
||||
private DateTimeOffset? lastChecked;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a restart is under way, including the look it takes before replacing anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It exists to keep the banner up. Everything the banner shows keys off <see cref="IsReady"/>, and the
|
||||
/// last look moves the state to Checking and possibly Downloading — so without this, pressing RESTART NOW
|
||||
/// makes the strip vanish and leaves nothing on screen saying why, for however long a package takes. See
|
||||
/// <see cref="ReadyHeadline"/>, which is what it says instead.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isRestarting;
|
||||
|
||||
internal bool IsSupported => State is not UpdateState.Unsupported;
|
||||
|
||||
internal bool IsUnsupported => State is UpdateState.Unsupported;
|
||||
@@ -155,12 +183,37 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
|
||||
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
|
||||
|
||||
internal bool IsBannerShowing => IsReady && !IsBannerDismissed;
|
||||
internal bool IsBannerShowing => (IsReady || IsRestarting) && !IsBannerDismissed;
|
||||
|
||||
/// <summary>What the banner says.</summary>
|
||||
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
|
||||
? $"DodoSSH {version} is ready to install."
|
||||
: "An update is ready to install.";
|
||||
/// <remarks>
|
||||
/// Three sentences rather than one, because a restart no longer necessarily starts with a build in hand:
|
||||
/// it takes one last look first, and may find something newer than what was fetched six hours ago. That
|
||||
/// look is short and the fetch behind it is not, so both say what they are doing — a strip that read
|
||||
/// "ready to install" while it downloaded would be describing the wrong build, and a blank one would be
|
||||
/// describing nothing.
|
||||
/// </remarks>
|
||||
internal string ReadyHeadline
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsRestarting && State is UpdateState.Checking)
|
||||
{
|
||||
return "Checking for a newer build before restarting…";
|
||||
}
|
||||
|
||||
if (IsRestarting && State is UpdateState.Downloading)
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"A newer build appeared. Fetching it before restarting — {DownloadPercent}%");
|
||||
}
|
||||
|
||||
return ReadyVersion is { Length: > 0 } version
|
||||
? $"DodoSSH {version} is ready to install."
|
||||
: "An update is ready to install.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
|
||||
/// <remarks>
|
||||
@@ -201,10 +254,24 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
OnPropertyChanged(nameof(CanCheckNow));
|
||||
OnPropertyChanged(nameof(IsBannerShowing));
|
||||
OnPropertyChanged(nameof(RestartWarning));
|
||||
OnPropertyChanged(nameof(ReadyHeadline));
|
||||
}
|
||||
|
||||
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
||||
|
||||
partial void OnIsRestartingChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsBannerShowing));
|
||||
OnPropertyChanged(nameof(ReadyHeadline));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The headline carries the percentage while a restart is fetching, so it has to be re-read as the bar
|
||||
/// moves. Raised unconditionally rather than only while restarting, because a condition here would be a
|
||||
/// second place that has to agree with the headline's own about when the percentage is in it.
|
||||
/// </remarks>
|
||||
partial void OnDownloadPercentChanged(int value) => OnPropertyChanged(nameof(ReadyHeadline));
|
||||
|
||||
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
|
||||
|
||||
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
||||
@@ -278,14 +345,32 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
|
||||
/// <summary>One pass of the loop.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
|
||||
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
|
||||
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
|
||||
/// nobody asked for may only speak when it has something to say.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>A pass still runs when one is already fetched, and this used to be the one state that stopped it
|
||||
/// for good.</b> The guard read <c>Downloading or Ready</c>, on the reasonable-looking argument that
|
||||
/// there is no point fetching what has already been fetched. What it actually did was stop <em>asking</em>
|
||||
/// — so the moment anything was downloaded, the banner was pinned to that build for the rest of the
|
||||
/// process's life, however many newer ones were published afterwards. On the nightly channel, where the
|
||||
/// release is deleted and remade on every push to main, that is most of the time: the client offered the
|
||||
/// build it happened to see first, installed it, restarted, and immediately offered the next one. Two
|
||||
/// updates and two restarts to move one step.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not fetching what is already fetched is still the rule, and <see cref="FetchAsync"/> is where it now
|
||||
/// lives — by comparing versions rather than by refusing to look. Downloading is still excluded, because
|
||||
/// a fetch in flight must not be restarted underneath itself; Checking is excluded for the same reason,
|
||||
/// and it is a pressed CHECK NOW that a tick would otherwise land on top of.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
|
||||
if (!IsAutomatic || State is UpdateState.Checking or UpdateState.Downloading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -339,27 +424,91 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Checks, and fetches whatever it finds.</summary>
|
||||
/// <returns>The update found, or null.</returns>
|
||||
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
|
||||
/// <summary>Checks, and fetches whatever it finds that is not already fetched.</summary>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <param name="checkWithin">
|
||||
/// A bound on the check alone, or null for none. See <see cref="LastLookTimeout"/>. Deliberately not a
|
||||
/// bound on the whole method: a package legitimately takes minutes, and a caller that wants a short
|
||||
/// answer wants it about the <em>question</em>, not about the download the answer may lead to.
|
||||
/// </param>
|
||||
/// <returns>What is waiting to be installed once this has run, or null if nothing is.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>It answers with what is pending rather than with what the check returned, and the difference is
|
||||
/// what makes a second pass safe.</b> A client running 1.0 that has fetched 1.1 is told 1.1 by every
|
||||
/// later check, because 1.1 is still the newest thing on the feed — so "the check found something" is
|
||||
/// not the same question as "there is something new". Comparing the two is what lets the loop keep
|
||||
/// asking without fetching the same package every six hours, and it is what replaces the pending build
|
||||
/// when the feed has moved on underneath it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A build already fetched is never thrown away, including when a check answers null. Null means the
|
||||
/// feed's newest is no newer than the build that is <em>running</em>, which says nothing about the one
|
||||
/// on disk; that one is still newer than what is running, and withdrawing an offer somebody has already
|
||||
/// been shown would be the worse answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<AvailableUpdate?> FetchAsync(
|
||||
CancellationToken cancellationToken,
|
||||
TimeSpan? checkWithin = null)
|
||||
{
|
||||
State = UpdateState.Checking;
|
||||
Status = string.Empty;
|
||||
|
||||
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
|
||||
var found = await AskAsync(checkWithin, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LastChecked = clock.GetUtcNow();
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
State = UpdateState.Idle;
|
||||
// Answered with what is still pending rather than with nothing, which is what keeps a pressed
|
||||
// CHECK NOW honest: "you are on the latest build" is true of the build that is running and false
|
||||
// of the machine, if there is one downloaded and waiting to be it.
|
||||
State = ready is null ? UpdateState.Idle : UpdateState.Ready;
|
||||
|
||||
return null;
|
||||
if (ready is { } waiting)
|
||||
{
|
||||
Status = Waiting(waiting);
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
if (ready is { } already
|
||||
&& string.Equals(already.Version, found.Version, StringComparison.Ordinal))
|
||||
{
|
||||
// The ordinary answer for every pass after the first one that found something. Not fetched
|
||||
// again, which is the rule the old Ready guard was reaching for and got by not asking at all.
|
||||
State = UpdateState.Ready;
|
||||
Status = Waiting(already);
|
||||
|
||||
return already;
|
||||
}
|
||||
|
||||
return await FetchIntoReadyAsync(found, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Downloads one update and offers it.</summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="FetchAsync"/> for length rather than for reuse — it has one caller — and the
|
||||
/// division is a real one: above is which build to fetch, below is fetching it.
|
||||
/// </remarks>
|
||||
private async Task<AvailableUpdate> FetchIntoReadyAsync(
|
||||
AvailableUpdate found,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var superseded = ready;
|
||||
|
||||
State = UpdateState.Downloading;
|
||||
DownloadPercent = 0;
|
||||
|
||||
// Said while it downloads, and only when this displaces something already fetched. Both places that
|
||||
// shows are places somebody is looking: a banner whose version changes under them, and a RESTART NOW
|
||||
// that starts a download instead of a restart.
|
||||
Status = superseded is { } stale
|
||||
? $"DodoSSH {found.Version} appeared while {stale.Version} was waiting. Fetching it instead."
|
||||
: string.Empty;
|
||||
|
||||
// Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
|
||||
// synchronisation context rather than invoking them inline, so a report can arrive after the
|
||||
// download has already returned — and an unguarded assignment then puts a stale smaller number
|
||||
@@ -382,13 +531,43 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
DownloadPercent = 100;
|
||||
ready = found;
|
||||
ReadyVersion = found.Version;
|
||||
|
||||
// Undismissed, because this is a different build from the one that was dismissed. LATER was an
|
||||
// answer about a version, and there is a new question.
|
||||
IsBannerDismissed = false;
|
||||
State = UpdateState.Ready;
|
||||
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
|
||||
Status = Waiting(found);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>Asks the channel, giving up after <paramref name="within"/> if one was given.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wait is bounded rather than the request cancelled, and that is not the same thing: the desktop
|
||||
/// channel's own remark records that Velopack's check takes no token, so cancelling would be observed
|
||||
/// only once the call had already returned. Waiting on it with a timeout is what actually gives an
|
||||
/// answer back in ten seconds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which leaves the request running, and it is the reason the channel had to learn to remember what it
|
||||
/// <em>downloaded</em> rather than only what it last found: an orphaned check can land afterwards and
|
||||
/// move the channel's idea of the newest build, and the restart that gave up on it is about to name the
|
||||
/// older one. See <c>VelopackUpdateChannel.Matched</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<AvailableUpdate?> AskAsync(TimeSpan? within, CancellationToken cancellationToken)
|
||||
{
|
||||
var asking = updates.CheckAsync(cancellationToken);
|
||||
|
||||
return within is { } bound
|
||||
? await asking.WaitAsync(bound, clock, cancellationToken).ConfigureAwait(true)
|
||||
: await asking.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static string Waiting(AvailableUpdate update) =>
|
||||
$"DodoSSH {update.Version} is downloaded and will run after a restart.";
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
|
||||
@@ -406,26 +585,89 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
[RelayCommand]
|
||||
private async Task RestartNowAsync()
|
||||
{
|
||||
if (ready is not { } update)
|
||||
if (ready is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the process
|
||||
// a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess — the whole
|
||||
// point is that this returns and the application carries on, so an exception has somewhere to go:
|
||||
// out of a command handler, past the dispatcher, and into the process. What that looks like to
|
||||
// somebody holding the phone is pressing INSTALL and having the application close, with nothing
|
||||
// installed and nothing said. Failed and the message is the same answer CheckNowAsync gives, and
|
||||
// it is the difference between a bug that describes itself and one that presents as a crash.
|
||||
IsRestarting = true;
|
||||
|
||||
try
|
||||
{
|
||||
await restart(update).ConfigureAwait(true);
|
||||
await TakeOneLastLookAsync().ConfigureAwait(true);
|
||||
|
||||
if (ready is not { } update)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the
|
||||
// process a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess —
|
||||
// the whole point is that this returns and the application carries on, so an exception has
|
||||
// somewhere to go: out of a command handler, past the dispatcher, and into the process. What
|
||||
// that looks like to somebody holding the phone is pressing INSTALL and having the application
|
||||
// close, with nothing installed and nothing said. Failed and the message is the same answer
|
||||
// CheckNowAsync gives, and it is the difference between a bug that describes itself and one
|
||||
// that presents as a crash.
|
||||
try
|
||||
{
|
||||
await restart(update).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
State = UpdateState.Failed;
|
||||
Status = exception.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Never reached on a desktop, where restart() replaces the process from inside the try. It is
|
||||
// for the phone, where INSTALL is a request the platform may refuse, and for every failure
|
||||
// path: a flag that keeps the banner up has to come down again.
|
||||
IsRestarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the feed once more, on the way to replacing this process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because what was fetched can be hours old, and a restart is the one moment being one build
|
||||
/// behind is expensive.</b> The loop asks every six hours; a build published between the last pass and
|
||||
/// this press would otherwise be installed on the <em>next</em> restart, which is to say: update,
|
||||
/// restart, be told at once that another update is waiting, update, restart again. Two of everything to
|
||||
/// move one step, which is exactly what a rolling nightly channel produces.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It costs one small request in the ordinary case, because the feed answers with the build already on
|
||||
/// disk and <see cref="FetchAsync"/> compares rather than downloads. It costs a download only when there
|
||||
/// genuinely is something newer, which is the case where the alternative was two downloads anyway.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here refuses a restart.</b> A forge that cannot be reached, a look that ran out of time, a
|
||||
/// fetch of the newer build that failed halfway — all of them leave the build already on disk untouched
|
||||
/// and still newer than the one running, so the restart goes ahead with it. That is why this swallows
|
||||
/// rather than reporting: <see cref="UpdateState.Failed"/> is for something a person is waiting on an
|
||||
/// answer to, and this person is waiting on a restart they are about to get.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task TakeOneLastLookAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await FetchAsync(lifetime.Token, checkWithin: LastLookTimeout).ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||
{
|
||||
// Shutting down, which is the one case that must not go on to restart: whatever is fetched is
|
||||
// applied at the next ordinary launch anyway, and that is the policy rather than a fallback.
|
||||
ready = null;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
State = UpdateState.Failed;
|
||||
Status = exception.Message;
|
||||
// Including the bound running out. Back to the offer that was already on screen.
|
||||
State = UpdateState.Ready;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
|
||||
/// <summary>Whether this heading has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
|
||||
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
|
||||
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
|
||||
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
|
||||
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
|
||||
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
|
||||
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
|
||||
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
|
||||
/// refuses it.
|
||||
/// </remarks>
|
||||
internal Guid CollapseKey { get; init; }
|
||||
|
||||
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||||
|
||||
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
|
||||
internal bool HasHeader => header is not null;
|
||||
|
||||
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
|
||||
|
||||
/// <summary>
|
||||
/// The vault this section opens, or empty where it is not the first section of one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
|
||||
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
|
||||
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
|
||||
/// "production" made the point: they were two headings with the same word on them and a small grey
|
||||
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
|
||||
/// and groups sort the hosts inside one, which is the shape the data has.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Carried on the first section of each vault rather than as a section of its own, because a vault
|
||||
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
|
||||
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
|
||||
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
|
||||
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string VaultHeading { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
|
||||
internal bool StartsAVault => VaultHeading.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
|
||||
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
||||
|
||||
/// <summary>
|
||||
/// What a card says when the pointer rests on it: the name in full, and the address under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
|
||||
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
|
||||
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
|
||||
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
|
||||
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
|
||||
/// six servers behind one address differ only by the half that was trimmed away.
|
||||
/// </remarks>
|
||||
internal string Tooltip => $"{Label}\n{Address}";
|
||||
|
||||
/// <remarks>
|
||||
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
||||
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
||||
@@ -893,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,
|
||||
@@ -900,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" />
|
||||
@@ -917,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>
|
||||
@@ -941,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>
|
||||
@@ -1391,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
|
||||
@@ -5170,25 +5257,109 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(CollapseAllLabel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The board, vault by vault: every vault's own groups under its own heading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
|
||||
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
|
||||
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
|
||||
/// are flattened on their own, and the first section that comes back wears the vault's name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
|
||||
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
|
||||
/// and the single-vault board is what most of this application's users have. The count is of vaults
|
||||
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private List<HostSectionViewModel> BuildHostSections()
|
||||
{
|
||||
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
|
||||
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
|
||||
var vaults = BoardVaults();
|
||||
|
||||
var sections = vaults.Count < 2
|
||||
? SectionsFrom(
|
||||
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
|
||||
vaultHeading: null)
|
||||
: vaults
|
||||
.SelectMany(vault => SectionsFrom(
|
||||
FlattenIntoSections(
|
||||
[.. shown.Where(row => row.VaultId == vault.VaultId)],
|
||||
ungroupedFirst: true,
|
||||
ungroupedLabel: "No group",
|
||||
vaultId: vault.VaultId),
|
||||
vault.Name.ToUpperInvariant()))
|
||||
.ToList();
|
||||
|
||||
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
|
||||
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
|
||||
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
|
||||
// that row for the control to sit on.
|
||||
for (var index = 0; index < sections.Count; index++)
|
||||
{
|
||||
if (sections[index].Header is not { } first)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sections[index] = new HostSectionViewModel(
|
||||
first with { IsFirstBoardSection = true },
|
||||
sections[index].Hosts)
|
||||
{
|
||||
VaultHeading = sections[index].VaultHeading,
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
|
||||
/// <remarks>
|
||||
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
|
||||
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
|
||||
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
|
||||
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
|
||||
/// </remarks>
|
||||
private List<(Guid VaultId, string Name)> BoardVaults() =>
|
||||
[
|
||||
.. session.ReadableVaults
|
||||
.Where(vault => IsVaultShown(vault.VaultId))
|
||||
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
||||
.Select(vault => (vault.VaultId, vault.Name)),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
|
||||
/// </summary>
|
||||
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
|
||||
/// <param name="vaultHeading">
|
||||
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
|
||||
/// it is given, the group headings drop their own vault badge: the name is already on the row above
|
||||
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
|
||||
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
|
||||
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
|
||||
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
|
||||
/// </remarks>
|
||||
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
|
||||
{
|
||||
var sections = new List<HostSectionViewModel>();
|
||||
|
||||
if (flat.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (Groups.Count == 0)
|
||||
{
|
||||
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
|
||||
}
|
||||
|
||||
var sections = new List<HostSectionViewModel>();
|
||||
SidebarGroupHeader? header = null;
|
||||
var members = new List<HostRowViewModel>();
|
||||
var firstHeading = true;
|
||||
var loose = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var row in flat)
|
||||
{
|
||||
@@ -5199,15 +5370,12 @@ internal sealed partial class VaultViewModel(
|
||||
sections.Add(new HostSectionViewModel(header, members));
|
||||
}
|
||||
|
||||
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
|
||||
// all — see IsFirstBoardSection and the XAML.
|
||||
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
|
||||
firstHeading = false;
|
||||
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
|
||||
members = [];
|
||||
}
|
||||
else if (row is HostRowViewModel host)
|
||||
{
|
||||
members.Add(host);
|
||||
(header is null ? loose : members).Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5215,6 +5383,18 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
sections.Add(new HostSectionViewModel(header, members));
|
||||
}
|
||||
else
|
||||
{
|
||||
sections.Add(new HostSectionViewModel(null, loose));
|
||||
}
|
||||
|
||||
if (vaultHeading is { Length: > 0 })
|
||||
{
|
||||
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
|
||||
{
|
||||
VaultHeading = vaultHeading,
|
||||
};
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -5230,6 +5410,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
|
||||
/// </param>
|
||||
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
|
||||
/// <param name="vaultId">
|
||||
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
|
||||
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
|
||||
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
|
||||
/// vault. The phone's single list passes null and is unchanged by any of this.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
|
||||
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
|
||||
@@ -5240,17 +5426,27 @@ internal sealed partial class VaultViewModel(
|
||||
/// left empty.
|
||||
/// </remarks>
|
||||
private List<ISidebarRow> FlattenIntoSections(
|
||||
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
|
||||
IReadOnlyList<HostRowViewModel> shown,
|
||||
bool ungroupedFirst,
|
||||
string ungroupedLabel,
|
||||
Guid? vaultId = null)
|
||||
{
|
||||
var rows = new List<ISidebarRow>();
|
||||
|
||||
if (Groups.Count == 0)
|
||||
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
|
||||
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
|
||||
// vault nobody has made a group in falls into the case below, drawing its cards flat.
|
||||
var groups = vaultId is { } only
|
||||
? Groups.Where(group => group.VaultId == only).ToList()
|
||||
: [.. Groups];
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
rows.AddRange(shown);
|
||||
return rows;
|
||||
}
|
||||
|
||||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||||
var known = groups.Select(group => group.EntityId).ToHashSet();
|
||||
|
||||
void AddUngrouped() => AddFlatSection(
|
||||
rows,
|
||||
@@ -5258,14 +5454,15 @@ internal sealed partial class VaultViewModel(
|
||||
null,
|
||||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||||
ungroupedLabel,
|
||||
onlyWhenOccupied: true);
|
||||
onlyWhenOccupied: true,
|
||||
ungroupedKey: vaultId ?? Guid.Empty);
|
||||
|
||||
if (ungroupedFirst)
|
||||
{
|
||||
AddUngrouped();
|
||||
}
|
||||
|
||||
foreach (var group in Groups)
|
||||
foreach (var group in groups)
|
||||
{
|
||||
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
||||
}
|
||||
@@ -5289,13 +5486,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// <param name="belongs">Which of the shown hosts fall under it.</param>
|
||||
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
|
||||
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
||||
/// <param name="ungroupedKey">
|
||||
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
|
||||
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
|
||||
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
|
||||
/// </param>
|
||||
private void AddFlatSection(
|
||||
List<ISidebarRow> rows,
|
||||
IReadOnlyList<HostRowViewModel> shown,
|
||||
HostGroupRowViewModel? group,
|
||||
Func<HostRowViewModel, bool> belongs,
|
||||
string ungroupedLabel,
|
||||
bool onlyWhenOccupied = false)
|
||||
bool onlyWhenOccupied = false,
|
||||
Guid ungroupedKey = default)
|
||||
{
|
||||
var members = shown.Where(belongs).ToArray();
|
||||
|
||||
@@ -5304,7 +5507,8 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
|
||||
var key = group?.EntityId ?? ungroupedKey;
|
||||
var expanded = !collapsedGroups.Contains(key);
|
||||
|
||||
rows.Add(new SidebarGroupHeader(
|
||||
group?.EntityId,
|
||||
@@ -5313,6 +5517,7 @@ internal sealed partial class VaultViewModel(
|
||||
expanded)
|
||||
{
|
||||
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||||
CollapseKey = key,
|
||||
});
|
||||
|
||||
if (!expanded)
|
||||
@@ -5458,7 +5663,7 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (!collapsedGroups.Remove(key))
|
||||
{
|
||||
@@ -5490,7 +5695,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (collapsing)
|
||||
{
|
||||
@@ -6259,6 +6464,69 @@ internal sealed partial class VaultViewModel(
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
|
||||
/// </summary>
|
||||
/// <param name="row">The card that was Ctrl-clicked.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
|
||||
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
|
||||
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
|
||||
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
|
||||
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
|
||||
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
|
||||
/// user could act on without noticing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
|
||||
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
|
||||
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
|
||||
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
|
||||
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
|
||||
/// to tell it was heard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
|
||||
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
|
||||
/// press has already put something in it, and a selection nothing draws is not one anybody can be
|
||||
/// surprised to find in a count.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ExtendHostChoice(HostRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FoldTheLitCardIn(besides: row);
|
||||
ToggleHostChoice(row);
|
||||
}
|
||||
|
||||
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
|
||||
/// <param name="besides">A card to leave out — the one the gesture is about. See
|
||||
/// <see cref="ExtendHostChoice"/> for why.</param>
|
||||
/// <remarks>
|
||||
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
|
||||
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
|
||||
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
|
||||
/// caller, which runs it once for the whole gesture.
|
||||
/// </remarks>
|
||||
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
|
||||
{
|
||||
if (chosenHostIds.Count > 0
|
||||
|| SelectedHost is not { } lit
|
||||
|| ReferenceEquals(lit, besides)
|
||||
|| !HostBoardOrder.Contains(lit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
chosenHostIds.Add(lit.EntityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||
/// </summary>
|
||||
@@ -6288,6 +6556,14 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
chosenHostIds.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
|
||||
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
|
||||
// for the whole of the argument. No card is left out here, because a band adds rather than
|
||||
// toggles and cannot undo its own fold.
|
||||
FoldTheLitCardIn();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
@@ -8283,12 +8559,25 @@ internal sealed partial class VaultViewModel(
|
||||
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
||||
/// too.
|
||||
/// </summary>
|
||||
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
|
||||
/// <param name="vaultId">
|
||||
/// Which vault to write them into, or null for the active one.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
||||
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
|
||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
|
||||
/// be written and no second place for the sync wiring to be forgotten.
|
||||
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
|
||||
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
|
||||
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
|
||||
/// key to be written and no second place for the sync wiring to be forgotten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
|
||||
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
|
||||
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
|
||||
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
|
||||
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
|
||||
/// end up split across two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||||
@@ -8315,10 +8604,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||
IReadOnlyList<ImportedHostRequest> requests,
|
||||
Guid? vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var target = vaultId ?? session.ActiveVaultId;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
@@ -8340,7 +8631,8 @@ internal sealed partial class VaultViewModel(
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
||||
var (keyId, created) = await BindImportedKeyAsync(
|
||||
request, bound, target, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
@@ -8351,7 +8643,7 @@ internal sealed partial class VaultViewModel(
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.CreateAsync(target, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
hosts++;
|
||||
@@ -8381,6 +8673,7 @@ internal sealed partial class VaultViewModel(
|
||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||
ImportedHostRequest request,
|
||||
Dictionary<string, Guid> bound,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||
@@ -8407,7 +8700,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
var id = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.CreateAsync(vaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
bound[path] = id;
|
||||
@@ -8482,6 +8775,12 @@ internal sealed partial class VaultViewModel(
|
||||
// destroys it, is not a pane anybody should have to read carefully.
|
||||
PendingDeletion = null;
|
||||
movingHostId = row.EntityId;
|
||||
|
||||
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
|
||||
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
|
||||
// whole of what makes the board's context menu entry work, since a right click on a card does not
|
||||
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
|
||||
IsHostPaneOpen = true;
|
||||
IsMovingHost = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
@@ -10967,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>
|
||||
@@ -11005,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}…";
|
||||
@@ -11240,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)
|
||||
@@ -11268,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>
|
||||
@@ -11337,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
|
||||
@@ -11349,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
|
||||
@@ -11381,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.
|
||||
@@ -11395,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>
|
||||
@@ -11613,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>
|
||||
|
||||
@@ -33,11 +33,15 @@ const SERVER_SESSION_ACTIVATED = 4;
|
||||
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;
|
||||
const CLIENT_RESIZE = 3;
|
||||
const CLIENT_FONT_SIZE_STEP = 4;
|
||||
const CLIENT_CLIPBOARD_COPY = 5;
|
||||
const CLIENT_CLIPBOARD_PASTE = 6;
|
||||
|
||||
const HEADER_LENGTH = 5;
|
||||
const SCROLLBACK_LINES = 5000;
|
||||
@@ -279,6 +283,66 @@ function handleKey(event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
|
||||
|
||||
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
|
||||
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
|
||||
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
|
||||
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
|
||||
works — which is correct behaviour for a web page and wrong for a terminal.
|
||||
|
||||
◆ THE CLIPBOARD IS THE HOST'S, IN BOTH DIRECTIONS, AND THE READING IS WHY.
|
||||
|
||||
navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an
|
||||
embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt
|
||||
on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and
|
||||
the host answers, exactly as the font-size chords do — see requestFontSizeStep. Writing would have
|
||||
worked from here on Windows, and does not go that way either: a clipboard that is the host's in one
|
||||
direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and
|
||||
Android's WebView.
|
||||
|
||||
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
|
||||
|
||||
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
|
||||
moves the helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it,
|
||||
selected. Both of those exist to serve the native menu; with no menu they are an invisible textarea
|
||||
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
|
||||
and stopping it there is what keeps the gesture wholly ours.
|
||||
|
||||
rightClickSelectsWord is off for the same reason from the other side — see createSession.
|
||||
*/
|
||||
function handleRightClick(event, sessionId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = session.term.getSelection();
|
||||
|
||||
if (selection.length > 0) {
|
||||
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
|
||||
|
||||
/*
|
||||
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
|
||||
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
|
||||
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
|
||||
about would turn every later right-click into a copy of it, and the paste would look broken.
|
||||
*/
|
||||
session.term.clearSelection();
|
||||
} else {
|
||||
send(CLIENT_CLIPBOARD_PASTE, sessionId);
|
||||
}
|
||||
|
||||
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
|
||||
// own focus() call gives.
|
||||
session.term.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pane for a session, or returns the one already there.
|
||||
*
|
||||
@@ -302,6 +366,19 @@ function createSession(sessionId) {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
fontSize,
|
||||
|
||||
/*
|
||||
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
|
||||
this to true there, so a right-click would select the word under the pointer. With the gesture
|
||||
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
|
||||
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
|
||||
|
||||
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
|
||||
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
|
||||
already is on the Firefox path.
|
||||
*/
|
||||
rightClickSelectsWord: false,
|
||||
|
||||
scrollback: SCROLLBACK_LINES,
|
||||
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
|
||||
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
|
||||
@@ -346,6 +423,10 @@ function createSession(sessionId) {
|
||||
|
||||
term.attachCustomKeyEventHandler(handleKey);
|
||||
|
||||
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
|
||||
// ahead of xterm's handler on the element inside it. See handleRightClick.
|
||||
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
|
||||
|
||||
term.onData((data) => {
|
||||
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||
});
|
||||
@@ -454,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);
|
||||
|
||||
@@ -562,6 +661,35 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_CLIPBOARD: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || payload.length < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
|
||||
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
|
||||
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
|
||||
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
|
||||
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
|
||||
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
|
||||
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
|
||||
|
||||
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
|
||||
sidebar's RUN does, and it is a thing they asked for by name.
|
||||
*/
|
||||
session.term.paste(new TextDecoder().decode(payload));
|
||||
|
||||
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
|
||||
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
|
||||
// on the pane, so the host's window never took Win32 focus away.
|
||||
session.term.focus();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_FONT_SIZE: {
|
||||
if (payload.length < 1) {
|
||||
break;
|
||||
@@ -808,4 +936,15 @@ document.addEventListener('visibilitychange', () => {
|
||||
window.addEventListener('focus', reconnectIfDown);
|
||||
window.addEventListener('online', reconnectIfDown);
|
||||
|
||||
/*
|
||||
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
|
||||
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
|
||||
reaches here.
|
||||
|
||||
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
|
||||
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
|
||||
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
|
||||
*/
|
||||
document.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||
|
||||
connect();
|
||||
+8
-2
@@ -24,5 +24,11 @@ These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.
|
||||
|
||||
1. Download the four files at the new versions and update the table above.
|
||||
2. Confirm the UMD global names are unchanged.
|
||||
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
|
||||
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
|
||||
3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it
|
||||
and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and
|
||||
an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click
|
||||
handling on `contextmenu`, which `handleRightClick` gets ahead of in the capture phase. A build that moved
|
||||
that to `mousedown`, as it already is on the Firefox path, would put xterm back in the gesture.
|
||||
4. Run the terminal suite, then start the app and open a session: `RendererClipboardTests` runs the page's
|
||||
own logic against a stubbed terminal, so what is left for a person is everything about the real one —
|
||||
`docs/manual-checks.md` 1.11 is that list.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||
|
||||
/// <summary>Raised when the page has text for the system clipboard.</summary>
|
||||
/// <remarks>
|
||||
/// A right-click over a selection. Raised on the socket's receive loop, like
|
||||
/// <see cref="FontSizeStepRequested"/> and with the same obligation on a handler that touches a view
|
||||
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
|
||||
/// be marshalled even where nothing is bound to.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
||||
|
||||
/// <summary>Raised when the page wants the system clipboard's text pasted into one of its panes.</summary>
|
||||
/// <remarks>
|
||||
/// A right-click with nothing selected. Raised on the socket's receive loop; see
|
||||
/// <see cref="ClipboardCopyRequested"/>. The answer goes back as
|
||||
/// <see cref="TerminalServerOpcode.Clipboard"/>, which is <see cref="TerminalWorkspace"/>'s to send.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a socket attaches — the first one, and every later takeover.
|
||||
/// </summary>
|
||||
@@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Answered before the session lookup, because it is the one frame that is not about a session. The
|
||||
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
|
||||
// ended is still a request to make the text bigger.
|
||||
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
|
||||
if (Announce(opcode, sessionId, payload))
|
||||
{
|
||||
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
||||
{
|
||||
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -518,6 +527,59 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the frames no pump needs to see, and says whether this was one of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
|
||||
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
|
||||
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
|
||||
/// rather than by writing to a pty, and <see cref="TerminalWorkspace.SendClipboardAsync"/> does its own
|
||||
/// liveness check — which is the one that can honestly refuse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
|
||||
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
|
||||
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
|
||||
/// the one place the gesture silently did nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
switch ((TerminalClientOpcode)opcode)
|
||||
{
|
||||
case TerminalClientOpcode.FontSizeStep:
|
||||
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
||||
{
|
||||
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case TerminalClientOpcode.ClipboardCopy:
|
||||
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
|
||||
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
|
||||
// was on it — and the page does not send one, which makes this a guard against a frame
|
||||
// arriving from something other than the page we shipped.
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
ClipboardCopyRequested?.Invoke(
|
||||
this,
|
||||
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
case TerminalClientOpcode.ClipboardPaste:
|
||||
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
|
||||
/// </summary>
|
||||
|
||||
@@ -95,6 +95,57 @@ public enum TerminalServerOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
FontSize = 7,
|
||||
|
||||
/// <summary>
|
||||
/// The system clipboard's text, for this session's pane to paste. Payload is UTF-8.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Sent in answer to <see cref="TerminalClientOpcode.ClipboardPaste"/>, which is the only thing that
|
||||
/// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because
|
||||
/// the page cannot — see that opcode for why not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not <see cref="Paste"/>, and the difference is the bracketing.</b> That opcode carries a snippet
|
||||
/// off the sidebar, and the page deliberately <em>types</em> a single-line one rather than bracketing
|
||||
/// it, because bash marks a bracketed paste as an active region and a snippet should read as though it
|
||||
/// had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it
|
||||
/// has been pasted into — so this one is always bracketed. One opcode for both would need a flag
|
||||
/// meaning "which of the two kinds of paste is this", which is the same distinction spelled less
|
||||
/// clearly, on a payload that already has a flag byte meaning something else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No flag byte, therefore, and no Enter: a clipboard paste inserts and stops. Pressing Enter for
|
||||
/// somebody is what the sidebar's RUN does, and it is a thing they asked for by name.
|
||||
/// </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>
|
||||
@@ -131,6 +182,52 @@ public enum TerminalClientOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
FontSizeStep = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Text to put on the system clipboard. Payload is UTF-8.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A right-click over a pane with something selected. What gets copied is the selection, and only the
|
||||
/// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it
|
||||
/// is the text that travels here rather than a request for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Through the host rather than through the page's own <c>navigator.clipboard</c>.</b> Writing the
|
||||
/// clipboard from the page would work on Windows, where the renderer is Blink and
|
||||
/// <c>clipboard-write</c> is granted to a focused document without asking. It is the <em>reading</em>
|
||||
/// that cannot be done that way — see <see cref="ClipboardPaste"/> — and a clipboard that is the host's
|
||||
/// in one direction and the page's in the other is two mechanisms to keep working across WebView2,
|
||||
/// WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its
|
||||
/// COPY buttons; this is that one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a
|
||||
/// property of one terminal — and it is carried because every frame carries one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
ClipboardCopy = 5,
|
||||
|
||||
/// <summary>
|
||||
/// The page asking for the clipboard's text, to paste into this session. No payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A right-click over a pane with nothing selected. The answer arrives as
|
||||
/// <see cref="TerminalServerOpcode.Clipboard"/>, addressed to the session id this frame carried, so the
|
||||
/// text lands in the pane the pointer was over rather than in whichever one the host believes is
|
||||
/// selected. They agree in every arrangement that exists today, because only the active pane is
|
||||
/// visible — but the page is the one that knows, so the page is the one that says.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A request rather than the page reading the clipboard itself, and this is the half with no
|
||||
/// alternative.</b> <c>navigator.clipboard.readText()</c> is gated behind the <c>clipboard-read</c>
|
||||
/// permission in every Chromium, and an embedded WebView answers a permission request it has no handler
|
||||
/// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the
|
||||
/// change this was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
ClipboardPaste = 6,
|
||||
}
|
||||
|
||||
/// <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,
|
||||
|
||||
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
|
||||
public int Step { get; } = step;
|
||||
}
|
||||
|
||||
/// <summary>The renderer handing over text for the system clipboard.</summary>
|
||||
/// <param name="text">
|
||||
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
|
||||
/// letting a stray gesture wipe the machine's clipboard.
|
||||
/// </param>
|
||||
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
|
||||
{
|
||||
/// <summary>What to put on the clipboard.</summary>
|
||||
public string Text { get; } = text;
|
||||
}
|
||||
|
||||
/// <summary>The renderer asking for the system clipboard's text.</summary>
|
||||
/// <param name="sessionId">
|
||||
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
|
||||
/// the host to infer it from whichever tab it believes is selected.
|
||||
/// </param>
|
||||
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
|
||||
{
|
||||
/// <summary>The terminal to paste into.</summary>
|
||||
public uint SessionId { get; } = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the loopback data plane and every live terminal session.
|
||||
/// </summary>
|
||||
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// what remembers it between launches.
|
||||
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
||||
|
||||
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
|
||||
// is reached is a property of the platform the shell is running on, and this object has never seen a
|
||||
// visual.
|
||||
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
|
||||
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
|
||||
|
||||
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
||||
// handshake handling, and has no business making that wait on however long a replay takes. See
|
||||
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
||||
@@ -181,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
|
||||
@@ -313,6 +342,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||
|
||||
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
|
||||
/// <remarks>
|
||||
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
|
||||
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
|
||||
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
|
||||
/// receive loop; marshal before touching one.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
||||
|
||||
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
|
||||
/// <remarks>
|
||||
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
|
||||
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
|
||||
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
|
||||
/// which is a UI-thread object on more than one platform.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
||||
/// </summary>
|
||||
@@ -359,6 +406,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
TerminalFrame.CreateFontSizePayload(pixels)),
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Hands the system clipboard's text to one pane, to be pasted into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
|
||||
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
|
||||
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
|
||||
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
|
||||
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
|
||||
/// that a caller which grows a message one day has something to hang it on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
|
||||
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
|
||||
/// to paste" is a fact about the machine rather than about this terminal.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">The terminal to paste into.</param>
|
||||
/// <param name="text">The clipboard's text. Sent verbatim.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
|
||||
public async Task<bool> SendClipboardAsync(
|
||||
uint sessionId,
|
||||
string text,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
if (!IsSessionLive(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dataPlane
|
||||
.SendAsync(
|
||||
TerminalFrame.Create(
|
||||
(byte)TerminalServerOpcode.Clipboard,
|
||||
sessionId,
|
||||
Encoding.UTF8.GetBytes(text)),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the renderer page has attached its socket.
|
||||
/// </summary>
|
||||
@@ -547,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>
|
||||
@@ -560,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);
|
||||
@@ -753,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">
|
||||
@@ -803,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)
|
||||
{
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user