Public Access
88bd924d83d01a3eff5838dcc33228643db3a369
18
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e0509c6679 |
Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel then asked rpm for the installed version, got nothing because there is no rpm, read the silence as "0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the machine could never install, every six hours. UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that, UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only where the path already matched. CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The comment that called the fallback a deliberate choice is replaced with the reason it was wrong. LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a copy rpm owns gets the channel. The newer, equal and older comparisons were already covered. UpdateFlowTests covers the reason reaching the view model. Manual check 19.13. App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
37e9d74938 | Desktop: keep a vault-visibility failure from closing the app, and leave a crash log when anything else does | ||
|
|
365954c541 | Desktop: probe for the TPM once, off the UI thread, instead of on every unlock | ||
|
|
0e8fe4fc80 |
Give each profile its own device key, and make the Linux PIN window able to open at all
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the existing name for the default profile and gives every other profile its own; sign-out deletes only its own key, and a profile sealed before this keeps reading the shared one. - DevicePinWindow named two palette resources that do not exist and loaded itself with AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no PIN prompt was ever shown. A layout test now builds it against the real palette. |
||
|
|
03c8e8589a |
Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot be a file there is left on the host and reported. - EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on macOS, and refuses a .desktop launcher on Linux. - A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue starts those over. - The SFTP host picker re-found its selection from the empty moment of every vault reload and landed on the first host once a minute; the chosen host is now remembered, and a typed password is only sent to the host it was typed for. |
||
|
|
6e39807cc0 |
Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
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. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
|
||
|
|
dabc691c35 |
Keep the doorbell listening between callers, and stop a hung test taking CI with it
The test step ran for twenty minutes and had to be stopped by hand. Eighteen suites
had passed; DodoSSH.Client.App.Tests never reported.
◆ ON UNIX, A FRESH SERVER PER CONNECTION LOSES THE NEXT ONE.
The doorbell's accept loop built a NamedPipeServerStream inside the loop, served one
caller and disposed it. That is fine on Windows and quietly wrong on Linux, where a
named pipe is a Unix domain socket: disposing the server closes the *listening*
socket and unlinks its path, so a second launch that connected while the first was
being served has its connection dropped. Its Connect returned, its write succeeded
into a buffer, and nobody ever read it — the launch was told it had rung the
doorbell and the window never came forward.
Reproduced in a container rather than reasoned about, because the two platforms
disagree and this machine is only one of them. Three rings back to back:
one instance, Disconnect between callers -> 3 of 3 answered
a fresh instance per connection -> 1 of 3 answered
So there is one server for the life of the doorbell now, Disconnect()ed between
conversations, and the socket is never not listening. Which makes leaveOpen on the
reader load-bearing too: a StreamReader disposes the stream it was given, and that
stream now has to survive into the next iteration.
Two things I had wrong about this, both worth recording. Cancelling
WaitForConnectionAsync *does* unblock a pending accept on Linux — that was my first
theory and the container disproved it, so DisposeAsync awaiting the loop was never
the problem. And the app itself never hung: the lost ring is a launch that is
ignored, not a process that stops.
◆ AND THE REASON IT COST TWENTY MINUTES RATHER THAN TWENTY SECONDS.
await twice.Task.WaitAsync(TestContext.Current.CancellationToken);
That token carries no timeout. A ring that never arrives is then not a failing test,
it is a job that sits there — which is exactly what happened, and it is the more
expensive of the two mistakes because it turns any future bug of this shape into a
runner somebody has to go and stop. Both awaits are bounded now.
TheDoorbellAnswersMoreThanOnce keeps its shape deliberately: two rings back to back
rather than one and a pause, because the gap between accepts is the whole defect. A
test that waited for the first to be answered would pass against the broken version.
ci.yml had no timeout-minutes anywhere, on any job. Every job has a ceiling now, and
the test step has a tighter one of its own — ten minutes against a suite that takes
two — because a hang there is the likeliest of the lot and a step-level timeout names
the step instead of leaving a reader to work out which of fifteen was still running.
None of them is a performance budget; a build that legitimately grows past one should
have the number raised.
Verified both ways on Linux, in a container, against the committed tree and this one:
|
||
|
|
b8c94ecb73 |
Put a window on screen while it starts, and refuse the second one
Two things a desktop application is expected to do and this one did not. ◆ THREE SECONDS OF NOTHING. Measured on a debug build against a cold profile: the main window arrives 3.1 seconds after the executable does. Nothing was on screen for any of it — the icon had been double-clicked and the desktop looked unchanged. The main window cannot come sooner. It needs a view model, and the view model needs composition to have opened the SQLite cache, bound the terminal's loopback socket, asked the platform about a TPM and read the install layout off disk. Its own IsStarting card already narrates what happens after it appears; this is the part before, which no arrangement of that window can cover. So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the window that replaces it. Two mechanics are load-bearing rather than incidental. The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that property holds once OnFrameworkInitializationCompleted returns; a window merely Show()n there would have no lifetime attached, and under ShutdownMode.OnLastWindowClose the first close of anything would end the process. Handing the property over is one assignment and Avalonia reads it lazily. And composition is posted at DispatcherPriority.Background rather than called. It is synchronous and slow — which is the entire argument for a splash — so running it inline would block the very dispatcher turn that has to paint the thing. The window is SizeToContent="Height", which is about the failure and not the splash: CouldNotStart carries an exception's own message, and inside a fixed height a long one would wrap and be clipped at exactly the moment that text is all the window is for. A composition that throws is caught rather than left to the dispatcher, for the same reason — before this there was nothing on screen to abandon, and now there is, so an escaping exception would leave a window saying "Starting…" over a process that had stopped. Escape closes it, and only then. ◆ AND ONE INSTANCE. Claimed in Main before Avalonia is configured, so a second launch starts nothing at all: no toolkit, no window, no splash flashing up before it thinks better of it. Exclusion is an exclusive handle on instance.lock inside the profile directory, and that location settles two questions at once. A nightly and a release build do not exclude each other — separate profiles, ADR 0013 — and two people signed in to one machine each get their own instance. A global mutex would have had to re-derive both from somewhere. A file handle rather than a named mutex because the kernel releases it however the process ends. There is no such thing as a stale lock here, which is a state a named mutex makes both platforms model differently and neither model simply. The second launch is not silent. Somebody who double-clicks an icon and sees nothing has been told the application is broken, so it rings a named pipe — named from a digest of the profile path, so it is scoped exactly as the lock is — and the running instance un-minimises and activates. Activate is a request and not a guarantee: Windows refuses the foreground to a process that did not receive the input, and the flashing taskbar button that results is still an answer. Three seconds of retrying when the lock is held by something that does not answer its pipe, and that window has one job. Velopack's apply ends this process and its updater starts the new build; the two are ordered, but a lock held a moment too long would make the successor refuse itself and vanish — an update that looked like an uninstall. The doorbell is disposed before Shutdown for the same reason, so the pipe name is free before the process is. ◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE. Two launches of the built executable: the first stayed up with instance.lock in the profile, the second exited. Window sizes over time are where the two timings above come from — 420x160 then 1196x776. SingleInstanceTests covers what a single process can reach: the lock, its release by disposal, the doorbell answering more than once, and two profiles not excluding each other. SplashCardTests measures the one thing the splash can get wrong, which is a failure message long enough to need the height the window is willing to give it. Checks 1.12 and 16.11 are what needs a second executable and a real screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56c19b006d |
Stop the updater offering the build it saw first, whatever has been published since
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c2c860b4f2 |
Give the seated user the TPM, so nobody has to join the tss group
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.
Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.
Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.
tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.
◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.
TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.
An administrator who does not accept that masks the file:
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.
Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.
Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.
Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.
Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
|
||
|
|
4552538533 |
Let the Linux head check for updates, and seal its device key in the TPM
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument 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 a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
|
||
|
|
890a5f2246 |
Give the desktop a macOS head, signed from the first release
The same application, the same Velopack and the same two-phase person-run release as Windows, with four things forced to differ. Signing is a precondition rather than an improvement: Gatekeeper refuses an un-notarized download outright instead of warning about it, so there was never the "unsigned for now" that ADR 0013 decision 8 argues for on Windows, and release-macos.sh refuses to start without the identities. The packaging split is narrower than it first looked, and the old claim at the foot of ci.yml is why it was worth checking rather than assuming. vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on any platform, and CI now publishes osx-arm64 and bundles it on every main and tag build, which is what catches a restore graph with no macOS native asset. There is no '[osx] pack' off a Mac, and that part is correct — pack drives codesign, notarytool and stapler, which exist nowhere else. The dylib signing loop in the script looks redundant beside vpk's own pass and is not. vpk signs with 'codesign --deep', which is the shape Apple documents as wrong for nested code, and platform-flags has recorded a notarization rejection that names no file since before any of this existed. Signing each native binary inside-out first leaves that pass nothing to get wrong. MacDeviceKeyStore reaches ADR 0007's conclusion through different hardware: a P-256 key in the Secure Enclave under an access control requiring user presence, so the platform enforces the gate rather than this process — which is the whole point of that ADR's amendment. The enclave holds no other kind of key, hence ECIES where Windows uses RSA-OAEP, and the shape that falls out is better than the Windows one: sealing needs only the public half and is silent, so only unlock prompts. IsSupported probes rather than infers, because three ordinary Macs answer no — an Intel machine without a T2, one with no login password, and every unsigned development build, since enclave keys need a signing identity. Two decisions worth stating because they are reversible. arm64 only: a second channel is small work and nobody here has an Intel Mac to walk Phase 18 on, and an x64 package would be the only artefact in this repository reaching users unverified. And the pack id stays DodoSSH.Desktop even though vpk names the bundle after it, so /Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds harder here, because a pack id of DodoSSH would put Velopack's install root on top of ClientPaths.DataDirectory and let an uninstall take the user's un-synced outbox with it. CFBundleDisplayName puts the product name back in front of a person. Measured rather than assumed, since none of it is obvious: the publish and the bundle were both run, LSMinimumSystemVersion is 12.0 because that is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a custom Info.plist verbatim with no substitution at all — which is why the plist is a template the script renders and not a committed file. What is not done is the half that needs the hardware. There is no macOS runner, so nothing past "it bundles" has ever run. Phase 18 is the whole of the verification, and the two checks most likely to fail are the terminal against WKWebView and the enclave interop, neither of which has executed once. |
||
|
|
af0e29a98b |
Give the desktop a nightly channel, the way the phone has one
ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so the two heads had different answers to the same question — how does somebody try what is on main? — for no reason except the order the work happened in. This is the desktop's answer: CI publishes a build from main on every push, and it installs beside the release one rather than over it. The phone gets its separation from the platform. Android refuses an update signed by a different key, so its two channels cannot replace one another whatever anybody does. Nothing refuses anything here: Velopack applies what its feed serves and verifies no signature. So all of it is construction, and there are four separations because each closes a different door. A pack id each, so the two install in different directories and neither feed's package can be applied to the other's install. A Velopack channel each — win and win-nightly — so neither build ever reads the other's release index; the name reaches the wire as releases.win-nightly.json, which is why the constant in VelopackUpdateChannel and the argument in ci.yml have to agree or the channel answers nothing forever with no error. A prerelease flag, so the release channel cannot see the nightly even by accident. And a profile directory each, which 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. Both are installed at once by design, so that is an ordinary Tuesday rather than a corner case. The prerelease flag turns out to be load-bearing across heads as well. The phone's release channel reads releases/latest, which skips prereleases — so 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. Which build this is arrives as assembly metadata, the same mechanism and the same reasoning as the Android head: the updater needs the string rather than a branch, and a value baked into the assembly is one a crash report can be asked for. Three things read it — the feed, the prerelease flag, and the profile — and one more shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes the two is somewhere nobody is looking while typing a passphrase into one of them. The version needed a floor and it is applied to the whole build rather than to the packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses anything below 0.0.1, so the job lifts the patch digit and keeps the height — through MinVerVersionOverride, so the assemblies carry the same number the installer does. Packing a version the assembly disagreed with would put one string on the preferences screen and another in the feed, which is the screen somebody reads when asked which nightly they are on. Two things found by running it rather than reading it. -t:MinVer needs a restore first, because the target arrives with the package and MSB4057 on a clean checkout reads like a typo in the workflow rather than a missing restore; the release script had the same gap and now restores before it reads. And vpk rejects an empty --packVersion loudly, which is how a broken version handoff announces itself rather than shipping a package called 1.0.0. Rule 3 is untouched. The release channel still has no job, no token and no runner, and the two channels cannot see each other. What a nightly costs is written where somebody reads it before installing one: whoever can write a release here can put a build on every nightly machine, which is fine for a build being tried and is not fine for a build holding somebody's infrastructure credentials. Verified by running the job's own steps against a clone in a Linux container: DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step not exercised — it needs a real forge and a write token, and check 16.10 is what walks the half no runner can. |
||
|
|
253c72d2b7 |
Name the organisation the repository actually lives in
It moved to DodoTech-Public, and every address in the product still said DodoTech. That looked like it worked, which is the part worth writing down: Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so both update channels would have kept polling through it. What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop release by POSTing to that URL, so the stale address would have failed at the one step the whole feature depends on — and a redirect is a thing an operator can delete, which turns "works today" into the same silent outage this session has already spent two commits on. So both channel constants, both release scripts, the workflow's REPO, the image's source label and the curl in phase 16 all name the live path. The local remote too, which had been printing a redirect warning on every push. Measured after the move: the org, the repo and the nightly release all answer 200 anonymously, and that release now carries both assets — the manifest and a 54 MB APK. The upload going through also answers the open question about the reverse proxy's body-size limit, which nothing local could test. |
||
|
|
b4a6c19ac1 |
Let the phone replace itself, and give CI a channel it may sign
The Android head had no updater and no release path, and the two are one problem: Android refuses an update signed by a different key, and CI generates a fresh debug key in every container. An APK released from a workflow could be installed once and never updated again — each new one an uninstall, which on this product means losing the cache, the outbox and the device key. So there are two channels, and they are two applications because the platform gives no third option. dev.dodotech.dodossh is cut from a v* tag by a person running scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners. dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore committed here in the open — a key everybody has cannot be stolen and grants nothing by being held, which is why putting it in CI does not touch the rule. Neither can update the other, by construction. See ADR 0014. The android job assumed an image with a JDK and an Android SDK on it, which is what a GitHub runner is and what this project's is not. It now installs a JDK, fetches Google's command-line tools, accepts the licences and installs API 36 — each a no-op where it is already satisfied, and each cached by the persistent runner's own disk rather than by an action that would move a quarter of a gigabyte to rebuild a directory that never left. The client reads a small JSON manifest beside the APK, the counterpart of releases.win.json, and compares Android's versionCode rather than a version name: that integer is what the platform itself uses to accept or refuse an install, so comparing anything else would offer updates the phone then rejects. It fetches, and then asks Android to ask — the system draws its own confirmation, and from API 26 will not draw even that until unknown sources is on for this application. IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files and restarts, so the shell disposes the vault first and that is what zeroes the keys. On the phone the install is a request and the answer may be no, so disposing first would answer "not now" with a locked keychain and every shell closed — a punishment for declining an update. Two measured bugs found on the way, both older than this work and both invisible to a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a top-level PropertyGroup, so the target setting it from MinVer ran after the only thing that reads it: every APK ever built here said versionName 1.0.0. And nothing found so far varies the launcher name per channel — four mechanisms tried, all of them recorded in platform-flags, none of them reaching the label the launcher shows. The two channels share an icon name for now and are told apart by package name, version, and what the preferences screen says. |
||
|
|
3ead865f01 |
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these. |
||
|
|
6728a0a597 |
Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.
**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.
**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.
**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.
**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.
Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.
The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.
----
Three defects surfaced on the way, none of them in the feature being built.
**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.
**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.
**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.
All three are in docs/platform-flags.md, which is where the next person will look.
----
Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.
Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
|
||
|
|
d07b336868 |
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding. |