Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa85ad1eff | ||
|
|
0cecf1afc4 | ||
|
|
089de72834 | ||
|
|
f43436218a | ||
|
|
3c8c0980c2 | ||
|
|
5b2e3f3651 | ||
|
|
988248fa57 | ||
|
|
902761d37b | ||
|
|
509937c4b8 | ||
|
|
5b32c76c95 | ||
|
|
6806e542f9 | ||
|
|
ad1a129159 | ||
|
|
e240c0cf26 | ||
|
|
96dd176121 | ||
|
|
367cebae9b | ||
|
|
c21de4ade4 | ||
|
|
858e36279d | ||
|
|
55e5de0ce4 | ||
|
|
8cb1457ca9 | ||
|
|
656a33b4b1 | ||
|
|
41f368ae75 | ||
|
|
24c18205c3 | ||
|
|
8759b12d47 | ||
|
|
7b072bcded | ||
|
|
b1d4a270d4 | ||
|
|
a2362d1e3e | ||
|
|
079a273d46 | ||
|
|
37e9d74938 | ||
|
|
170caa1391 | ||
|
|
965c9a7043 | ||
|
|
ff8a790f6c | ||
|
|
b2516ca7fb | ||
|
|
36fb0817a4 | ||
|
|
cc3d46f78c | ||
|
|
8bee476039 | ||
|
|
441709f1e9 | ||
|
|
873405e591 | ||
|
|
f997e87c5f | ||
|
|
365954c541 | ||
|
|
3ccafdd53b | ||
|
|
22b4d82ba8 | ||
|
|
619f49a35a | ||
|
|
a6d3924e0d | ||
|
|
079914bfb4 | ||
|
|
6c09bdecb6 | ||
|
|
372a7cbdc7 | ||
|
|
a4b81da597 | ||
|
|
0e8fe4fc80 | ||
|
|
2ff9dff560 | ||
|
|
6846f67c20 | ||
|
|
f332d9a56f | ||
|
|
03c8e8589a | ||
|
|
4c7f47c026 | ||
|
|
412c314aec | ||
|
|
b08f2c58e3 | ||
|
|
ee423b25ce | ||
|
|
38ab9b431b | ||
|
|
07bd5c9886 | ||
|
|
31aca26e89 | ||
|
|
843713b118 | ||
|
|
9c7be8ae9b | ||
|
|
3d0a66f997 | ||
|
|
689ba6c1bf | ||
|
|
6e39807cc0 | ||
|
|
d7d002c972 | ||
|
|
c3dfa42108 | ||
|
|
a6a21decbd | ||
|
|
f05deb4fbc | ||
|
|
c8848ec1e3 | ||
|
|
ab432e475e | ||
|
|
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 | ||
|
|
06369274dc | ||
|
|
cfc2e3ae0c | ||
|
|
e2733e5cbc | ||
|
|
c2c860b4f2 | ||
|
|
4552538533 | ||
|
|
1bf79c7c26 | ||
|
|
1970e20e56 | ||
|
|
b2f93a172b | ||
|
|
882f01c515 | ||
|
|
2f8daacb60 | ||
|
|
ef12e8cc99 | ||
|
|
963cb7f670 | ||
|
|
68964d8a34 | ||
|
|
6934efb1e6 | ||
|
|
e252d337d1 | ||
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
881562e81b | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 | ||
|
|
cec73010d3 | ||
|
|
53ff15ba86 | ||
|
|
766fe6aebe | ||
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 | ||
|
|
6fc1e3a7c5 | ||
|
|
8a77b7ca68 | ||
|
|
9755b5f6ae | ||
|
|
afc6a042f1 | ||
|
|
e41eca01a8 | ||
|
|
e96d01aab9 |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
"rollForward": false
|
||||
},
|
||||
"vpk": {
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.161",
|
||||
"commands": [
|
||||
"vpk"
|
||||
],
|
||||
|
||||
@@ -20,8 +20,14 @@
|
||||
*.sql text
|
||||
|
||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
||||
# containers, and Dockerfiles are read by a Linux daemon.
|
||||
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
|
||||
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
|
||||
# carriage return at the end of a line is an argument rather than nothing. A udev
|
||||
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
|
||||
# of the value it is reading.
|
||||
*.sh text eol=lf
|
||||
*.spec text eol=lf
|
||||
*.rules text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
|
||||
@@ -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
|
||||
@@ -462,6 +479,55 @@ jobs:
|
||||
|
||||
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||
|
||||
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
|
||||
#
|
||||
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
|
||||
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
|
||||
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
|
||||
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
|
||||
#
|
||||
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
|
||||
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
|
||||
# host running another is correct rather than lucky.
|
||||
#
|
||||
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
|
||||
#
|
||||
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
|
||||
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
|
||||
# package, updated by dnf rather than by the application — so the dependency metadata is
|
||||
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
|
||||
# rather than trusting an exit code. Every check in there is a way a package can be built
|
||||
# successfully and then refuse to install on every host in the world; one of them was written
|
||||
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
|
||||
#
|
||||
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
|
||||
# that what is proved on every main build is the packaging the release channel would use. The
|
||||
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
|
||||
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
|
||||
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
|
||||
- name: publish and package the linux desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
|
||||
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
|
||||
# nowhere.
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/linux-x64"
|
||||
|
||||
scripts/ci-linux-rpm.sh \
|
||||
--payload "$RUNNER_TEMP/linux-x64" \
|
||||
--version '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--channel release \
|
||||
--out "$RUNNER_TEMP/linux-rpm"
|
||||
|
||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||
@@ -507,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.
|
||||
@@ -866,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
|
||||
@@ -877,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.
|
||||
@@ -1048,6 +1121,45 @@ jobs:
|
||||
|
||||
ls -la "$RUNNER_TEMP/nightly-releases"
|
||||
|
||||
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
|
||||
#
|
||||
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
|
||||
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
|
||||
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
|
||||
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
|
||||
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
|
||||
# that at length.
|
||||
#
|
||||
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
|
||||
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
|
||||
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
|
||||
# repository, which is a deployment this project does not have.
|
||||
#
|
||||
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
|
||||
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
|
||||
# installed beside it share no cache, no outbox and no device key. The package name, the tree
|
||||
# under /opt and the desktop entry are the packaging half of that separation and are the
|
||||
# script's `--channel nightly` — see the note there for why it is four things and not one.
|
||||
- name: publish and package the linux nightly
|
||||
id: linuxpack
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
-p:DodoChannel=nightly \
|
||||
-p:MinVerVersionOverride="$VERSION" \
|
||||
--output "$RUNNER_TEMP/nightly-linux-x64"
|
||||
|
||||
scripts/ci-linux-rpm.sh \
|
||||
--payload "$RUNNER_TEMP/nightly-linux-x64" \
|
||||
--version "$VERSION" \
|
||||
--channel nightly \
|
||||
--out "$RUNNER_TEMP/nightly-rpm"
|
||||
|
||||
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
|
||||
#
|
||||
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
|
||||
@@ -1109,27 +1221,55 @@ jobs:
|
||||
fi
|
||||
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
||||
|
||||
# vpk rather than curl, unlike the android job above, and the difference is what is being
|
||||
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
|
||||
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
|
||||
# index by hand would be reimplementing the tool that is already here.
|
||||
# curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
|
||||
# generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
|
||||
# 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
|
||||
# NullReferenceException before uploading anything, leaving an empty draft behind on every push.
|
||||
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
|
||||
# beside the packages, and with no earlier release downloaded into that directory it is exactly
|
||||
# the index `vpk upload` would have generated from it.
|
||||
#
|
||||
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||
# A draft first, published only once everything is on it, which is the order vpk used too: the
|
||||
# client reads the index and then fetches what it names, and a published release missing a
|
||||
# package is a few seconds in which every desktop is told to download something that is not
|
||||
# there. The index goes up last for the same reason.
|
||||
#
|
||||
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
||||
# channel reads: a desktop nightly published as a stable release would become the newest release
|
||||
# in this repository and every phone on the release channel would start failing its update check
|
||||
# against a release carrying no android manifest.
|
||||
dotnet vpk upload gitea \
|
||||
--skip-updates \
|
||||
--repoUrl "$FORGE/$REPO" \
|
||||
--token "$TOKEN" \
|
||||
--outputDir "$RUNNER_TEMP/nightly-releases" \
|
||||
--channel win-nightly \
|
||||
--tag "$TAG" \
|
||||
--releaseName "Nightly desktop $VERSION" \
|
||||
--targetCommitish "$GITHUB_SHA" \
|
||||
--pre \
|
||||
--publish
|
||||
releases="$RUNNER_TEMP/nightly-releases"
|
||||
|
||||
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
|
||||
"$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
|
||||
"$api/releases")"
|
||||
|
||||
draft="$(printf '%s' "$created" | release_id)"
|
||||
if [ -z "$draft" ]; then
|
||||
echo "Gitea accepted the release call and returned no id:" >&2
|
||||
printf '%s\n' "$created" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
|
||||
"$releases"/releases.win-nightly.json; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "vpk pack did not leave $file behind:" >&2
|
||||
ls -la "$releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
name="$(basename "$file")"
|
||||
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||
curl -fsS -X POST -H "$auth" \
|
||||
-F "attachment=@$file" \
|
||||
"$api/releases/$draft/assets?name=$name" >/dev/null
|
||||
done
|
||||
|
||||
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d '{"draft":false}' \
|
||||
"$api/releases/$draft" >/dev/null
|
||||
|
||||
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
||||
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
||||
@@ -1138,23 +1278,91 @@ 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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Published nightly desktop $VERSION as $TAG"
|
||||
# ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||
#
|
||||
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
||||
# the same version, and one page a person looks at to find out what is on main. A second tag
|
||||
# would double the delete-and-recreate dance this step already does, and would let the two
|
||||
# drift apart on a run where one of them failed.
|
||||
#
|
||||
# It has to happen here, after the Windows upload, and not in a step before it. This step
|
||||
# deletes and recreates the release, so an RPM uploaded first would be attached to something
|
||||
# that no longer exists by the time the job ends — silently, because the delete succeeds.
|
||||
release="$(printf '%s' "$published" | release_id)"
|
||||
if [ -z "$release" ]; then
|
||||
echo "The release was published and its id could not be read back:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
|
||||
#
|
||||
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
|
||||
# phone reads android-nightly.json. A manifest published before its package is a window in
|
||||
# which every installed client is told to download something that is not there yet — and the
|
||||
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
|
||||
# failure. Same rule, same reason, and the android job's comment says it first.
|
||||
rpm='${{ steps.linuxpack.outputs.rpm }}'
|
||||
manifest='${{ steps.linuxpack.outputs.manifest }}'
|
||||
|
||||
linuxNames=""
|
||||
for file in "$rpm" "$manifest"; do
|
||||
name="$(basename "$file")"
|
||||
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||
curl -fsS -X POST -H "$auth" \
|
||||
-F "attachment=@$file" \
|
||||
"$api/releases/$release/assets?name=$name" >/dev/null
|
||||
linuxNames="$linuxNames $name"
|
||||
done
|
||||
|
||||
# Asked for back, exactly as the Velopack assets above are and for the same history: the
|
||||
# android job once created a release, uploaded nothing, and reported success for every
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $linuxNames; do
|
||||
# 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
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
|
||||
|
||||
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
|
||||
# inspects — it is the thing that gets deployed.
|
||||
needs: [build]
|
||||
runs-on: [linux]
|
||||
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
|
||||
# the repository - so the image is listed with the repository's packages rather than on a
|
||||
# registry of its own. Lowercase, because an image reference may not contain capitals and
|
||||
# the owner is DodoTech-Public.
|
||||
#
|
||||
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
|
||||
# package read and write, allowed to publish for that owner. Not the job's built-in
|
||||
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
|
||||
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
|
||||
# pushes the same way, with the same two secrets.
|
||||
env:
|
||||
REGISTRY: git.dodotech.cloud
|
||||
IMAGE: dodotech-public/dodossh-api
|
||||
steps:
|
||||
# Duplicated from the build job; see the comment there for why it cannot be factored
|
||||
# out. Any change here has to be made in all three.
|
||||
@@ -1280,9 +1488,6 @@ jobs:
|
||||
# changes, and where setup-buildx-action starts earning its place.
|
||||
- name: work out the tags
|
||||
id: tags
|
||||
env:
|
||||
REGISTRY: registry-docker.dodotech.cloud
|
||||
IMAGE: dodotech/dodossh-api
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo="$REGISTRY/$IMAGE"
|
||||
@@ -1407,7 +1612,7 @@ jobs:
|
||||
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
||||
|
||||
printf '%s' "$REGISTRY_PASSWORD" \
|
||||
| docker login registry-docker.dodotech.cloud \
|
||||
| docker login "$REGISTRY" \
|
||||
--username "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: push
|
||||
@@ -1425,7 +1630,113 @@ jobs:
|
||||
# does not leave it behind.
|
||||
- name: log out
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: docker logout registry-docker.dodotech.cloud
|
||||
run: docker logout "$REGISTRY"
|
||||
|
||||
# Rolls ssh.dodotech.cloud onto the image the job above just pushed, by calling the deploy hook of the
|
||||
# Spelunk stack that runs it. Without this nothing told the server a new image existed, so a commit with
|
||||
# a migration in it reached the registry and stopped there, and the schema never moved.
|
||||
#
|
||||
# The migration itself is applied by the new container as it starts — Database:AutoMigrate, see
|
||||
# Persistence.MigrateDodoDatabaseAsync — under an advisory lock, before its port opens. So triggering
|
||||
# the deploy is what applies it, and the wait below is what proves it: /healthz/ready is unhealthy for
|
||||
# as long as any migration the running image knows about is pending, and an image whose migration
|
||||
# failed does not start at all.
|
||||
#
|
||||
# main only, like the :main tag the stack follows. A failure here fails the run, unlike Spelunk's own
|
||||
# trigger: a server left behind its schema is the outage this job exists to prevent, and a green build
|
||||
# over one is the quiet version of it.
|
||||
#
|
||||
# SPELUNK_DEPLOY_TOKEN is a repository secret (Settings -> Actions -> Secrets), sent as a header so it
|
||||
# stays out of every proxy and runner log a URL would be written to.
|
||||
deploy:
|
||||
name: deploy ssh.dodotech.cloud
|
||||
needs: [image]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: [linux]
|
||||
timeout-minutes: 15
|
||||
# A newer push to main cancels this run like any other (see concurrency at the top). That is safe:
|
||||
# once the hook has answered, the rollout and its migration happen on the server, and the newer
|
||||
# run's own deploy job does the waiting instead.
|
||||
env:
|
||||
DEPLOY_HOOK: https://api.spelunk.dodotech.cloud/api/hooks/deploy
|
||||
SERVER: https://ssh.dodotech.cloud
|
||||
steps:
|
||||
# See the desktop job's step of the same name.
|
||||
- name: ensure curl
|
||||
run: |
|
||||
set -eu
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "Installing curl"
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache curl
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends curl
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y curl
|
||||
else
|
||||
echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
curl --version | head -1
|
||||
|
||||
- name: trigger the deploy
|
||||
env:
|
||||
SPELUNK_DEPLOY_TOKEN: ${{ secrets.SPELUNK_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${SPELUNK_DEPLOY_TOKEN:-}" ]; then
|
||||
echo "SPELUNK_DEPLOY_TOKEN is empty or unset, so the server was not told about this image." >&2
|
||||
echo "Add it under Settings -> Actions -> Secrets (not Variables)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
code="$(curl -sS -o "$RUNNER_TEMP/deploy-response" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $SPELUNK_DEPLOY_TOKEN" \
|
||||
"$DEPLOY_HOOK" || echo 000)"
|
||||
|
||||
case "$code" in
|
||||
200|202|204) echo "Deploy hook accepted (HTTP $code)." ;;
|
||||
401|404)
|
||||
echo "Deploy hook rejected the token (HTTP $code): revoked or regenerated, or the stack is gone." >&2
|
||||
exit 1 ;;
|
||||
409)
|
||||
echo "Deploy hook refused the token (HTTP 409): it is limited to a service the stack no longer has." >&2
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "Deploy hook answered HTTP $code:" >&2
|
||||
cat "$RUNNER_TEMP/deploy-response" >&2 || true
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
# Ten minutes, polled every ten seconds. A rollout restarts the container, so a few failed probes
|
||||
# in the middle are expected; what fails the job is never becoming ready again — a migration that
|
||||
# is still pending, one that crashed the start, or a stack holding the deploy for its schedule.
|
||||
- name: wait for the server to be ready
|
||||
run: |
|
||||
set -eu
|
||||
deadline=$(( $(date +%s) + 600 ))
|
||||
last=""
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
sleep 10
|
||||
last="$(curl -sS -m 10 -w ' (HTTP %{http_code})' "$SERVER/healthz/ready" 2>&1 || true)"
|
||||
case "$last" in
|
||||
"Healthy (HTTP 200)")
|
||||
echo "$SERVER is ready, with no migration pending."
|
||||
curl -sS -m 10 "$SERVER/api/v1/meta" || true
|
||||
echo
|
||||
exit 0 ;;
|
||||
esac
|
||||
echo "not ready yet: $last"
|
||||
done
|
||||
echo "$SERVER did not become ready within ten minutes. Last answer: $last" >&2
|
||||
echo "A pending migration is named in the container's log (PendingMigrations)." >&2
|
||||
exit 1
|
||||
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
|
||||
@@ -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/
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||
|
||||
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||
|
||||
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||
NU1004 before a single file is compiled.
|
||||
|
||||
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||
request went red together, on
|
||||
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
|
||||
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||
time anything ran.
|
||||
|
||||
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||
|
||||
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||
same ceremony any other dependency bump gets.
|
||||
|
||||
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||
Directory.Build.props is being evaluated.
|
||||
|
||||
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||
analysed is a real way to miss a trim warning.
|
||||
-->
|
||||
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||
<!--
|
||||
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||
unconditioned Update it would have to become rewrites every framework's entry.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||
ILLinkPackVersion="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -88,8 +88,12 @@
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<!--
|
||||
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||
-->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -98,8 +102,14 @@
|
||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||
|
||||
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||
recursive mode that trusts server-supplied names (everything up to and including
|
||||
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||
list no breaking changes over 2025.1.0.
|
||||
-->
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||
<!--
|
||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||
@@ -162,7 +172,29 @@
|
||||
constant rather than a setting: an operator who could answer the update check could pin a
|
||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||
-->
|
||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||
<PackageVersion Include="Velopack" Version="1.2.161" />
|
||||
|
||||
<!--
|
||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||
|
||||
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
|
||||
behind it, and macOS reaches the Secure Enclave through the Security framework that
|
||||
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
|
||||
library with a large surface — so this is the one head that needs a package to talk to its own
|
||||
hardware.
|
||||
|
||||
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
|
||||
managed all the way down, so there is no native dependency to add to the RPM and nothing to
|
||||
resolve at load time; and it works against a software TPM over a socket exactly as it does
|
||||
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
|
||||
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
|
||||
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
|
||||
|
||||
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
|
||||
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
|
||||
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Versioning">
|
||||
<!--
|
||||
@@ -211,5 +243,20 @@
|
||||
mistakes that cause real authorization holes.
|
||||
-->
|
||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||
<!--
|
||||
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||
project; nothing in src depends on it.
|
||||
|
||||
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||
same reason several times over.
|
||||
|
||||
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||
-->
|
||||
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -44,7 +44,7 @@ Three consequences worth knowing before you read further:
|
||||
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
|
||||
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
|
||||
|
||||
The desktop client's interface was built from a design covering more product than exists yet — file
|
||||
The desktop client's interface was built from a design covering more product than existed at the time — file
|
||||
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
|
||||
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
|
||||
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
|
||||
@@ -69,9 +69,10 @@ 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 integration
|
||||
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
|
||||
tests/ one test project per source project
|
||||
docs/adr/ architecture decision records
|
||||
@@ -81,7 +82,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
|
||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||
release-macos.sh — the same, signed and notarized, on a Mac
|
||||
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
|
||||
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||
build/linux/ the RPM spec the Linux package is built from
|
||||
```
|
||||
|
||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||
@@ -177,6 +180,74 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt
|
||||
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||
first never touches the second.
|
||||
|
||||
## Installing on Linux
|
||||
|
||||
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
|
||||
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
|
||||
|
||||
```bash
|
||||
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
|
||||
```
|
||||
|
||||
Everything above about where a client may come from applies unchanged. Four things differ, and the first
|
||||
two are the ones to read before installing.
|
||||
|
||||
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
|
||||
their own files; this one never does. It checks the project's forge every six hours, and when there is a
|
||||
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
|
||||
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
|
||||
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
|
||||
you have keeps running until you close it.
|
||||
|
||||
That split is the point rather than a compromise. Installing software is your package manager's job and it
|
||||
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
|
||||
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
|
||||
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
|
||||
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
|
||||
|
||||
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
|
||||
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
|
||||
|
||||
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
|
||||
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
|
||||
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
|
||||
useless. Two honest caveats, both in
|
||||
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
|
||||
|
||||
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
|
||||
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
|
||||
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
|
||||
is not the same and this page will not pretend it is.
|
||||
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
|
||||
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
|
||||
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
|
||||
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
|
||||
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
|
||||
|
||||
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
|
||||
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
|
||||
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
|
||||
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
|
||||
|
||||
```bash
|
||||
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||
```
|
||||
|
||||
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
|
||||
|
||||
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
|
||||
|
||||
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
|
||||
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
|
||||
the older 4.0 API line under a different library name and the package deliberately does not claim them —
|
||||
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
|
||||
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
|
||||
|
||||
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
|
||||
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
|
||||
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
|
||||
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||
not only that the runners are Linux.
|
||||
@@ -189,6 +260,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo
|
||||
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
|
||||
because it genuinely is a second installation.
|
||||
|
||||
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
|
||||
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
|
||||
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
|
||||
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
|
||||
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
|
||||
|
||||
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
|
||||
yet, because no person has walked a Linux build through
|
||||
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
|
||||
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
|
||||
package is built on every commit and uploaded nowhere.
|
||||
|
||||
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
||||
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
||||
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
||||
@@ -343,6 +426,90 @@ What is not here: transferring a directory, dragging between the panes, and rout
|
||||
bastion — the last needs jump hosts the connection layer has not got. All three are in
|
||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
||||
|
||||
### Forwarding ports
|
||||
|
||||
A host can carry **port forwards**, added under PORT FORWARDS in its editor, in the three kinds OpenSSH has:
|
||||
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
|
||||
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
|
||||
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
|
||||
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
|
||||
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
|
||||
sheet off the terminal's bar.
|
||||
|
||||
Three things about them are worth expecting rather than discovering.
|
||||
|
||||
**Everything listens on localhost, and there is no setting for it.** A forward is a field any member of a
|
||||
shared keychain can fill in, and one bound to every interface would hand the remote's network to anybody who
|
||||
can reach your machine for as long as the terminal is open. A remote forward asks the remote for its loopback
|
||||
too; whether the remote honours that is its own `GatewayPorts`.
|
||||
|
||||
**A forward lives as long as its terminal.** It runs over that tab's connection, so closing the tab, losing
|
||||
the connection or reconnecting stops it — a reconnect starts the host's forwards again on the new session. A
|
||||
second tab to the same host runs the same forwards, and they fail there because the ports are taken; the row
|
||||
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
|
||||
|
||||
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
|
||||
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
||||
[The Android head](#the-android-head).
|
||||
|
||||
### Reaching a server through Spelunk
|
||||
|
||||
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
|
||||
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
|
||||
Spelunk's API address, the server's id in Spelunk, and your Spelunk API key. A connection then asks Spelunk
|
||||
for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so
|
||||
the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk
|
||||
are one choice, and ticking one unticks the other.
|
||||
|
||||
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
|
||||
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
|
||||
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
|
||||
ever sent to that address**: a host that names the key with any other address is refused at connect. One
|
||||
key serves every server in that Spelunk organisation; saving a key for an address that already has one
|
||||
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
|
||||
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
|
||||
this machine.
|
||||
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
|
||||
its password goes to whatever address the host names.
|
||||
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
|
||||
is for every host, and the port is what the agent connects to on the server.
|
||||
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will
|
||||
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
||||
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
||||
|
||||
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
|
||||
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
|
||||
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
|
||||
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
||||
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
|
||||
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
|
||||
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
|
||||
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
|
||||
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
|
||||
so a DodoSSH older than this shows it read-only.
|
||||
|
||||
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
|
||||
link, or hands the link to the copy already running, and **connects straight away.** The link never
|
||||
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
||||
|
||||
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
|
||||
- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host
|
||||
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
|
||||
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
|
||||
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
|
||||
you saved yourself.
|
||||
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
|
||||
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
|
||||
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
|
||||
sent to an address you have not sent it to — saving the key under Security is how you say you have.
|
||||
|
||||
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
|
||||
its own. A link that arrives while the vault is locked waits for the unlock.
|
||||
|
||||
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
||||
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
||||
The phone reaches Spelunk hosts the same way but does not answer links yet.
|
||||
|
||||
### Sharing a vault
|
||||
|
||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||
@@ -558,8 +725,9 @@ its host if it has one and back into the box if it does not.
|
||||
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
|
||||
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
|
||||
sessions as pills, and a `+` on the right offering the three connections this application can make — a
|
||||
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
|
||||
that menu first if it is open.
|
||||
shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
|
||||
opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
|
||||
one is open.
|
||||
|
||||
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
||||
screen exactly the width it was designed against.
|
||||
@@ -627,10 +795,25 @@ What it does *not* yet do is use the extra width inside a screen: the host list
|
||||
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
|
||||
heading has to span — which is shared with the desktop and is its own piece of work.
|
||||
|
||||
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
|
||||
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
|
||||
every control on that screen would have had no effect. See
|
||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
||||
**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
|
||||
terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
|
||||
any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
|
||||
listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
|
||||
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
|
||||
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
|
||||
with a session, and both already have a place. What the phone has not got is an editor for them — its host
|
||||
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
|
||||
screen says so in a paragraph.
|
||||
|
||||
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
|
||||
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
|
||||
else needs to: the service is never started for a forward alone, because there is never a listening forward
|
||||
without a live shell. What forwards change is the notification, which counts them beside the shells —
|
||||
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
|
||||
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
|
||||
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
|
||||
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
|
||||
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
||||
|
||||
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
|
||||
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
|
||||
@@ -790,6 +973,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.
|
||||
@@ -920,10 +1110,31 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||
[Installing on macOS](#installing-on-macos).
|
||||
|
||||
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
|
||||
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
|
||||
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
|
||||
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
|
||||
installs. Splitting it that way keeps the part that can put software on your machine where it already
|
||||
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
|
||||
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
|
||||
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
|
||||
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
|
||||
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
|
||||
the one symlink that undoes it, is in the rule's own comments. See
|
||||
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
|
||||
[Installing on Linux](#installing-on-linux).
|
||||
|
||||
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||
everything above is verified only as far as the bundle.
|
||||
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
|
||||
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
|
||||
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
|
||||
package is verified as far as what it *says* about itself — CI builds it and interrogates its
|
||||
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
|
||||
that are code rather than packaging do have tests: the update channel's version comparison and digest
|
||||
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
|
||||
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
|
||||
which is what Phase 19 is for.
|
||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Android design audit — 2026-10-02
|
||||
|
||||
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
|
||||
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
|
||||
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
|
||||
connecting, host-key trust, terminal, and every Settings sub-page.
|
||||
|
||||
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
|
||||
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
|
||||
|
||||
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
|
||||
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
|
||||
so treat any performance note as "confirm on Release".
|
||||
|
||||
---
|
||||
|
||||
## What works well
|
||||
|
||||
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
|
||||
with *close* and *logs* available while it's in progress.
|
||||
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
|
||||
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
|
||||
both full width.
|
||||
- **Recovery-code screen**:
|
||||
- no back and no skip;
|
||||
- **CONTINUE** stays disabled until the tick;
|
||||
- a full-width **COPY CODE**;
|
||||
- capture is blocked.
|
||||
- **Terminal** ([14](android/14-terminal.png)):
|
||||
- resizes above the keyboard;
|
||||
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
|
||||
- the open-sessions strip and the tab strip are clear.
|
||||
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
|
||||
moment").
|
||||
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
|
||||
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
|
||||
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
|
||||
and the Vaults explanation.
|
||||
|
||||
---
|
||||
|
||||
## Critical / high
|
||||
|
||||
### H1 · Password fields expose their plaintext to accessibility services
|
||||
|
||||
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
|
||||
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
|
||||
attribute is the secret itself, in clear.
|
||||
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
|
||||
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
|
||||
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
|
||||
the passphrase aloud character by character.
|
||||
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
|
||||
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
|
||||
ignores it.
|
||||
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
|
||||
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
|
||||
the desktop's UIA peer for the same leak.
|
||||
|
||||
### H2 · The passphrase survives enrolment and pre-fills the unlock box
|
||||
|
||||
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
|
||||
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
|
||||
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
|
||||
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
|
||||
readable from the a11y tree on that screen. It also skips the one moment that proves the user
|
||||
remembers the passphrase.
|
||||
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
|
||||
cleared.
|
||||
|
||||
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
|
||||
|
||||
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
|
||||
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
|
||||
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
|
||||
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
|
||||
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
|
||||
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
|
||||
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
|
||||
key event). Both connected cleanly.
|
||||
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
|
||||
the Enter key-up is still in flight.
|
||||
- **Repro:** host row → type password → hardware Enter.
|
||||
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
|
||||
device with a keyboard.
|
||||
|
||||
### H4 · Screen-reader names are type names across the app
|
||||
|
||||
The a11y tree announces these:
|
||||
|
||||
| Control | Announced as |
|
||||
|---|---|
|
||||
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
|
||||
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
|
||||
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
|
||||
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
|
||||
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
|
||||
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
|
||||
| FAB / header add | `+` (no name) |
|
||||
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
|
||||
|
||||
There are two further problems:
|
||||
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
|
||||
screen-reader user can wander into a screen they can't see.
|
||||
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
|
||||
|
||||
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
|
||||
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
|
||||
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
|
||||
covers it.
|
||||
|
||||
### H5 · Copying the recovery code isn't marked sensitive
|
||||
|
||||
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
|
||||
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
|
||||
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
|
||||
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
|
||||
not observed, because the screen can't be captured.)
|
||||
|
||||
---
|
||||
|
||||
## Medium — visual system
|
||||
|
||||
### M1 · The accent glow is clipped to a square on every primary button and the FAB
|
||||
|
||||
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
|
||||
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
|
||||
FILES;
|
||||
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
|
||||
|
||||
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
|
||||
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
|
||||
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
|
||||
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
|
||||
|
||||
### M2 · Body copy is monospace, against the shared-typeface decision
|
||||
|
||||
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
|
||||
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
|
||||
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
|
||||
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
|
||||
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
|
||||
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
|
||||
[16](android/16-settings.png), [19](android/19-sftp.png)).
|
||||
|
||||
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
|
||||
`.body`, `.title` and `.detail` to the default sans.
|
||||
|
||||
### M3 · Fluent's selection fill leaks into the host list
|
||||
|
||||
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
|
||||
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
|
||||
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
|
||||
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
|
||||
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
|
||||
|
||||
### M4 · Disabled secondary buttons are heavier than enabled ones
|
||||
|
||||
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
|
||||
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
|
||||
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
|
||||
|
||||
### M5 · Unstyled Fluent controls among styled ones
|
||||
|
||||
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
|
||||
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
|
||||
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
|
||||
password, press-Enter; [10](android/10-connect-sheet.png)).
|
||||
- **Focused fields:** the fill switches from `Field` to pure `#000`.
|
||||
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
|
||||
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
|
||||
bars.
|
||||
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
|
||||
`:pointerover` styles need a touch-safe variant.
|
||||
|
||||
### M6 · The terminal's colour and font have drifted from the palette
|
||||
|
||||
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
|
||||
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
|
||||
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
|
||||
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
|
||||
monospace faces on one screen.
|
||||
|
||||
### M7 · Iconography
|
||||
|
||||
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
|
||||
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
|
||||
hairline `◳`; [16](android/16-settings.png)).
|
||||
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
|
||||
"Lock the keychain".
|
||||
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
|
||||
the gradient.
|
||||
|
||||
---
|
||||
|
||||
## Medium — information architecture and flows
|
||||
|
||||
### M8 · "Settings" holds the product
|
||||
|
||||
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
|
||||
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
|
||||
settings. Options:
|
||||
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
|
||||
- or promote SFTP to the bottom nav. Its icon is already ⇅.
|
||||
|
||||
### M9 · Keychain can't add anything
|
||||
|
||||
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
|
||||
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
|
||||
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
|
||||
desktop-only, the empty state should say so.
|
||||
|
||||
### M10 · Three ways to add, two ways to edit
|
||||
|
||||
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
|
||||
means "new session".
|
||||
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
|
||||
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
|
||||
([18](android/18-snippet-editor.png)).
|
||||
|
||||
Pick one pattern for each.
|
||||
|
||||
### M11 · SFTP
|
||||
|
||||
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
|
||||
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
|
||||
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
|
||||
writes "Choose something on the host to rename." into the status line at the bottom.
|
||||
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
|
||||
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
|
||||
- **Password again:** it's asked for again although a shell to the same host is already open.
|
||||
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
|
||||
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
|
||||
|
||||
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
|
||||
listing given the screen.
|
||||
|
||||
### M12 · Bottom sheets
|
||||
|
||||
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
|
||||
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
|
||||
[10](android/10-connect-sheet.png)).
|
||||
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
|
||||
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
|
||||
the confirming action on the right.
|
||||
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
|
||||
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
|
||||
([13 before trust](android/13-host-key.png)).
|
||||
|
||||
### M13 · Sign-in and enrolment
|
||||
|
||||
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
|
||||
user leaves the app. Chrome's own first-run prompts then land on top.
|
||||
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
|
||||
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
|
||||
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
|
||||
confirm your identity once more" on the passphrase screen.
|
||||
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
|
||||
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
|
||||
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
|
||||
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
|
||||
|
||||
### M14 · The global status line leaks across screens
|
||||
|
||||
A single `StatusMessage` follows the user around:
|
||||
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
|
||||
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
|
||||
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
|
||||
- "audit-box asks for a password." repeated under the sheet that asks for it.
|
||||
|
||||
Scope messages to the screen that raised them, or use transient snackbars.
|
||||
|
||||
---
|
||||
|
||||
## Low — input, copy, polish
|
||||
|
||||
- **Keyboards:**
|
||||
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
|
||||
([02](android/02-server-keyboard.png));
|
||||
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
|
||||
- hostname and username get autocorrect suggestions.
|
||||
|
||||
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
|
||||
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
|
||||
and the snippet fields have no labels, so each field's meaning disappears once typed in.
|
||||
- **Terminology:**
|
||||
- "CREATE THE VAULT", then "Creating your keychain";
|
||||
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
|
||||
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
|
||||
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
|
||||
([13](android/13-host-key.png)).
|
||||
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
|
||||
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
|
||||
- **Notification permission:** Android's prompt appears without explanation at the first connect.
|
||||
Explain the keep-alive notification first.
|
||||
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
|
||||
- **Empty and loading states:**
|
||||
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
|
||||
- Vaults first rendered an empty list with no loading indicator;
|
||||
- the keychain shows both "0 items" and an empty-state sentence.
|
||||
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
|
||||
([11](android/11-connecting.png)).
|
||||
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
|
||||
it belongs to.
|
||||
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
|
||||
([04](android/04-enrol-busy.png)), and the keyboard stays up.
|
||||
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
|
||||
as a radio button, not a switch.
|
||||
|
||||
## Not verified here
|
||||
|
||||
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
|
||||
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
|
||||
to confirm.
|
||||
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
|
||||
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
|
||||
|
||||
## Environment notes for a re-run
|
||||
|
||||
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
|
||||
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
|
||||
`adb reverse tcp:18180`.
|
||||
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
|
||||
shows OFFLINE.
|
||||
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
|
||||
user `dodo`).
|
||||
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
|
||||
`packages.lock.json`. Restore those files afterwards.
|
||||
@@ -0,0 +1,147 @@
|
||||
# DodoSSH full sweep — 2026-10-02
|
||||
|
||||
## Fix status (same day)
|
||||
|
||||
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
|
||||
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
|
||||
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
|
||||
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
|
||||
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
|
||||
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
|
||||
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
|
||||
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
|
||||
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
|
||||
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
|
||||
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
|
||||
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
|
||||
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
|
||||
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
|
||||
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
|
||||
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
|
||||
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
|
||||
|
||||
Second round, on branch `claude/audit-followups`:
|
||||
|
||||
| Item | Fix |
|
||||
|---|---|
|
||||
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
|
||||
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
|
||||
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
|
||||
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
|
||||
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
|
||||
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
|
||||
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
|
||||
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
|
||||
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
|
||||
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
|
||||
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
|
||||
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
|
||||
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
|
||||
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
|
||||
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
|
||||
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
|
||||
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
|
||||
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
|
||||
|
||||
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
|
||||
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
|
||||
|
||||
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
|
||||
|
||||
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
|
||||
|
||||
Not done, and why:
|
||||
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
|
||||
|
||||
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
|
||||
|
||||
How it was done:
|
||||
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
|
||||
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
|
||||
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
|
||||
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
|
||||
|
||||
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
|
||||
|
||||
---
|
||||
|
||||
## Critical — data loss, security
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
|
||||
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
|
||||
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
|
||||
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
|
||||
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
|
||||
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
|
||||
|
||||
## High — broken behaviour
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
|
||||
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
|
||||
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
|
||||
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
|
||||
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
|
||||
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
|
||||
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
|
||||
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
|
||||
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
|
||||
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
|
||||
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
|
||||
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
|
||||
|
||||
## UI issues seen in the running app
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
|
||||
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
|
||||
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
|
||||
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
|
||||
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
|
||||
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
|
||||
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
|
||||
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
|
||||
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
|
||||
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
|
||||
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
|
||||
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
|
||||
|
||||
## Performance
|
||||
|
||||
| # | Finding | Where |
|
||||
|---|---|---|
|
||||
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
|
||||
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
|
||||
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
|
||||
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
|
||||
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
|
||||
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
|
||||
|
||||
## Lower / plausible
|
||||
|
||||
- **Transfers:**
|
||||
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
|
||||
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
|
||||
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
|
||||
- **Sync and server:**
|
||||
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
|
||||
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
|
||||
- A team can be archived while a vault is being created in it.
|
||||
- Relay sessions survive removal from the team, for up to 12 hours.
|
||||
- **Shell:**
|
||||
- Lock can race an unlock that is still loading.
|
||||
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
|
||||
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
|
||||
|
||||
## Observations from the live run
|
||||
|
||||
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
|
||||
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,54 @@
|
||||
# Lets the person sitting at this machine use its TPM, without joining a group.
|
||||
#
|
||||
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
|
||||
#
|
||||
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
|
||||
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
|
||||
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
|
||||
# are untouched, so everything that worked before still works and an ACL is layered on top.
|
||||
#
|
||||
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
|
||||
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
|
||||
# session* and takes it away again when that session ends. So:
|
||||
#
|
||||
# - somebody logged in at the physical machine gets access;
|
||||
# - somebody logged in over SSH does not;
|
||||
# - a user switched away to another VT does not;
|
||||
# - a system daemon running as its own user does not.
|
||||
#
|
||||
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
|
||||
# and applies whether or not they are the one at the keyboard.
|
||||
#
|
||||
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
|
||||
#
|
||||
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
|
||||
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
|
||||
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
|
||||
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
|
||||
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
|
||||
# would not be, and this rule never names it.
|
||||
#
|
||||
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
|
||||
#
|
||||
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
|
||||
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
|
||||
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
|
||||
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
|
||||
#
|
||||
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
|
||||
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
|
||||
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
|
||||
# knowing rather than discovering. An administrator who does not accept it can mask this file:
|
||||
#
|
||||
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||
#
|
||||
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
|
||||
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
|
||||
#
|
||||
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
|
||||
# is what creates the device node's ownership in the first place. Anything outside that gap either
|
||||
# never runs or runs too late to matter.
|
||||
|
||||
ACTION=="remove", GOTO="dodossh_tpm_end"
|
||||
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
|
||||
LABEL="dodossh_tpm_end"
|
||||
@@ -0,0 +1,350 @@
|
||||
# ==================================================================================================
|
||||
# The Linux desktop head, as an RPM.
|
||||
#
|
||||
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
|
||||
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
|
||||
# fail on the Version line, which is the intended way round.
|
||||
#
|
||||
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
|
||||
#
|
||||
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
|
||||
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
|
||||
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
|
||||
#
|
||||
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
|
||||
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
|
||||
# a second one inside the application — answering to this project instead — would be building the
|
||||
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
|
||||
#
|
||||
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
|
||||
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
|
||||
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
|
||||
# say yes. See ADR 0013 rule 11.
|
||||
#
|
||||
# The consequence to know: discovery is worse here than on the other two desktops, because there is
|
||||
# no package repository to subscribe to and the in-app check is what stands in for one.
|
||||
# ==================================================================================================
|
||||
|
||||
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
|
||||
#
|
||||
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
|
||||
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
|
||||
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
|
||||
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
|
||||
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
|
||||
# is musl, which is a class of breakage with no good failure message.
|
||||
#
|
||||
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
|
||||
# third-party binaries nobody here will ever step through.
|
||||
#
|
||||
# _build_id_links is the one that surprises people, because turning the other two off does not turn
|
||||
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
|
||||
# For a bundled tree that means dozens of links in a system directory this package has no business
|
||||
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
|
||||
# *warning* about duplicates rather than an error, so it goes unnoticed.
|
||||
%global __os_install_post %{nil}
|
||||
%global debug_package %{nil}
|
||||
%global _build_id_links none
|
||||
|
||||
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
|
||||
#
|
||||
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
|
||||
# and the requires half of that is worth much more than a hand-written list: it produces
|
||||
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
|
||||
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
|
||||
# express that and would go stale the first time a dependency changed underneath it.
|
||||
#
|
||||
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
|
||||
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
|
||||
# as everything else in ci.yml.
|
||||
#
|
||||
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
|
||||
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
|
||||
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
|
||||
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
|
||||
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
|
||||
# outside this package is meant to link against anything inside it.
|
||||
#
|
||||
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
|
||||
#
|
||||
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
|
||||
# produces a requirement for a soname this package carries and — after the exclusion above — no
|
||||
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
|
||||
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
|
||||
# tried against produced a package `dnf install` refuses outright.
|
||||
#
|
||||
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
|
||||
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
|
||||
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
|
||||
# failure mode is the uninstallable package above.
|
||||
#
|
||||
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
|
||||
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
|
||||
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
|
||||
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
|
||||
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
|
||||
# it does on every machine that runs a .NET application without it. Requiring it instead would put
|
||||
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
|
||||
# an install failure on a supported distribution, in exchange for a tracing integration this
|
||||
# application does not use.
|
||||
#
|
||||
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
|
||||
# an exclusion that silently stopped matching would put the uninstallable package back with no
|
||||
# warning anywhere.
|
||||
%global __provides_exclude_from ^/opt/.*$
|
||||
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
|
||||
|
||||
Name: %{dodo_name}
|
||||
Version: %{dodo_version}
|
||||
Release: %{dodo_release}
|
||||
Summary: SSH client with an end-to-end encrypted credential vault
|
||||
License: MIT
|
||||
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
|
||||
BuildArch: x86_64
|
||||
|
||||
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
|
||||
#
|
||||
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
|
||||
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
|
||||
# cleanly on a host with none of it and fail at the first window.
|
||||
#
|
||||
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
|
||||
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
|
||||
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
|
||||
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
|
||||
#
|
||||
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
|
||||
#
|
||||
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
|
||||
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
|
||||
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
|
||||
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
|
||||
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
|
||||
#
|
||||
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
|
||||
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
|
||||
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
|
||||
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
|
||||
# a 4.0 host, this becomes a rich `or` and a line in the README.
|
||||
#
|
||||
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
|
||||
# which every desktop shipping this package has.
|
||||
#
|
||||
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
|
||||
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
|
||||
# a person who expects their own locale's conventions. A .NET process configured that way and given
|
||||
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
|
||||
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
|
||||
# bumps its soname on every release and pinning one would make this package expire.
|
||||
Requires: libwebkit2gtk-4.1.so.0()(64bit)
|
||||
Requires: libgtk-3.so.0()(64bit)
|
||||
Requires: libgdk-3.so.0()(64bit)
|
||||
Requires: libgio-2.0.so.0()(64bit)
|
||||
Requires: libglib-2.0.so.0()(64bit)
|
||||
Requires: libgobject-2.0.so.0()(64bit)
|
||||
Requires: libX11.so.6()(64bit)
|
||||
Requires: libicu
|
||||
|
||||
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
|
||||
# package, which is the right way round — every application drops icons into them. Depending on the
|
||||
# owner is how those directories come to exist at all on a minimal host, and how they get removed
|
||||
# again when the last thing using them goes.
|
||||
Requires: hicolor-icon-theme
|
||||
|
||||
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
|
||||
#
|
||||
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
|
||||
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
|
||||
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
|
||||
# deliberately built without it — a minimal or an immutable one — should still be able to install an
|
||||
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
|
||||
# service and the file it fetched, so the answer is one command the user was going to run anyway.
|
||||
#
|
||||
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
|
||||
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
|
||||
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
|
||||
Recommends: PackageKit
|
||||
|
||||
%description
|
||||
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
|
||||
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
|
||||
encrypted on the server, so the deployment holding a team's secrets can never read them.
|
||||
|
||||
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
|
||||
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
|
||||
replaces anything. The address it checks is fixed and is never a DodoSSH server.
|
||||
|
||||
%install
|
||||
rm -rf %{buildroot}
|
||||
|
||||
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
|
||||
#
|
||||
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
|
||||
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
|
||||
# managed assemblies and half a dozen native libraries that only make sense together and are only
|
||||
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
|
||||
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
|
||||
# above exists to keep this package out of.
|
||||
#
|
||||
# The directory name carries the channel, so a release and a nightly are two trees rather than one
|
||||
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
|
||||
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
|
||||
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
|
||||
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
|
||||
# installs share no state at all.
|
||||
mkdir -p %{buildroot}/opt/%{dodo_dir}
|
||||
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
|
||||
|
||||
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
|
||||
#
|
||||
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
|
||||
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
|
||||
# the consequence on the first tree this was tried against: every file in the package was mode 777,
|
||||
# because the tree had come off a mount that reports everything that way.
|
||||
#
|
||||
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
|
||||
# any local account replace the SSH client belonging to every other account on the machine — the
|
||||
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
|
||||
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
|
||||
# need to attack the crypto; it would edit the client.
|
||||
#
|
||||
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
|
||||
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
|
||||
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
|
||||
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
|
||||
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
|
||||
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
|
||||
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
|
||||
|
||||
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
|
||||
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
|
||||
# above this section has already refused a publish without one.
|
||||
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
|
||||
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
|
||||
true
|
||||
|
||||
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
|
||||
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
|
||||
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
|
||||
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
|
||||
# does not exist here, and would be one more file to keep in step with the layout.
|
||||
#
|
||||
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
|
||||
# on a running system and only one works inside a chroot or an image being assembled somewhere other
|
||||
# than /, which is where absolute symlinks out of a package are checked and warned about.
|
||||
mkdir -p %{buildroot}%{_bindir}
|
||||
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
||||
|
||||
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
|
||||
#
|
||||
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
|
||||
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
|
||||
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
|
||||
# cannot.
|
||||
#
|
||||
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
|
||||
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
|
||||
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||
# name deliberately and not the package's binary name — the window does not know which package
|
||||
# started it.
|
||||
#
|
||||
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
|
||||
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
|
||||
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
|
||||
# it, or passes it to the instance already running. update-desktop-database in the post-install
|
||||
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
|
||||
# installed answers, and with both installed the desktop's own default decides.
|
||||
mkdir -p %{buildroot}%{_datadir}/applications
|
||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=%{dodo_title}
|
||||
GenericName=SSH Client
|
||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||
Exec=%{dodo_bin} %%u
|
||||
Icon=%{dodo_bin}
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=DodoSSH
|
||||
Categories=Network;RemoteAccess;System;
|
||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||
MimeType=x-scheme-handler/dodossh;
|
||||
DESKTOP
|
||||
|
||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
|
||||
# comment argues for; adding one there means adding it here.
|
||||
for size in 48 64 128 256; do
|
||||
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
|
||||
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
|
||||
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
|
||||
done
|
||||
|
||||
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
|
||||
#
|
||||
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
|
||||
# the tss group by an administrator. The rule itself carries the whole argument, including what it
|
||||
# costs and the one-line way to mask it.
|
||||
#
|
||||
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
|
||||
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
|
||||
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
|
||||
# access, and giving each its own copy would mean removing one package silently revoked the other's.
|
||||
# rpm refuses to let two packages own one path with different content, which is what makes this
|
||||
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
|
||||
#
|
||||
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
|
||||
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
|
||||
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
|
||||
mkdir -p %{buildroot}/usr/lib/udev/rules.d
|
||||
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||
|
||||
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
|
||||
# resolves its argument against the build directory, which this spec does not have — there is no
|
||||
# %prep and no %build, because the payload arrives already published. Putting the file where it
|
||||
# belongs first and marking the installed path second is the form that works with neither.
|
||||
mkdir -p %{buildroot}%{_licensedir}/%{name}
|
||||
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
|
||||
|
||||
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
|
||||
# measured against they are redundant. They are here for the ones that do not, and every line ends in
|
||||
# `|| :` so that a host without the tool installed gets a package that installs rather than a
|
||||
# scriptlet failure at the end of a 130 MB transaction.
|
||||
%post
|
||||
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||
|
||||
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
|
||||
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
|
||||
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
|
||||
# rules for one device rather than for every device on the machine.
|
||||
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||
|
||||
%postun
|
||||
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||
|
||||
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
|
||||
# session's user holding access to the TPM until the next reboot.
|
||||
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||
|
||||
%files
|
||||
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
|
||||
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
|
||||
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
|
||||
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
|
||||
/opt/%{dodo_dir}
|
||||
%{_bindir}/%{dodo_bin}
|
||||
%{_datadir}/applications/%{dodo_bin}.desktop
|
||||
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
|
||||
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||
%license %{_licensedir}/%{name}/LICENSE
|
||||
|
||||
%changelog
|
||||
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
|
||||
# and a %changelog generated per build from a version number carries no information that `rpm -q`
|
||||
# does not already answer — while a hand-written one is a third place a release has to remember to
|
||||
# edit. rpmlint says W: no-changelog and that warning is accepted.
|
||||
@@ -119,6 +119,25 @@
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
|
||||
<!--
|
||||
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
|
||||
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
|
||||
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
|
||||
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
|
||||
for a Velopack install is on first launch from /Applications.
|
||||
-->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>dev.dodotech.dodossh.link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>dodossh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© DodoTech. MIT licensed.</string>
|
||||
</dict>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
|
||||
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
||||
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
|
||||
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
|
||||
query filter — guarded by an explicit permission check.
|
||||
query filter — guarded by an explicit permission check. **The collection has since been built**
|
||||
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
|
||||
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
|
||||
refused. The client then reads from the beginning, and when that read finishes it removes the
|
||||
items it held that the read never mentioned. An item with an unsent local edit is kept: the
|
||||
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
|
||||
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
|
||||
beginning would be refused on its own early pages, which sit behind the horizon whenever an
|
||||
older item survives.
|
||||
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
||||
Without it this ADR's central bug is invisible until production.
|
||||
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
||||
|
||||
@@ -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.
|
||||
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
|
||||
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
|
||||
envelope's minimum-length rule.
|
||||
|
||||
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
|
||||
|
||||
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
|
||||
The third desktop platform reached this file the way the first two did — by having to answer the amendment
|
||||
above — and it is the only one where the answer is not a clean yes.
|
||||
|
||||
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
|
||||
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
|
||||
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
|
||||
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
|
||||
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
|
||||
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
|
||||
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
|
||||
|
||||
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
|
||||
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
|
||||
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
|
||||
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
|
||||
file, and it is asserted by a test rather than left to a reading of the template, because removing the
|
||||
attribute would leave every other assertion in that file passing.
|
||||
|
||||
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
|
||||
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
|
||||
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
|
||||
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
|
||||
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
|
||||
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
|
||||
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
|
||||
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
|
||||
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
|
||||
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
|
||||
|
||||
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
|
||||
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
|
||||
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
|
||||
machine with no TPM does.
|
||||
|
||||
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
|
||||
system-wide decision an application package should not make. That refusal was overturned deliberately, and
|
||||
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
|
||||
one thing. It is two, and they are not close:
|
||||
|
||||
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
|
||||
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
|
||||
administrator and a fresh login before the feature appears at all.
|
||||
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
|
||||
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
|
||||
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
|
||||
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
|
||||
|
||||
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
|
||||
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
|
||||
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
|
||||
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
|
||||
it was designed for.
|
||||
|
||||
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
|
||||
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
|
||||
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
|
||||
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
|
||||
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
|
||||
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
|
||||
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
|
||||
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
|
||||
person who needs to know is reading the file rather than this document.
|
||||
|
||||
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
|
||||
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
|
||||
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
|
||||
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
|
||||
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
|
||||
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
|
||||
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
|
||||
already provides.
|
||||
|
||||
## Consequences
|
||||
|
||||
### The cache key had to move, and the spec changed
|
||||
@@ -138,9 +213,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
|
||||
### Operational
|
||||
|
||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||
is therefore required, not a nicety.
|
||||
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
|
||||
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
|
||||
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
|
||||
default rather than the exception, because the device node is not readable by an ordinary desktop user
|
||||
until somebody says it should be. See decision 4.
|
||||
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||
|
||||
@@ -316,6 +316,106 @@ preferences screen, which is the screen somebody reads when asked which nightly
|
||||
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
|
||||
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
|
||||
|
||||
### 11. Linux is a third desktop platform, and it is a package rather than an updater
|
||||
|
||||
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
|
||||
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
|
||||
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
|
||||
addition, and it is not an AppImage.
|
||||
|
||||
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
|
||||
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
|
||||
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
|
||||
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
|
||||
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
|
||||
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
|
||||
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
|
||||
to.
|
||||
|
||||
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
|
||||
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
|
||||
and *installing* it. The second is what the argument above is about, and the first is not — a client that
|
||||
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
|
||||
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
|
||||
|
||||
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
|
||||
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
|
||||
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
|
||||
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
|
||||
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
|
||||
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
|
||||
to think about:
|
||||
|
||||
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
|
||||
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
|
||||
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
|
||||
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
|
||||
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
|
||||
installed side is read out of the rpm database rather than derived from the assembly, so the translation
|
||||
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
|
||||
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
|
||||
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
|
||||
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
|
||||
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
|
||||
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
|
||||
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
|
||||
would fail every update at the last step. Clearing the flag routes the request to polkit's
|
||||
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
|
||||
user is told what they are agreeing to, which is the honest form of an unsigned package.
|
||||
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
|
||||
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
|
||||
consequences argue for on Windows, arrived at for free.
|
||||
|
||||
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
|
||||
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
|
||||
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
|
||||
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
|
||||
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
|
||||
|
||||
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
|
||||
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
|
||||
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
|
||||
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
|
||||
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
|
||||
packaging without putting an installer anybody has run in a place that invites passing it on.
|
||||
|
||||
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
|
||||
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
|
||||
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
|
||||
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
|
||||
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
|
||||
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
|
||||
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
|
||||
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
|
||||
plaintext credentials.
|
||||
|
||||
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
|
||||
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
|
||||
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
|
||||
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
|
||||
|
||||
**The device key store is settled separately and is not nothing.** The first version of this rule said this
|
||||
platform had none and that the passphrase was asked for at every unlock. It has one:
|
||||
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
|
||||
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
|
||||
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
|
||||
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
|
||||
whose cost it states.
|
||||
|
||||
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
|
||||
now": nothing on this platform
|
||||
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
|
||||
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
|
||||
address. An RPM repository would change that, because repository metadata is signed and a repository is a
|
||||
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
|
||||
and it is the reason building one is a decision and not a chore.
|
||||
|
||||
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
|
||||
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
|
||||
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
|
||||
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
|
||||
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||
@@ -369,6 +469,10 @@ ADR 0011 changes.
|
||||
genuinely the nicest onboarding available and will be proposed again.
|
||||
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||
inherit it.
|
||||
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
|
||||
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
|
||||
because this is the one desktop platform that already has an updater the user controls, and a second one
|
||||
answering to this project is the capability decision 4 exists to make unsettable.
|
||||
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
||||
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
||||
matters here: the tag is already the version of record for the container image, and two places to write
|
||||
|
||||
@@ -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
|
||||
@@ -625,7 +631,8 @@ go at 360dp:
|
||||
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
|
||||
because it is shared. What v2 asked for and did not get is in
|
||||
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
|
||||
named as absent rather than drawn, because nothing in the SSH layer forwards anything.
|
||||
named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with
|
||||
every terminal and lists them in a sheet off the terminal's bar. It cannot edit them yet.
|
||||
|
||||
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
|
||||
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
|
||||
@@ -633,6 +640,16 @@ go at 360dp:
|
||||
the membership test, the tab that lights and the back gesture's first case all read that one property.
|
||||
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
|
||||
deliberately differently, after the hub itself.
|
||||
|
||||
**And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first
|
||||
entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the
|
||||
keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8);
|
||||
`IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is
|
||||
about this phone and this account: an account card, security, vaults and the log, files, preferences.
|
||||
The strip of open shells above the bar went too — Connections carries the count in a badge instead. The
|
||||
keychain gained an add sheet and an editor page over the editors the shared view model already had, the
|
||||
files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+`
|
||||
sheet now opens SFTP on that terminal's own host.
|
||||
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
|
||||
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
|
||||
reflows a terminal while output is arriving.
|
||||
@@ -749,6 +766,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.
|
||||
|
||||
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
|
||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
||||
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
|
||||
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
|
||||
`17` SpelunkKey.
|
||||
|
||||
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
||||
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
||||
@@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
||||
|
||||
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
|
||||
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
|
||||
|
||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||
|
||||
### 4.4 What this prevents
|
||||
|
||||
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
>
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built, as a sheet off the terminal rather than a screen, and running on the phone as on the desktop.** A host's forwards start with every terminal opened to it on either head — `MainWindowViewModel.StartForwards` no longer skips Android. The terminal's bar draws a `⇄` for a tab that has forwards, with the listening count in green and the glyph in the danger colour when one failed, and it raises a sheet (`IsForwardsSheetOpen`) listing `SelectedTab.Forwards` with notation, route, state word, failure reason and a tap-to-toggle per row — the desktop sidebar's FORWARDS section over the same rows. Not a screen, for the desktop's reason: a forward belongs to a host and runs with a session. **Two gaps remain.** The phone's host form has no PORT FORWARDS section, so forwards are edited on the desktop and arrive with the host; the MORE screen's paragraph, now tagged IN THE TERMINAL, says so. And byte counts are built on neither head: SSH.NET keeps none. The keep-alive notification counts listening forwards beside the shells they run over, and is never started for a forward alone, since a forward cannot outlive its shell. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwarded anything at the time (forwards now have the `⇄` in the bar above; see the row before this one), so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
||||
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
|
||||
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
|
||||
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
|
||||
@@ -78,7 +78,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
>
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. |
|
||||
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | **Built, as three smaller things.** Forwards are rules on the host, edited in its drawer under PORT FORWARDS and shown read-only on its detail pane; they run per terminal, listed with a switch each under FORWARDS in the session sidebar; and the session status bar counts the listening ones as `⇄ N FORWARDS`. No screen of their own, because a forward is not a thing you visit — it belongs to a host and runs with a session, and both of those already have a place. |
|
||||
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. |
|
||||
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
|
||||
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
|
||||
@@ -171,7 +171,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
|
||||
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
||||
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
||||
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
||||
> | **Port Forwarding** in the sidebar | No rail entry; see the FORWARDING row above for where forwards are instead. |
|
||||
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
||||
>
|
||||
> ## The desktop's v5 — hosts, restyled, and two reversals
|
||||
@@ -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. |
|
||||
@@ -295,12 +295,12 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | v5b element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||
> | A **Port forward** button on the host header | **Add forward**, at the foot of the session sidebar's FORWARDS list, which opens the host's editor the way Pin folder does. The header it would have sat on has since moved into that sidebar. |
|
||||
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||
@@ -392,9 +392,10 @@ sharing that would rest on them — or a decision recorded here not to build the
|
||||
|
||||
Three facts explain nearly every row below.
|
||||
|
||||
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
|
||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
|
||||
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
|
||||
**An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
|
||||
and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
|
||||
what removes every `via bastion-eu` in the design. Port forwarding arrived later than most of what this
|
||||
document accounts for, and the rows about it now say where it went.
|
||||
|
||||
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
|
||||
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
|
||||
@@ -461,8 +462,10 @@ configuration, a server kind that refuses every plaintext field, a codec, a merg
|
||||
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
|
||||
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
|
||||
|
||||
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now
|
||||
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
|
||||
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
|
||||
`PortForward` are now reserved *against a decision* rather than pending one. Port forwards shipped inside the
|
||||
host's payload, for the reason `RelayTicketRequest.PortForwardId` already assumed and the one below gives for
|
||||
tags. Which hosts wear a tag is a set of ids inside each
|
||||
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
|
||||
without either losing, and the client's per-key three-way merge already gives it. A second item type would
|
||||
be a second table and a second pass through every rule in this file, for nothing.
|
||||
@@ -507,9 +510,9 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
||||
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
|
||||
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
|
||||
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
|
||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
|
||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
|
||||
@@ -533,7 +536,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
||||
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
||||
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
||||
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. |
|
||||
| `⇄ FORWARDS · 2` | client-ssh | — | **Shipped**, as `⇄ 2 FORWARDS` on the session status bar, counting only forwards actually listening. |
|
||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||
@@ -720,3 +723,48 @@ grid of cards with a drawer — see above. The split it describes did not change
|
||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||
|
||||
---
|
||||
|
||||
## v5c-4 — two more, asked for after living with v5b
|
||||
|
||||
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
||||
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
||||
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
||||
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
||||
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
||||
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
||||
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
||||
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
||||
subtracting the retired window-wide tab strip.
|
||||
|
||||
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
||||
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
||||
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
||||
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.
|
||||
@@ -388,6 +388,19 @@ agent of our own plus ProxyJump covers the real use cases.
|
||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||
|
||||
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
||||
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
||||
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
||||
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
||||
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
||||
is explicit that the cure is unproven.
|
||||
|
||||
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
||||
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
||||
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
||||
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
||||
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
||||
|
||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||
@@ -699,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
|
||||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||||
on being noticed later than every other one.
|
||||
|
||||
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||
|
||||
```
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
```
|
||||
|
||||
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||
|
||||
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||
the version in the error before believing a regeneration.
|
||||
|
||||
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||
repository has chosen the floating side deliberately.
|
||||
|
||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
|
||||
#
|
||||
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
|
||||
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
|
||||
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
|
||||
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
|
||||
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
|
||||
#
|
||||
# It is also runnable at a desk, which is the point of it being a script:
|
||||
#
|
||||
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
# --configuration Release --runtime linux-x64 --self-contained true \
|
||||
# -p:RestoreLockedMode=false --output /tmp/linux-x64
|
||||
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
||||
#
|
||||
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
|
||||
# ADR 0013 rule 3 is what decides which channel may do it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
payload=""
|
||||
version=""
|
||||
channel="release"
|
||||
out=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--payload) payload="$2"; shift 2 ;;
|
||||
--version) version="$2"; shift 2 ;;
|
||||
--channel) channel="$2"; shift 2 ;;
|
||||
--out) out="$2"; shift 2 ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for required in payload version out; do
|
||||
if [ -z "${!required}" ]; then
|
||||
echo "--$required is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
case "$channel" in
|
||||
release|nightly) ;;
|
||||
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
|
||||
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
|
||||
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
|
||||
if [ ! -s "$payload/DodoSSH" ]; then
|
||||
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
|
||||
ls -la "$payload" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
|
||||
#
|
||||
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
|
||||
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
|
||||
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
|
||||
# have two identical launchers and no way to tell which was which.
|
||||
#
|
||||
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
|
||||
# — is not here and must not be, because it belongs to the build rather than to the package: the
|
||||
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
|
||||
# Doing it here as well would be a second answer to a question that already has one.
|
||||
if [ "$channel" = nightly ]; then
|
||||
name=dodossh-nightly
|
||||
dir=DodoSSH.Nightly
|
||||
bin=dodossh-nightly
|
||||
title="DodoSSH Nightly"
|
||||
else
|
||||
name=dodossh
|
||||
dir=DodoSSH
|
||||
bin=dodossh
|
||||
title="DodoSSH"
|
||||
fi
|
||||
|
||||
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
|
||||
#
|
||||
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
|
||||
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
|
||||
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
|
||||
#
|
||||
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
|
||||
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
|
||||
# rising within a floored version and the first real tag moves past all of them, which is the same
|
||||
# ordering property the Velopack channels have and is worth having on both.
|
||||
#
|
||||
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
|
||||
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
|
||||
# rule for into the field ordering is done on.
|
||||
rpm_version="${version%%+*}"
|
||||
rpm_version="${rpm_version/-/\~}"
|
||||
|
||||
case "$rpm_version" in
|
||||
*-*)
|
||||
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
|
||||
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
|
||||
# only ever one packaging here — the version already carries the build height, because upstream and
|
||||
# packager are the same repository.
|
||||
rpm_release=1
|
||||
|
||||
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
|
||||
|
||||
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
|
||||
#
|
||||
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
|
||||
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
|
||||
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
|
||||
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
|
||||
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
|
||||
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
|
||||
# the android job for the hour that cost.
|
||||
if ! command -v rpmbuild >/dev/null 2>&1; then
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
|
||||
echo "==> installing rpm"
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
$SUDO apk add --no-cache rpm
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
$SUDO dnf install -y rpm-build
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y --no-install-recommends rpm
|
||||
else
|
||||
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
|
||||
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rpmbuild --version
|
||||
|
||||
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
|
||||
#
|
||||
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
|
||||
# libraries there are this application's private copies and have no business satisfying anybody
|
||||
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
|
||||
# library that links another bundled library asks for a soname nothing provides and the package
|
||||
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
|
||||
# does on every publish.
|
||||
#
|
||||
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
|
||||
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
|
||||
# going stale in the direction that produces a package nobody can install.
|
||||
#
|
||||
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
|
||||
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
|
||||
#
|
||||
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
|
||||
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
|
||||
# exclusion and put the uninstallable package back.
|
||||
bundled="$(
|
||||
for library in "$payload"/lib*.so*; do
|
||||
[ -f "$library" ] && basename "$library"
|
||||
done |
|
||||
sed 's/\./\\./g' |
|
||||
sort -u |
|
||||
paste -sd '|' -
|
||||
)"
|
||||
|
||||
if [ -z "$bundled" ]; then
|
||||
echo "There are no native libraries at the top of $payload." >&2
|
||||
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
|
||||
echo "rather than a package with nothing to exclude." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
mkdir -p "$out"
|
||||
|
||||
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
|
||||
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
|
||||
# an aarch64 runner would silently make them disagree.
|
||||
#
|
||||
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
|
||||
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
|
||||
# be both meaningless and, if rpm guessed something plausible, actively misleading.
|
||||
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
|
||||
--target x86_64 \
|
||||
--define "_topdir $work" \
|
||||
--define "_rpmdir $out" \
|
||||
--define "dist %{nil}" \
|
||||
--define "dodo_name $name" \
|
||||
--define "dodo_version $rpm_version" \
|
||||
--define "dodo_release $rpm_release" \
|
||||
--define "dodo_dir $dir" \
|
||||
--define "dodo_bin $bin" \
|
||||
--define "dodo_title $title" \
|
||||
--define "dodo_payload $payload" \
|
||||
--define "dodo_bundled $bundled" \
|
||||
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
|
||||
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
|
||||
--define "dodo_licence $repo_root/LICENSE"
|
||||
|
||||
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
|
||||
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
|
||||
if [ ! -s "$rpm_file" ]; then
|
||||
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
|
||||
find "$out" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
mv "$rpm_file" "$out/"
|
||||
rmdir "$out/x86_64" 2>/dev/null || true
|
||||
rpm_file="$out/$(basename "$rpm_file")"
|
||||
|
||||
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||
|
||||
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
|
||||
#
|
||||
# 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")"
|
||||
files="$(rpm -qp --list "$rpm_file")"
|
||||
|
||||
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
|
||||
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
|
||||
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
|
||||
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
|
||||
# the actual contents rather than assumed.
|
||||
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 -F "$soname(" >/dev/null; then
|
||||
unsatisfiable="$unsatisfiable $soname"
|
||||
fi
|
||||
done
|
||||
if [ -n "$unsatisfiable" ]; then
|
||||
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
|
||||
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
|
||||
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
|
||||
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
|
||||
exit 1
|
||||
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 -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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
|
||||
# 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 '^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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
|
||||
# entries and its absence would be caught by size alone; these are the small ones whose absence
|
||||
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
|
||||
for expected in \
|
||||
"/opt/$dir/DodoSSH" \
|
||||
"/usr/bin/$bin" \
|
||||
"/usr/share/applications/$bin.desktop" \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
do
|
||||
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" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
|
||||
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
|
||||
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
|
||||
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
|
||||
# other account on the machine runs.
|
||||
#
|
||||
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
|
||||
# link points at is what has permissions.
|
||||
writable="$(
|
||||
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
|
||||
grep -v '^l' |
|
||||
awk '$1 ~ /^.{8}w/ { print }'
|
||||
)"
|
||||
if [ -n "$writable" ]; then
|
||||
echo "This package contains world-writable files:" >&2
|
||||
printf '%s\n' "$writable" >&2
|
||||
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> requires:"
|
||||
printf '%s\n' "$requires" | sed 's/^/ /'
|
||||
|
||||
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
|
||||
|
||||
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
|
||||
#
|
||||
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
|
||||
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
|
||||
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
|
||||
#
|
||||
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
|
||||
#
|
||||
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
|
||||
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
|
||||
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
|
||||
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
|
||||
#
|
||||
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
|
||||
# should be able to answer with the string the preferences screen prints, and that screen reads the
|
||||
# assembly.
|
||||
#
|
||||
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
|
||||
#
|
||||
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
|
||||
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
|
||||
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
|
||||
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
|
||||
#
|
||||
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
|
||||
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
|
||||
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
|
||||
manifest="$out/linux-$channel.json"
|
||||
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
|
||||
|
||||
cat > "$manifest" <<JSON
|
||||
{
|
||||
"rpmVersion": "$rpm_version-$rpm_release",
|
||||
"version": "$version",
|
||||
"rpm": "$(basename "$rpm_file")",
|
||||
"sha256": "$digest"
|
||||
}
|
||||
JSON
|
||||
|
||||
echo "==> $(basename "$manifest"):"
|
||||
sed 's/^/ /' "$manifest"
|
||||
|
||||
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
|
||||
# desk use above still tells you where the file went.
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
echo "rpm=$rpm_file"
|
||||
echo "rpmName=$(basename "$rpm_file")"
|
||||
echo "manifest=$manifest"
|
||||
echo "manifestName=$(basename "$manifest")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "$rpm_file"
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Authorization;
|
||||
|
||||
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
|
||||
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
|
||||
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
|
||||
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
|
||||
/// that no endpoint can be reached without it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
|
||||
/// reached by this; it ends on its own idle and lifetime limits.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
|
||||
|
||||
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
|
||||
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
|
||||
: AuthorizationHandler<ActiveAccountRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ActiveAccountRequirement requirement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
|
||||
// as well would turn that into a 403.
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
|
||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (user.Status == UserStatus.Active)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
|
||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
||||
/// <para>
|
||||
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
||||
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
||||
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
||||
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
||||
/// against the version from before the update and failed, so enrolling more than an hour after first
|
||||
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
||||
return;
|
||||
}
|
||||
|
||||
await database.Users
|
||||
var written = await database.Users
|
||||
.Where(u => u.Id == user.Id
|
||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (written > 0)
|
||||
{
|
||||
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
||||
|
||||
var accountNotActive = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } refusal
|
||||
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
||||
|
||||
if (accountNotActive)
|
||||
{
|
||||
await TypedResults.Problem(
|
||||
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
||||
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["code"] = ProblemCodes.AccountNotActive,
|
||||
})
|
||||
.ExecuteAsync(context)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var enrollmentMissing = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } failure
|
||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
#
|
||||
# docker build -f src/DodoSSH.Api/Dockerfile .
|
||||
#
|
||||
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
|
||||
# the root and all four are load-bearing here — central package management means a csproj
|
||||
# alone does not name a single version, and a build that cannot see them resolves nothing.
|
||||
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
|
||||
# global.json all sit at the root and all five are load-bearing here — central package
|
||||
# management means a csproj alone does not name a single version, and a build that cannot see
|
||||
# them resolves nothing.
|
||||
#
|
||||
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
|
||||
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
|
||||
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
|
||||
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
|
||||
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
|
||||
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
|
||||
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Build
|
||||
@@ -26,7 +35,7 @@ WORKDIR /src
|
||||
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
||||
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
||||
# rather than skipping a node.
|
||||
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./
|
||||
COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
|
||||
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
|
||||
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
||||
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
||||
@@ -130,7 +139,8 @@ EXPOSE 8080
|
||||
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
|
||||
# than one node — DODOSSH_Sync__CursorSigningKey.
|
||||
#
|
||||
# Nothing migrates the database from in here. That is the API's own design: it fails
|
||||
# /healthz/ready while a migration is pending and names it, so the schema is applied by
|
||||
# `dotnet ef database update` alongside the deployment rather than by a racing container.
|
||||
# The process migrates the database itself as it starts, before its port opens and under an
|
||||
# advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
|
||||
# for ssh.dodotech.cloud: the CI deploy job relies on it. Turned off, /healthz/ready fails
|
||||
# while a migration is pending and names it, and something else has to apply the schema.
|
||||
ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]
|
||||
@@ -211,10 +211,23 @@ internal sealed class VaultEventsEndpoint(
|
||||
|
||||
/// <summary>Runs the socket until something ends it, then closes it politely.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the
|
||||
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/> and then <em>all</em>
|
||||
/// awaited before the close is written, because a close frame racing a notice frame is a protocol
|
||||
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
|
||||
/// finished before the close is written, because a close frame racing a notice frame is a protocol
|
||||
/// violation that presents as a client dropping its connection for no visible reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on
|
||||
/// the socket Kestrel hands over aborts it, and an aborted socket cannot send the close frame that
|
||||
/// is the only way a client learns <em>why</em> it was dropped — 4401 "reconnect with a fresh
|
||||
/// token" against 1001 "the server is going away" against a failure to back off from. This was
|
||||
/// once the case here, and it went unnoticed because <c>TestServer</c>'s in-memory socket survives
|
||||
/// the cancellation that a real one does not. So the token stops the queue 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. The relay's pump does the same, for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task PumpAsync(
|
||||
WebSocket socket,
|
||||
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
|
||||
VaultCount: connection.VaultCount));
|
||||
|
||||
var sending = SendAsync(socket, connection, pump.Token);
|
||||
var receiving = ReceiveAsync(socket, connection, pump.Token);
|
||||
var receiving = ReceiveAsync(socket, connection);
|
||||
var minding = MindAsync(connection, closure, pump.Token);
|
||||
|
||||
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
|
||||
|
||||
await pump.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
// Nothing may still be mid-send when the close frame goes out.
|
||||
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false);
|
||||
// Nothing may still be mid-send when the close frame goes out. A send that does not finish is
|
||||
// a client that has stopped reading, which will not read a close frame either.
|
||||
if (!await SettlesAsync(sending).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
}
|
||||
|
||||
await SettlesAsync(minding).ConfigureAwait(false);
|
||||
|
||||
if (lifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
|
||||
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
|
||||
|
||||
await CloseAsync(socket, closure).ConfigureAwait(false);
|
||||
|
||||
// The client's answering close is what ends the receiver. Returning before it arrives would
|
||||
// dispose the socket under a close frame still in flight.
|
||||
if (!await SettlesAsync(receiving).ConfigureAwait(false))
|
||||
{
|
||||
socket.Abort();
|
||||
await SettlesAsync(receiving).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
|
||||
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
|
||||
|
||||
// Not cancellable, deliberately. See PumpAsync.
|
||||
await socket
|
||||
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken)
|
||||
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
|
||||
/// Reads what the client sends, which in this version is heartbeats and a close.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
|
||||
/// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure
|
||||
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
|
||||
/// caller's access and nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no token: it ends on the client's close, a frame it refuses, or the socket failing —
|
||||
/// and when the server is the one ending the connection, the close it sends is what prompts the
|
||||
/// first of those. See <see cref="PumpAsync"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task ReceiveAsync(
|
||||
WebSocket socket,
|
||||
VaultEventConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
|
||||
{
|
||||
var buffer = new byte[MaxInboundFrameBytes];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (true)
|
||||
{
|
||||
var received = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
// Not cancellable, deliberately. See PumpAsync.
|
||||
var received = await socket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits a pump loop, treating its cancellation and its socket faults as the ordinary end.
|
||||
/// Waits for a pump loop to finish, for at most the close timeout.
|
||||
/// </summary>
|
||||
/// <returns>Whether it finished.</returns>
|
||||
/// <remarks>
|
||||
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception
|
||||
/// here is the expected shape of "this connection is over" rather than a fault to propagate — and
|
||||
/// propagating it would skip the close frame the other side is waiting for.
|
||||
/// </remarks>
|
||||
private static async Task Settled(Task loop)
|
||||
private static async Task<bool> SettlesAsync(Task loop)
|
||||
{
|
||||
try
|
||||
{
|
||||
await loop.ConfigureAwait(false);
|
||||
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
||||
{
|
||||
// Expected.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
|
||||
/// <remarks>
|
||||
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump
|
||||
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a
|
||||
/// memory barrier. Writes race only with each other, and any of them is a true answer.
|
||||
/// Mutable and shared, and safe without a lock for one specific reason: it is written only by
|
||||
/// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
|
||||
/// barrier. If that loop outlived the close timeout, a late write races the read, and either
|
||||
/// answer is still a true one.
|
||||
/// </remarks>
|
||||
private sealed class Closure(int code, string reason)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
|
||||
namespace DodoSSH.Api.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
|
||||
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
|
||||
/// touch any of the places a removal happens.
|
||||
/// </remarks>
|
||||
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void VaultAccessChanged(Guid userId)
|
||||
{
|
||||
hub.VaultAccessChanged(userId);
|
||||
_ = relay.AccessChangedAsync(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DodoSSH.Domain;
|
||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
||||
byte[] fingerprint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
return await database.ExecuteFreshAsync([user], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
|
||||
@@ -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,62 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
|
||||
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
|
||||
/// their keys and their vault list away at once, and left any shell they had open through the relay running
|
||||
/// into the team's machines until it went idle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Told by the same announcement that tells their event socket to re-read its vaults — see
|
||||
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
|
||||
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
|
||||
/// has already committed and answered, and a slow re-check must not hold it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayAccessWatch(
|
||||
RelaySessionRegistry sessions,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<RelayAccessWatch> logger)
|
||||
{
|
||||
/// <summary>Re-checks every session the user has open, in the background.</summary>
|
||||
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
|
||||
internal Task AccessChangedAsync(Guid userId)
|
||||
{
|
||||
var open = sessions.SessionsOf(userId);
|
||||
|
||||
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
|
||||
}
|
||||
|
||||
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
|
||||
|
||||
foreach (var lease in open)
|
||||
{
|
||||
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
|
||||
{
|
||||
lease.Revoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
RelayLog.AccessRecheckFailed(logger, userId, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,759 @@
|
||||
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, ticket.VaultId);
|
||||
|
||||
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, lease.Revoked, 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 revoked,
|
||||
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, revoked, 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 revoked,
|
||||
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, revoked, 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 revoked,
|
||||
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;
|
||||
}
|
||||
|
||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||
|
||||
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
|
||||
{
|
||||
return ending;
|
||||
}
|
||||
|
||||
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, revoked, lifetime.ApplicationStopping)
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
|
||||
/// <remarks>
|
||||
/// Revocation first, because it is the one reason here that is about the user rather than the session:
|
||||
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
|
||||
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
|
||||
/// </remarks>
|
||||
private static Ending? Due(
|
||||
DateTimeOffset now,
|
||||
bool revoked,
|
||||
DateTimeOffset hardDeadline,
|
||||
DateTimeOffset? drainDeadline,
|
||||
DateTimeOffset idleDeadline)
|
||||
{
|
||||
if (revoked)
|
||||
{
|
||||
return AccessRevoked;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return now >= idleDeadline
|
||||
? new Ending(
|
||||
RelayCloseReason.IdleTimeout,
|
||||
RelayProtocol.IdleTimeoutCloseCode,
|
||||
"This relay session carried nothing for the idle timeout.")
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Ending AccessRevoked { get; } = new(
|
||||
RelayCloseReason.AccessRevoked,
|
||||
RelayProtocol.AccessRevokedCloseCode,
|
||||
"You no longer have access to this host.");
|
||||
|
||||
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,104 @@
|
||||
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);
|
||||
|
||||
/// <remarks>
|
||||
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
|
||||
/// existed, and each still ends at its idle timeout or its lifetime.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2309,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
|
||||
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, 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,150 @@
|
||||
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 readonly HashSet<Lease> open = [];
|
||||
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>
|
||||
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
|
||||
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. open.Where(lease => lease.UserId == userId)];
|
||||
}
|
||||
}
|
||||
|
||||
internal Lease? TryAdmit(Guid userId, Guid vaultId)
|
||||
{
|
||||
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++;
|
||||
|
||||
var lease = new Lease(this, userId, vaultId);
|
||||
open.Add(lease);
|
||||
|
||||
return lease;
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(Lease lease, Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
open.Remove(lease);
|
||||
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, Guid vaultId) : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource revoked = new();
|
||||
|
||||
private int released;
|
||||
|
||||
/// <summary>Whose session this is.</summary>
|
||||
internal Guid UserId { get; } = userId;
|
||||
|
||||
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
|
||||
internal Guid VaultId { get; } = vaultId;
|
||||
|
||||
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
|
||||
internal CancellationToken Revoked => revoked.Token;
|
||||
|
||||
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
|
||||
internal void Revoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
revoked.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already over.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||
{
|
||||
registry.Release(this, UserId);
|
||||
revoked.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ internal static class ItemKinds
|
||||
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
||||
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
||||
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
||||
new SpelunkKeyKind(),
|
||||
}.ToDictionary(kind => kind.WireType);
|
||||
|
||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||
@@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
|
||||
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkKeyKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.SpelunkKeys
|
||||
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
|
||||
|
||||
database.SpelunkKeys.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <remarks>
|
||||
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
|
||||
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
|
||||
/// manage their servers from.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A Spelunk key is not something the server dials; its address stays encrypted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "A Spelunk key's contents are inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A Spelunk key carries no plaintext fields at all.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
@@ -27,4 +27,16 @@ internal static partial class SyncLog
|
||||
int appliedCount,
|
||||
int operationCount,
|
||||
Guid vaultId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2003,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
|
||||
internal static partial void Collected(ILogger logger, int count);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2004,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Tombstone collection failed; it will run again at the next interval.")]
|
||||
internal static partial void CollectionFailed(ILogger logger, Exception exception);
|
||||
}
|
||||
@@ -26,12 +26,59 @@ internal sealed class SyncService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var afterSequence = 0L;
|
||||
var issuedUnder = 0L;
|
||||
if (!string.IsNullOrEmpty(request.Cursor)
|
||||
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
|
||||
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
|
||||
{
|
||||
throw new InvalidCursorException();
|
||||
}
|
||||
|
||||
// Read before the changes, for the cursor this read hands out; see the check after it.
|
||||
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
|
||||
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
|
||||
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
|
||||
//
|
||||
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
|
||||
// a read from the beginning that started after the collection, and its early pages are behind the
|
||||
// horizon because old, untouched items are; refusing those would send that read back to the start
|
||||
// for ever. See SyncCursor.
|
||||
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
|
||||
{
|
||||
throw new InvalidCursorException();
|
||||
}
|
||||
|
||||
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// When nothing came back the cursor must not move, or a concurrent write landing between
|
||||
// this read and the next would be skipped forever.
|
||||
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
||||
|
||||
return new SyncPullResponse(
|
||||
Changes: hydrated,
|
||||
// The horizon from before the read, never the one after: a collection landing between the two
|
||||
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
|
||||
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
|
||||
// a restart.
|
||||
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
|
||||
HasMore: hasMore,
|
||||
ServerTime: clock.GetUtcNow(),
|
||||
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
||||
}
|
||||
|
||||
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
|
||||
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
|
||||
Guid vaultId,
|
||||
long afterSequence,
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var limit = Math.Clamp(
|
||||
request.Limit ?? options.DefaultPullLimit,
|
||||
1,
|
||||
@@ -43,7 +90,7 @@ internal sealed class SyncService(
|
||||
|
||||
// One extra row, so "is there more" needs no second query.
|
||||
var query = database.VaultChanges
|
||||
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
|
||||
.Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
|
||||
|
||||
if (types is not null)
|
||||
{
|
||||
@@ -62,18 +109,7 @@ internal sealed class SyncService(
|
||||
changes.RemoveAt(changes.Count - 1);
|
||||
}
|
||||
|
||||
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// When nothing came back the cursor must not move, or a concurrent write landing between
|
||||
// this read and the next would be skipped forever.
|
||||
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
||||
|
||||
return new SyncPullResponse(
|
||||
Changes: hydrated,
|
||||
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
|
||||
HasMore: hasMore,
|
||||
ServerTime: clock.GetUtcNow(),
|
||||
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
||||
return (changes, hasMore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,9 +136,7 @@ internal sealed class SyncService(
|
||||
{
|
||||
ValidateBatchLimits(request);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
return await database.ExecuteFreshAsync([vault], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
@@ -536,9 +570,21 @@ internal sealed class SyncService(
|
||||
|
||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
|
||||
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
|
||||
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
|
||||
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
|
||||
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
|
||||
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
|
||||
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
|
||||
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||
Vault vault,
|
||||
@@ -571,10 +617,13 @@ internal sealed class SyncService(
|
||||
}
|
||||
}
|
||||
|
||||
return [.. changes.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
return [.. changes
|
||||
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||
|| row.ChangeSequence <= change.Sequence)
|
||||
.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
}
|
||||
|
||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||
@@ -609,14 +658,30 @@ internal sealed class SyncService(
|
||||
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
||||
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||
|
||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||
await database.VaultChanges
|
||||
/// <remarks>
|
||||
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
|
||||
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
|
||||
/// cost a full resync for nothing.
|
||||
/// </remarks>
|
||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var head = await database.VaultChanges
|
||||
.Where(c => c.VaultId == vaultId)
|
||||
.OrderByDescending(c => c.Sequence)
|
||||
.Select(c => c.Sequence)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
|
||||
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||
database.Vaults
|
||||
.Where(v => v.Id == vaultId)
|
||||
.Select(v => v.SyncHorizon)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
||||
new(
|
||||
operation.OperationId,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DodoSSH.Api.Features.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
|
||||
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
|
||||
/// edit ever made to read the items that exist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
|
||||
/// before both an item's old entry and its new one reads the new one, and a client between them already has
|
||||
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
|
||||
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
|
||||
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
|
||||
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
|
||||
/// has; see the client's <c>SyncEngine</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
|
||||
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TombstoneCollector(
|
||||
DodoDbContext database,
|
||||
IOptions<SyncOptions> syncOptions,
|
||||
TimeProvider clock,
|
||||
ILogger<TombstoneCollector> logger)
|
||||
{
|
||||
/// <summary>Collects what is due, once.</summary>
|
||||
/// <returns>How many tombstones and superseded entries went.</returns>
|
||||
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
|
||||
|
||||
var collected = 0;
|
||||
|
||||
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
|
||||
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
SyncLog.Collected(logger, collected);
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
private async Task<int> CollectAsync<T>(
|
||||
Func<DodoDbContext, DbSet<T>> table,
|
||||
ChangeEntityType type,
|
||||
DateTimeOffset before,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class, IVaultItem
|
||||
{
|
||||
var due = await table(database)
|
||||
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
|
||||
.Select(item => new { item.VaultId, item.Id })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var collected = 0;
|
||||
|
||||
foreach (var vault in due.GroupBy(item => item.VaultId))
|
||||
{
|
||||
var ids = vault.Select(item => item.Id).ToArray();
|
||||
|
||||
collected += await database.ExecuteFreshAsync(
|
||||
[],
|
||||
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
private async Task<int> CollectFromVaultAsync<T>(
|
||||
Func<DodoDbContext, DbSet<T>> table,
|
||||
ChangeEntityType type,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
DateTimeOffset before,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class, IVaultItem
|
||||
{
|
||||
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
await database.Database
|
||||
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read again under the lock: an item restored since it was found is no longer due.
|
||||
var still = await table(database)
|
||||
.Where(item => item.VaultId == vaultId
|
||||
&& ids.Contains(item.Id)
|
||||
&& item.DeletedAtUtc != null
|
||||
&& item.DeletedAtUtc < before)
|
||||
.Select(item => new { item.Id, item.ChangeSequence })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (still.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var going = still.Select(item => item.Id).ToArray();
|
||||
var horizon = still.Max(item => item.ChangeSequence);
|
||||
|
||||
await table(database)
|
||||
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.VaultChanges
|
||||
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.Vaults
|
||||
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
|
||||
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return still.Count;
|
||||
}
|
||||
|
||||
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
|
||||
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
|
||||
database.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
|
||||
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
|
||||
+ "WHERE newer.vault_id = older.vault_id "
|
||||
+ "AND newer.entity_type = older.entity_type "
|
||||
+ "AND newer.entity_id = older.entity_id "
|
||||
+ "AND newer.sequence > older.sequence",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
|
||||
internal sealed class TombstoneCollectionService(
|
||||
IServiceScopeFactory scopes,
|
||||
IOptions<SyncOptions> syncOptions,
|
||||
TimeProvider clock,
|
||||
ILogger<TombstoneCollectionService> logger) : BackgroundService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
|
||||
.RunOnceAsync(stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The next run tries again; nothing collected late is harmed by waiting.
|
||||
SyncLog.CollectionFailed(logger, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
|
||||
}
|
||||
|
||||
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
||||
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
|
||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
||||
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||
// checks it again, because deleting a team's last vault archives it too.
|
||||
if (!access.IsOwner)
|
||||
{
|
||||
return Problems.Coded(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TeamLock
|
||||
{
|
||||
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||
database.Database.ExecuteSqlAsync(
|
||||
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -244,28 +245,37 @@ internal sealed class TeamService(
|
||||
Team team,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(team);
|
||||
|
||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (vaultCount > 0)
|
||||
// Both callers check this first and answer the caller properly; this is what stops a third one that
|
||||
// forgets from letting an admin archive the team out from under its owner.
|
||||
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
throw new TeamNotEmptyException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var archived = await strategy.ExecuteAsync(async () =>
|
||||
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
|
||||
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (vaultCount > 0)
|
||||
{
|
||||
throw new TeamNotEmptyException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||
}
|
||||
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var revoked = await strategy.ExecuteAsync(async () =>
|
||||
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
|
||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
||||
{
|
||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
||||
|
||||
if (orphaned is not null)
|
||||
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
|
||||
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
|
||||
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
|
||||
// the owner, who can archive it or make a vault in it.
|
||||
if (orphaned is not null
|
||||
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||
// the same id, which must not surface as a 500 about a constraint.
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||
|
||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
||||
RekeyRequired: false);
|
||||
}
|
||||
|
||||
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||
private Task WriteVaultAsync(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
string name,
|
||||
UserKey key,
|
||||
CancellationToken cancellationToken) =>
|
||||
database.ExecuteFreshAsync([team], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||
// the same id, which must not surface as a 500 about a constraint.
|
||||
throw new TeamInvalidException(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||
{
|
||||
var live = await database.Teams
|
||||
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!live)
|
||||
{
|
||||
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||
private void AddVaultWithSelfGrant(
|
||||
UserAccount user,
|
||||
|
||||
@@ -41,17 +41,24 @@ builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
||||
// a reference to connection management to get them.
|
||||
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||
builder.Services.AddSingleton<VaultEventHub>();
|
||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||
provider => provider.GetRequiredService<VaultEventHub>());
|
||||
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||
|
||||
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
|
||||
builder.Services.AddScoped<TombstoneCollector>();
|
||||
builder.Services.AddHostedService<TombstoneCollectionService>();
|
||||
|
||||
// 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>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||
|
||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||
|
||||
@@ -62,7 +62,14 @@ internal static class Auth
|
||||
|
||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||
{
|
||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
||||
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
||||
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
||||
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
||||
options.AddPolicy(
|
||||
AuthenticatedPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
||||
|
||||
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||
@@ -72,7 +79,7 @@ internal static class Auth
|
||||
EnrolledPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.EnrolledRequirement()));
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
||||
|
||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||
|
||||
@@ -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;
|
||||
@@ -252,6 +289,10 @@ public sealed class SyncOptions
|
||||
[Range(1, 3650)]
|
||||
public int TombstoneRetentionDays { get; set; } = 90;
|
||||
|
||||
/// <summary>How often the tombstone collector runs, in minutes.</summary>
|
||||
[Range(1, 7 * 24 * 60)]
|
||||
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
|
||||
|
||||
/// <summary>
|
||||
/// Key used to sign sync cursors, base64.
|
||||
/// </summary>
|
||||
|
||||
@@ -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,72 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
|
||||
/// rather than from what the failed attempt left in the change tracker.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
|
||||
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
|
||||
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
|
||||
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
|
||||
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
|
||||
/// its two role changes, each reporting success.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
|
||||
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
|
||||
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
|
||||
/// before: nothing is cleared or reloaded until something has failed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FreshAttempts
|
||||
{
|
||||
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
|
||||
/// <param name="database">The request's context.</param>
|
||||
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
|
||||
/// <param name="attempt">The transaction, which begins and commits its own.</param>
|
||||
internal static Task<T> ExecuteFreshAsync<T>(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task<T>> attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(carried);
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
var attempts = 0;
|
||||
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
if (attempts++ > 0)
|
||||
{
|
||||
database.ChangeTracker.Clear();
|
||||
|
||||
foreach (var entity in carried)
|
||||
{
|
||||
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return await attempt().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
|
||||
internal static Task ExecuteFreshAsync(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task> attempt) =>
|
||||
database.ExecuteFreshAsync(
|
||||
carried,
|
||||
async () =>
|
||||
{
|
||||
await attempt().ConfigureAwait(false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
||||
?? throw new InvalidOperationException(
|
||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||
|
||||
services.AddDbContext<DodoDbContext>(options => options
|
||||
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||
.UseNpgsql(connectionString, npgsql => npgsql
|
||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||
// Retry on transient faults only. Note this is safe here because writes go
|
||||
// through explicitly-managed transactions rather than relying on the execution
|
||||
// strategy to replay ambient ones.
|
||||
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||
// the same context, which still holds what the failed attempt tracked — every such
|
||||
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||
// the database instead.
|
||||
.EnableRetryOnFailure(3))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
.UseSnakeCaseNamingConvention()
|
||||
// None in production. The seam a test uses to make a commit fail the way a dropped
|
||||
// connection would, which is the only way to watch a retry happen.
|
||||
.AddInterceptors(provider.GetServices<IInterceptor>()));
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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<RelayAccessWatch>();
|
||||
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;
|
||||
|
||||
@@ -126,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
var keepAlive = new SessionKeepAlive(
|
||||
workspace,
|
||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
||||
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
|
||||
listeningForwards: () => viewModel.ListeningForwardCount);
|
||||
|
||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||
@@ -142,6 +149,11 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||
|
||||
// A forward starting, stopping or failing changes the notification's wording and nothing else — it
|
||||
// rides a shell's connection, so the shell is already what holds the service up. The user is still
|
||||
// entitled to know a phone in their pocket is holding ports open.
|
||||
viewModel.ForwardsChanged += (_, _) => keepAlive.Refresh();
|
||||
|
||||
keepAlive.Refresh();
|
||||
}
|
||||
|
||||
@@ -176,6 +188,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 +218,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 +269,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,375 @@
|
||||
using global::Android.OS;
|
||||
using global::Android.Views;
|
||||
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Shell.Keyboard;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
|
||||
/// and turns a long press and drag into arrow keys.
|
||||
/// </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>
|
||||
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
|
||||
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
|
||||
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
|
||||
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
|
||||
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
|
||||
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
|
||||
/// can be drawn over the terminal to say it instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
|
||||
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
|
||||
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
|
||||
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
|
||||
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
|
||||
/// did before.
|
||||
/// </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>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
|
||||
private const double CursorGestureDelay = 0.8;
|
||||
|
||||
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
|
||||
private const long TickMs = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
|
||||
/// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
|
||||
/// </summary>
|
||||
/// <param name="onTapped">A tap: bring the keyboard back.</param>
|
||||
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
|
||||
/// <param name="onArrows">How many arrows to send, and which.</param>
|
||||
/// <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, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
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();
|
||||
|
||||
WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
|
||||
|
||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, 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. The
|
||||
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
|
||||
/// </remarks>
|
||||
private static void WatchTouches(
|
||||
global::Android.Webkit.WebView webView,
|
||||
Action onTapped,
|
||||
Func<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
var touch = new TouchState(webView, gesturesEnabled, onArrows);
|
||||
|
||||
webView.Touch += (_, e) =>
|
||||
{
|
||||
if (e.Event is not { } motion)
|
||||
{
|
||||
e.Handled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Observed and passed on, except while the cursor gesture holds the finger: then the page must not
|
||||
// see it scroll.
|
||||
e.Handled = touch.Handle(motion, onTapped);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A class rather than locals captured by the handler, because two timers — the long press and the
|
||||
/// repeat tick — read and write the same state from outside the touch callback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
|
||||
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
|
||||
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
|
||||
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class TouchState
|
||||
{
|
||||
private readonly global::Android.Webkit.WebView webView;
|
||||
private readonly Func<bool> gesturesEnabled;
|
||||
private readonly Action<ArrowDirection, int> onArrows;
|
||||
private readonly ArrowGesture gesture = new();
|
||||
private readonly int slop;
|
||||
private readonly float density;
|
||||
private readonly DispatcherTimer longPress;
|
||||
private readonly DispatcherTimer tick;
|
||||
|
||||
private float downX;
|
||||
private float downY;
|
||||
private float lastX;
|
||||
private float lastY;
|
||||
private long downTime;
|
||||
private bool moved;
|
||||
private bool steering;
|
||||
private int lastSpeed;
|
||||
|
||||
internal TouchState(
|
||||
global::Android.Webkit.WebView webView,
|
||||
Func<bool> gesturesEnabled,
|
||||
Action<ArrowDirection, int> onArrows)
|
||||
{
|
||||
this.webView = webView;
|
||||
this.gesturesEnabled = gesturesEnabled;
|
||||
this.onArrows = onArrows;
|
||||
|
||||
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
|
||||
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
|
||||
|
||||
longPress = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
|
||||
};
|
||||
longPress.Tick += (_, _) => BeginSteering();
|
||||
|
||||
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
|
||||
tick.Tick += (_, _) => Tick();
|
||||
}
|
||||
|
||||
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
|
||||
internal bool Handle(MotionEvent motion, Action onTapped)
|
||||
{
|
||||
switch (motion.ActionMasked)
|
||||
{
|
||||
case MotionEventActions.Down:
|
||||
Press(motion);
|
||||
return false;
|
||||
|
||||
case MotionEventActions.PointerDown:
|
||||
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
|
||||
longPress.Stop();
|
||||
moved = true;
|
||||
return steering;
|
||||
|
||||
case MotionEventActions.Move when steering:
|
||||
Steer(motion.GetX(), motion.GetY());
|
||||
return true;
|
||||
|
||||
case MotionEventActions.Move:
|
||||
Travel(motion.GetX(), motion.GetY());
|
||||
return false;
|
||||
|
||||
case MotionEventActions.Up or MotionEventActions.Cancel:
|
||||
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
|
||||
|
||||
default:
|
||||
return steering;
|
||||
}
|
||||
}
|
||||
|
||||
private void Press(MotionEvent motion)
|
||||
{
|
||||
downX = lastX = motion.GetX();
|
||||
downY = lastY = motion.GetY();
|
||||
downTime = motion.DownTime;
|
||||
moved = false;
|
||||
steering = false;
|
||||
|
||||
longPress.Stop();
|
||||
|
||||
if (gesturesEnabled())
|
||||
{
|
||||
longPress.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
|
||||
private void Travel(float x, float y)
|
||||
{
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
|
||||
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
|
||||
{
|
||||
moved = true;
|
||||
longPress.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
|
||||
private bool Release(bool lifted, Action onTapped)
|
||||
{
|
||||
longPress.Stop();
|
||||
|
||||
if (steering)
|
||||
{
|
||||
EndSteering();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lifted && !moved)
|
||||
{
|
||||
onTapped();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
|
||||
private void BeginSteering()
|
||||
{
|
||||
longPress.Stop();
|
||||
|
||||
steering = true;
|
||||
lastSpeed = 0;
|
||||
gesture.Reset();
|
||||
|
||||
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
|
||||
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
|
||||
// intercept either — the gesture now owns the finger until it lifts.
|
||||
var now = SystemClock.UptimeMillis();
|
||||
|
||||
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
|
||||
{
|
||||
if (cancel is not null)
|
||||
{
|
||||
webView.OnTouchEvent(cancel);
|
||||
cancel.Recycle();
|
||||
}
|
||||
}
|
||||
|
||||
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
|
||||
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
|
||||
|
||||
tick.Start();
|
||||
}
|
||||
|
||||
private void Steer(float x, float y)
|
||||
{
|
||||
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
|
||||
|
||||
Emit(count);
|
||||
}
|
||||
|
||||
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
|
||||
private void Tick()
|
||||
{
|
||||
if (steering)
|
||||
{
|
||||
Emit(gesture.Advance(SystemClock.UptimeMillis()));
|
||||
}
|
||||
}
|
||||
|
||||
private void Emit(int count)
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
onArrows(gesture.Direction, count);
|
||||
}
|
||||
|
||||
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
|
||||
// levels without looking: the terminal under the finger cannot draw anything to show them.
|
||||
if (gesture.Speed > lastSpeed && lastSpeed > 0)
|
||||
{
|
||||
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
|
||||
}
|
||||
|
||||
lastSpeed = gesture.Speed;
|
||||
}
|
||||
|
||||
private void EndSteering()
|
||||
{
|
||||
steering = false;
|
||||
tick.Stop();
|
||||
gesture.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
|
||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
|
||||
/// <param name="listeningForwards">
|
||||
/// Port forwards listening over those shells. Named in the summary and nothing else: a forward rides a
|
||||
/// shell's connection, so it cannot be the reason the process has to stay up — see
|
||||
/// <see cref="SessionKeepAlive"/>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
||||
/// always. Something live and the service not yet running starts it. Something live and the service
|
||||
@@ -152,7 +157,7 @@ internal sealed class SessionForegroundService : Service
|
||||
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
|
||||
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||
/// </remarks>
|
||||
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
|
||||
{
|
||||
var context = PhoneEnvironment.Require();
|
||||
|
||||
@@ -165,7 +170,7 @@ internal sealed class SessionForegroundService : Service
|
||||
|
||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
||||
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
||||
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
||||
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
|
||||
|
||||
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
|
||||
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
|
||||
@@ -255,15 +260,22 @@ internal sealed class SessionForegroundService : Service
|
||||
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
||||
}
|
||||
|
||||
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
|
||||
{
|
||||
var parts = new List<string>(3);
|
||||
var parts = new List<string>(4);
|
||||
|
||||
if (liveSessions > 0)
|
||||
{
|
||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||
}
|
||||
|
||||
// Beside the shells they run over, and only while one is: a count left over from a session that has
|
||||
// just ended would be a receipt for ports nothing holds open any more.
|
||||
if (liveSessions > 0 && listeningForwards > 0)
|
||||
{
|
||||
parts.Add(listeningForwards == 1 ? "1 port forwarded" : $"{listeningForwards} ports forwarded");
|
||||
}
|
||||
|
||||
if (holdsFileSession)
|
||||
{
|
||||
parts.Add("Files connected");
|
||||
|
||||
@@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
|
||||
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Listening port forwards are a fourth fact, and a different kind: they only change what the notification
|
||||
/// says. A forward runs over a shell's connection and stops with it, so there is never a listening forward
|
||||
/// without a live shell already holding the service up — and a count that could start the service on its own
|
||||
/// would only ever be wrong, in the moment a forward's Stopped had not been heard yet. What they add is the
|
||||
/// receipt: the user is entitled to know the phone is holding ports open, as well as shells.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SessionKeepAlive : IDisposable
|
||||
{
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly Func<int> activeTransfers;
|
||||
private readonly Func<bool> holdsFileSession;
|
||||
private readonly Func<int> listeningForwards;
|
||||
|
||||
/// <param name="workspace">The live shells.</param>
|
||||
/// <param name="activeTransfers">
|
||||
@@ -40,11 +48,20 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
|
||||
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||
/// </param>
|
||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
||||
/// <param name="listeningForwards">
|
||||
/// How many port forwards are listening, across every tab. For the notification's wording only; see the
|
||||
/// class remarks. See <c>MainWindowViewModel.ListeningForwardCount</c>.
|
||||
/// </param>
|
||||
public SessionKeepAlive(
|
||||
TerminalWorkspace workspace,
|
||||
Func<int> activeTransfers,
|
||||
Func<bool> holdsFileSession,
|
||||
Func<int> listeningForwards)
|
||||
{
|
||||
this.workspace = workspace;
|
||||
this.activeTransfers = activeTransfers;
|
||||
this.holdsFileSession = holdsFileSession;
|
||||
this.listeningForwards = listeningForwards;
|
||||
|
||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||
@@ -56,14 +73,15 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
|
||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||
/// <remarks>
|
||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||
/// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
|
||||
/// finishing, a Files connection opening or closing, a forward starting or stopping. Calling it when nothing changed is free:
|
||||
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
|
||||
/// and Android treats all of those as no-ops.
|
||||
/// </remarks>
|
||||
public void Refresh() =>
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||
SessionForegroundService.Reconcile(
|
||||
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
@@ -72,7 +90,7 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
|
||||
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
||||
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
||||
SessionForegroundService.Reconcile(0, 0, false);
|
||||
SessionForegroundService.Reconcile(0, 0, false, 0);
|
||||
}
|
||||
|
||||
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||
|
||||