From c270f3fe39c13914c0f92dfaca2fe8b253743432 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 26 Aug 2026 10:51:11 +0200 Subject: [PATCH] Import a Hetzner Cloud project's servers, and forget the token that read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An estate on Hetzner is already described somewhere: the project lists every server, its addresses, its plan and where it runs. Getting that into a keychain meant copying forty addresses out of a browser by hand, which is the same work the ssh_config importer exists to remove for the machines described in a file. ◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS. ImportScreen reads a file that is already on this machine, so it scans on open and shows a list. This cannot: it needs a credential that has to be made in another company's console first, and then it makes a request over the internet. Those are two moments rather than one, and a screen that mixed the token box in with the list would ask for the credential again every time somebody came back to look at the list. So three steps — token, choose, done — and the first two write nothing. Step two carries one address choice, one login user and one port for the whole import, because an estate is reached the same way throughout and a per-server dropdown would be forty answers to one question. ◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED. The half worth stating is the success path. Clearing only on failure would leave a credential capable of listing somebody's whole estate sitting in a text box for as long as they spend reading forty rows, which is the longest anybody stands on this screen. Nothing writes it to the vault, to settings or to a log, and reopening the wizard restarts at step one rather than resuming — a resumed step two would be a list nothing on screen has the means to refresh, going on looking current for as long as the application stays open. Read is the only permission it needs and the note above the box says so: a Read & Write token would work identically here and would be a credential capable of deleting the estate, pasted in for no gain. The note also says a token belongs to one project — there is no API call that will say which, and none that lists the others — so an account with four projects runs this four times. Without that sentence the wizard looks like it lost most of the estate. ◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT. Public IPv4 and public IPv6 fall back to each other: the same machine over the same internet, so the worst case is a route nobody picked, and the row says which. Private does not fall back at all. A server on no private network is simply not importable under that choice — its tickbox is disabled, and neither direction of the header's tick-all can reach it — because falling back would put an address on the open internet into a bookmark that was asked to stay off it. IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the network, which nothing can dial; the address a stock image answers on is the first one in it. That is a convention of Hetzner's own images rather than a fact the API stated, so every use of it carries a warning on the row and in the stored note, and it is computed by setting the low byte rather than by string surgery on the prefix. It is not an edge case either — IPv4 is billed separately now and a project built since then can be entirely IPv6-only. ◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON. Hetzner has never held the private half of an SSH key. The API will name a project's keys and hand over their public halves; there is no private half to bring. So every host arrives bound to nothing and asks for a password until somebody points it at a key already in the keychain — the same shape an ssh_config import has with its key tick off, and stated above the button on the same reasoning: somebody who is not told will conclude the import was broken. What the note keeps is what does not go stale — the id, the plan, the location, the image, the labels. The power state is a column and never a note: "running" about a machine somebody switched off last March is worse than no note. ◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN. Drawn over the Preferences page inside settings mode exactly as ImportScreen is, with SettingsNav unmoved and the titlebar offering "Back to preferences". IsHetznerImportOpen is a second flag rather than a "which importer" enum, because they are two independent facts about the content column and a single value would need a "neither" member IsImportOpen already expresses; what keeps them from both being true is that every path that opens one closes the other. The titlebar and the Preferences page ask IsImporterOpen, which is written against both. Preferences grows a second importer row beside the first rather than one row that asks "from where?", which would put a question in front of the far more common answer. ◆ WHAT IS NOT HERE. Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind a different credential, and nothing in this touches it. Said on the Preferences row and on the token step rather than left to be discovered by an account that has both. And there is no phone layout. This one would work on Android, unlike the ssh_config importer beside it: it is a token and a network request, the view model is shared already, and scoped storage has nothing to do with it. What is missing is a screen — the desktop draws a five-column table, and the phone version of that is a different layout rather than the same one narrower. Recorded in android-port.md and on the phone's own Preferences screen, because the note there previously read as though no import of any kind could work. ◆ TESTS, AND WHAT IS LEFT FOR A PERSON. HetznerCloudClientTests is 16 cases over the shapes the API actually returns, against a stubbed HttpMessageHandler: paging followed, the token in a header and never a query string, a refused token distinguished from a fault at Hetzner's end, a token with a line break refused before anything is sent, the /64, the private-network asymmetry, and a server the API described without a name. ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real cache and real server — split into a file of its own, as FakeVaultServer keeps its team endpoints. What they establish beyond the reader's own suite is the half it cannot reach: that reading stores nothing, that importing goes through the ordinary create-and-push path and lands on the server with nothing pressing Sync, that a server the keychain already has arrives unticked, and that the token does not outlive the request. Two layout cases measure both steps against the same content budget every other settings page gets, which is also what proves the markup binds to names that exist. Manual checks 2.11a–2.11d are what no test in this repository can do, because there is no account, no token and no network in the ordinary suite: a real project listed against the real console, a wrong token, the private choice, and what an imported host does when you connect to it. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- docs/android-port.md | 6 + docs/manual-checks.md | 72 +- .../Views/PreferencesScreen.axaml | 2 + .../Views/HetznerImportScreen.axaml | 314 +++++++++ .../Views/HetznerImportScreen.axaml.cs | 25 + .../Views/SettingsPreferencesPage.axaml | 26 +- .../Views/SettingsTitleBar.axaml | 11 +- .../Views/SettingsView.axaml | 10 + .../DodoSSH.Client.Import.csproj | 9 +- .../HetznerCloudClient.cs | 476 +++++++++++++ src/DodoSSH.Client.Import/HetznerServer.cs | 237 +++++++ .../ViewModels/HetznerImportViewModel.cs | 646 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 92 ++- .../SettingsPagesLayoutTests.cs | 115 ++++ .../ShellFlowTests.Hetzner.cs | 445 ++++++++++++ .../ShellFlowTests.cs | 7 +- .../DodoSSH.Client.Import.Tests.csproj | 8 +- .../HetznerCloudClientTests.cs | 416 +++++++++++ 19 files changed, 2903 insertions(+), 17 deletions(-) create mode 100644 src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml create mode 100644 src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Import/HetznerCloudClient.cs create mode 100644 src/DodoSSH.Client.Import/HetznerServer.cs create mode 100644 src/DodoSSH.Client.Shell/ViewModels/HetznerImportViewModel.cs create mode 100644 tests/DodoSSH.Client.App.Tests/ShellFlowTests.Hetzner.cs create mode 100644 tests/DodoSSH.Client.Import.Tests/HetznerCloudClientTests.cs diff --git a/README.md b/README.md index 7410447..1fb7d3d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ src/ DodoSSH.Client.Terminal the loopback data plane and credit-based flow control DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP - DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked + DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner + Cloud project over its API DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration DodoSSH.Client.Android the phone head: its views, and its Android integration diff --git a/docs/android-port.md b/docs/android-port.md index bf044ba..31c7e51 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -755,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi local-and-non-roaming requirement more cleanly than any desktop platform does. - **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns something like `localhost` on Android and would make every entry from a phone indistinguishable. +- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.** + Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so + both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to + read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column + table of servers, and a phone version of that is a different screen rather than the same one narrower. + Said on the Preferences screen rather than left to be discovered. - **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into WebView2 but does not come back — and Android's focus model is different enough that the problem should be confirmed to exist before anything is written to solve it. diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 97a9c47..836458f 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -165,7 +165,9 @@ ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An comes up over settings mode the same way it would over any other screen. **Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both -bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is +bound to the same side of `IsImporterOpen` — which is the flag now, rather than `IsImportOpen`, because +there are two importers over that page and the bar behaves identically for both. SettingsNav lighting a +different row while an importer is up is `MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not — `Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for @@ -410,6 +412,74 @@ machine. That is the ordinary case rather than an error. --- +## Phase 2 — Importing from Hetzner Cloud + +The reader has its own suite over the shapes the API returns, and the end-to-end path is covered by +`ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked` against a stubbed transport. What no +test in this repository does is talk to Hetzner — there is no account, no token and no network in the +ordinary suite — so everything below needs a real project. + +### 2.11a A read-only token lists your project + +Make a token in the Hetzner console under **Security → API tokens** with **Read** permission only. Settings +→ Preferences → **OPEN WIZARD**, paste it, press **READ THE PROJECT**. + +**Pass:** every server in that project is listed, with the address, the plan and the location the Hetzner +console shows for it. The token box is **empty** afterwards — it is used for one request and dropped — and +the Hosts screen is unchanged, because reading writes nothing. + +**What to look for specifically:** +- A project with more than fifty servers must list all of them. Fifty is Hetzner's page size, and a list + that stops there exactly is the paginator not being followed. +- A server with no public IPv4 should show the first address of its `/64` with a warning chip saying so. + Check that address against what the server actually answers on — the convention is Hetzner's own images, + not a fact the API states, and a machine whose operator moved it elsewhere in the `/64` will not answer. +- A server the console shows as **off** should still be listed, with `off` in the STATE column. + +**Failure means:** the importer and the console disagree about what the project holds, which produces +bookmarks that dial the wrong machine. Worse than an import that refused. + +### 2.11b A wrong token says so, and says where tokens come from + +Paste anything that is not a valid token — a truncated one is the realistic case, since the console shows +the value once. + +**Pass:** the wizard stays on the token step, the message names the Hetzner console and the Security → API +tokens page, and the box is cleared for a fresh paste. Nothing has been stored. + +Now try a token from a **different** project. + +**Pass:** it lists that project instead. A token belongs to one project and there is no API call that will +say which — an account with several projects runs this once per project, which the note above the box says. + +### 2.11c The private-network choice does not fall back + +With a project holding at least one server on a private network and one on none, press **PRIVATE NETWORK**. + +**Pass:** the server with no private network shows an em dash for its address, a chip reading *no address of +that kind*, and a tickbox that cannot be ticked — including by the tick-all box in the header. The other +one shows its `10.x` address. + +**Failure means:** a public address quietly stored under a choice that asked to stay off the internet. This +is the one asymmetry in how addresses resolve and it is deliberate; v4 and v6 fall back to each other, +private does not. + +### 2.11d Imported hosts are correct, and ask for a password + +Import a couple, then open one on the Hosts screen. + +**Pass:** the address, port and login user are what the wizard showed, and the notes carry the server id, +the plan, the location and the image — and **not** the power state, which would be stale the moment somebody +pressed a button in the console. Connecting asks for a **password**: no key came with these hosts, because +Hetzner has never held the private half of one. Bind it to a key in your keychain and it connects. + +Now go back to the list and import the same project again. + +**Pass:** the ones already stored are marked *already here* and arrive unticked. Importing one twice is +allowed and takes a click. + +--- + ## Phase 2 — Drag and drop on the SFTP page **This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native diff --git a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml index 5009514..a9ba586 100644 --- a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml @@ -163,6 +163,8 @@ Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." /> + diff --git a/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml b/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml new file mode 100644 index 0000000..8416b52 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml @@ -0,0 +1,314 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +