Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6bdb75db | ||
|
|
df64012865 | ||
|
|
8543c69cca | ||
|
|
88bd924d83 | ||
|
|
abd7e56bb5 | ||
|
|
57d3d3c801 | ||
|
|
e0509c6679 | ||
|
|
6f6e92f315 | ||
|
|
ef365f462e | ||
|
|
f7354aa7e8 | ||
|
|
d71f057bde | ||
|
|
36b6ded63e | ||
|
|
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 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
890a5f2246 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 | ||
|
|
e936ab4646 | ||
|
|
506d2803a2 | ||
|
|
cc8bf37321 | ||
|
|
3f5979d639 | ||
|
|
aaff81272a | ||
|
|
4d1f07f253 | ||
|
|
095774c498 | ||
|
|
3977f68870 | ||
|
|
48ea5e22d5 | ||
|
|
810bc48d3f | ||
|
|
671611a9a0 | ||
|
|
21cf77f64a | ||
|
|
dbf6ce1bcf | ||
|
|
242280ce6b | ||
|
|
de0b5f12ae | ||
|
|
009b35e069 | ||
|
|
d32f5609e3 | ||
|
|
9d5ff9f23a | ||
|
|
8209f15741 | ||
|
|
8915650a0d | ||
|
|
b931a06998 | ||
|
|
ca48e18b57 | ||
|
|
c59b517fdf | ||
|
|
bb2f973687 | ||
|
|
c8507b44fe | ||
|
|
422d5ca10e | ||
|
|
d91729c0b8 | ||
|
|
43c939b697 | ||
|
|
1b76c51fbb | ||
|
|
f3c0b9ca1b | ||
|
|
7ca74a1e35 | ||
|
|
d7f0bea258 | ||
|
|
7d64027972 | ||
|
|
281e828e25 | ||
|
|
dca2e888d6 | ||
|
|
2ba7c14e35 | ||
|
|
c3ef4bd8b4 | ||
|
|
369109dd4e | ||
|
|
06f9dcfc27 | ||
|
|
bea0279937 | ||
|
|
49645db680 | ||
|
|
82966af37b | ||
|
|
575a9a9f5e | ||
|
|
6185d74800 | ||
|
|
88809f0d66 | ||
|
|
d8cf16fb46 | ||
|
|
507cd9ff88 | ||
|
|
7b616e0bb0 | ||
|
|
36b8a23020 | ||
|
|
1e8a1f2e83 | ||
|
|
4f9faa2fe3 | ||
|
|
e750ba05e3 | ||
|
|
6d6edb02c1 | ||
|
|
808a9a7fc1 | ||
|
|
f1d6499bb5 | ||
|
|
c882fa0cd3 | ||
|
|
69858f82d1 | ||
|
|
509a7c34f5 | ||
|
|
185790fb14 | ||
|
|
3d9ed03b09 | ||
|
|
cddfeb1f55 | ||
|
|
174ef7c420 | ||
|
|
af0e29a98b | ||
|
|
8591035170 | ||
|
|
ca7fee2358 | ||
|
|
b4619db8d2 | ||
|
|
3d3d0bc95f | ||
|
|
205f946450 | ||
|
|
5cb361ea13 | ||
|
|
0c61ea3a97 | ||
|
|
dc1ebf6afa | ||
|
|
253c72d2b7 | ||
|
|
23f1db9dc8 | ||
|
|
1bcf422bbe |
No files matched your search
@@ -10,7 +10,7 @@
|
|||||||
"rollForward": false
|
"rollForward": false
|
||||||
},
|
},
|
||||||
"vpk": {
|
"vpk": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.161",
|
||||||
"commands": [
|
"commands": [
|
||||||
"vpk"
|
"vpk"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,8 +20,14 @@
|
|||||||
*.sql text
|
*.sql text
|
||||||
|
|
||||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
# 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
|
*.sh text eol=lf
|
||||||
|
*.spec text eol=lf
|
||||||
|
*.rules text eol=lf
|
||||||
Dockerfile text eol=lf
|
Dockerfile text eol=lf
|
||||||
*.Dockerfile text eol=lf
|
*.Dockerfile text eol=lf
|
||||||
Caddyfile text eol=lf
|
Caddyfile text eol=lf
|
||||||
|
|||||||
@@ -87,9 +87,16 @@ Releases/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
*.snk
|
*.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
|
||||||
appsettings.*.Local.json
|
appsettings.*.Local.json
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!deploy/.env.example
|
|
||||||
secrets/
|
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="NSec.Cryptography" Version="26.4.0" />
|
||||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
<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>
|
||||||
<ItemGroup Label="Desktop client">
|
<ItemGroup Label="Desktop client">
|
||||||
<!--
|
<!--
|
||||||
@@ -98,8 +102,14 @@
|
|||||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
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
|
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/.
|
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
|
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
|
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
|
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.
|
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>
|
||||||
<ItemGroup Label="Versioning">
|
<ItemGroup Label="Versioning">
|
||||||
<!--
|
<!--
|
||||||
@@ -211,5 +243,20 @@
|
|||||||
mistakes that cause real authorization holes.
|
mistakes that cause real authorization holes.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</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 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 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
|
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
|
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
|
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.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.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.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.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
|
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||||
tests/ one test project per source project
|
tests/ one test project per source project
|
||||||
docs/adr/ architecture decision records
|
docs/adr/ architecture decision records
|
||||||
@@ -80,6 +81,10 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
|
|||||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
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
|
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.
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
@@ -141,7 +146,7 @@ not go. If the repository is private, every check answers `404`, and the honest
|
|||||||
about that is say so. It is one line to check:
|
about that is say so. It is one line to check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -so /dev/null -w '%{http_code}\n' https://git.dodotech.cloud/api/v1/repos/DodoTech/DodoSSH
|
curl -so /dev/null -w '%{http_code}\n' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH
|
||||||
```
|
```
|
||||||
|
|
||||||
`200` and updates work. `404` and they cannot. `/api/v1/version` answering `200` proves only that the forge
|
`200` and updates work. `404` and they cannot. `/api/v1/version` answering `200` proves only that the forge
|
||||||
@@ -152,8 +157,129 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
|
|||||||
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||||
this machine's device key from your account.
|
this machine's device key from your account.
|
||||||
|
|
||||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
## Installing on macOS
|
||||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
|
||||||
|
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||||
|
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||||
|
paragraphs.
|
||||||
|
|
||||||
|
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||||
|
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||||
|
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||||
|
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||||
|
|
||||||
|
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||||
|
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||||
|
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||||
|
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||||
|
|
||||||
|
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||||
|
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||||
|
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||||
|
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||||
|
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.
|
||||||
|
|
||||||
|
### The nightly desktop build
|
||||||
|
|
||||||
|
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
|
||||||
|
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
|
||||||
|
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
|
||||||
|
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
|
||||||
|
and not one for a build holding your infrastructure credentials, which is the entire reason the two
|
||||||
|
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
|
||||||
|
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
|
||||||
|
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
|
||||||
|
decision 9.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -195,7 +321,10 @@ dotnet run --project src/DodoSSH.Client.App
|
|||||||
|
|
||||||
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
|
||||||
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
|
||||||
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
|
skipped and cannot be recovered from the server. **COPY CODE** puts it on the clipboard so it can go
|
||||||
|
straight into a password manager: it is the one secret this application deliberately offers to a clipboard,
|
||||||
|
because there is nowhere better for it to go and the alternative people actually reach for is a photograph
|
||||||
|
of the screen. You can then add a host and open a shell on it — double-click
|
||||||
its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same
|
its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same
|
||||||
command with the password box above it. Keycloak's admin console is at `http://localhost:18080`
|
command with the password box above it. Keycloak's admin console is at `http://localhost:18080`
|
||||||
(`admin` / `admin`).
|
(`admin` / `admin`).
|
||||||
@@ -297,6 +426,99 @@ 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
|
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).
|
[`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 and the server's id in Spelunk. 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.
|
||||||
|
|
||||||
|
- **Sign this device in to Spelunk once, under Settings → Security → Spelunk** (or with the button in a
|
||||||
|
Spelunk host's editor). Give Spelunk's API address and press SIGN IN: your browser opens at Spelunk, you
|
||||||
|
sign in there however you normally do — password, passkey, your company's single sign-on, second factor —
|
||||||
|
and allow *DodoSSH on "<this device>"*. No key is pasted anywhere, and DodoSSH never sees your Spelunk
|
||||||
|
password.
|
||||||
|
- **One sign-in reaches every organisation you are in** on that Spelunk, as you, with the role you have on
|
||||||
|
each server at the time: Admin opens a terminal, anything less is refused for that server alone. DodoSSH
|
||||||
|
sends no organisation; Spelunk takes it from the server.
|
||||||
|
- **It stays on this device.** Nothing about it is synced: each device signs in on its own, and the host
|
||||||
|
itself carries only the address and the server id, so the same host opens on every device signed in to its
|
||||||
|
Spelunk. The sign-in is sealed in the local cache like the remembered DodoSSH sign-in, and only ever sent to
|
||||||
|
the Spelunk it came from. The API address must be `https://`, except for a Spelunk on this machine.
|
||||||
|
- **It has no expiry, and ends with you.** Spelunk refuses it once you are removed, change your password or
|
||||||
|
second factors, or sign it out under *Your account → Signed-in apps* there; DodoSSH then forgets it and asks
|
||||||
|
you to sign in again. SIGN OUT beside it under Settings → Security ends it in both places, and so does
|
||||||
|
signing out of DodoSSH.
|
||||||
|
- **DodoSSH has to be online and signed in.** Before every connection through Spelunk it asks its identity
|
||||||
|
provider whether your DodoSSH sign-in still stands (at most once a minute). An account the provider no longer
|
||||||
|
signs in is signed out of DodoSSH and of every Spelunk on this device; a provider that cannot be reached
|
||||||
|
holds the connection until it can.
|
||||||
|
- **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: signed out, not
|
||||||
|
allowed on that server, a server Spelunk does not know, 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 last box 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 your sign-in 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 this device is signed in to that Spelunk, 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, 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 this device is not signed in to** opens the host editor instead, filled in the same
|
||||||
|
way with sign-in ticked and the tag on, and offers to sign in to that Spelunk. Sign in, save, and every later
|
||||||
|
link from that Spelunk connects straight away. A link can be sent by anything on this machine, so it never
|
||||||
|
starts a sign-in by itself.
|
||||||
|
|
||||||
|
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
|
### Sharing a vault
|
||||||
|
|
||||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||||
@@ -404,8 +626,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
|
|||||||
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
||||||
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
||||||
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
||||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||||
to" picker on the Keychain screen and cannot be moved yet.
|
Keychain screen and cannot be moved yet.
|
||||||
|
|
||||||
|
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
|
||||||
|
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
|
||||||
|
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
|
||||||
|
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
|
||||||
|
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
|
||||||
|
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
|
||||||
|
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
|
||||||
|
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
|
||||||
|
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
|
||||||
|
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
|
||||||
|
|
||||||
|
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
|
||||||
|
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
|
||||||
|
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
|
||||||
|
every group lending it as a default, across every vault you can write to. Without that the move would be a
|
||||||
|
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
|
||||||
|
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
|
||||||
|
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
|
||||||
|
afterwards. The panel says what points at the key before you press it, not after.
|
||||||
|
|
||||||
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
||||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||||
@@ -492,8 +734,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
|
**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
|
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
|
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
|
shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
|
||||||
that menu first if it is open.
|
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
|
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
||||||
screen exactly the width it was designed against.
|
screen exactly the width it was designed against.
|
||||||
@@ -543,12 +786,43 @@ desktop job. Pins and import have no phone screen either, and importing an `~/.s
|
|||||||
on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not
|
on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not
|
||||||
have anticipated: a vault arrives without being asked for — somebody wraps its key to you — so the person
|
have anticipated: a vault arrives without being asked for — somebody wraps its key to you — so the person
|
||||||
it arrives for is at least as likely to be holding a phone as sitting at a desktop, and a membership
|
it arrives for is at least as likely to be holding a phone as sitting at a desktop, and a membership
|
||||||
visible only on a head they have not installed is a membership they cannot see.
|
visible only on a head they have not installed is a membership they cannot see. **Somebody can be added to
|
||||||
|
a vault from the phone too**, with the same box, the same role and the same sentence about what adding does
|
||||||
|
not do — and if that phone can open the vault, it wraps the key in the same press. What the phone still
|
||||||
|
does not draw is anything that takes access away: REMOVE and WITHDRAW KEY act on the first press, and an
|
||||||
|
irreversible revocation under a thumb with its explanation in a tooltip nothing can show is the wrong
|
||||||
|
trade.
|
||||||
|
|
||||||
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
|
**On a surface wider than 600dp it lays itself out like the desktop.** A tablet, an unfolded foldable, a
|
||||||
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
|
phone in landscape or a freeform window gets a nav rail down the left carrying every destination at once,
|
||||||
every control on that screen would have had no effect. See
|
and the three-entry bottom bar stands down with the SETTINGS hub it led to — a hub is an extra tap between
|
||||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
you and a screen there is visibly room for. Below that width nothing changes. The one number is Android's
|
||||||
|
own boundary between a compact window and a medium one, measured in the density-independent units Avalonia
|
||||||
|
lays out in.
|
||||||
|
|
||||||
|
What it does *not* yet do is use the extra width inside a screen: the host list is still one column at any
|
||||||
|
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 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
|
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
|
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
|
||||||
@@ -708,6 +982,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
|
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
||||||
are consequences rather than choices.
|
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
|
*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
|
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.
|
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
|
||||||
@@ -830,8 +1111,39 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||||
|
|
||||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||||
first release aimed at strangers), and macOS and Linux packaging.
|
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||||
|
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||||
|
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||||
|
on every main build to prove it still packages, and uploads nothing. See
|
||||||
|
[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, 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.
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## 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.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
What the hardened runtime has to be asked to relax before a .NET application will run under it.
|
||||||
|
|
||||||
|
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
|
||||||
|
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
|
||||||
|
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
|
||||||
|
listed one at a time, with what breaks without each, because the temptation when notarization
|
||||||
|
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
|
||||||
|
|
||||||
|
◆ WHAT IS DELIBERATELY NOT HERE.
|
||||||
|
|
||||||
|
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
|
||||||
|
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
|
||||||
|
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
|
||||||
|
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
|
||||||
|
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
|
||||||
|
ruled out for the same loopback reason — docs/platform-flags.md.
|
||||||
|
|
||||||
|
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
|
||||||
|
anything, and it is the entitlement most worth not having.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
|
||||||
|
hardened runtime's default is that no page is both writable and executable, so without this the
|
||||||
|
process does not start — it dies during runtime initialisation, before any of this application's
|
||||||
|
code runs, which means before anything exists that could report it.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The broader form of the same permission, and it is needed as well as allow-jit rather than
|
||||||
|
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
|
||||||
|
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
|
||||||
|
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
|
||||||
|
still fails.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Library validation requires every loaded dylib to be signed by the same team as the main
|
||||||
|
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
|
||||||
|
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
|
||||||
|
with this Developer ID, which would in principle satisfy validation.
|
||||||
|
|
||||||
|
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
|
||||||
|
relaunches it, and the process doing the replacing is not always signed by the same team as the
|
||||||
|
process being replaced during the changeover. Leaving validation on makes the failure mode of a
|
||||||
|
bad update "the application will not start", with no way to recover except a reinstall the user
|
||||||
|
would have to be told about through some other channel.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
|
||||||
|
update path sets them. Without this the hardened runtime strips them silently and the failure
|
||||||
|
surfaces later as a library that cannot be found, naming a file that is plainly present.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
The Info.plist for the macOS bundle, with the version left as a placeholder.
|
||||||
|
|
||||||
|
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
|
||||||
|
|
||||||
|
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
|
||||||
|
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
|
||||||
|
--bundleId together — with a plist supplied, every key is the caller's problem.
|
||||||
|
|
||||||
|
So a committed Info.plist would carry whatever version it was written with into every release
|
||||||
|
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
|
||||||
|
right version, the updater would compare correctly and update correctly, and only the About window,
|
||||||
|
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
|
||||||
|
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
|
||||||
|
and passes that.
|
||||||
|
|
||||||
|
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
|
||||||
|
|
||||||
|
Three keys it does not write, each of which is a real defect without it:
|
||||||
|
|
||||||
|
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
|
||||||
|
be DodoSSH — see scripts/release-macos.sh for the directory collision
|
||||||
|
that rule prevents. On Windows the pack id is invisible; on macOS it
|
||||||
|
names the thing in /Applications and in the Dock. This key is what puts
|
||||||
|
"DodoSSH" back in front of a person while the bundle keeps the id.
|
||||||
|
|
||||||
|
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
|
||||||
|
never built for, and the user gets a dyld crash rather than a sentence.
|
||||||
|
|
||||||
|
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
|
||||||
|
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
|
||||||
|
directory does not. See the note above.
|
||||||
|
-->
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
|
||||||
|
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
|
||||||
|
id: changing it makes an update a different application, and it orphans anything the previous
|
||||||
|
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
|
||||||
|
to this identifier and cannot be migrated because its whole point is that it never leaves the
|
||||||
|
enclave.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>dev.dodotech.dodossh</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
|
||||||
|
than for the project. Must match --mainExe or the bundle launches nothing.
|
||||||
|
-->
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
|
||||||
|
defines them as one to three dot-separated integers and notarization rejects what it cannot
|
||||||
|
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
|
||||||
|
is the one the updater compares. These two are for Finder and for Gatekeeper.
|
||||||
|
|
||||||
|
They are the same value rather than the usual marketing/build split, because there is no build
|
||||||
|
counter here that a release does not already bump.
|
||||||
|
-->
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
|
||||||
|
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
|
||||||
|
showing the generic application icon and no error anywhere.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>dodossh.icns</string>
|
||||||
|
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
|
||||||
|
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
|
||||||
|
so 12.0 is the oldest release these bytes are built to load on.
|
||||||
|
|
||||||
|
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
|
||||||
|
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
|
||||||
|
all, and refusing on a release where it would in fact have run is the worse of the two errors.
|
||||||
|
A user on an unsupported-but-working macOS gets the application; the support matrix governs
|
||||||
|
what gets fixed if it misbehaves there, which is a different question.
|
||||||
|
-->
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>12.0</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
|
||||||
|
terminal — the one surface in this application that is nothing but small text — into a blur.
|
||||||
|
-->
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
|
||||||
|
this one is an ordinary windowed application and the default is already false, but the key
|
||||||
|
being absent is indistinguishable from somebody having removed it.
|
||||||
|
-->
|
||||||
|
<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>
|
||||||
|
</plist>
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# Development dependencies only: PostgreSQL and Keycloak.
|
# Development dependencies only: PostgreSQL and Keycloak.
|
||||||
#
|
#
|
||||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
# 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
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
# dotnet run --project src/DodoSSH.Api
|
# dotnet run --project src/DodoSSH.Api
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||||
},
|
},
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://127.0.0.1/callback"
|
"http://127.0.0.1/callback",
|
||||||
|
"dev.dodotech.dodossh:/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [],
|
"webOrigins": [],
|
||||||
"protocolMappers": [
|
"protocolMappers": [
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
# Adding hosts on the phone
|
|
||||||
|
|
||||||
The phone can read a keychain and connect through it. It cannot put anything in one. This document is the
|
|
||||||
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
|
|
||||||
ordered work, and the traps that are already known.
|
|
||||||
|
|
||||||
> **Status: built.** All six steps. Each one compiled with the whole suite green before the next began,
|
|
||||||
> which is the rule the ordering below sets.
|
|
||||||
>
|
|
||||||
> | Step | State | Notes |
|
|
||||||
> | --- | --- | --- |
|
|
||||||
> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
|
|
||||||
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
|
|
||||||
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
|
|
||||||
> | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
|
|
||||||
> | 5. The phone | **Done** | The `+`, the sheet, both editor cards, the back guard and EDIT on the connect bar. |
|
|
||||||
> | 6. Tests and false prose | **Done** | Plus a fourth "groups are flat" site in `README.md` this plan did not list, and a cross-vault resolution bug the scouting for it turned up. |
|
|
||||||
>
|
|
||||||
> **Tags shipped after the six steps, in a seventh pass.** They were storable and unreachable when step 6
|
|
||||||
> closed — a full item kind that no screen drew. Both heads now show a chip per tag on a host row and toggle
|
|
||||||
> them in the host editor, which also creates one inline, because wanting a tag and tagging a host are the
|
|
||||||
> same moment. Renaming and deleting live in a TAGS category on the keychain screen: a tag is an item so
|
|
||||||
> that renaming it is one write instead of twenty, and that rename needed somewhere to happen.
|
|
||||||
>
|
|
||||||
> That pass also made the desktop host editor scroll. A picker's height is a chip per tag in the keychain,
|
|
||||||
> so no fixed height holds it — which the layout suite caught the moment its seeder grew tags.
|
|
||||||
>
|
|
||||||
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
|
|
||||||
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
|
|
||||||
> derived from a *different* item goes stale when that item is edited and nothing re-pushes the hosts beneath
|
|
||||||
> it, so the relay would keep dialling the old port — which is worse than the restriction.
|
|
||||||
>
|
|
||||||
> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
|
|
||||||
> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
|
|
||||||
> and two nullable ids express three of those. Naming neither id now means *inherit*, so
|
|
||||||
> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out
|
|
||||||
> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing
|
|
||||||
> host resolves exactly as it did.
|
|
||||||
|
|
||||||
## What was asked for
|
|
||||||
|
|
||||||
A floating `+` at the bottom right of the phone's HOSTS screen, opening a menu at the bottom offering **new
|
|
||||||
host** or **new group**.
|
|
||||||
|
|
||||||
A **host** carries: which vault it is filed into (personal or a team's), an alias, a hostname or IP address, a
|
|
||||||
group, tags, an SSH port, and a username with either a password or an SSH key.
|
|
||||||
|
|
||||||
A **group** carries: an alias, an optional parent group, and a default SSH port, username and
|
|
||||||
password-or-key — **defaults for the hosts inside it**.
|
|
||||||
|
|
||||||
## The shape of the problem
|
|
||||||
|
|
||||||
Three of those fields do not exist at any layer, and one of them is refused on the record.
|
|
||||||
|
|
||||||
| Asked for | What exists today |
|
|
||||||
| --- | --- |
|
|
||||||
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
|
|
||||||
| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. |
|
|
||||||
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
|
|
||||||
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
|
|
||||||
| A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. |
|
|
||||||
| A group's defaults | **Nothing.** `HostGroupSecret` has exactly one field, `Label`. |
|
|
||||||
|
|
||||||
So the phone half is mostly wiring. The domain half is three new capabilities, and one of them overturns a
|
|
||||||
written decision.
|
|
||||||
|
|
||||||
## The three decisions
|
|
||||||
|
|
||||||
**Tags are a real vault item, and a host names them.** A tag is a shared, reusable thing — the point of it is
|
|
||||||
to put the same tag on twenty machines and filter by it later — so it needs an identity of its own, not a
|
|
||||||
string repeated inside twenty payloads. `SyncEntityType.Tag = 5` becomes a live item kind. Membership is a
|
|
||||||
`TagIds` set on `HostSecret`, **not** the reserved `HostTag = 6` join.
|
|
||||||
|
|
||||||
The reason to skip the join is that the merge does not need it. The argument for `HostSecret.GroupId` living on
|
|
||||||
the host is that filing two hosts into one group must be two writes to two items; the same holds here. What a
|
|
||||||
join would have bought beyond that is two machines tagging *the same* host without one of them losing — and
|
|
||||||
`ThreeWayMerge.Map` already gives that. It resolves a keyed collection key by key, honouring presence and
|
|
||||||
absence, which is set semantics with removals. `HostOptions` is merged through it today via `ToNameMap()`, and
|
|
||||||
a tag set keyed by tag id is the same shape. So `HostTag = 6` stays reserved and unused, and the second run
|
|
||||||
through the item-kind checklist is not spent.
|
|
||||||
|
|
||||||
**Group defaults are inherited, not copied.** A host that leaves a field blank uses its group's value; a host
|
|
||||||
that fills one in overrides it. The editor shows the group's value as the field's placeholder, so the form
|
|
||||||
says what leaving it blank will get you. Copying the values into the host at create time was the alternative
|
|
||||||
and it was rejected: it makes a group a one-shot template rather than a live default, and changing a group
|
|
||||||
would then leave every host that had ever been created under it pinned to the old value.
|
|
||||||
|
|
||||||
**Groups get a parent, and the resolver breaks cycles.** `HostGroupSecret` gains `ParentId`. The editor refuses
|
|
||||||
a parent that is already a descendant, which stops a cycle being made here; the resolver walks with a visited
|
|
||||||
set and stops at a repeat, which contains a cycle that arrives from somewhere else. A cycle therefore degrades
|
|
||||||
to a group that reads as a root — flat headings, defaults unresolved past that point — instead of hanging the
|
|
||||||
connect path, and clearing the parent in the editor is the repair. **This is the load-bearing part of the
|
|
||||||
decision:** with inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable
|
|
||||||
sidebar, it is a shell that never opens.
|
|
||||||
|
|
||||||
`HostGroupSecret`'s "No parent. Groups are flat." remark and `HostGroupSecretTests`' class remark both become
|
|
||||||
false prose and must be rewritten to say what replaced the argument, not deleted. Same for the four rows in
|
|
||||||
`docs/design-import-gaps.md` recording the phone's missing `+`.
|
|
||||||
|
|
||||||
## What the payload becomes
|
|
||||||
|
|
||||||
```
|
|
||||||
HostSecret HostGroupSecret
|
|
||||||
Label Label
|
|
||||||
Hostname ParentId ← new, optional
|
|
||||||
Port int? — null inherits DefaultPort ← new
|
|
||||||
Username null inherits, "" none DefaultUsername ← new
|
|
||||||
Notes DefaultSshKeyId ← new
|
|
||||||
JumpHostIds DefaultCredentialId ← new
|
|
||||||
Options
|
|
||||||
SshKeyId null inherits TagSecret
|
|
||||||
CredentialId null inherits Label
|
|
||||||
AsksForPassword ← new, true only
|
|
||||||
GroupId null = ungrouped
|
|
||||||
TagIds ← new
|
|
||||||
RelayEnabled
|
|
||||||
```
|
|
||||||
|
|
||||||
`Port` becoming `int?` is the change with the widest blast radius, and it is unavoidable: `int` with a default
|
|
||||||
of 22 has no way to say "I have no port of my own". `HostSecret.DefaultPort = 22` stays, as the last fallback
|
|
||||||
after the chain runs out.
|
|
||||||
|
|
||||||
### Three states where there were two
|
|
||||||
|
|
||||||
`SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an
|
|
||||||
absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated.
|
|
||||||
Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password
|
|
||||||
(ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means
|
|
||||||
"no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still
|
|
||||||
reachable and still refused.
|
|
||||||
|
|
||||||
**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed
|
|
||||||
password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword`
|
|
||||||
carries the difference: null is "not stated", which walks the chain and lands on a typed password if the
|
|
||||||
chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is
|
|
||||||
ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes
|
|
||||||
exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null
|
|
||||||
is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain.
|
|
||||||
|
|
||||||
Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per
|
|
||||||
record; a host naming a credential under a group naming a key is two individually valid records that resolve to
|
|
||||||
two bindings. **Exclusion has to be enforced at resolution**, host-over-group, and the resolver must never
|
|
||||||
return both.
|
|
||||||
|
|
||||||
## The order to build it in
|
|
||||||
|
|
||||||
Each step compiles and its tests pass before the next one starts. Steps 1–3 are the domain and are
|
|
||||||
prerequisites for everything; step 4 is the only user-visible one.
|
|
||||||
|
|
||||||
### 1. `HostGroupSecret` grows
|
|
||||||
|
|
||||||
`src/DodoSSH.Client.Domain/HostGroupSecret.cs`, `HostGroupSecretCodec.cs`, `HostGroupSecretMerge.cs`.
|
|
||||||
|
|
||||||
- Add `ParentId`, `DefaultPort`, `DefaultUsername`, `DefaultSshKeyId`, `DefaultCredentialId`.
|
|
||||||
- `TryValidate`: reject `Guid.Empty` for each id, reject a `ParentId` equal to the group's own id where that is
|
|
||||||
knowable, apply the same 1–65535 range rule to `DefaultPort`, and refuse a group naming both a default key
|
|
||||||
and a default credential.
|
|
||||||
- **The codec needs the host codec's rule, which it does not have today.** `HostGroupSecretCodec` stamps
|
|
||||||
`CurrentSchemaVersion` unconditionally. Copy `HostSecretCodec.SchemaVersionFor` — a *maximum over the fields
|
|
||||||
actually present*, not a ladder — or upgrading one machine makes every group read-only on every other. A
|
|
||||||
group carrying none of the new fields must still encode at version 1, byte for byte.
|
|
||||||
- `HostGroupSecretMerge` gains the five fields, each a scalar.
|
|
||||||
- Add a group byte pin mirroring `AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne`. The group codec has
|
|
||||||
none, and the same "old vaults must re-encode identically" argument applies to it.
|
|
||||||
|
|
||||||
### 2. The `Tag` item kind
|
|
||||||
|
|
||||||
`SyncEntityType.Tag = 5`, `CryptoSpec.AadResourceType.Tag = 8`, `ChangeEntityType.Tag = 5` and the
|
|
||||||
`PublicAPI.Unshipped.txt` line **already exist** — no contract change, no crypto-spec change, no `docs/crypto.md`
|
|
||||||
change. `LocalCacheProtector`'s AAD switch already maps `Tag`.
|
|
||||||
|
|
||||||
New: `TagSecret.cs`, `TagSecretCodec.cs`, `TagSecretMerge.cs` in `DodoSSH.Client.Domain`; `TagCipher.cs`,
|
|
||||||
`TagRepository.cs` in `DodoSSH.Client.Sync`. Edited: `Client.Sync/ItemKinds.cs` (registry entry + `TagKind`),
|
|
||||||
`VaultSession.cs` (a `Tags` repository beside `HostGroups`), `Domain/Hosts.cs` (`VaultTag`),
|
|
||||||
`Infrastructure/DodoDbContext.cs`, `Infrastructure/Configurations/HostAndSyncConfigurations.cs`,
|
|
||||||
`Api/Features/Sync/ItemKinds.cs` (registry entry + server `TagKind`).
|
|
||||||
|
|
||||||
**A server migration is required** — a tag is its own table. Generate it, do not hand-write it:
|
|
||||||
`dotnet ef migrations add AddTagItem --project src/DodoSSH.Infrastructure`, committing all three resulting file
|
|
||||||
changes. **No client migration**: `ClientCacheContext` has one `item` table keyed by `(VaultId, EntityType,
|
|
||||||
EntityId)`, so a new kind is new rows.
|
|
||||||
|
|
||||||
Four traps, all of which pass a naive test suite:
|
|
||||||
|
|
||||||
- **`TagCipher` must say `AadResourceType.Tag` as a constant, never cast from `SyncEntityType.Tag`.** Tag is 5
|
|
||||||
on the wire and 8 in the crypto enum. A cast seals tags under `Credential`, which encrypts and decrypts
|
|
||||||
perfectly on the machine that wrote it and is a spec violation nothing notices until another implementation
|
|
||||||
refuses the item — and the AAD is frozen into stored ciphertext. `AadResourceTypeTests` is the test that
|
|
||||||
catches this, and it only catches it once `PinnedPairs` and `SealSample` have a Tag arm.
|
|
||||||
- **The `ItemKinds.Registry` entry is what `SyncEngine` pulls with.** `SyncedTypes` is projected from it. Miss
|
|
||||||
it and tags encrypt, merge and list perfectly on the machine that made them and are never once requested from
|
|
||||||
the server.
|
|
||||||
- **`TagKind.Fields` returns `null`, not an empty `SyncPlaintextFields`.** An empty record still serialises
|
|
||||||
`relayEnabled: false`, which invites a reader to believe the type has a relay setting that is off.
|
|
||||||
- **`ValidateFields` must refuse everything, with reasons.** There is no default; an unimplemented one accepts
|
|
||||||
whatever arrives. `RelatedId` matters more here than it did for a group — it is the field a future `HostTag`
|
|
||||||
would reach for, and `SyncPlaintextFields` is frozen so it cannot be removed. Follow `SnippetKind` and
|
|
||||||
`ObjectStoreKind`, which are more complete than `HostGroupKind`.
|
|
||||||
|
|
||||||
### 3. `HostSecret` grows, and `Port` goes nullable
|
|
||||||
|
|
||||||
`HostSecret.cs`, `HostSecretCodec.cs`, `HostSecretMerge.cs`, plus a new `TagSet` value type and a new resolver.
|
|
||||||
|
|
||||||
- `TagSet` is a structural-equality wrapper in the shape of `JumpChain` — a plain `IReadOnlyList<Guid>` on a
|
|
||||||
record gets reference equality from the compiler-generated `Equals`, which would make every host read as
|
|
||||||
changed on every sync pass and two identical edits register as a conflict. Unlike `JumpChain` it is a *set*:
|
|
||||||
order carries no meaning, so it sorts and dedupes, and it exposes a `ToIdMap()` for the merge.
|
|
||||||
- `HostSecretMerge` merges `TagIds` through `ThreeWayMerge.Map`, per tag, so two people each adding a different
|
|
||||||
tag to one host both keep theirs. It is not a scalar; a whole-value merge here would silently drop one side.
|
|
||||||
- `Port` becomes `int?`. `TryValidate`'s `is < 1 or > 65535` is already false for null, which is the wanted
|
|
||||||
behaviour but is silent — say so in a comment, and fix the message, which interpolates `{Port}` and renders
|
|
||||||
empty for null.
|
|
||||||
- **`SchemaVersionFor` needs branches for both new fields, and this is the one that loses data if forgotten.**
|
|
||||||
A host with `Port = null` written at version 1 omits the property (`WhenWritingNull`); an older client
|
|
||||||
deserialises `int Port` as 0, `TryValidate` refuses it, and the item reads as **unreadable rather than
|
|
||||||
read-only** — gone, not locked. Add `PortInheritSchemaVersion = 5` and `TagIdsSchemaVersion = 5` (or 5 and 6)
|
|
||||||
and make both bump the maximum.
|
|
||||||
- **`AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne` pins the exact bytes** of a minimal host,
|
|
||||||
including `"port":22`. A host that now inherits its port drops that key. Keep the pinned literal for a host
|
|
||||||
with an explicit port, and add a companion for the inheriting one — do not edit the old literal to match
|
|
||||||
whatever the code produces.
|
|
||||||
- `TagIds` must serialise **last** in `HostPayloadDocument`, and empty must serialise as absent, or every host
|
|
||||||
in every vault looks changed on the first sync after the upgrade.
|
|
||||||
|
|
||||||
New file: the resolver. Given a host and the group list, walk `GroupId` then `ParentId` with a visited set,
|
|
||||||
returning resolved port, username and binding, plus which group each came from — the editor needs the second
|
|
||||||
half for its placeholders. It must never return both a key and a credential.
|
|
||||||
|
|
||||||
### 4. The shared view model, and the two heads
|
|
||||||
|
|
||||||
`VaultViewModel` is where nearly all of it lands, and both heads get it at once because both bind the same
|
|
||||||
properties.
|
|
||||||
|
|
||||||
The choke points, from the connect-path trace:
|
|
||||||
|
|
||||||
- **`TryBuildAuthentication(HostSecret, …)` and `TryBuildConnectionRequest(HostSecret, …)`** both take a bare
|
|
||||||
`HostSecret` — this signature is where group context is currently lost. Both must take the resolved value.
|
|
||||||
This is the single highest-leverage change: it is the only auth resolution in the product, and both heads
|
|
||||||
and both transports go through it.
|
|
||||||
- The credential-username fallback becomes three levels: credential → host → group chain.
|
|
||||||
- `Complete(...)` must refuse on an empty username only *after* the chain has been consulted.
|
|
||||||
- `HostRowViewModel.Address`, `Dialled`, `TransfersViewModel`'s connected-to label and
|
|
||||||
`TerminalWorkspace.Describe` must show the **dialled** address. `MainWindowViewModel.Rank` searches
|
|
||||||
`Address`, so an unresolved one makes hosts unfindable by their real port.
|
|
||||||
- **`HostFields.From` in `HostCipher` must emit the resolved port.** This is the one place where an unresolved
|
|
||||||
read is a wrong wire rather than a wrong label: it fills the relay's plaintext columns, which is what the
|
|
||||||
*server* dials. A relay host with a null port also fails server validation outright.
|
|
||||||
- Host-key pinning keys on host and port. `ForgetHostKeyAsync` and the "pin in use" endpoint set read
|
|
||||||
`row.Host.Port` directly — a host inheriting 2222 pins under 2222 while both look under 22.
|
|
||||||
- `HostRowViewModel.Authentication`, `SelectedHostAsksForAPassword` (in **both** `VaultViewModel` and
|
|
||||||
`TransfersViewModel`) and `SelectedHostAuthenticationNote` all switch on the host's own two ids and must
|
|
||||||
switch on the resolved binding, with new wording for a binding that came from a group.
|
|
||||||
- **`HostsBoundTo`** counts hosts naming a key explicitly. A key deleted while only a *group* names it warns
|
|
||||||
nobody and then refuses every host under that group at connect time. It must walk groups too.
|
|
||||||
- The editor: load must distinguish "unset" from "explicitly this", `BuildHost` must preserve that in both
|
|
||||||
directions, and each field needs the resolved value as its placeholder.
|
|
||||||
- `ImportViewModel.IsAlreadyPresent` compares port and username directly, so a stored host inheriting 22 will
|
|
||||||
never match an imported host at 22 and the import screen re-offers duplicates. Decide whether an imported
|
|
||||||
block with no `Port` should inherit rather than pin 22 — `SshConfigResolver` returns `DefaultPort` today.
|
|
||||||
|
|
||||||
### 5. The phone
|
|
||||||
|
|
||||||
`Views/HostsScreen.axaml` and `Theme/Phone.axaml`. No csproj edit — Avalonia globs `**/*.axaml`. Compiled
|
|
||||||
bindings are on, so a binding to a property that does not exist is a build error; `TreatWarningsAsErrors` is on
|
|
||||||
repo-wide, so any XAML warning fails CI.
|
|
||||||
|
|
||||||
- A `Button.fab` style, and the comment that currently refuses one rewritten to say what it now opens.
|
|
||||||
- The `+` needs a `Panel` wrapping the screen's root `Grid` so it can sit bottom-right without moving. Placing
|
|
||||||
it inside the list row would put it above the connect bar, which appears and disappears with the selection.
|
|
||||||
- The bottom sheet follows `HostKeySheet.axaml`, the head's one existing sheet: a scrim
|
|
||||||
(`Border Background="#9E0E1220"`, a literal because the palette holds no alpha surface), a bottom-aligned
|
|
||||||
`Border` with `CornerRadius="22,22,0,0"`, a decorative grab handle, and **no tap-to-dismiss** unless it is
|
|
||||||
wired by hand, because nothing on this head implements it.
|
|
||||||
- **Scrim reach is a real choice.** Declared inside `HostsScreen`, a scrim darkens the screen area only and the
|
|
||||||
64px nav bar stays lit and tappable. To cover the display the sheet has to sit in `PhoneShell.axaml` beside
|
|
||||||
`HostKeySheet`, which is exactly why that control lives there.
|
|
||||||
- **The back gesture needs a new guard**, above the `switch` in `PhoneShell.axaml.cs`, in the shape of the
|
|
||||||
host-key guard: back should close the sheet or the editor rather than leave the screen. The comment on that
|
|
||||||
method states an invariant about the switch cases staying in step with the surface tests — so add a guard,
|
|
||||||
do not extend the switch. *(This named `IsVaultsSurface` and `IsConnectionsSurface`. Neither exists
|
|
||||||
anywhere in the repo; the real properties are `IsMoreSurface` and `IsTerminalSurface`, and only the first
|
|
||||||
is what the switch mirrors.)*
|
|
||||||
- The editors follow `SnippetsScreen.axaml`: a `Border Classes="card"` in the same grid row as the list,
|
|
||||||
toggled on `IsEditing`, with the list toggled on `!IsEditing`. There is no dialog, no modal and no
|
|
||||||
editor-screen anywhere on this head, and the reason is written down — a form stacked over the list hides what
|
|
||||||
it is about.
|
|
||||||
- Editing an existing host comes nearly free: the shared `IsEditing` panel serves both, so the connect bar gets
|
|
||||||
an EDIT button beside CONNECT. Not asked for, but a `+` that adds hosts with no way to correct one is a
|
|
||||||
strange thing to ship.
|
|
||||||
|
|
||||||
### 6. Tests
|
|
||||||
|
|
||||||
House style: `Subject_Predicate`, articles included, the predicate smuggling in the reason
|
|
||||||
(`_RefusesRatherThanFallingBackToThePassword`). No `Async` suffix on test methods; always on private helpers.
|
|
||||||
Every class carries a remark naming the class of failure it exists to catch. xunit.v3 + Shouldly, with the
|
|
||||||
because-string used as prose.
|
|
||||||
|
|
||||||
- Three shared builders need the new fields: `Domain.Tests/HostFactory.cs`, `Sync.Tests/SyncHarness.cs`,
|
|
||||||
`Sync.Tests/HostCipherTests.cs`. There is no shared group factory and with five new fields there should be.
|
|
||||||
- Three guard tests fail by design and that is what they are for:
|
|
||||||
`HostSecretMergeTests.EveryScalarField_IsRoutedThroughAMerge` (note `GroupId` is missing from its `with`
|
|
||||||
block today — an existing gap worth closing while there) *(closed: `GroupId` is in that block now)*,
|
|
||||||
`ValueSemanticsTests.TryValidate_RejectsWhatCannotBeStored`, and the two "groups are flat" remarks.
|
|
||||||
- `ItemKindsTests.ThePullFilterNamesEveryTypeThisBuildSynchronises` is an ordered list — add Tag in registry
|
|
||||||
order. `AadResourceTypeTests` needs `PinnedPairs`, a `SealSample` arm and a `NewTag()` builder.
|
|
||||||
- `SyncEndpointTests.AHostGroupCarryingAParent_IsRejected` asserts the server refuses a plaintext `ParentId`
|
|
||||||
with a reason containing "flat". Putting the parent **inside the payload** does not break it mechanically,
|
|
||||||
but its stated reason becomes wrong. It should survive with a rewritten comment — the parent lives in the
|
|
||||||
payload, the plaintext column stays refused, and ADR 0004 is why. Decide that deliberately rather than
|
|
||||||
letting it drift. *(Decided that way, and the name says it now:
|
|
||||||
`SyncEndpointTests.AHostGroupCarryingAPlaintextParent_IsRejected`. The column is still refused and the
|
|
||||||
reason is the trust model rather than flatness.)*
|
|
||||||
- `Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch` picks the first type with no kind registered, very
|
|
||||||
likely Tag today. It self-heals to `HostTag` via `Assert.SkipWhen`.
|
|
||||||
- `App.Layout.Tests` measures pixels and bends rather than breaks. Extend the seeders — nesting and a tag row
|
|
||||||
change what the widest realistic content is — rather than the assertions. `ScreenLayoutTests.cs:1310` pins
|
|
||||||
`ChangedFields = "Hostname, Port, Username, Options, Group"` as a literal; add `Tags` to keep it measuring
|
|
||||||
the worst case.
|
|
||||||
- `ShellFlowTests.ReadyToConnectAsync` gives its host both a username and a port, so the inheritance tests need
|
|
||||||
a host with neither, or the group is never the source.
|
|
||||||
|
|
||||||
## Found on the way, and out of scope
|
|
||||||
|
|
||||||
`LocalCacheProtector`'s AAD switch is missing `ConnectionLogEntry`, `ActivityLogEntry` and `ObjectStore`, so
|
|
||||||
`ConflictStore.Record` throws `ArgumentOutOfRangeException` on a conflict for any of those three. Pre-existing,
|
|
||||||
unrelated to any of this, and `Tag` is already in that switch. Worth a separate fix.
|
|
||||||
|
|
||||||
> **Fixed 2026-08-04**, having outlived the phases that shipped the logs and the buckets — which is exactly
|
|
||||||
> the drift a note like this is meant to prevent, so it is worth saying what let it last. Of the three callers
|
|
||||||
> of `AadResourceTypes.For`, two reach it only when an item carries plaintext fields and none of these three
|
|
||||||
> does; the third, `ConflictStore.RecordAsync`, calls it unconditionally but is reached only by a real merge
|
|
||||||
> conflict, which every existing test raised against a `Host`. The arms are in, and two tests now hold them
|
|
||||||
> there: `CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem` records one per kind, and
|
|
||||||
> `AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping` fails on the *next* item type added
|
|
||||||
> without one, by name rather than by a list kept by hand.
|
|
||||||
|
|
||||||
## Prose that becomes false
|
|
||||||
|
|
||||||
Not a tidy-up — these are the places the codebase currently tells a user or a maintainer that this feature is
|
|
||||||
deliberately absent, and each states a reason that will no longer hold.
|
|
||||||
|
|
||||||
- `Theme/Phone.axaml` — the "No floating action button" block.
|
|
||||||
- `Views/HostsScreen.axaml` — the v2 and v3 notes on the missing `+`.
|
|
||||||
- ~~`Views/ConnectionsScreen.axaml`~~ — **this file does not exist and never did.** The real inventory is
|
|
||||||
the two above plus `README.md`, `docs/android-port.md` and `docs/design-import-gaps.md`. Neither phone
|
|
||||||
site was "rendered on screen": both are XAML comments.
|
|
||||||
- `HostGroupSecret.cs` and `HostGroupSecretTests.cs` — "No parent. Groups are flat."
|
|
||||||
- `docs/design-import-gaps.md` — the **Add host** row, the two floating-button rows, the "Tag / HostTag still
|
|
||||||
reserved and unused" paragraph, and the tag-chips and groups-on-a-team's-hosts rows in the Hosts table.
|
|
||||||
@@ -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.**
|
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
|
- 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
|
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.**
|
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
||||||
Without it this ADR's central bug is invisible until production.
|
Without it this ADR's central bug is invisible until production.
|
||||||
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
- 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
|
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
|
||||||
proxy into the operator's network.
|
proxy into the operator's network.
|
||||||
- **Allow-listing by hostname.** Defeated by DNS rebinding.
|
- **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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR 0007 — What protects the device key on Windows
|
# ADR 0007 — What protects the device key on the desktop
|
||||||
|
|
||||||
**Status:** accepted, 2026-07-30
|
**Status:** accepted, 2026-07-30
|
||||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
||||||
@@ -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
|
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
|
||||||
envelope's minimum-length rule.
|
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
|
## Consequences
|
||||||
|
|
||||||
### The cache key had to move, and the spec changed
|
### The cache key had to move, and the spec changed
|
||||||
@@ -138,9 +213,19 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
|||||||
|
|
||||||
### Operational
|
### Operational
|
||||||
|
|
||||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
|
||||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
|
||||||
is therefore required, not a nicety.
|
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
|
||||||
|
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||||
|
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||||
|
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||||
|
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||||
|
probe answers no, one of which is every unsigned development build.
|
||||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||||
|
|||||||
@@ -85,13 +85,38 @@ packs and stops. Phase two, a separate invocation, asks for the token and upload
|
|||||||
what reaches users has been installed and started by a human first, and so that the credential is in memory
|
what reaches users has been installed and started by a human first, and so that the credential is in memory
|
||||||
only for the minutes that need it.
|
only for the minutes that need it.
|
||||||
|
|
||||||
There is a second, smaller reason the release could not be a CI job here anyway: `vpk` stamps and embeds
|
A second, smaller reason was recorded here — that `vpk` stamps and embeds the `Setup.exe` and
|
||||||
the `Setup.exe` and `Update.exe` stubs with Windows tooling, and every job in `ci.yml` is
|
`Update.exe` stubs with Windows tooling, and every job in `ci.yml` is `runs-on: [linux]` — on the
|
||||||
`runs-on: [linux]`. Both reasons are recorded because somebody will fix one and believe they are done.
|
reasoning that somebody will fix one reason and believe they are done.
|
||||||
|
|
||||||
What CI does gain is the same thing the `android` job already does — it proves the artefact still builds.
|
◆ **That second reason was not true, and the correction is worth more than the claim was.** `vpk`
|
||||||
A `win-x64` publish runs on main and on tags, so a restore graph that resolves for `net10.0` and not for
|
cross-compiles when it is told to: the bracketed OS directive, `vpk [win] pack`, builds the setup stub,
|
||||||
`net10.0/win-x64` fails there rather than under a person midway through a release.
|
the portable bundle and the `.nupkg` on Linux, and reports `Directive enabled for cross-compiling from
|
||||||
|
Linux (current os) to Windows` while doing it. Only signing needs Windows tooling, and rule 8 above is
|
||||||
|
why nothing is signed yet. Plain `vpk pack` on Linux targets Linux and rejects a Windows RID, which is
|
||||||
|
the shape of the mistake: the tool does not fail because it cannot, it fails because it was not asked.
|
||||||
|
|
||||||
|
The correction does not move this rule at all, and that is the point of separating the two. The reason
|
||||||
|
that stands was never mechanical, so a Linux runner that *can* pack changes nothing: what may not be on
|
||||||
|
a runner is the token, not the build.
|
||||||
|
|
||||||
|
What CI gains is the same thing the `android` job already does — it proves the artefact still builds and
|
||||||
|
still packages. On main and on tags the build job publishes `win-x64` and then packs it, so a restore
|
||||||
|
graph that resolves for `net10.0` and not for `net10.0/win-x64` fails there rather than under a person
|
||||||
|
midway through a release. Packaging is the more valuable half and adds a few seconds to a publish that
|
||||||
|
has already happened — measured at two to seven, against the minutes the publish itself takes: `vpk` verifies
|
||||||
|
that the main executable actually calls `VelopackApp.Build().Run()`, so the change that quietly produces
|
||||||
|
an application which never updates itself — compiling, tested, and invisible until a release goes
|
||||||
|
unreceived — fails in CI. The packages are written to `RUNNER_TEMP` and discarded, and are deliberately
|
||||||
|
not offered as workflow artefacts: an installer nobody has run should not sit somewhere that invites
|
||||||
|
passing it on.
|
||||||
|
|
||||||
|
CI does not call `scripts/release-windows.ps1` to do any of this, and should not. That script is the
|
||||||
|
person's procedure — it refuses a dirty tree, requires a tagged `HEAD`, fetches the previous release for
|
||||||
|
deltas, and prompts for the token — so running it on a runner would mean either weakening it with CI
|
||||||
|
switches or having CI satisfy conditions that only make sense at a desk. The workflow writes the same
|
||||||
|
`vpk` arguments out in shell, and the constants they share (pack id, title, authors, channel, icon) are
|
||||||
|
noted in both places as a contract with `VelopackUpdateChannel` and with every installed client.
|
||||||
|
|
||||||
### 4. The update check points at the project's forge, and the address is a constant
|
### 4. The update check points at the project's forge, and the address is a constant
|
||||||
|
|
||||||
@@ -118,7 +143,7 @@ than assumed:
|
|||||||
|
|
||||||
- **The feed repository must be readable with no credentials, and that is a deployment precondition** rather
|
- **The feed repository must be readable with no credentials, and that is a deployment precondition** rather
|
||||||
than a property of the code. It is checkable in one line, which is the only reason it is worth stating:
|
than a property of the code. It is checkable in one line, which is the only reason it is worth stating:
|
||||||
`curl -so /dev/null -w '%{http_code}' https://git.dodotech.cloud/api/v1/repos/DodoTech/DodoSSH` answers
|
`curl -so /dev/null -w '%{http_code}' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH` answers
|
||||||
`200` when the design holds and `404` when it does not. `/api/v1/version` answering `200` proves only that
|
`200` when the design holds and `404` when it does not. `/api/v1/version` answering `200` proves only that
|
||||||
the forge is reachable, which is what made this look like nothing was wrong.
|
the forge is reachable, which is what made this look like nothing was wrong.
|
||||||
- **An unreachable channel must be distinguishable from a current build.** `IUpdateChannel.CheckAsync` used
|
- **An unreachable channel must be distinguishable from a current build.** `IUpdateChannel.CheckAsync` used
|
||||||
@@ -203,6 +228,194 @@ changes.
|
|||||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||||
|
|
||||||
|
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||||
|
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||||
|
|
||||||
|
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||||
|
|
||||||
|
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||||
|
things differ, and each is forced rather than chosen.
|
||||||
|
|
||||||
|
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||||
|
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||||
|
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||||
|
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||||
|
signing identities, where the Windows script refuses nothing.
|
||||||
|
|
||||||
|
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||||
|
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||||
|
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||||
|
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||||
|
release channel.
|
||||||
|
|
||||||
|
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||||
|
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||||
|
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||||
|
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||||
|
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||||
|
the id.
|
||||||
|
|
||||||
|
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||||
|
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||||
|
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||||
|
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||||
|
discipline exists to refuse.
|
||||||
|
|
||||||
|
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||||
|
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||||
|
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||||
|
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||||
|
|
||||||
|
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||||
|
|
||||||
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
none, which left the two heads with different answers to the same question — *how does somebody try what
|
||||||
|
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
|
||||||
|
it is the phone's arrangement with one difference that changes how much of it has to be built.
|
||||||
|
|
||||||
|
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
|
||||||
|
one another because the installer refuses a package signed by a different key; a mistake there is loud.
|
||||||
|
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
|
||||||
|
construction:
|
||||||
|
|
||||||
|
| | release | nightly |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
|
||||||
|
| Velopack channel | `win` | `win-nightly` |
|
||||||
|
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
|
||||||
|
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
|
||||||
|
| cut by | a person, from a `v*` tag | CI, on every push to main |
|
||||||
|
|
||||||
|
Four separations rather than one, because each closes a different door. The pack id decides the install
|
||||||
|
directory and what an installed client matches an update against, so it is what makes a nightly install
|
||||||
|
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
|
||||||
|
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
|
||||||
|
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
|
||||||
|
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
|
||||||
|
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
|
||||||
|
release build then opens.
|
||||||
|
|
||||||
|
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
|
||||||
|
reads `releases/latest`, which skips prereleases. 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 check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
|
||||||
|
sake and for that one.
|
||||||
|
|
||||||
|
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
|
||||||
|
this repository can put a build on every nightly desktop, because the client applies what the feed serves
|
||||||
|
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
|
||||||
|
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
|
||||||
|
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
|
||||||
|
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
|
||||||
|
|
||||||
|
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
|
||||||
|
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
|
||||||
|
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
|
||||||
|
— packing a number the assemblies disagree with would put one version in the installer and another on the
|
||||||
|
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
|
||||||
|
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
|
## Consequences
|
||||||
|
|
||||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||||
@@ -256,6 +469,10 @@ ADR 0011 changes.
|
|||||||
genuinely the nicest onboarding available and will be proposed again.
|
genuinely the nicest onboarding available and will be proposed again.
|
||||||
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||||
inherit it.
|
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.
|
- **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
|
- **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
|
matters here: the tag is already the version of record for the container image, and two places to write
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ losing the local cache, the outbox and the device key.
|
|||||||
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
|
turned this application on in the unknown-sources screen. Two deliberate answers, neither to a screen
|
||||||
DodoSSH controls.
|
DodoSSH controls.
|
||||||
|
|
||||||
|
◆ "Asks Android to ask" is one step longer than it sounds, and reading it as one step is why this
|
||||||
|
installed nothing at all from the day it shipped until 2026-08-05. An application holding only
|
||||||
|
`REQUEST_INSTALL_PACKAGES` gets no verdict back from committing a session: what the platform returns
|
||||||
|
first is `STATUS_PENDING_USER_ACTION`, carrying the confirmation activity in `Intent.EXTRA_INTENT` for
|
||||||
|
the application to start. **Android does not draw the dialogue on its own.** The commit succeeded, the
|
||||||
|
session staged, and nobody was ever asked anything. The receiver that starts it is
|
||||||
|
`InstallSessionReceiver`; the intent naming it has to be explicit, since a mutable pending intent
|
||||||
|
wrapping an implicit one is refused outright from API 34. Check 17.5 asks for exactly the right thing
|
||||||
|
— "Android's own installer appears naming the package" — so nothing needed rewording; it wants an
|
||||||
|
older build installed and a newer one published to run at all, and on the evidence it was never run
|
||||||
|
against a real pair.
|
||||||
|
|
||||||
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
|
7. **Applying does not end the process, and the shell had to learn that.** On Windows, applying replaces
|
||||||
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
|
the files and restarts, so the shell disposes the vault first — that is what zeroes the identity keys,
|
||||||
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
|
the vault keys and the cache key. On Android the install is a *request* and the answer may be no, so
|
||||||
@@ -104,6 +116,15 @@ is the entire reason there are two.
|
|||||||
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
||||||
caches are encrypted under keys held by two package identities the platform keeps apart.
|
caches are encrypted under keys held by two package identities the platform keeps apart.
|
||||||
|
|
||||||
|
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
|
||||||
|
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
|
||||||
|
CI publishes from main, installing beside the release build rather than over it. The difference worth
|
||||||
|
carrying back here is that every separation this ADR gets from the platform — different package identity,
|
||||||
|
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
|
||||||
|
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
|
||||||
|
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
|
||||||
|
a nightly install and an arbitrary build.
|
||||||
|
|
||||||
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
||||||
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
||||||
that an installed copy can now learn a newer one exists.
|
that an installed copy can now learn a newer one exists.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
|
|||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||||
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||||
with the foreground service now counting transfers as well as shells.
|
with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
|
||||||
|
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
|
||||||
|
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
|
||||||
|
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
|
||||||
|
|
||||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||||
|
|
||||||
@@ -235,6 +238,43 @@ The parts that are definitely different are the on-screen keyboard, and the fact
|
|||||||
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
||||||
accessory key row for this. That is UI work, not porting.
|
accessory key row for this. That is UI work, not porting.
|
||||||
|
|
||||||
|
> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and
|
||||||
|
> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and
|
||||||
|
> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once,
|
||||||
|
> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own
|
||||||
|
> renderer process is a separate thing from the app process the foreground service above is keeping alive,
|
||||||
|
> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no
|
||||||
|
> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on
|
||||||
|
> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a
|
||||||
|
> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a
|
||||||
|
> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop,
|
||||||
|
> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever
|
||||||
|
> drain again; and every byte sent while no page was attached had already spent flow-control credit that no
|
||||||
|
> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for
|
||||||
|
> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own
|
||||||
|
> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit
|
||||||
|
> unaware for the whole 30-second keepalive.
|
||||||
|
>
|
||||||
|
> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all
|
||||||
|
> checked exactly as before — now displaces whatever socket was attached instead of being refused, since
|
||||||
|
> only this app's own page ever knows the token, so a second valid attach *is* that page, back again.
|
||||||
|
> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody
|
||||||
|
> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit
|
||||||
|
> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page
|
||||||
|
> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And
|
||||||
|
> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection
|
||||||
|
> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong
|
||||||
|
> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page
|
||||||
|
> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view
|
||||||
|
> 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
|
## Decisions taken
|
||||||
@@ -280,13 +320,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
|
|||||||
|
|
||||||
### Sessions survive backgrounding, via a foreground service
|
### Sessions survive backgrounding, via a foreground service
|
||||||
|
|
||||||
A persistent notification for as long as a shell or a transfer is live.
|
A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
|
||||||
|
live.
|
||||||
|
|
||||||
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
||||||
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
||||||
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
||||||
notification is to look at.
|
notification is to look at.
|
||||||
|
|
||||||
|
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
|
||||||
|
|
||||||
|
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
|
||||||
|
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
|
||||||
|
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
|
||||||
|
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
|
||||||
|
`App.axaml.cs`'s `ComposeKeepAlive`.
|
||||||
|
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
|
||||||
|
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
|
||||||
|
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
|
||||||
|
— `IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
|
||||||
|
takes.
|
||||||
|
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
|
||||||
|
`StartForegroundService` on every refresh, including the common case of a service that was already
|
||||||
|
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
|
||||||
|
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
|
||||||
|
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
|
||||||
|
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
|
||||||
|
start anything.
|
||||||
|
|
||||||
|
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
|
||||||
|
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
|
||||||
|
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
|
||||||
|
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
|
||||||
|
what the manifest's own comment on the permission says.
|
||||||
|
|
||||||
|
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
|
||||||
|
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
|
||||||
|
run's own finally block — where the run task is by definition not yet complete, so the
|
||||||
|
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
|
||||||
|
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
|
||||||
|
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
|
||||||
|
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
|
||||||
|
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
|
||||||
|
already "reconcile to reality" handlers for which a second announcement is harmless.
|
||||||
|
|
||||||
### Phone first
|
### Phone first
|
||||||
|
|
||||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||||
@@ -523,7 +600,12 @@ go at 360dp:
|
|||||||
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||||||
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||||||
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||||||
dishonesty the unlock screen's count exists to prevent.
|
dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
|
||||||
|
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
|
||||||
|
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
|
||||||
|
the notification in place instead of restarting the service, which the API throws on; and
|
||||||
|
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
|
||||||
|
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
|
||||||
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
||||||
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
||||||
choosing a passphrase).
|
choosing a passphrase).
|
||||||
@@ -549,7 +631,8 @@ go at 360dp:
|
|||||||
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
|
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
|
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
|
[`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
|
**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
|
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
|
||||||
@@ -557,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 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
|
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
|
||||||
deliberately differently, after the hub itself.
|
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
|
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
|
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
|
||||||
reflows a terminal while output is arriving.
|
reflows a terminal while output is arriving.
|
||||||
@@ -568,9 +661,16 @@ go at 360dp:
|
|||||||
believing they typed an `l`.
|
believing they typed an `l`.
|
||||||
|
|
||||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
||||||
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
|
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
|
||||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
the session pills, and a `+` raising a sheet with the three connections there are.
|
||||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
|
||||||
|
*A shell rather than the surface, and the two parted company once that surface gained a connect page.*
|
||||||
|
With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
|
||||||
|
page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
|
||||||
|
it and the Connections entry lights for the first time. It is the one screen reachable by closing your
|
||||||
|
last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
|
||||||
|
Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
|
||||||
|
the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
|
||||||
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
||||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||||
collapsing is correct under either answer.
|
collapsing is correct under either answer.
|
||||||
@@ -666,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.
|
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
|
- **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.
|
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
|
- **`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
|
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.
|
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,
|
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
`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,
|
||||||
|
`18` SpelunkSignIn. `17` is retired and never reused.
|
||||||
|
|
||||||
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
> **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
|
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
||||||
@@ -318,6 +319,13 @@ 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
|
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
||||||
|
|
||||||
|
> **Added 2026-09-29, removed 2026-10-07:** `17` SpelunkKey, a Spelunk API key and the address it
|
||||||
|
> may be sent to (`14` on the wire). Removed in beta when DodoSSH started signing in to Spelunk instead;
|
||||||
|
> both numbers stay retired.
|
||||||
|
|
||||||
|
> **Added 2026-10-07:** `18` SpelunkSignIn, a device's sign-in to one Spelunk, sealed in the local cache
|
||||||
|
> (§4.2's LocalCache AAD) and bound to its row. It is never synced and has no `SyncEntityType`.
|
||||||
|
|
||||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||||
|
|
||||||
### 4.4 What this prevents
|
### 4.4 What this prevents
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
|
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
|
||||||
>
|
>
|
||||||
> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the
|
> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the
|
||||||
> design had no slot for — and it is on the phone because a vault arrives without being asked for.
|
> design had no slot for — and it is on the phone because a vault arrives without being asked for, and
|
||||||
|
> because somebody can be let into one from there.
|
||||||
> Somebody wraps its key to you from their machine, and the person it arrives for is at least as likely to
|
> Somebody wraps its key to you from their machine, and the person it arrives for is at least as likely to
|
||||||
> be holding a phone as sitting at a desktop; a vault the server has just put somebody in, visible only on
|
> be holding a phone as sitting at a desktop; a vault the server has just put somebody in, visible only on
|
||||||
> a head they may not have installed, is a membership they cannot see. It runs over the same view model
|
> a head they may not have installed, is a membership they cannot see. It runs over the same view model
|
||||||
@@ -38,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
>
|
>
|
||||||
> | v2 element | What ships instead |
|
> | 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. |
|
> | 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 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. |
|
> | `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. |
|
> | `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. |
|
> | 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. |
|
> | 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. |
|
||||||
@@ -68,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
||||||
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
||||||
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
||||||
> looking.
|
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
|
||||||
>
|
>
|
||||||
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
||||||
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
||||||
@@ -77,7 +78,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
>
|
>
|
||||||
> | v2 element | What ships instead |
|
> | 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 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. |
|
> | 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. |
|
> | 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. |
|
||||||
@@ -101,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
||||||
> a page test rather than a fourth `ShellSurface`.
|
> a page test rather than a fourth `ShellSurface`.
|
||||||
>
|
>
|
||||||
|
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
|
||||||
|
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
|
||||||
|
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
|
||||||
|
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
|
||||||
|
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
|
||||||
|
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
|
||||||
|
> three tabs was.
|
||||||
|
>
|
||||||
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
||||||
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
||||||
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
||||||
@@ -159,11 +168,214 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
||||||
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
||||||
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
||||||
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
|
> | 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. |
|
> | 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. |
|
> | **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 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
|
||||||
|
>
|
||||||
|
> A fifth pass, and the first that undoes as much of its own predecessors as it adds: a re-theme both heads
|
||||||
|
> share, pinned folders as a full feature, a last-connected time read off the connection log, and two pieces
|
||||||
|
> of earlier passes put back on the user's own instruction rather than kept.
|
||||||
|
>
|
||||||
|
> **The palette went purple, and three fonts are embedded that were only ever named before.** The shared
|
||||||
|
> scheme moves from v3's near-black-and-blue to a purple accent (`#5D42DE`, with a gradient and a glow of its
|
||||||
|
> own) over the same near-black canvas, and both heads recolour together — `Palette.axaml` is shared, and a
|
||||||
|
> colour is not allowed to mean something different on the two of them. What changed on the desktop alone is
|
||||||
|
> the type: Montserrat, JetBrains Mono and Material Icons are embedded as `AvaloniaResource` in the Shell
|
||||||
|
> project rather than requested by name and left to whatever the machine happens to have, the way `WithInterFont`
|
||||||
|
> alone used to leave Inter registered but unused. Montserrat is the default sans now, with Inter kept as its
|
||||||
|
> fallback rather than the whole answer; `MonoFont` gains JetBrains Mono at the front of its own list, ahead of
|
||||||
|
> the system-mono stack that is still behind it for a glyph JetBrains Mono does not cover. **Android recoloured
|
||||||
|
> with the shared palette and kept its own default sans when this paragraph was first written; it does not any
|
||||||
|
> more** — see "the phone catches up", directly below.
|
||||||
|
>
|
||||||
|
> **The phone catches up.** A later reversal than the one above, on the same reasoning: a face is not a
|
||||||
|
> colour, and the user decided the phone should share both rather than only the second. `WithInterFont` gains
|
||||||
|
> the same `FontManagerOptions` block `Program.cs` sets, off the same embedded Montserrat, no new asset
|
||||||
|
> required. `Theme/Phone.axaml`'s six-rung v2 ladder — 4/9/10/11/12/14 — collapses to the desktop's three:
|
||||||
|
> chip or tag 6, button or field 10, card or output 12; `Button.fab`'s 28 and every sheet's 22 survive
|
||||||
|
> unchanged, being geometry rather than ladder rungs. `Button.primary` and `Button.fab` take `AccentGradient`
|
||||||
|
> and `AccentGlow` in place of a flat fill, the same swap `Button.accent` made on the desktop, `BoxShadow`
|
||||||
|
> cleared the same way on disabled. And the phone's own chrome — the vault header, the bottom bar, the shells
|
||||||
|
> strip, `HostActionBar`, the editor headers, the terminal's collapsed bar and every screen's raised selection
|
||||||
|
> bar — moves from `Chrome`/`Sidebar` to `DeepChrome`, joining the desktop's own v5b titlebar-and-rail move;
|
||||||
|
> `PhoneRail`'s hover takes `Track`, which `Palette.axaml` already names for that row. Borders stay keyed.
|
||||||
|
>
|
||||||
|
> **Flat sections replace the grid of group cards and the breadcrumb trail — reversing v3, on the user's own
|
||||||
|
> approval rather than a defect found in it.** v3's grid held one level of the group tree at a time, opened by
|
||||||
|
> a double-click, with a trail above it saying where you were; that model is retired outright rather than
|
||||||
|
> folded into anything; every group is now a heading on one flat board, in label order, exactly the flattening
|
||||||
|
> the phone's `SidebarRows` has always drawn — a headerless board for a groupless keychain, "No group" first
|
||||||
|
> where there is one, a chevron per heading and one Collapse All. Nothing here was found broken; the grid did
|
||||||
|
> what v3 asked for. The user asked for the phone's shape on the desktop too, and decision 2 in
|
||||||
|
> `hosts-v5-design-spec.md` is that request recorded.
|
||||||
|
>
|
||||||
|
> **Tag chips and a mono address line are back on the card — reversing v4, also by approval and not by
|
||||||
|
> defect.** v4 had replaced both with a two-line `ssh, root, pci, eu-west-1` subtitle and moved the address to
|
||||||
|
> the tooltip alone, on the reasoning that a card read while scanning forty machines wants the kind of machine
|
||||||
|
> more than its address. v5's own mock draws chips and an address on every card, and the user chose the mock's
|
||||||
|
> arrangement over v4's when the two were put side by side — so the card now carries both: the full address is
|
||||||
|
> still on the tooltip, as v4 left it, and a truncated mono copy sits under the name as well.
|
||||||
|
>
|
||||||
|
> **Pinned folders are a full feature, not a field with nowhere to be edited.** `HostSecret.PinnedPaths` is an
|
||||||
|
> ordered, deduplicated list merged per path the way `TagIds` is merged per tag, so two machines pinning
|
||||||
|
> different folders on one host both keep theirs. The drawer's QUICK ACCESS section edits it — a row per pin
|
||||||
|
> with a close box, an add field and button, staged on `VaultViewModel.EditorPinnedPaths` the way the tag
|
||||||
|
> picker stages `editorTagIds` — the card shows a pin-count badge once there is at least one, and the detail
|
||||||
|
> pane repeats the list read-only in its own fieldrow idiom. Clicking a pin above a connected terminal opens
|
||||||
|
> the SFTP tab with the remote pane navigated straight to that path, through the same connect path "Browse
|
||||||
|
> files" already used — a second authenticated connection, exactly as every SFTP session here is.
|
||||||
|
>
|
||||||
|
> **Last connected is read off the synced connection log, and it is deliberately narrow about when.**
|
||||||
|
> `max(StartedAt)` per `HostId` is computed on the hosts screen's own activation and again when a session ends,
|
||||||
|
> then restrung into words on a one-minute tick for as long as the screen stays visible — never on the sync
|
||||||
|
> loop, which is the same rule `LogsViewModel` states for the log itself: nobody is watching their own
|
||||||
|
> connection from an hour ago update on a screen they are not looking at. A connected host prints nothing here;
|
||||||
|
> the green dot already answers "right now", and printing an age beside it would be answering a question about
|
||||||
|
> a session that is not the one still running. **It is scoped to the active vault alone**, the same limitation
|
||||||
|
> `LogsViewModel` and `MainWindowViewModel.RecentConnections` already carry — a host filed in a second vault
|
||||||
|
> this session can read gets no ago-text until something on this screen reads more than one vault's connection
|
||||||
|
> history, which nothing does yet. And a session ending can, briefly, still show the previous entry: the
|
||||||
|
> recorder's own write is queued onto a background task rather than made inline, so a re-read landing before
|
||||||
|
> that write drains reads the log as it stood before the session it is reporting on closed. It self-heals on
|
||||||
|
> the next activation or the next session end, and closing the gap outright was judged not worth blocking a
|
||||||
|
> screen transition on the recorder's queue.
|
||||||
|
>
|
||||||
|
> **Quick connect grew to match, and quietly answers a different question than the mock's own row does.** The
|
||||||
|
> card widened from 520 to 640, gained the mock's `>_` prompt styling and a live dot per row the way the hosts
|
||||||
|
> board's own two-state dot works. What it does not draw is the mock's SSH/SFTP kind column — every palette
|
||||||
|
> connection here is SSH, so a constant printed as though it varied would be exactly the kind of decoration
|
||||||
|
> this document has refused before (the terminal's session footer, the snippet's kind badge); the auth word
|
||||||
|
> (credential/key/password) answers a question that is actually different per row instead.
|
||||||
|
>
|
||||||
|
> | 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 | 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. |
|
||||||
|
> | QUICK ACCESS's editor, on the phone | ◆ **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
|
||||||
|
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
||||||
|
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
||||||
|
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
||||||
|
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5b — chrome, session shell and SFTP restyled
|
||||||
|
>
|
||||||
|
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
|
||||||
|
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
|
||||||
|
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
|
||||||
|
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
|
||||||
|
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
|
||||||
|
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
|
||||||
|
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
|
||||||
|
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
|
||||||
|
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
|
||||||
|
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
|
||||||
|
>
|
||||||
|
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
|
||||||
|
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
|
||||||
|
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
|
||||||
|
> because the segmented switcher now living at its head is only worth having if it stays reachable from
|
||||||
|
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
|
||||||
|
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
|
||||||
|
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
|
||||||
|
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
|
||||||
|
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
|
||||||
|
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
|
||||||
|
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
|
||||||
|
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
|
||||||
|
> surfaces pay for their own tab row out of their own session-shell budget instead. See
|
||||||
|
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
|
||||||
|
>
|
||||||
|
> | 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 | **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 | **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. |
|
||||||
|
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
|
||||||
|
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
|
||||||
|
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
|
||||||
|
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
|
||||||
|
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
|
||||||
|
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
|
||||||
|
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
|
||||||
|
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
|
||||||
|
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
|
||||||
|
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
|
||||||
|
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
|
||||||
|
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
|
||||||
|
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
|
||||||
|
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
|
||||||
|
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5c — a settings area, at last
|
||||||
|
>
|
||||||
|
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
|
||||||
|
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
|
||||||
|
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
|
||||||
|
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
|
||||||
|
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
|
||||||
|
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
|
||||||
|
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
|
||||||
|
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
|
||||||
|
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
|
||||||
|
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
|
||||||
|
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
|
||||||
|
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
|
||||||
|
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
|
||||||
|
> either screen directly.
|
||||||
|
>
|
||||||
|
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
|
||||||
|
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
|
||||||
|
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
|
||||||
|
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
|
||||||
|
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
|
||||||
|
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
|
||||||
|
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
|
||||||
|
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
|
||||||
|
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
|
||||||
|
> no status-bar fact to print and no pin for a sidebar to show.
|
||||||
|
>
|
||||||
|
> | v5c element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
|
||||||
|
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
|
||||||
|
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
|
||||||
|
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
|
||||||
|
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
|
||||||
|
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
|
||||||
|
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
|
||||||
|
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
|
||||||
|
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
|
||||||
|
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
|
||||||
|
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
|
||||||
|
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
|
||||||
|
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
|
||||||
|
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
|
||||||
|
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
|
||||||
|
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
|
||||||
|
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
|
||||||
|
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
|
||||||
|
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
|
||||||
|
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
|
||||||
|
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
|
||||||
|
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
|
||||||
|
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
|
||||||
|
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
|
||||||
|
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
|
||||||
|
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
|
||||||
|
|
||||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||||
@@ -172,16 +384,18 @@ it for revocation, for relay session recording and for what locking does not do
|
|||||||
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
||||||
interface either omits it or says so.
|
interface either omits it or says so.
|
||||||
|
|
||||||
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect; it is
|
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect. M2 and
|
||||||
M2 and M3 arriving in a design before it arrives in the code.
|
M3 have since arrived and their rows say so; what is left is either M5 — per-item content keys, and the
|
||||||
|
sharing that would rest on them — or a decision recorded here not to build the thing at all.
|
||||||
|
|
||||||
## The shape of the problem
|
## The shape of the problem
|
||||||
|
|
||||||
Three facts explain nearly every row below.
|
Three facts explain nearly every row below.
|
||||||
|
|
||||||
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
|
**An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
|
||||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
|
and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
|
||||||
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
|
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:
|
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
|
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
|
||||||
@@ -203,9 +417,12 @@ reason and is gone: membership is granted only to an account that exists, so the
|
|||||||
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
||||||
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
||||||
|
|
||||||
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
|
**The client's settings file holds two things.** It writes three files — `cache.db`, `device.key` and
|
||||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
`settings.json` — and the cache's six tables still hold none of them. `ClientSettings` carries the
|
||||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
terminal's text size and whether this machine looks for a newer build on its own, and both survive a
|
||||||
|
restart. Everything else on the design's TERMINAL panel is unbuilt rather than unbuildable: the data plane
|
||||||
|
grew `TerminalServerOpcode.FontSize` to carry the one that shipped, so a second option is an opcode, a
|
||||||
|
setting and a control rather than a new mechanism.
|
||||||
|
|
||||||
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
||||||
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
||||||
@@ -245,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
|
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.
|
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
|
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
|
||||||
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
|
`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
|
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
|
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.
|
be a second table and a second pass through every rule in this file, for nothing.
|
||||||
@@ -291,12 +510,12 @@ 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. |
|
| 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. |
|
| `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. |
|
| `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 `· 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 `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. |
|
| 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. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
| 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. |
|
||||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||||
|
|
||||||
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
||||||
@@ -317,15 +536,15 @@ 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. |
|
| 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. |
|
| `· ⤷ 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. |
|
| `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. |
|
| `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. |
|
| 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. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
|
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||||
| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
|
| Terminal font size (`--termfs`, 11–16px) | client-storage | — | **Shipped**, and wider than the design's range: 8–32px from the preferences screen or Ctrl+plus/minus/0 over a terminal, carried by `TerminalServerOpcode.FontSize` and kept in `settings.json`. It resizes the grid rather than magnifying it, so every live pane refits and the remotes are told how many columns they now have. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,11 +568,11 @@ connect, and it goes through the same host key gate, the same pin and the same t
|
|||||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
||||||
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
||||||
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
||||||
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
|
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs somewhere to record *what* wrote each part file — the source, the offset and the run — which is a table rather than a setting; `settings.json` holds two scalars and is not that. |
|
||||||
|
|
||||||
| Design element | Layer | What it would take | What ships instead |
|
| Design element | Layer | What it would take | What ships instead |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state per host, which is a table rather than a scalar — `settings.json` holds two of those and is the wrong shape for a row per machine. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
||||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
||||||
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
||||||
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
||||||
@@ -475,8 +694,8 @@ lists the rest as absent rather than omitting it silently.
|
|||||||
|
|
||||||
| Design element | Layer | What it would take |
|
| Design element | Layer | What it would take |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Any preference, saved | client-storage | A seventh table in the client cache, or a preference item type in the vault. Which one matters: the vault syncs, so a preference stored there follows you between machines, and a terminal font size probably should not. |
|
| Any preference, saved | — | **Two are.** `settings.json` beside the cache holds the terminal's text size and whether this machine checks for updates on its own. It is deliberately not in the vault: the vault syncs, so a preference kept there would follow you between machines, and a font size chosen for a laptop screen should not arrive on a desktop. Anything per-host or per-item wants a table instead — see the transfers rows above. |
|
||||||
| Terminal font, size, cursor style, cursor blink, scrollback | client-terminal | The store, **and** a frame to carry it. `TerminalServerOpcode` has four values (`Output`, `SessionOpened`, `SessionClosed`, `SessionActivated`) and none carries an option. |
|
| Terminal font, cursor style, cursor blink, scrollback | client-terminal | A setting, an opcode and a control, in that order — the mechanism is no longer the obstacle. `TerminalServerOpcode.FontSize` proved the path and `ClientSettings` is where the value would live; what none of these four has is somebody deciding it is worth a row on the preferences screen. |
|
||||||
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
||||||
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
||||||
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
||||||
@@ -504,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
|
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,
|
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.
|
`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.
|
||||||
@@ -3,8 +3,18 @@
|
|||||||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||||
in question and must not be assumed to work.
|
assumed to work.
|
||||||
|
|
||||||
|
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||||
|
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||||
|
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||||
|
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||||
|
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||||
|
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||||
|
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||||
|
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||||
|
otherwise.
|
||||||
|
|
||||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||||
has been verified or made moot — not when it merely stops being convenient.
|
has been verified or made moot — not when it merely stops being convenient.
|
||||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
|||||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||||
any vault on macOS.
|
any vault on macOS.
|
||||||
|
|
||||||
|
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||||
|
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||||
|
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||||
|
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||||
|
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||||
|
too. *Unverified:* no enclave call in this repository has ever run.
|
||||||
|
|
||||||
|
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||||
|
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||||
|
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||||
|
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||||
|
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||||
|
|
||||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
|||||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||||
or notarization fails with an error that does not name the offending file.
|
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||||
|
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||||
|
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||||
|
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||||
|
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||||
|
|
||||||
## Desktop client
|
## Desktop client
|
||||||
|
|
||||||
@@ -168,6 +195,18 @@ that reports `GetFocus()`, the class name of the window holding it, and the page
|
|||||||
could fire. Not Escape, and not a bare F6: both are keys a TUI legitimately binds, and Ctrl+Shift is the
|
could fire. Not Escape, and not a bare F6: both are keys a TUI legitimately binds, and Ctrl+Shift is the
|
||||||
range terminal emulators conventionally keep for themselves.
|
range terminal emulators conventionally keep for themselves.
|
||||||
|
|
||||||
|
**On the phone the same asymmetry arrives through a button, and the fix is one property.** The terminal's
|
||||||
|
accessory row — Ctrl, Esc, Tab, the arrows, and the two text-size keys — is a set of ordinary Avalonia
|
||||||
|
buttons over a `NativeWebView`. An ordinary button takes focus on tap, which takes it off the WebView, and
|
||||||
|
`OnLostFocus` then calls the adapter's `ResignFocus()`. So pressing Tab handed the terminal one byte and
|
||||||
|
took the keyboard away from it: everything typed afterwards on a hardware keyboard went nowhere.
|
||||||
|
|
||||||
|
The symptom is what makes it worth an entry. The row goes on working — its keys are *pressed* rather than
|
||||||
|
typed into — so what a user sees is a terminal that answers the buttons and ignores the keyboard, which
|
||||||
|
reads as the session having died rather than as a focus problem. `Focusable = false` is what a toolbar
|
||||||
|
button is, and it means the focused element never changes, so nothing resigns and nothing has to be handed
|
||||||
|
back. Every button on that row carries it; check 11.10 is the measurement.
|
||||||
|
|
||||||
None of this is covered by a test, and cannot be here: headless Avalonia has no native window, so a
|
None of this is covered by a test, and cannot be here: headless Avalonia has no native window, so a
|
||||||
headless test renders and focuses correctly and would confirm the wrong belief. What the suite covers is
|
headless test renders and focuses correctly and would confirm the wrong belief. What the suite covers is
|
||||||
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
|
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
|
||||||
@@ -349,11 +388,55 @@ 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
|
**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.
|
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
|
**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
|
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
|
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||||
|
|
||||||
|
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||||
|
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||||
|
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||||
|
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||||
|
application without solving the data plane differently first. See
|
||||||
|
`build/macos/DodoSSH.entitlements`.
|
||||||
|
|
||||||
|
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||||
|
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||||
|
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||||
|
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||||
|
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||||
|
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||||
|
process that dies during runtime initialisation, before anything exists that could report it.
|
||||||
|
|
||||||
|
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||||
|
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||||
|
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||||
|
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||||
|
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||||
|
|
||||||
|
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||||
|
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||||
|
template the release script renders rather than a committed file. A committed plist would carry one
|
||||||
|
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||||
|
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||||
|
|
||||||
|
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||||
|
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||||
|
that fraction and the `.ico` at full bleed, from one geometry.
|
||||||
|
|
||||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||||
@@ -556,12 +639,51 @@ Packaging the desktop client had broken the server's image build, and nothing bu
|
|||||||
have caught it. Found by running `docker build` locally rather than by reading the lock files.
|
have caught it. Found by running `docker build` locally rather than by reading the lock files.
|
||||||
|
|
||||||
So the RID stays out of the committed state, and the two commands that need one — the release script's
|
So the RID stays out of the committed state, and the two commands that need one — the release script's
|
||||||
publish and the `windows publish still resolves` step in `ci.yml` — pass `-p:RestoreLockedMode=false` for
|
publish and the `publish the windows desktop client` step in `ci.yml` — pass `-p:RestoreLockedMode=false`
|
||||||
themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
|
for themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
|
||||||
whose checkout is discarded and does matter on a developer's machine, so the release script runs
|
whose checkout is discarded and does matter on a developer's machine, so the release script runs
|
||||||
`git checkout -- '*packages.lock.json'` afterwards. `-p:RestorePackagesWithLockFile=false` is not an
|
`git checkout -- '*packages.lock.json'` afterwards. `-p:RestorePackagesWithLockFile=false` is not an
|
||||||
alternative: it fails NU1005 whenever a lock file already exists.
|
alternative: it fails NU1005 whenever a lock file already exists.
|
||||||
|
|
||||||
|
**`vpk` picks its target from the host, and cross-compiling is a bracketed directive rather than a flag.**
|
||||||
|
The CI job packages the Windows desktop client on a Linux runner, and plain `vpk pack --runtime win-x64`
|
||||||
|
there refuses outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
To build packages for Linux, the target rid must be Linux (actually was Windows). If your real intention
|
||||||
|
was to cross-compile a release for Windows then you should provide an OS directive: eg. 'vpk [win] pack ...'
|
||||||
|
```
|
||||||
|
|
||||||
|
The directive goes before the verb — `dotnet vpk '[win]' pack …` — and must be quoted in a POSIX shell,
|
||||||
|
where `[win]` is a glob matching any one of `w`, `i` and `n`. With it, a Linux runner logs
|
||||||
|
`Directive enabled for cross-compiling from Linux (current os) to Windows` and writes
|
||||||
|
`DodoSSH.Desktop-win-Setup.exe`, the portable zip, the `.nupkg` and `releases.win.json` — the same set a
|
||||||
|
Windows machine produces. `--runtime win-x64` is still required: the directive says which OS is being
|
||||||
|
targeted, not which RID. Only signing needs Windows tooling, which is why `ci.yml` can package and
|
||||||
|
`scripts/release-windows.ps1` will still be the thing that signs when there is a certificate to sign with.
|
||||||
|
|
||||||
|
**`dotnet msbuild -getProperty:Version` answers `1.0.0`, and MinVer is not to blame.** `-getProperty`
|
||||||
|
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
||||||
|
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
||||||
|
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
||||||
|
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||||
|
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||||
|
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
|
||||||
|
guard, which would have blamed `fetch-depth`.
|
||||||
|
|
||||||
|
**And naming that target requires a restore first, which is a second failure wearing a very different
|
||||||
|
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
|
||||||
|
exist at all on a clean checkout:
|
||||||
|
|
||||||
|
```
|
||||||
|
error MSB4057: The target "MinVer" does not exist in the project.
|
||||||
|
```
|
||||||
|
|
||||||
|
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
|
||||||
|
machine that has built the project before — which is every developer machine and no fresh runner. The
|
||||||
|
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
|
||||||
|
release script each restore before reading, deliberately and with a comment saying why.
|
||||||
|
|
||||||
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
||||||
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
||||||
`DodoSSH.Client.Android` is deliberately not in `DodoSSH.slnx` (it needs a workload the other two jobs
|
`DodoSSH.Client.Android` is deliberately not in `DodoSSH.slnx` (it needs a workload the other two jobs
|
||||||
@@ -590,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
|
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.
|
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
|
**.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
|
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.
|
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# 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
|
||||||
|
one of them works.
|
||||||
|
|
||||||
|
- 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: 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, 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 | **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
|
||||||
|
|
||||||
|
`HostSecret.RelayEnabled` is stored, validated — a relay host may not inherit its port — encoded, merged,
|
||||||
|
and drawn as a checkbox in the host editor on **both** heads. The desktop's says *"Connect through the
|
||||||
|
server relay"* and warns underneath that the address will be stored on the server in plain text. The
|
||||||
|
phone's says the same at more length.
|
||||||
|
|
||||||
|
Nothing on the client reads it. `VaultViewModel` builds `SshConnectionRequest(hostname, port, username,
|
||||||
|
credential)` and `SshNetConnectionFactory` dials that address directly, whether the box is ticked or not.
|
||||||
|
|
||||||
|
So a user who ticks it **pays the privacy and gets nothing**: the host's address and port leave the
|
||||||
|
encrypted payload and land in plaintext columns on the server — the one deliberate concession in the whole
|
||||||
|
design, per ADR 0004 — and the connection is still made from their laptop to the machine they already could
|
||||||
|
not reach. It then fails exactly as it did before, with no hint that the box did nothing.
|
||||||
|
|
||||||
|
This is worse than the jump chain, which is invisible and harmless. It is a control that spends something
|
||||||
|
real.
|
||||||
|
|
||||||
|
**Step 0 is done.** Both heads now name the absence in the label and lead the paragraph under it with what
|
||||||
|
ticking the box actually does today — the way this codebase already handles port forwarding on the phone's
|
||||||
|
More screen. It is left *tickable* rather than disabled, deliberately: a host that already carries the flag
|
||||||
|
has to be able to lose it, and a disabled control would trap the concession on. The sentence comes out again
|
||||||
|
at step 3.
|
||||||
|
|
||||||
|
## The comparison
|
||||||
|
|
||||||
|
Both answers put something between the user and a machine they cannot dial. What differs is *what* is in
|
||||||
|
between, what it costs, and who has to own it.
|
||||||
|
|
||||||
|
| | Relay | Jump host |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Reaches** | Anything the **deployment** can reach | Anything a **machine already in the keychain** can reach |
|
||||||
|
| **Asks of the deployment** | It must sit where it can dial the target, and have the relay enabled | Nothing. The server is not involved at all |
|
||||||
|
| **Tells the operator** | The host's address and port, in plaintext columns, for every opted-in host — plus an audit row per session: target, duration, bytes, close reason, client IP | Nothing beyond the sync metadata every item already produces |
|
||||||
|
| **The intermediate's credentials** | None to manage. The deployment is the intermediate | The bastion is an ordinary host: its own key or password, its own host key to pin, its own group defaults |
|
||||||
|
| **Where SSH terminates** | On the laptop. The relay sees ciphertext, and ADR 0004 is emphatic that no recording is possible | On the laptop. The bastion forwards a TCP stream inside a session the user opened to it |
|
||||||
|
| **When it is unavailable** | Deployment down, no connection — including to hosts that were reachable directly | Bastion down, no connection to what is behind it |
|
||||||
|
| **Fits an estate where** | The DodoSSH server is *inside* the network the targets are on | A bastion is the policy and the server is outside — which is the ordinary enterprise shape |
|
||||||
|
| **Auditable by the operator** | Yes, coarsely, and that is a feature for a team deployment | No, and that is a feature for a private one |
|
||||||
|
|
||||||
|
**The two are not substitutes, and the deciding question is where the deployment sits.** The relay only
|
||||||
|
answers "unreachable" when the server has line of sight the laptop lacks — a deployment inside the VPC, on
|
||||||
|
the office network, on the same Tailnet. Point it at a self-hosted box outside the target's network, which
|
||||||
|
is what most people running this on a VPS will have, and the relay reaches nothing the laptop could not
|
||||||
|
already reach.
|
||||||
|
|
||||||
|
**And you do not get to choose other people's topology.** Shipping this to strangers means shipping into
|
||||||
|
estates whose shape is already decided, and bastion-fronted is the common one. Their `ssh_config` says so:
|
||||||
|
the importer reads `ProxyJump`, records it as an option and writes a note on the host saying *"DodoSSH does
|
||||||
|
not route through a jump host yet"* — a first-run experience that names the limitation on the hosts it
|
||||||
|
matters for.
|
||||||
|
|
||||||
|
**A relay is not a bastion with better manners.** ADR 0004 rejected "server terminates SSH" and kept
|
||||||
|
zero-knowledge, which is right and is not what a jump host asks for either: forwarding a TCP stream through
|
||||||
|
a machine the user has authenticated to reveals nothing to the operator, because the operator is not in it.
|
||||||
|
The privacy ordering is the opposite of what the ADR's framing suggests — the relay is the mechanism that
|
||||||
|
costs a plaintext address, and the jump host is the one that costs nothing.
|
||||||
|
|
||||||
|
## They are one piece of work, and ADR 0004 says so
|
||||||
|
|
||||||
|
The last consequence in ADR 0004, written before either half was built:
|
||||||
|
|
||||||
|
> On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP
|
||||||
|
> bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — **one mechanism, two features**.
|
||||||
|
|
||||||
|
That is the plan, and it holds up against the pinned package. SSH.NET 2025.1.0 offers
|
||||||
|
`ForwardedPortDynamic`, which is a SOCKS5 proxy served over an established `SshClient`, and
|
||||||
|
`ConnectionInfo(host, port, username, ProxyTypes, proxyHost, proxyPort, proxyUsername, proxyPassword,
|
||||||
|
AuthenticationMethod[])` with `ProxyTypes.Socks5` — checked in `Renci.SshNet.xml` rather than remembered. So:
|
||||||
|
|
||||||
|
- **Jump host:** connect to the bastion as an ordinary host, `AddForwardedPort(new ForwardedPortDynamic(0))`
|
||||||
|
on it, then dial the target with a `ConnectionInfo` pointed at that loopback SOCKS5 port. A chain of two
|
||||||
|
is the same trick twice.
|
||||||
|
- **Relay:** the same shape with a different thing on the loopback socket — a listener that pipes bytes into
|
||||||
|
the `dodossh.relay.v1` WebSocket instead of into a bastion's forward.
|
||||||
|
|
||||||
|
### ◆ But the bridge is the relay's half, not the shared one
|
||||||
|
|
||||||
|
Written into step 1 of this plan and wrong. Building it turned the sentence around: **the jump-host path
|
||||||
|
needs no bridge from this repository at all.** `ForwardedPortDynamic` *is* the loopback listener — SSH.NET
|
||||||
|
accepts on it, speaks SOCKS5 on it, and tunnels what it accepts through the bastion. Nothing is left for a
|
||||||
|
bridge of ours to do. The relay is the case with no `SshClient` to hang a forward off, so it is the one that
|
||||||
|
needs a listener written here, and it is the bridge's only consumer.
|
||||||
|
|
||||||
|
What the two genuinely share is one step lower: **the connection being told to reach its target through a
|
||||||
|
loopback SOCKS5 proxy, while staying about the target**. That is the piece both features stand on, it is
|
||||||
|
fifteen lines in `BuildConnectionInfo`, and it is what step 1 turned out to be.
|
||||||
|
|
||||||
|
So the bridge moves to step 3 and lands with the feature that uses it. Building it now would have been a
|
||||||
|
component whose only caller was two steps away — which is the shape of the two defects this document is
|
||||||
|
about.
|
||||||
|
|
||||||
|
## The work, in order
|
||||||
|
|
||||||
|
**0. Stop promising the relay.** ✅ The checkbox states that the relay is not wired up yet. One line on each
|
||||||
|
head, and the only step that should ship on its own.
|
||||||
|
|
||||||
|
**1. Dial through a loopback proxy.** ✅ `SshConnectionRequest` carries an optional `SshLoopbackProxy`, and
|
||||||
|
`BuildConnectionInfo` builds SSH.NET's proxy `ConnectionInfo` when it is there. Three properties are worth
|
||||||
|
knowing, and each is held by a test in `LoopbackProxyTests`:
|
||||||
|
|
||||||
|
- **The type is a port and nothing else.** A proxy on any interface but loopback cannot be expressed, which
|
||||||
|
matters because the failure mode is an open SOCKS proxy on the user's network for the life of a shell,
|
||||||
|
and nothing would report it.
|
||||||
|
- **SOCKS5 rather than a dumb pipe, so the target stays the target.** The host and port in the request are
|
||||||
|
the ones SSH.NET dials *through* the proxy and the ones the host key gate pins — so the same machine
|
||||||
|
reached through a bastion is pinned under its own name, not under `127.0.0.1:<ephemeral>`, which is not
|
||||||
|
an identity at all.
|
||||||
|
- **A proxy that is not listening fails as a connection error**, not as an unknown host key. The gate
|
||||||
|
translates "no host key seen" into a fingerprint prompt, and a connection that never reached a server has
|
||||||
|
seen none either — so the prompt would offer to fix the wrong thing, with no fingerprint to show.
|
||||||
|
|
||||||
|
Nothing calls it with a proxy yet. That is deliberate and it is one step wide: step 2 is the caller.
|
||||||
|
|
||||||
|
**2. Jump hosts.** No server change, and no bridge. In order:
|
||||||
|
|
||||||
|
- The hop's own connection, and a `ForwardedPortDynamic("127.0.0.1", 0)` started on it, whose `BoundPort`
|
||||||
|
becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice.
|
||||||
|
- `VaultViewModel` resolves `JumpHostIds` to hosts in the same vault, applying group inheritance per hop the
|
||||||
|
way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group
|
||||||
|
picker already makes, for the same reason.
|
||||||
|
- ◆ **The target's address is resolved at the last hop, not here.** A SOCKS CONNECT names the target and the
|
||||||
|
bastion resolves it, so what has to be stored on the host is the address *the bastion* can reach — which
|
||||||
|
is what an `ssh_config` means by `HostName` beside a `ProxyJump`, and what the importer is already
|
||||||
|
carrying across verbatim. Nothing needs to change for that to be true; it needs to be said, because a
|
||||||
|
host that resolves here and not there fails as a SOCKS "general failure" naming neither end.
|
||||||
|
- Per-hop host keys. Each hop is a separate handshake against a separate endpoint, so the pin, the unknown
|
||||||
|
key prompt and the changed-key refusal run per hop. **The prompt has to name which hop it is about**, or
|
||||||
|
somebody approves a bastion's fingerprint believing it is the target's — see `HostKeyCard`, which is built
|
||||||
|
around one connection and one question.
|
||||||
|
- Per-hop credentials, including a hop that wants a typed password. `IsAskingForConnectPassword` asks about
|
||||||
|
one host today.
|
||||||
|
- Teardown: the hops belong to the outer session and go with it, including when the outer connect fails
|
||||||
|
half way. A leaked bastion connection is an open session on a machine the user believes they left.
|
||||||
|
- The schema version. A chain becomes a real field, so it joins the ladder in `HostSecretCodec` — a host
|
||||||
|
carrying one must not be editable by a client that would drop it. That is the whole point of the rule.
|
||||||
|
- The editor: a picker over other hosts in the same vault, and the host detail's subtitle finally getting
|
||||||
|
the `⤷ bastion-eu` the design asked for.
|
||||||
|
|
||||||
|
**3. The relay, and the bridge with it.** A loopback `TcpListener` on an ephemeral port that accepts one
|
||||||
|
connection, answers a SOCKS5 CONNECT on it, and pipes the rest into the `dodossh.relay.v1` WebSocket —
|
||||||
|
SOCKS5 rather than a raw pipe so that this path presents the same interface step 1 already speaks, and the
|
||||||
|
target's identity stays the target's. Then `POST /relay/tickets` with the host id and the WebSocket with the
|
||||||
|
ticket in `Sec-WebSocket-Protocol`; the ticket is single-use and expires in 30 seconds, so it is fetched per
|
||||||
|
connect and never cached. The bridge binds `127.0.0.1` explicitly, accepts once and stops listening. Then
|
||||||
|
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. ✅ 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
|
||||||
|
|
||||||
|
**A relay host may not inherit its port, and a jump host has no such rule.** `TryValidate` enforces the
|
||||||
|
first because the server stores the port and a group edit would silently change what the relay dials. The
|
||||||
|
chain has no plaintext counterpart, so it inherits normally — do not copy the restriction across out of
|
||||||
|
symmetry.
|
||||||
|
|
||||||
|
**Two hosts can name each other.** A chain is ids, and nothing stops A jumping through B while B jumps
|
||||||
|
through A. Resolve iteratively with a visited set and refuse a cycle before dialling anything, rather than
|
||||||
|
discovering it as a stack overflow inside a connect.
|
||||||
|
|
||||||
|
**The bastion's own group defaults matter.** A hop is a host, so it resolves its port, username and binding
|
||||||
|
through `HostInheritance` exactly as the target does. Skipping that dials 22 as nobody on a bastion that is
|
||||||
|
on 2222 as `deploy`.
|
||||||
|
|
||||||
|
**`ForwardedPortDynamic(0)` reports its port, and the one-argument constructor's bind address is
|
||||||
|
undocumented.** The first is now pinned by a test rather than trusted. The second is why the two-argument
|
||||||
|
constructor is always used: if the default were `0.0.0.0` the failure would not be a test failure, it would
|
||||||
|
be a SOCKS proxy into the developer's network that nothing reports, so the bound host is asserted too.
|
||||||
|
|
||||||
|
**◆ The test server refuses forwarding, and says so nowhere useful.** `linuxserver/openssh-server` ships
|
||||||
|
`AllowTcpForwarding no`. A dynamic forward starts anyway — opening one asks the server nothing — and every
|
||||||
|
connection through it is then refused at channel-open, which SSH.NET surfaces as `SOCKS5: General failure`,
|
||||||
|
naming neither the server nor the setting. `SshServerFixture` patches it after start and HUPs sshd. **There
|
||||||
|
are two `sshd_config` files in that image** and the running server uses `/config/sshd/sshd_config`; patching
|
||||||
|
`/etc/ssh/sshd_config`, which is the one a search finds first, changes the text and nothing else.
|
||||||
|
|
||||||
|
**The relay bridge and the jump bridge are the same class and not the same lifetime.** A ticket is
|
||||||
|
single-use with a 30-second expiry; a bastion's forward lives as long as the session. Sharing the listener
|
||||||
|
is right, sharing a lifetime policy is not.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- ✅ A connect through a real SOCKS5 forward to a real `sshd`, in `LoopbackProxyTests`. **One container,
|
||||||
|
used as both ends** — the forward is opened on a connection to the fixture's server and the connection
|
||||||
|
under test goes back to the same server through it. A second container would look more like the topology
|
||||||
|
and establish nothing extra: what is under test is that the proxy is honoured, that the target is what
|
||||||
|
gets pinned, and that a failure on the way through is reported as itself.
|
||||||
|
- A cycle in a chain is refused before any socket is opened.
|
||||||
|
- Each hop's host key is asked about separately, and the question names the hop.
|
||||||
|
- A chain crossing a vault is refused with a reason, as the group picker's is.
|
||||||
|
- The bridge binds loopback only — assert the bound address, because the failure is silent and the
|
||||||
|
consequence is an open proxy.
|
||||||
|
- SFTP to a host behind a chain, once step 4 lands.
|
||||||
|
- Mutations that must fail something: bind the bridge on `IPAddress.Any`; drop the visited set; skip group
|
||||||
|
inheritance for a hop; and tear down the outer session without the hops.
|
||||||
|
|
||||||
|
## Prose that becomes false
|
||||||
|
|
||||||
|
- `docs/design-import-gaps.md` — the host subtitle's `⤷ bastion-eu` row, the SFTP `sftp over bastion-eu`
|
||||||
|
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. ✅ 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
|
||||||
|
|
||||||
|
It recommended deleting `JumpHostIds`, on the evidence that nothing writes it, nothing reads it, and it is
|
||||||
|
missing from the schema-version ladder. The first two facts are true and the conclusion did not follow.
|
||||||
|
|
||||||
|
Two things were missed. **ADR 0004 had already designed the implementation** — the loopback bridge, the
|
||||||
|
SOCKS5 dynamic forward, "one mechanism, two features" — so the transport was a solved problem sitting in an
|
||||||
|
accepted ADR, and the fortnight that draft estimated was priced without it. And **the stored shape is
|
||||||
|
right**: an ordered list of host ids is exactly what a chain is, the merge arm is already correct, and the
|
||||||
|
missing schema version is a line to add rather than evidence of a bad model.
|
||||||
|
|
||||||
|
The lesson is narrower than "read the ADRs": it is that *nothing reads this field* was taken as evidence the
|
||||||
|
field was a mistake, when it was evidence of an unfinished feature — and the same reasoning applied one
|
||||||
|
paragraph further would have found the relay checkbox, which is the same shape and is actively lying to
|
||||||
|
users.
|
||||||
|
|
||||||
|
**And then step 1 was wrong too**, in the same direction: it named the bridge as the shared foundation on
|
||||||
|
the strength of ADR 0004's "one mechanism, two features", without checking which half of the mechanism
|
||||||
|
SSH.NET already provides. It provides the jump host's half entirely. The shared piece was one level down and
|
||||||
|
a tenth of the size, and the bridge belongs with the relay. Both corrections came from writing the code
|
||||||
|
rather than from reading more — which is an argument for the step-at-a-time ordering rather than against
|
||||||
|
planning, but only if the plan is edited when a step answers back.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Unlocking without the passphrase
|
||||||
|
|
||||||
|
Every account here is issued a recovery code at enrollment. It is generated on the client, it wraps the
|
||||||
|
identity bundle, the server stores that wrap, and both heads go to some trouble to make sure the user writes
|
||||||
|
it down — the phone raises `FLAG_SECURE` for that screen alone and refuses to let anybody click past it.
|
||||||
|
|
||||||
|
**Nothing can use it.** There is no code path in this product that opens a recovery wrap. This document is
|
||||||
|
the plan for the change that fixes that, and it is written to be picked up cold.
|
||||||
|
|
||||||
|
> **Status: planned. None of the six steps below is built.**
|
||||||
|
>
|
||||||
|
> | Step | State | Notes |
|
||||||
|
> | --- | --- | --- |
|
||||||
|
> | 1. The endpoint that serves the wrap | Not started | `GET /api/v1/me/recovery-wrap`, and deliberately not `/me` |
|
||||||
|
> | 2. `SessionOpener.UnlockWithRecoveryAsync` | Not started | The unwrap, then the path `UnlockAsync` already takes |
|
||||||
|
> | 3. Setting a new passphrase | Not started | `PUT /api/v1/me/wrap`. Delivers *change passphrase* as well |
|
||||||
|
> | 4. Both heads | Not started | A way in from the unlock screen, and a box for the code |
|
||||||
|
> | 5. The prose that becomes false | Not started | Three shipped claims disagree with each other today |
|
||||||
|
> | 6. `crypto.md`'s status note | Not started | "One of four ways" is one of two, and will be one of three |
|
||||||
|
|
||||||
|
## What is wrong
|
||||||
|
|
||||||
|
Walk the failure through, because it is worse than a missing feature.
|
||||||
|
|
||||||
|
Forget the passphrase and the identity bundle cannot be unwrapped. No bundle means no vault keys, and no
|
||||||
|
vault keys means every item in every vault is unreadable. Signing out and back in does not help: the server
|
||||||
|
hands back the same passphrase wrap. The device key would be the other door, and **sign-out withdraws it** —
|
||||||
|
which is the advice the unlock screen gives for exactly this situation. The recovery wrap sitting on the
|
||||||
|
server is the only thing left, and nothing opens it.
|
||||||
|
|
||||||
|
So the loss is total and permanent, and the thing built to prevent it is inert.
|
||||||
|
|
||||||
|
Meanwhile the product says three things about this, and they do not agree with one another:
|
||||||
|
|
||||||
|
| What it says | Where | True today |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| "The code is the only thing standing between a forgotten passphrase and an unrecoverable vault" | `docs/manual-checks.md` §10.2 | **No.** It stands between nothing |
|
||||||
|
| "losing it *along with* the passphrase means the vault is unrecoverable" | `docs/android-port.md`, state 4 | **No.** It implies the code alone is enough |
|
||||||
|
| "Signing out … is the only answer to a forgotten passphrase — nothing can recover one" | `README.md`, Locking | Yes, and it contradicts both of the above |
|
||||||
|
|
||||||
|
The third is the honest one. That is the state this plan changes, and until it does, the first two are the
|
||||||
|
two sentences in this repository most likely to cost somebody their keychain.
|
||||||
|
|
||||||
|
## What already exists
|
||||||
|
|
||||||
|
More than half of it, which is why this is worth doing now rather than treating as a feature.
|
||||||
|
|
||||||
|
- **The code.** `ClientEnrollment.GenerateRecoveryCode` — 160 bits, base32 over a 32-character alphabet with
|
||||||
|
`I`, `L`, `O` and `U` left out so a transcription cannot land on a different valid code, printed as
|
||||||
|
32 characters in groups of five.
|
||||||
|
- **The wrap.** The same class derives `KEK_rc` under `Argon2Profile.RandomSecret` (64 MiB, 3 passes) and
|
||||||
|
sends `RecoveryWrappedPrivateKey` and `RecoveryKdfParameters` with the enrollment.
|
||||||
|
- **The row.** `EnrollmentService` stores it as `UserKeyWrapKind.Recovery`, beside the passphrase and device
|
||||||
|
wraps.
|
||||||
|
- **The specification.** `docs/crypto.md` §2 gives the parameters for `KEK_rc` and §3 puts the wrap in the
|
||||||
|
key hierarchy beside the passphrase and device ones. Nothing below needs a spec change.
|
||||||
|
- **The cache.** `LocalCacheKey` derives from the identity bundle under `dsh1/localcache/v2`, not from `MK`
|
||||||
|
— see the changed-2026-07-30 note in `crypto.md` §3.2. That change was made partly *for* this: a recovery
|
||||||
|
unlock derives a different `MK` and would otherwise open the identity and then fail to read the cache it
|
||||||
|
had itself written. It is already paid for and currently untested from this direction.
|
||||||
|
|
||||||
|
## What is missing, exactly
|
||||||
|
|
||||||
|
| | Gap | Where it lands |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | Nothing serves the wrap. `MeResponse` carries `WrappedPrivateKey` and `KdfParameters` — the passphrase pair, and only that | `DodoSSH.Contracts`, `Api/Features/Identity` |
|
||||||
|
| B | No unlock path. `SessionOpener` has `UnlockAsync` and `UnlockWithDeviceAsync`, and nothing else | `Client.Session` |
|
||||||
|
| C | No way to set a new passphrase afterwards. No endpoint, no client path, no UI | server + client |
|
||||||
|
| D | No way in from either unlock screen | `UnlockCard.axaml`, Android `LockedScreen.axaml` |
|
||||||
|
|
||||||
|
The device wrap is not a precedent for A: it is cached locally in `StoredUnlockMaterial` at the moment it is
|
||||||
|
registered, so it never has to be fetched.
|
||||||
|
|
||||||
|
## The decisions, and the reasons
|
||||||
|
|
||||||
|
**A separate endpoint, not `/me`.** `GET /api/v1/me/recovery-wrap`, called only when somebody has said they
|
||||||
|
have forgotten their passphrase. `/me` is fetched by every client at every launch, and putting the recovery
|
||||||
|
wrap in it would hand that blob to anybody holding a stolen OIDC session, permanently, for no benefit. The
|
||||||
|
wrap's real defence is Argon2id over 160 bits and it does not stop being safe in the response — this is the
|
||||||
|
cheaper rule of not serving what nothing needs. It answers 404 for an account with no recovery row, which is
|
||||||
|
every account enrolled by a client that did not send one.
|
||||||
|
|
||||||
|
**Online only, and the screen says so.** The wrap is deliberately *not* added to `StoredUnlockMaterial`.
|
||||||
|
Caching it would put a second door on every laptop, protected by a weaker KDF profile than the passphrase
|
||||||
|
one, for a case that already requires a network — recovery begins with signing in.
|
||||||
|
|
||||||
|
**Setting a new passphrase is part of the flow, not a follow-up.** Without it the account unlocks with a
|
||||||
|
one-time code forever, and the code is a thing people keep on paper. This is the same re-wrap a *change
|
||||||
|
passphrase* feature needs, so step 3 delivers both; change-passphrase is then a button, not a project.
|
||||||
|
|
||||||
|
**The identity key is not rotated.** Rotating it would invalidate every vault key grant the account holds
|
||||||
|
and force a rekey of every vault it can read — see [ADR 0010](adr/0010-vault-key-rotation.md) for what one
|
||||||
|
of those costs, and it is per vault. Nothing about the bundle is compromised by its owner proving possession
|
||||||
|
of it, so there is nothing to rotate away from. Identity key rotation is M5 and stays there.
|
||||||
|
|
||||||
|
**The recovery code is not re-issued after use.** Issuing a new one means asking somebody to write down a
|
||||||
|
new code at the exact moment they have demonstrated they lose things, and the old code is not weakened by
|
||||||
|
having been typed. "Issue a new recovery code" is a separate feature with its own screen, and it is the
|
||||||
|
right place for that question.
|
||||||
|
|
||||||
|
**Rate limiting is wanted here and is not a blocker.** This is the first endpoint in the product with a
|
||||||
|
guessable secret behind it, and `docs/platform-flags.md` records that rate limiting is unimplemented (M2).
|
||||||
|
Argon2id at 64 MiB is the cost that matters — a guessing attack pays it per attempt — so this ships without
|
||||||
|
and the endpoint is the reason to do the M2 item next.
|
||||||
|
|
||||||
|
## The work, in order
|
||||||
|
|
||||||
|
Each step compiles with the whole suite green before the next begins.
|
||||||
|
|
||||||
|
1. **The endpoint.** `GET /api/v1/me/recovery-wrap`, returning the wrap and its `KdfParameters`, or 404. A
|
||||||
|
contract type, an entry in `PublicAPI.Unshipped.txt`, and a `SyncEndpointTests`-style refusal test for an
|
||||||
|
account without one.
|
||||||
|
2. **The unlock.** `SessionOpener.UnlockWithRecoveryAsync(string code)`: canonicalise the code, derive
|
||||||
|
`KEK_rc` from the served parameters, unwrap the bundle, and then join the path `UnlockAsync` already
|
||||||
|
takes from the moment it holds one. Nothing after the unwrap should be new code — if it is, the two
|
||||||
|
unlocks have diverged and one of them is wrong.
|
||||||
|
3. **The re-wrap.** `PUT /api/v1/me/wrap` taking a new passphrase wrap and its parameters, and the client
|
||||||
|
half that derives the new `KEK_pp` and calls it. Forced as the last step of a recovery: the session is
|
||||||
|
open, so refusing to go further until a passphrase is set costs nothing and is the only moment the user
|
||||||
|
is certainly present.
|
||||||
|
4. **Both heads.** A "forgotten your passphrase?" route from `UnlockCard` and from Android's
|
||||||
|
`LockedScreen`, a box that accepts the code as printed, and the new-passphrase form after it. The phone's
|
||||||
|
version has to survive the keyboard covering the box — `docs/manual-checks.md` §10.3 lists the five boxes
|
||||||
|
that already do, and this is a sixth.
|
||||||
|
5. **The prose.** Below.
|
||||||
|
6. **`crypto.md`.** A status note, not a spec change — see the last section.
|
||||||
|
|
||||||
|
## Traps already known
|
||||||
|
|
||||||
|
**◆ The code is derived from the string exactly as displayed, dashes included.** `GenerateRecoveryCode`
|
||||||
|
says so where it builds the groups: the separators are not decoration to be stripped before hashing, they
|
||||||
|
are part of the input. So step 2 must *canonicalise to the printed form* rather than normalise it away —
|
||||||
|
up-case, then re-group in fives — and it must not simply strip dashes and hash what is left. Getting this
|
||||||
|
wrong produces a code that verifies nowhere and an error message that says the code is wrong.
|
||||||
|
|
||||||
|
**The alphabet has holes in it.** `I`, `L`, `O` and `U` are not in it. A user who writes `O` for `0` should
|
||||||
|
be met with a code that works, so the canonicaliser should fold the four missing letters onto their
|
||||||
|
look-alikes before deriving. That is a deliberate leniency and belongs beside the alphabet's own comment.
|
||||||
|
|
||||||
|
**The two KDF profiles are not the same, and the stored parameters are what to use.** The passphrase wrap is
|
||||||
|
`PassphraseDefault` (256 MiB, 4 passes) and the recovery wrap is `RandomSecret` (64 MiB, 3), which is
|
||||||
|
correct — 160 random bits do not need the same stretching as a chosen phrase. Derive from the parameters the
|
||||||
|
server served with the wrap, never from a profile constant, or an account enrolled by a client with
|
||||||
|
different settings will refuse a valid code.
|
||||||
|
|
||||||
|
**The cache property is untested from this direction.** A recovery unlock derives a different `MK` and must
|
||||||
|
still read a cache written under a passphrase unlock. That works today because `LocalCacheKey` hangs off the
|
||||||
|
bundle, and no test asserts it, because nothing could reach the state. Assert it in step 2 rather than
|
||||||
|
trusting the note in `crypto.md`.
|
||||||
|
|
||||||
|
**Sign-out is the neighbouring path and must stay reachable.** The unlock screen offers signing out as the
|
||||||
|
answer to a forgotten passphrase. It stays: recovery needs the code, and somebody without it still needs a
|
||||||
|
way to hand a laptop on. What changes is that it stops being the *only* answer, so the copy on both screens
|
||||||
|
has to place them beside each other rather than replacing one with the other.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Round trip: enrol, recover, unlock, set a new passphrase, unlock again with it.
|
||||||
|
- The old passphrase stops working after step 3, and the recovery code still does.
|
||||||
|
- A wrong code is refused without disclosing whether the account has a recovery wrap at all.
|
||||||
|
- The cache written under a passphrase unlock is readable after a recovery unlock — the `localcache/v2`
|
||||||
|
property, asserted rather than assumed.
|
||||||
|
- An account with no recovery row: the endpoint 404s and the screen says the code was never issued rather
|
||||||
|
than that it is wrong.
|
||||||
|
- One `SystemTests` pass against the real API, because this is a two-endpoint flow and that suite is the one
|
||||||
|
that consumes the artefacts that ship.
|
||||||
|
- Mutations to confirm each test can fail: reverse the canonicalisation so dashes are stripped; derive from
|
||||||
|
`Argon2Profile.RandomSecret` instead of from the served parameters; and skip the forced re-wrap in step 3.
|
||||||
|
|
||||||
|
## Prose that becomes false
|
||||||
|
|
||||||
|
Not a tidy-up. Each of these currently tells a user something that this change makes wrong in the other
|
||||||
|
direction, and two of them are wrong right now.
|
||||||
|
|
||||||
|
- `docs/manual-checks.md` §10.2 — "the only thing standing between a forgotten passphrase and an
|
||||||
|
unrecoverable vault". True once this ships, false today. It also needs a new check: typing a code from
|
||||||
|
paper, which is the one part of this no test can perform.
|
||||||
|
- `docs/android-port.md`, state 4 — "losing it along with the passphrase means the vault is unrecoverable".
|
||||||
|
- `README.md`, Locking — "Signing out … is the only answer to a forgotten passphrase — nothing can recover
|
||||||
|
one." Correct today and the sentence this change exists to falsify.
|
||||||
|
- `docs/design-import-gaps.md`, Preferences — "a forgotten passphrase — which is unrecoverable by
|
||||||
|
construction, so the unlock screen would otherwise be a dead end", which is the stated justification for
|
||||||
|
signing out being on that screen. The justification survives; the parenthesis does not.
|
||||||
|
- Both unlock screens, wherever they name signing out as the answer.
|
||||||
|
|
||||||
|
## The spec's "four ways"
|
||||||
|
|
||||||
|
`docs/crypto.md` §3.2 says a passphrase is "only one of four ways to open a vault" — passphrase, device,
|
||||||
|
recovery and escrow. Two exist. This change makes it three; escrow is M5 by decision and has an enum member
|
||||||
|
and nothing else.
|
||||||
|
|
||||||
|
**Do not edit that sentence.** It is normative and it is about the DSH1 format, where four ways is exactly
|
||||||
|
right. What is missing is a statement about *this build*, so add a short table under §3.2 saying which wrap
|
||||||
|
kinds this client can create and which it can open, and update it here rather than in the frozen prose. Land
|
||||||
|
it in the same commit as step 2, so the two can never disagree again.
|
||||||
@@ -26,14 +26,37 @@ echo "==> restore"
|
|||||||
# Locked mode, the same gate the solution restore gives every other project. This head is not in
|
# Locked mode, the same gate the solution restore gives every other project. This head is not in
|
||||||
# DodoSSH.slnx — it needs a workload the other jobs have no reason to install — so this is the only
|
# DodoSSH.slnx — it needs a workload the other jobs have no reason to install — so this is the only
|
||||||
# place its lock file is ever checked. It has silently gone stale before.
|
# place its lock file is ever checked. It has silently gone stale before.
|
||||||
|
#
|
||||||
|
# Restored without a channel and built with one, which is safe and worth one line: the two channel
|
||||||
|
# PropertyGroups change the application id, the version and some assembly metadata, and none of those
|
||||||
|
# is in the restore graph. If a channel ever gains a PackageReference of its own, this splits and the
|
||||||
|
# restore has to move under the same -p: as the build.
|
||||||
|
#
|
||||||
|
# Into /root/.nuget/packages, which the job mounts as a named volume — see the workflow. That is the
|
||||||
|
# whole of this job's package caching and it is what took a run from eight minutes to two and a half.
|
||||||
dotnet restore "$project" --locked-mode
|
dotnet restore "$project" --locked-mode
|
||||||
|
|
||||||
echo "==> build"
|
|
||||||
dotnet build "$project" \
|
|
||||||
--no-restore --configuration Release \
|
|
||||||
"-p:Aapt2ToolPath=$build_tools"
|
|
||||||
|
|
||||||
echo "==> package the nightly"
|
echo "==> package the nightly"
|
||||||
|
# ◆ ONE BUILD, WHERE THERE WERE TWO, AND THE FIRST WAS NOT A CHECK.
|
||||||
|
#
|
||||||
|
# There used to be a plain `dotnet build` above this. It looked like a cheap compile before the
|
||||||
|
# expensive packaging and it was neither: SignAndroidPackage depends on Build, so this line compiles
|
||||||
|
# everything anyway, and the build above ran with no -p:DodoChannel — which means it ran as the
|
||||||
|
# *release* channel. Different ApplicationId, different ApplicationVersion, different assembly
|
||||||
|
# metadata; MSBuild treats a different set of global properties as a different project instance, so
|
||||||
|
# not one output was reused. It was a complete second compile of the whole reference closure,
|
||||||
|
# producing an APK for a channel this job must never build, thrown away unread.
|
||||||
|
#
|
||||||
|
# Removing it does not lose a check. Anything that fails in a compile fails in this one, and the
|
||||||
|
# link-time failures below are the ones only packaging finds.
|
||||||
|
#
|
||||||
|
# Measured in the toolchain image with a warm package volume, same commit, same APK out of both:
|
||||||
|
#
|
||||||
|
# two builds 2m52 + 2m26 5m21 total
|
||||||
|
# one build 3m01 3m04 total
|
||||||
|
#
|
||||||
|
# versionCode 203, versionName 0.0.0-alpha.0.136, dev.dodotech.dodossh.nightly, either way.
|
||||||
|
#
|
||||||
# Packaging rather than only compiling, because the two failures this head is most exposed to are both
|
# Packaging rather than only compiling, because the two failures this head is most exposed to are both
|
||||||
# link-time: a native library with no android ABI, and a managed assembly that resolves for net10.0 but
|
# link-time: a native library with no android ABI, and a managed assembly that resolves for net10.0 but
|
||||||
# has nothing to dex. Neither shows up in a compile.
|
# has nothing to dex. Neither shows up in a compile.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -50,7 +50,7 @@ $RepoRoot = Split-Path -Parent $PSScriptRoot
|
|||||||
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj'
|
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj'
|
||||||
$Staging = Join-Path $RepoRoot 'artifacts/android'
|
$Staging = Join-Path $RepoRoot 'artifacts/android'
|
||||||
$Forge = 'https://git.dodotech.cloud'
|
$Forge = 'https://git.dodotech.cloud'
|
||||||
$Repo = 'DodoTech/DodoSSH'
|
$Repo = 'DodoTech-Public/DodoSSH'
|
||||||
$Api = "$Forge/api/v1/repos/$Repo"
|
$Api = "$Forge/api/v1/repos/$Repo"
|
||||||
|
|
||||||
function Write-Step([string] $Message) {
|
function Write-Step([string] $Message) {
|
||||||
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
|||||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
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
|
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
#
|
||||||
& dotnet build $Project --no-restore --configuration Release `
|
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||||
-t:SignAndroidPackage `
|
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||||
-p:DodoChannel=release `
|
# every argument of every other with one call —
|
||||||
-p:AndroidKeyStore=true `
|
#
|
||||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
#
|
||||||
-p:AndroidSigningStorePass=$storePlain `
|
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||||
-p:AndroidSigningKeyPass=$keyPlain
|
# 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.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds, packages and publishes the macOS desktop client.
|
||||||
|
#
|
||||||
|
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
|
||||||
|
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
|
||||||
|
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
|
||||||
|
# capability to ship somebody a build on a machine which is not a runner, and
|
||||||
|
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
|
||||||
|
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
|
||||||
|
# it, so whoever can write a release can ship an update every install runs.
|
||||||
|
#
|
||||||
|
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
|
||||||
|
# except the notarization submission, which Apple sees and users do not.
|
||||||
|
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
|
||||||
|
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
|
||||||
|
#
|
||||||
|
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
|
||||||
|
#
|
||||||
|
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
|
||||||
|
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
|
||||||
|
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
|
||||||
|
# notarization round trip are the price of the package being installable at all, not an improvement to
|
||||||
|
# be bought later.
|
||||||
|
#
|
||||||
|
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
|
||||||
|
#
|
||||||
|
# Three values are read from the environment, and none of them is itself a secret — they name things the
|
||||||
|
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
|
||||||
|
#
|
||||||
|
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
|
||||||
|
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
|
||||||
|
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
|
||||||
|
#
|
||||||
|
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
|
||||||
|
# third is created once per machine:
|
||||||
|
#
|
||||||
|
# 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 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
|
||||||
|
# bash scripts/release-macos.sh --upload
|
||||||
|
# bash scripts/release-macos.sh --skip-tests
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UPLOAD=0
|
||||||
|
SKIP_TESTS=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--upload) UPLOAD=1 ;;
|
||||||
|
--skip-tests) SKIP_TESTS=1 ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $arg" >&2
|
||||||
|
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- The contract with every installed client ---------------------------------------------------------
|
||||||
|
|
||||||
|
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
|
||||||
|
# script states — it is what an installed client matches an update against.
|
||||||
|
#
|
||||||
|
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
|
||||||
|
#
|
||||||
|
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
|
||||||
|
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
|
||||||
|
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
|
||||||
|
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
|
||||||
|
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
|
||||||
|
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
|
||||||
|
#
|
||||||
|
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
|
||||||
|
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
|
||||||
|
PACK_ID='DodoSSH.Desktop'
|
||||||
|
PACK_TITLE='DodoSSH'
|
||||||
|
PACK_AUTHORS='DodoTech'
|
||||||
|
|
||||||
|
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||||
|
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||||
|
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
|
||||||
|
# upload does not.
|
||||||
|
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
||||||
|
|
||||||
|
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
|
||||||
|
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
|
||||||
|
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
CHANNEL='osx'
|
||||||
|
|
||||||
|
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
|
||||||
|
#
|
||||||
|
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
|
||||||
|
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
|
||||||
|
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
|
||||||
|
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
|
||||||
|
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
|
||||||
|
# repository that reached users unverified.
|
||||||
|
#
|
||||||
|
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
|
||||||
|
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
|
||||||
|
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
|
||||||
|
RUNTIME='osx-arm64'
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
|
||||||
|
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
|
||||||
|
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
|
||||||
|
RELEASES_DIR="$REPO_ROOT/Releases"
|
||||||
|
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
|
||||||
|
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
|
||||||
|
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
|
||||||
|
|
||||||
|
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
|
||||||
|
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---- Is this machine able to do the job at all? -------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != 'Darwin' ]; then
|
||||||
|
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
|
||||||
|
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
|
||||||
|
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
|
||||||
|
# cannot be produced anywhere but here.
|
||||||
|
stop_with 'This builds a signed macOS package and has to run on macOS.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
for tool in dotnet git xcrun codesign; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
|
||||||
|
done
|
||||||
|
|
||||||
|
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
|
||||||
|
# this script does and the slowest, and discovering there that a profile name was never exported means
|
||||||
|
# throwing away a full build and test run.
|
||||||
|
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
|
||||||
|
if [ -z "${!required-}" ]; then
|
||||||
|
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# ---- What is being released ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
|
||||||
|
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
|
||||||
|
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
|
||||||
|
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||||
|
# clone where obj/ has no MinVer targets to import yet.
|
||||||
|
write_step 'Restoring the desktop head, so the version can be read'
|
||||||
|
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
|
||||||
|
|
||||||
|
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||||
|
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
|
||||||
|
|
||||||
|
TAG="v$VERSION"
|
||||||
|
|
||||||
|
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
|
||||||
|
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
|
||||||
|
#
|
||||||
|
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
|
||||||
|
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
|
||||||
|
# care that the string parses and not what it says. See build/macos/Info.plist.template.
|
||||||
|
PLIST_VERSION="${VERSION%%-*}"
|
||||||
|
PLIST_VERSION="${PLIST_VERSION%%+*}"
|
||||||
|
|
||||||
|
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
|
||||||
|
|
||||||
|
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$UPLOAD" -eq 1 ]; then
|
||||||
|
# The installer package is the artefact a person downloads, so its absence is the honest test of
|
||||||
|
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
|
||||||
|
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
|
||||||
|
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
|
||||||
|
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
|
||||||
|
|
||||||
|
# -s so the token is never echoed and never lands in the shell's history.
|
||||||
|
printf 'Gitea token (write:repository): '
|
||||||
|
read -r -s TOKEN
|
||||||
|
echo
|
||||||
|
|
||||||
|
[ -n "$TOKEN" ] || stop_with 'No token given.'
|
||||||
|
|
||||||
|
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
|
||||||
|
# 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"
|
||||||
|
--outputDir "$RELEASES_DIR"
|
||||||
|
--channel "$CHANNEL"
|
||||||
|
--releaseName "$TAG"
|
||||||
|
--tag "$TAG"
|
||||||
|
--merge
|
||||||
|
--publish
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
|
||||||
|
# release candidate is a prerelease in every channel or in none.
|
||||||
|
case "$VERSION" in
|
||||||
|
*-*) UPLOAD_ARGS+=(--pre) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
write_step 'Uploading'
|
||||||
|
|
||||||
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
|
||||||
|
|
||||||
|
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
|
||||||
|
|
||||||
|
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
|
||||||
|
|
||||||
|
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
|
||||||
|
# somebody pins a version by hand this is the guard that notices.
|
||||||
|
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
|
||||||
|
|
||||||
|
write_step 'Restoring tools'
|
||||||
|
dotnet tool restore || stop_with 'dotnet tool restore failed.'
|
||||||
|
|
||||||
|
write_step 'Restoring packages (locked, exactly as CI does)'
|
||||||
|
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
|
||||||
|
|
||||||
|
write_step 'Building'
|
||||||
|
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
|
||||||
|
|
||||||
|
if [ "$SKIP_TESTS" -eq 0 ]; then
|
||||||
|
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
|
||||||
|
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
|
||||||
|
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
|
||||||
|
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
|
||||||
|
write_step 'Testing'
|
||||||
|
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_step "Publishing $RUNTIME"
|
||||||
|
rm -rf "$PUBLISH_DIR"
|
||||||
|
|
||||||
|
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
|
||||||
|
# per RID and a self-extracting bundle breaks delta updates.
|
||||||
|
#
|
||||||
|
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
|
||||||
|
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
|
||||||
|
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
|
||||||
|
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
|
||||||
|
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
|
||||||
|
# build. The gate that matters is the locked solution restore above, which is untouched.
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime "$RUNTIME" \
|
||||||
|
--self-contained true \
|
||||||
|
--output "$PUBLISH_DIR" \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
|| stop_with 'Publish failed.'
|
||||||
|
|
||||||
|
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
|
||||||
|
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
|
||||||
|
# tree, so anything modified here is its own.
|
||||||
|
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
|
||||||
|
|
||||||
|
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
|
||||||
|
# application that never checks for updates — which looks completely normal until the next release goes out
|
||||||
|
# and nobody receives it.
|
||||||
|
for required in DodoSSH Velopack.dll; do
|
||||||
|
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
|
||||||
|
|
||||||
|
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
|
||||||
|
|
||||||
|
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
|
||||||
|
#
|
||||||
|
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
|
||||||
|
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
|
||||||
|
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
|
||||||
|
# pass and applies the outer entitlements to everything it touches.
|
||||||
|
#
|
||||||
|
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
|
||||||
|
# notarization rejection that does not name the offending file, on a submission that took its time getting
|
||||||
|
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
|
||||||
|
# an already correctly signed binary with -f is a no-op in effect.
|
||||||
|
#
|
||||||
|
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
|
||||||
|
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
|
||||||
|
write_step 'Signing native libraries'
|
||||||
|
|
||||||
|
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
|
||||||
|
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
|
||||||
|
# people forget because it has no extension to grep for.
|
||||||
|
NATIVE_COUNT=0
|
||||||
|
while IFS= read -r -d '' binary; do
|
||||||
|
codesign --force --verbose=0 --timestamp --options runtime \
|
||||||
|
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|
||||||
|
|| stop_with "codesign failed on $binary"
|
||||||
|
NATIVE_COUNT=$((NATIVE_COUNT + 1))
|
||||||
|
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
|
||||||
|
|
||||||
|
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
|
||||||
|
echo " signed $NATIVE_COUNT native binaries"
|
||||||
|
|
||||||
|
# ---- The bundle's Info.plist --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
|
||||||
|
# so a committed one would carry whatever version it was written with into every release afterwards.
|
||||||
|
# See the header of build/macos/Info.plist.template.
|
||||||
|
write_step "Rendering Info.plist for $PLIST_VERSION"
|
||||||
|
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
|
||||||
|
trap 'rm -f "$RENDERED_PLIST"' EXIT
|
||||||
|
|
||||||
|
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
|
||||||
|
|
||||||
|
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
|
||||||
|
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
|
||||||
|
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
|
||||||
|
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
|
||||||
|
|
||||||
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
|
||||||
|
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
|
||||||
|
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
|
||||||
|
write_step 'Fetching the previous release, for deltas'
|
||||||
|
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
|
||||||
|
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
|
||||||
|
# part is not local: it builds the .app from the published files, signs it with the Developer ID
|
||||||
|
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
|
||||||
|
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
|
||||||
|
#
|
||||||
|
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
|
||||||
|
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
|
||||||
|
#
|
||||||
|
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
|
||||||
|
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
|
||||||
|
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
|
||||||
|
# reads like a keychain problem rather than like the wrong certificate.
|
||||||
|
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
|
||||||
|
|
||||||
|
dotnet vpk pack \
|
||||||
|
--packId "$PACK_ID" \
|
||||||
|
--packVersion "$VERSION" \
|
||||||
|
--packDir "$PUBLISH_DIR" \
|
||||||
|
--packTitle "$PACK_TITLE" \
|
||||||
|
--packAuthors "$PACK_AUTHORS" \
|
||||||
|
--mainExe 'DodoSSH' \
|
||||||
|
--icon "$ICON" \
|
||||||
|
--plist "$RENDERED_PLIST" \
|
||||||
|
--entitlements "$ENTITLEMENTS" \
|
||||||
|
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
|
||||||
|
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
|
||||||
|
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
|
||||||
|
--runtime "$RUNTIME" \
|
||||||
|
--channel "$CHANNEL" \
|
||||||
|
--outputDir "$RELEASES_DIR" \
|
||||||
|
|| stop_with 'vpk pack failed.'
|
||||||
|
|
||||||
|
# ---- Did the notarization actually take? --------------------------------------------------------------
|
||||||
|
|
||||||
|
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
|
||||||
|
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
|
||||||
|
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
|
||||||
|
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
|
||||||
|
# certificate.
|
||||||
|
write_step 'Verifying the notarization the way another Mac will'
|
||||||
|
|
||||||
|
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
|
||||||
|
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
|
||||||
|
|
||||||
|
if ! spctl --assess --type install --verbose=4 "$PKG"; then
|
||||||
|
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
|
||||||
|
|
||||||
|
write_step 'Built, notarized, and deliberately not uploaded'
|
||||||
|
|
||||||
|
ls -lh "$RELEASES_DIR" | tail -n +2
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Next:
|
||||||
|
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
|
||||||
|
2. Then: bash scripts/release-macos.sh --upload
|
||||||
|
EOF
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
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.
|
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
|
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||||
in a shell's memory the better.
|
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
|
.PARAMETER Upload
|
||||||
Publish the packages already in Releases/ instead of building.
|
Publish the packages already in Releases/ instead of building.
|
||||||
@@ -62,11 +64,20 @@ $PackAuthors = 'DodoTech'
|
|||||||
|
|
||||||
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||||
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||||
$RepoUrl = 'https://git.dodotech.cloud/DodoTech/DodoSSH'
|
#
|
||||||
|
# The owner is part of it. When the repository moved organisations Gitea left a 301 at the old path, which
|
||||||
|
# a GET follows and an upload does not — so a stale URL here fails at the one step that matters rather than
|
||||||
|
# redirecting quietly.
|
||||||
|
$RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
||||||
|
|
||||||
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
||||||
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
||||||
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
#
|
||||||
|
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
|
||||||
|
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
|
||||||
|
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
|
||||||
|
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
|
||||||
$Channel = 'win'
|
$Channel = 'win'
|
||||||
|
|
||||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
@@ -96,7 +107,21 @@ Push-Location $RepoRoot
|
|||||||
try {
|
try {
|
||||||
# ---- What is being released -----------------------------------------------------------------------
|
# ---- What is being released -----------------------------------------------------------------------
|
||||||
|
|
||||||
$version = (& dotnet msbuild $Project -getProperty:Version -nologo) -replace '\s', ''
|
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||||
|
#
|
||||||
|
# -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
|
||||||
|
# sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
|
||||||
|
# and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
|
||||||
|
# the same line and the same fault.
|
||||||
|
#
|
||||||
|
# And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||||
|
# clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
|
||||||
|
# before, which is every machine except the one this would otherwise fail on.
|
||||||
|
Write-Step 'Restoring the desktop head, so the version can be read'
|
||||||
|
& dotnet restore $Project --locked-mode
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
|
||||||
|
|
||||||
|
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
||||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||||
Stop-With 'Could not read the version from MSBuild.'
|
Stop-With 'Could not read the version from MSBuild.'
|
||||||
}
|
}
|
||||||
@@ -129,10 +154,24 @@ try {
|
|||||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
# --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
|
# 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.
|
# 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 = @(
|
$uploadArgs = @(
|
||||||
'upload', 'gitea',
|
'upload', 'gitea',
|
||||||
'--repoUrl', $RepoUrl,
|
'--repoUrl', $RepoUrl,
|
||||||
'--token', $token,
|
|
||||||
'--outputDir', $ReleasesDir,
|
'--outputDir', $ReleasesDir,
|
||||||
'--channel', $Channel,
|
'--channel', $Channel,
|
||||||
'--releaseName', $tag,
|
'--releaseName', $tag,
|
||||||
@@ -146,7 +185,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Step 'Uploading'
|
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.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||||
|
|
||||||
Write-Step "Published $tag."
|
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
|
/// 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
|
/// 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.
|
/// 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
|
/// <para>
|
||||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
/// ◆ <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>
|
/// </remarks>
|
||||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.Users
|
var written = await database.Users
|
||||||
.Where(u => u.Id == user.Id
|
.Where(u => u.Id == user.Id
|
||||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||||
.ExecuteUpdateAsync(
|
.ExecuteUpdateAsync(
|
||||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (written > 0)
|
||||||
|
{
|
||||||
|
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
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
|
var enrollmentMissing = authorizeResult.Forbidden
|
||||||
&& authorizeResult.AuthorizationFailure is { } failure
|
&& authorizeResult.AuthorizationFailure is { } failure
|
||||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
#
|
#
|
||||||
# docker build -f src/DodoSSH.Api/Dockerfile .
|
# docker build -f src/DodoSSH.Api/Dockerfile .
|
||||||
#
|
#
|
||||||
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
|
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
|
||||||
# the root and all four are load-bearing here — central package management means a csproj
|
# global.json all sit at the root and all five are load-bearing here — central package
|
||||||
# alone does not name a single version, and a build that cannot see them resolves nothing.
|
# 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
|
# Build
|
||||||
@@ -26,7 +35,7 @@ WORKDIR /src
|
|||||||
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
# 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
|
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
||||||
# rather than skipping a node.
|
# 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/DodoSSH.Api.csproj src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
||||||
@@ -111,7 +120,7 @@ LABEL org.opencontainers.image.title="DodoSSH API" \
|
|||||||
org.opencontainers.image.description="DodoSSH server: sync, identity, teams and relay authorization." \
|
org.opencontainers.image.description="DodoSSH server: sync, identity, teams and relay authorization." \
|
||||||
org.opencontainers.image.vendor="DodoTech" \
|
org.opencontainers.image.vendor="DodoTech" \
|
||||||
org.opencontainers.image.licenses="MIT" \
|
org.opencontainers.image.licenses="MIT" \
|
||||||
org.opencontainers.image.source="https://git.dodotech.cloud/DodoTech/DodoSSH" \
|
org.opencontainers.image.source="https://git.dodotech.cloud/DodoTech-Public/DodoSSH" \
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
org.opencontainers.image.revision="${REVISION}" \
|
org.opencontainers.image.revision="${REVISION}" \
|
||||||
org.opencontainers.image.created="${CREATED}"
|
org.opencontainers.image.created="${CREATED}"
|
||||||
@@ -130,7 +139,8 @@ EXPOSE 8080
|
|||||||
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
|
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
|
||||||
# than one node — DODOSSH_Sync__CursorSigningKey.
|
# than one node — DODOSSH_Sync__CursorSigningKey.
|
||||||
#
|
#
|
||||||
# Nothing migrates the database from in here. That is the API's own design: it fails
|
# The process migrates the database itself as it starts, before its port opens and under an
|
||||||
# /healthz/ready while a migration is pending and names it, so the schema is applied by
|
# advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
|
||||||
# `dotnet ef database update` alongside the deployment rather than by a racing container.
|
# 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"]
|
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>
|
/// <summary>Runs the socket until something ends it, then closes it politely.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the
|
/// 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>
|
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
|
||||||
/// awaited before the close is written, because a close frame racing a notice frame is a protocol
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private async Task PumpAsync(
|
private async Task PumpAsync(
|
||||||
WebSocket socket,
|
WebSocket socket,
|
||||||
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
VaultCount: connection.VaultCount));
|
VaultCount: connection.VaultCount));
|
||||||
|
|
||||||
var sending = SendAsync(socket, connection, pump.Token);
|
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);
|
var minding = MindAsync(connection, closure, pump.Token);
|
||||||
|
|
||||||
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
|
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
|
||||||
|
|
||||||
await pump.CancelAsync().ConfigureAwait(false);
|
await pump.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
// Nothing may still be mid-send when the close frame goes out.
|
// Nothing may still be mid-send when the close frame goes out. A send that does not finish is
|
||||||
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false);
|
// 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)
|
if (lifetime.ApplicationStopping.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
|
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
|
||||||
|
|
||||||
await CloseAsync(socket, closure).ConfigureAwait(false);
|
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>
|
/// <summary>
|
||||||
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
|
|
||||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
|
||||||
|
|
||||||
|
// Not cancellable, deliberately. See PumpAsync.
|
||||||
await socket
|
await socket
|
||||||
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken)
|
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
/// Reads what the client sends, which in this version is heartbeats and a close.
|
/// Reads what the client sends, which in this version is heartbeats and a close.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
|
/// 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>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
|
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
|
||||||
/// caller's access and nothing else.
|
/// 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>
|
/// </remarks>
|
||||||
private static async Task ReceiveAsync(
|
private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
|
||||||
WebSocket socket,
|
|
||||||
VaultEventConnection connection,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var buffer = new byte[MaxInboundFrameBytes];
|
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)
|
if (received.MessageType == WebSocketMessageType.Close)
|
||||||
{
|
{
|
||||||
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
|
/// <returns>Whether it finished.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception
|
/// 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
|
/// 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.
|
/// propagating it would skip the close frame the other side is waiting for.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task Settled(Task loop)
|
private static async Task<bool> SettlesAsync(Task loop)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await loop.ConfigureAwait(false);
|
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
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>
|
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump
|
/// Mutable and shared, and safe without a lock for one specific reason: it is written only by
|
||||||
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a
|
/// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
|
||||||
/// memory barrier. Writes race only with each other, and any of them is a true answer.
|
/// barrier. If that loop outlived the close timeout, a late write races the read, and either
|
||||||
|
/// answer is still a true one.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed class Closure(int code, string reason)
|
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.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
|||||||
byte[] fingerprint,
|
byte[] fingerprint,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([user], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
|
|||||||
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||||
internal const int SyncProtocolVersion = 1;
|
internal const int SyncProtocolVersion = 1;
|
||||||
|
|
||||||
/// <summary>Feature flag for the relay.</summary>
|
|
||||||
internal const string RelayFeature = "relay";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
|
|||||||
List<string> features = ["teams"];
|
List<string> features = ["teams"];
|
||||||
if (relay.Value.Enabled)
|
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
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,16 @@ internal static partial class SyncLog
|
|||||||
int appliedCount,
|
int appliedCount,
|
||||||
int operationCount,
|
int operationCount,
|
||||||
Guid vaultId);
|
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)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var afterSequence = 0L;
|
var afterSequence = 0L;
|
||||||
|
var issuedUnder = 0L;
|
||||||
if (!string.IsNullOrEmpty(request.Cursor)
|
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();
|
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(
|
var limit = Math.Clamp(
|
||||||
request.Limit ?? options.DefaultPullLimit,
|
request.Limit ?? options.DefaultPullLimit,
|
||||||
1,
|
1,
|
||||||
@@ -43,7 +90,7 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
// One extra row, so "is there more" needs no second query.
|
// One extra row, so "is there more" needs no second query.
|
||||||
var query = database.VaultChanges
|
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)
|
if (types is not null)
|
||||||
{
|
{
|
||||||
@@ -62,18 +109,7 @@ internal sealed class SyncService(
|
|||||||
changes.RemoveAt(changes.Count - 1);
|
changes.RemoveAt(changes.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
return (changes, hasMore);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -100,9 +136,7 @@ internal sealed class SyncService(
|
|||||||
{
|
{
|
||||||
ValidateBatchLimits(request);
|
ValidateBatchLimits(request);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([vault], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -536,9 +570,21 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
@@ -571,10 +617,13 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. changes.Select(change => Describe(
|
return [.. changes
|
||||||
change,
|
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||||
kinds.GetValueOrDefault(change.EntityType),
|
|| row.ChangeSequence <= change.Sequence)
|
||||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
.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>
|
/// <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>
|
/// <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 static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||||
|
|
||||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
/// <remarks>
|
||||||
await database.VaultChanges
|
/// 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)
|
.Where(c => c.VaultId == vaultId)
|
||||||
.OrderByDescending(c => c.Sequence)
|
.OrderByDescending(c => c.Sequence)
|
||||||
.Select(c => c.Sequence)
|
.Select(c => c.Sequence)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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) =>
|
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
||||||
new(
|
new(
|
||||||
operation.OperationId,
|
operation.OperationId,
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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 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
|
// 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
|
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
// checks it again, because deleting a team's last vault archives it too.
|
||||||
if (!access.IsOwner)
|
if (!access.IsOwner)
|
||||||
{
|
{
|
||||||
return Problems.Coded(
|
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 System.Globalization;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -244,28 +245,37 @@ internal sealed class TeamService(
|
|||||||
Team team,
|
Team team,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actor);
|
||||||
ArgumentNullException.ThrowIfNull(team);
|
ArgumentNullException.ThrowIfNull(team);
|
||||||
|
|
||||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
// 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 (vaultCount > 0)
|
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||||
{
|
{
|
||||||
throw new TeamNotEmptyException(
|
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||||
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 now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||||
|
|
||||||
var archived = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await using var _ = transaction.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
|
var memberships = await database.TeamMemberships
|
||||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
|||||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||||
|
|
||||||
await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||||
|
|
||||||
var revoked = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
|||||||
{
|
{
|
||||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
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);
|
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||||
|
|
||||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
|||||||
RekeyRequired: false);
|
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>
|
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||||
private void AddVaultWithSelfGrant(
|
private void AddVaultWithSelfGrant(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
|
|||||||
@@ -41,17 +41,24 @@ builder.Services.AddScoped<VaultGrantService>();
|
|||||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||||
|
|
||||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||||
// a reference to connection management to get them.
|
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||||
builder.Services.AddSingleton<VaultEventHub>();
|
builder.Services.AddSingleton<VaultEventHub>();
|
||||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||||
provider => provider.GetRequiredService<VaultEventHub>());
|
|
||||||
|
// 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
|
// 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.
|
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||||
|
|
||||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ internal static class Auth
|
|||||||
|
|
||||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
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.
|
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||||
@@ -72,7 +79,7 @@ internal static class Auth
|
|||||||
EnrolledPolicy,
|
EnrolledPolicy,
|
||||||
policy => policy
|
policy => policy
|
||||||
.RequireAuthenticatedUser()
|
.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.
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ internal static class Configuration
|
|||||||
+ "setting exists to provide.")
|
+ "setting exists to provide.")
|
||||||
.ValidateOnStart();
|
.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>()
|
services.AddOptions<RelayOptions>()
|
||||||
.BindConfiguration(RelayOptions.SectionName)
|
.BindConfiguration(RelayOptions.SectionName)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
@@ -63,11 +70,28 @@ internal static class Configuration
|
|||||||
.Validate(
|
.Validate(
|
||||||
options => options.IdleTimeout < options.MaxSessionDuration,
|
options => options.IdleTimeout < options.MaxSessionDuration,
|
||||||
"Relay:IdleTimeout must be shorter than Relay: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();
|
.ValidateOnStart();
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Warns about configuration that is individually valid but dangerous in combination.
|
/// Warns about configuration that is individually valid but dangerous in combination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -115,13 +115,50 @@ public sealed class RelayOptions
|
|||||||
/// Whether RFC1918 and other private ranges may be dialled.
|
/// Whether RFC1918 and other private ranges may be dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
/// 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
|
/// 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,
|
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
/// </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>
|
/// </remarks>
|
||||||
public bool AllowPrivateNetworks { get; set; } = true;
|
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>
|
/// <summary>Maximum concurrent sessions per user.</summary>
|
||||||
[Range(1, 1000)]
|
[Range(1, 1000)]
|
||||||
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
||||||
@@ -252,6 +289,10 @@ public sealed class SyncOptions
|
|||||||
[Range(1, 3650)]
|
[Range(1, 3650)]
|
||||||
public int TombstoneRetentionDays { get; set; } = 90;
|
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>
|
/// <summary>
|
||||||
/// Key used to sign sync cursors, base64.
|
/// Key used to sign sync cursors, base64.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
using DodoSSH.Api.Features.Identity;
|
using DodoSSH.Api.Features.Identity;
|
||||||
using DodoSSH.Api.Features.Meta;
|
using DodoSSH.Api.Features.Meta;
|
||||||
|
using DodoSSH.Api.Features.Relay;
|
||||||
using DodoSSH.Api.Features.Sync;
|
using DodoSSH.Api.Features.Sync;
|
||||||
using DodoSSH.Api.Features.Teams;
|
using DodoSSH.Api.Features.Teams;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
|
|||||||
typeof(SyncPullEndpoint),
|
typeof(SyncPullEndpoint),
|
||||||
typeof(SyncPushEndpoint),
|
typeof(SyncPushEndpoint),
|
||||||
typeof(VaultEventsEndpoint),
|
typeof(VaultEventsEndpoint),
|
||||||
|
typeof(RelayTicketEndpoint),
|
||||||
|
typeof(RelayConnectEndpoint),
|
||||||
typeof(CreateTeamEndpoint),
|
typeof(CreateTeamEndpoint),
|
||||||
typeof(ListTeamsEndpoint),
|
typeof(ListTeamsEndpoint),
|
||||||
typeof(UpdateTeamEndpoint),
|
typeof(UpdateTeamEndpoint),
|
||||||
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
|
|||||||
// Registered as each feature lands:
|
// Registered as each feature lands:
|
||||||
// Identity — key rotation, passphrase change
|
// Identity — key rotation, passphrase change
|
||||||
// Vaults — per-item ACLs
|
// Vaults — per-item ACLs
|
||||||
// Relay — tickets and the WebSocket
|
|
||||||
// Audit, Admin
|
// 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 DodoSSH.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||||
|
|
||||||
services.AddDbContext<DodoDbContext>(options => options
|
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql
|
.UseNpgsql(connectionString, npgsql => npgsql
|
||||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||||
// Retry on transient faults only. Note this is safe here because writes go
|
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||||
// through explicitly-managed transactions rather than relying on the execution
|
// the same context, which still holds what the failed attempt tracked — every such
|
||||||
// strategy to replay ambient ones.
|
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||||
|
// the database instead.
|
||||||
.EnableRetryOnFailure(3))
|
.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()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DatabaseHealthCheck>(
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
@@ -79,34 +81,133 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
|
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
|
||||||
DocumentStaging.Sweep();
|
DocumentStaging.Sweep();
|
||||||
|
|
||||||
var viewModel = ComposeShell(paths, caches, workspace, knownHosts, connections);
|
// Built before the view model, because the clipboard is reached through it — see ClipboardWriter,
|
||||||
|
// which takes the surface and looks the TopLevel up on each call rather than now.
|
||||||
|
var shell = new PhoneShell();
|
||||||
|
|
||||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
var viewModel = ComposeShell(
|
||||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
paths,
|
||||||
//
|
caches,
|
||||||
// The transfer count is real now that the document picker gives this head a way to start one, and
|
workspace,
|
||||||
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
knownHosts,
|
||||||
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
connections,
|
||||||
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
ClipboardWriter(shell),
|
||||||
// moves five files.
|
ClipboardReader(shell));
|
||||||
//
|
|
||||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
shell.DataContext = viewModel;
|
||||||
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
|
||||||
// because it is subscribed to the workspace, which lives as long as the process.
|
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||||
|
// does and why.
|
||||||
|
ComposeKeepAlive(workspace, viewModel);
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
||||||
|
/// a vault lock — true on a platform that stops backgrounded processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The transfer count is real now that the document picker gives this head a way to start one, and it
|
||||||
|
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
|
||||||
|
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
|
||||||
|
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
|
||||||
|
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
|
||||||
|
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
|
||||||
|
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
||||||
|
/// cipher, which a bucket never has — that answers whether one is open.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
|
||||||
|
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
|
||||||
|
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
var keepAlive = new SessionKeepAlive(
|
var keepAlive = new SessionKeepAlive(
|
||||||
workspace,
|
workspace,
|
||||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||||
|
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
|
||||||
|
listeningForwards: () => viewModel.ListeningForwardCount);
|
||||||
|
|
||||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||||
// announces transfers appearing and finishing. Without this the notification would come up when an
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||||
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
// Without this the notification would come up when an upload started and stay up after it
|
||||||
|
// finished, which is the failure this class exists to prevent.
|
||||||
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
keepAlive.Refresh();
|
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
|
||||||
|
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
|
||||||
|
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
|
||||||
|
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
|
||||||
|
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
|
||||||
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||||
|
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
return new PhoneShell { DataContext = viewModel };
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writing to this phone's clipboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>This head had none, and every control that wanted one said so out loud.</b> COPY PUBLIC KEY on
|
||||||
|
/// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the
|
||||||
|
/// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has
|
||||||
|
/// a clipboard and Avalonia surfaces it through the same <c>TopLevel</c> the desktop reaches, so there
|
||||||
|
/// was nothing platform-shaped about the gap.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
|
||||||
|
/// <c>TopLevel</c> yet, because this is the method building the view it will be attached to. A machine
|
||||||
|
/// that somehow has none falls through silently and the view model decides what to say, which is what
|
||||||
|
/// keeps "no clipboard here" and "copied" different answers.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A delegate rather than an <c>IClipboard</c>, so nothing in the view models needs a visual and every
|
||||||
|
/// test that drives them stays window-free.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<string, Task> ClipboardWriter(Visual surface) =>
|
||||||
|
async text =>
|
||||||
|
{
|
||||||
|
if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
|
||||||
|
{
|
||||||
|
await clipboard.SetTextAsync(text).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <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>
|
/// <remarks>
|
||||||
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
/// 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.
|
/// the platform graph, and below it is the shell every head shares.
|
||||||
@@ -116,7 +217,9 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
ClientCacheFactory caches,
|
ClientCacheFactory caches,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
VaultKnownHostStore knownHosts,
|
VaultKnownHostStore knownHosts,
|
||||||
SshNetConnectionFactory connections)
|
SshNetConnectionFactory connections,
|
||||||
|
Func<string, Task> copyToClipboard,
|
||||||
|
Func<Task<string?>> readClipboard)
|
||||||
{
|
{
|
||||||
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
// 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.
|
// key. A straight implementation of the interface the session layer has always taken.
|
||||||
@@ -143,11 +246,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
knownHosts,
|
knownHosts,
|
||||||
deviceKeys,
|
deviceKeys,
|
||||||
|
|
||||||
// Difference 4: the system browser by intent, and the response by intent too rather than on a
|
SignInThroughBrowser(browser),
|
||||||
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
|
||||||
async (url, cancellationToken) => await ServerConnection
|
|
||||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
|
||||||
.ConfigureAwait(false),
|
|
||||||
|
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
connections,
|
connections,
|
||||||
@@ -165,7 +264,12 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
// would name the same machine. See PhoneEnvironment.DeviceName.
|
// would name the same machine. See PhoneEnvironment.DeviceName.
|
||||||
deviceName: PhoneEnvironment.DeviceName,
|
deviceName: PhoneEnvironment.DeviceName,
|
||||||
|
|
||||||
updates: updates);
|
copyToClipboard: copyToClipboard,
|
||||||
|
readClipboard: readClipboard,
|
||||||
|
|
||||||
|
updates: updates,
|
||||||
|
|
||||||
|
spelunkAccounts: PhoneSpelunkAccounts(browser));
|
||||||
|
|
||||||
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
||||||
// view model shows its own progress and handles its own failures.
|
// view model shows its own progress and handles its own failures.
|
||||||
@@ -174,6 +278,23 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Difference 4: the system browser by intent, and the response by intent too rather than on a loopback
|
||||||
|
/// socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
||||||
|
/// </summary>
|
||||||
|
private static MainWindowViewModel.SignInHandler SignInThroughBrowser(AndroidBrowserLauncher browser) =>
|
||||||
|
async (url, cancellationToken) => await ServerConnection
|
||||||
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same registered
|
||||||
|
/// scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as the account's
|
||||||
|
/// device list does, for difference 5's reason.
|
||||||
|
/// </summary>
|
||||||
|
private static SpelunkAccounts PhoneSpelunkAccounts(AndroidBrowserLauncher browser) =>
|
||||||
|
new(browser, path => new AndroidRedirectCallback(path), TimeProvider.System, PhoneEnvironment.DeviceName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Difference 4: where the authorization response comes back to.
|
/// Difference 4: where the authorization response comes back to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using Avalonia.Media;
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using global::Android.App;
|
using global::Android.App;
|
||||||
using global::Android.Runtime;
|
using global::Android.Runtime;
|
||||||
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// This used to be the one place the two heads disagreed on purpose: the recorded v2 decision was
|
||||||
|
/// "Android recolours with the shared palette and keeps its own default sans", on the reasoning that a
|
||||||
|
/// phone's system font is a phone's own business. The user has reversed that, this pass — the phone now
|
||||||
|
/// takes the desktop's face as well as its colours, and <c>docs/design-import-gaps.md</c> is corrected
|
||||||
|
/// to say so rather than left asserting a decision that no longer holds.
|
||||||
|
///
|
||||||
|
/// <c>WithInterFont</c> still registers Inter, for the same reason <c>Program.cs</c> keeps it on the
|
||||||
|
/// desktop: it is what the layout suite pins and what a glyph Montserrat does not cover falls back to.
|
||||||
|
/// What changes is which face answers first. Montserrat ships embedded in
|
||||||
|
/// <c>DodoSSH.Client.Shell/Assets/Fonts</c> already — this project references that assembly for
|
||||||
|
/// <c>Theme/Phone.axaml</c>'s own <c>MonoFont</c>, so no csproj or asset work was needed to reach it
|
||||||
|
/// from here, only the same <see cref="FontManagerOptions"/> block the desktop's
|
||||||
|
/// <c>Program.BuildAvaloniaApp</c> sets.
|
||||||
|
/// </remarks>
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
base.CustomizeAppBuilder(builder)
|
||||||
|
.WithInterFont()
|
||||||
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||||
|
FontFallbacks =
|
||||||
|
[
|
||||||
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||