Import a Hetzner Cloud project's servers, and forget the token that read it
ci / build and test (push) Successful in 3m18s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 30s
ci / desktop nightly (push) Successful in 2m10s

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 <noreply@anthropic.com>
This commit is contained in:
jaap-janandClaude Opus 5 committed 2026-08-26 10:51:11 +02:00
1 parent 56c19b006d
commit c270f3fe39
19 files changed
+2903 -17

No files matched your search

+2 -1
View File
@@ -69,7 +69,8 @@ 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, macOS and Linux 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
+6
View File
@@ -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. 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.
+71 -1
View File
@@ -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. 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 **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 — `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 `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 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 ## 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 **This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
@@ -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." /> Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
<TextBlock Classes="body" <TextBlock Classes="body"
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." /> Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
<TextBlock Classes="body"
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
<TextBlock Classes="body" <TextBlock Classes="body"
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." /> Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
</StackPanel> </StackPanel>
@@ -0,0 +1,314 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:import="using:DodoSSH.Client.Import"
x:Class="DodoSSH.Client.App.Views.HetznerImportScreen"
x:DataType="vm:HetznerImportViewModel">
<!--
Importing the servers in a Hetzner Cloud project.
Drawn over the Preferences page inside settings mode, exactly as ImportScreen.axaml is — SettingsNav
stays lit on Preferences, the titlebar says "Back to preferences", and neither is reachable from the nav
rail. See MainWindowViewModel.IsHetznerImportOpen.
── WHY THIS ONE IS A WIZARD AND THE ssh_config IMPORTER IS NOT ────────────────────────────────────────
That screen reads a file that is already on this machine, so it can scan the moment it opens and show a
list. This one cannot: it needs a credential that has to be made in somebody else's console first, and
then it makes a request over the internet. Those are two separate moments and the screen is honest about
it — step one asks for the token, step two shows what the project holds, step three says what was stored.
Both of the first two write nothing.
── THE TOKEN IS NOT STORED ────────────────────────────────────────────────────────────────────────────
It is used for one request and cleared, whether the read succeeded or not. Nothing here writes it to the
vault, to settings or to a log, and the next import asks again — the same argument ImportScreen's key
tick makes for not remembering a "yes". The note above the box says which permission is needed (Read),
that a token is one project rather than one account, and that dedicated servers behind Hetzner's Robot
are not covered by any of this.
── NOTHING IMPORTED HERE CONNECTS ON ITS OWN ──────────────────────────────────────────────────────────
Hetzner has never held the private half of an SSH key, so no key can come with these hosts. That is said
above the button rather than after it, on the same reasoning ImportScreen.axaml gives for its own version
of this sentence: somebody who is not told will conclude the import was broken.
-->
<UserControl.Styles>
<!-- Matching ImportScreen's table exactly: this is the same table about a different source. -->
<Style Selector="Border.importrow:pointerover">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Border.meaningchip.new">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.meaningchip.new > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.meaningchip.exists">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Border.meaningchip.exists > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Border.meaningchip.warn">
<Setter Property="Background" Value="{StaticResource WarnWash}" />
</Style>
<Style Selector="Border.meaningchip.warn > TextBlock">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
</Style>
</UserControl.Styles>
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,*">
<!-- ============ HEADER ============ -->
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Classes="settingstitle" Text="Import from Hetzner Cloud" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="label" Text="{Binding StepLabel}" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding StepTitle}" FontSize="12"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,12,0,0"
TextWrapping="Wrap" />
<Panel Grid.Row="2" Margin="0,18,0,0">
<!-- ============ STEP 1 · THE TOKEN ============ -->
<Grid IsVisible="{Binding IsTokenStep}" RowDefinitions="Auto,*">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="14">
<TextBlock Text="Cloud API token" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding TokenNote}" />
<TextBox Text="{Binding Token}" PasswordChar="•" Height="42" FontSize="13"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="64 characters, from Security → API tokens">
<!-- Enter reads the project, so a pasted token does not need the mouse to go anywhere. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ScanCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Classes="ghost" Height="42" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing has been sent." />
<Button Classes="accent" Height="42" Content="READ THE PROJECT"
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Lists the project's servers. Nothing is stored, and the token is forgotten as soon as the list arrives." />
</StackPanel>
</StackPanel>
</Border>
</Grid>
<!-- ============ STEP 2 · WHAT THE PROJECT HOLDS ============ -->
<Grid IsVisible="{Binding IsChooseStep}" RowDefinitions="Auto,Auto,*,Auto">
<!--
What every imported host gets: which address, which login user, which port. One set of answers for
the whole import rather than one per row, because an estate is reached the same way throughout —
a per-server dropdown would be forty answers to one question.
-->
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="20,18">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0" Classes="label" Text="DIAL" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<!--
Chips rather than radio buttons: this application has no RadioButton style, and Fluent's
own beside a row of chips reads as belonging to a different program. Button.chiptoggle is
the tag picker's control, which is already "one of these is on" drawn as a chip.
-->
<Button Classes="chiptoggle" Content="PUBLIC IPv4"
Classes.worn="{Binding UsesPublicIPv4}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv4}" />
<Button Classes="chiptoggle" Content="PUBLIC IPv6"
Classes.worn="{Binding UsesPublicIPv6}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.PublicIPv6}" />
<Button Classes="chiptoggle" Content="PRIVATE NETWORK"
Classes.worn="{Binding UsesPrivate}"
Command="{Binding UseAddressCommand}"
CommandParameter="{x:Static import:HetznerAddressKind.Private}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" Margin="14,0,0,0" />
<TextBox Grid.Column="3" Text="{Binding Username}" Width="120" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="Every Hetzner Linux image puts the project's SSH keys on root and creates no other account." />
<TextBox Grid.Column="4" Text="{Binding PortText}" Width="66" Height="34"
FontFamily="{StaticResource MonoFont}" FontSize="12.5"
ToolTip.Tip="The SSH port every imported host gets." />
</Grid>
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
Text="{Binding AddressNote}" />
<TextBlock Classes="hint" FontSize="11" IsVisible="{Binding !IsPortValid}"
Foreground="{StaticResource WarnText}"
Text="That is not a port. Give a number between 1 and 65535." />
</StackPanel>
</Border>
<!-- What the project could not be made to say, at project level. -->
<Border Grid.Row="1" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="2" Margin="0,14,0,0" CornerRadius="12" Background="{StaticResource Pane}"
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
IsVisible="{Binding HasRows}">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*">
<!-- A Button rather than a CheckBox, for the reason ImportScreen.axaml's own header gives. -->
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
ToolTip.Tip="Tick or untick every row this address choice can reach">
<Panel Width="18" Height="18">
<Border CornerRadius="5" Background="{StaticResource Accent}"
IsVisible="{Binding AllTicked}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="13"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
IsVisible="{Binding !AllTicked}" />
</Panel>
</Button>
<TextBlock Grid.Column="1" Classes="label" Text="SERVER" FontSize="9.5" LetterSpacing="1"
Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5"
LetterSpacing="1" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
<Grid ColumnDefinitions="24,1*,1.3*,0.6*,1.1*,1.3*" Margin="12" MinHeight="20">
<!--
Disabled where the address choice cannot reach this server, which is the private
case: a server on no private network is not importable and must not be silently
given a public address instead.
-->
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
IsEnabled="{Binding CanImport}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Kind}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
HorizontalAlignment="Left" VerticalAlignment="Center"
Classes.new="{Binding IsMeaningNew}"
Classes.exists="{Binding IsMeaningExisting}"
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
MaxWidth="230" TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- ============ FOOTER ============ -->
<StackPanel Grid.Row="3" Margin="0,16,0,0" Spacing="14">
<!--
Said before the button. Whether a credential comes with the host is the difference between a
bookmark that connects and one that asks for a password, and nothing about a Hetzner project can
supply one.
-->
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
Text="{Binding SelectionSummary}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN"
Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project. This list goes with it — the token it came from was not kept." />
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing is stored." />
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}"
Command="{Binding ImportCommand}" IsEnabled="{Binding CanImport}" />
</StackPanel>
</Grid>
</StackPanel>
</Grid>
<!-- ============ STEP 3 · WHAT WAS STORED ============ -->
<Grid IsVisible="{Binding IsDoneStep}" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" CornerRadius="12" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="24,22">
<StackPanel Spacing="12">
<TextBlock Text="What happens next" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding AuthenticationNote}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="The list this came from is one press away. Going back to it and importing the rest costs nothing — what was just stored is marked as already here. It is the list this scan produced, though, not a fresh one: a server made in the Hetzner console since then is not in it." />
</StackPanel>
</Border>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"
Margin="0,16,0,0">
<Button Classes="ghost" Height="44" Content="ANOTHER TOKEN" Command="{Binding RestartCommand}"
ToolTip.Tip="Back to the token box, for another project." />
<Button Classes="ghost" Height="44" Content="BACK TO THE LIST"
Command="{Binding ReviewCommand}"
ToolTip.Tip="The same servers, with what you just stored marked as already here." />
<Button Classes="accent" Height="44" Content="DONE" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences." />
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>
@@ -0,0 +1,25 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Importing hosts from a Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// A task rather than a destination, reached from Preferences and not from the nav rail, and drawn inside
/// settings mode over the Preferences page — the same shape <see cref="ImportScreen"/> has, for the same
/// reasons, including having no keyboard target of its own to hand back.
/// </para>
/// <para>
/// No code behind it, unlike <see cref="ImportScreen"/>, and the difference is worth a sentence: that screen
/// listens for bubbled tick events to keep its footer count honest, because its rows have no way to reach the
/// list they are in. This one's view model subscribes to each row's own change notification instead — see
/// <c>HetznerImportViewModel.OnRowChanged</c> — which puts the same wiring where the count actually lives
/// rather than in a routed-event handler on a control.
/// </para>
/// </remarks>
internal sealed partial class HetznerImportScreen : UserControl
{
public HetznerImportScreen() => InitializeComponent();
}
@@ -15,8 +15,10 @@
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor, TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now, scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY Sync now/Sign in, and the importer rows; the design's own SECURITY card (clipboard-clear delay,
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here. confirm-run-on-insert) has nothing behind either row and is not drawn here.
Two importer rows now rather than the one the old screen had: ~/.ssh/config, and a Hetzner Cloud project.
--> -->
<ScrollViewer> <ScrollViewer>
@@ -95,7 +97,7 @@
</Grid> </Grid>
</Border> </Border>
<Border Classes="settingsrow last"> <Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0"> <StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" /> <TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
@@ -108,6 +110,24 @@
</Grid> </Grid>
</Border> </Border>
<!--
The second importer, beside the first rather than folded into it: they read different things
through different code, and one row that asked "from where?" would put a question in front of
the far more common answer. See HetznerImportScreen.axaml.
-->
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import from Hetzner Cloud" />
<TextBlock Classes="settingsrowcaption"
Text="Lists the servers in one Hetzner Cloud project and imports the ones you tick. Needs a read-only API token, which is used for a single request and never stored; a token belongs to one project, so an account with several runs this once each. No key can come with them — Hetzner has never held the private half of one — so imported hosts ask for a password until you bind them to a key in your keychain. Dedicated servers behind Hetzner's Robot are not covered." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN WIZARD"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.HetznerImport}" />
</Grid>
</Border>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -17,9 +17,12 @@
bars needs its own window behaviour a shared base would have to be pulled apart first. bars needs its own window behaviour a shared base would have to be pulled apart first.
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in MainWindowViewModel.IsImporterOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving place of "Back to application" while an importer is up, and CloseImportCommand closes it without leaving
settings mode, unlike LeaveSettingsCommand. settings mode, unlike LeaveSettingsCommand.
IsImporterOpen rather than IsImportOpen because there are two importers now — the ssh_config one and the
Hetzner wizard — and this bar behaves identically for both: same label, same destination, same command.
--> -->
<Border Height="53" Background="{StaticResource DeepChrome}" <Border Height="53" Background="{StaticResource DeepChrome}"
@@ -28,7 +31,7 @@
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0"> <Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left" <Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding !IsImportOpen}" IsVisible="{Binding !IsImporterOpen}"
Command="{Binding LeaveSettingsCommand}" Command="{Binding LeaveSettingsCommand}"
ToolTip.Tip="Back to the screen you were on before opening Settings"> ToolTip.Tip="Back to the screen you were on before opening Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
@@ -40,7 +43,7 @@
</Button> </Button>
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left" <Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding IsImportOpen}" IsVisible="{Binding IsImporterOpen}"
Command="{Binding CloseImportCommand}" Command="{Binding CloseImportCommand}"
ToolTip.Tip="Back to preferences, without leaving Settings"> ToolTip.Tip="Back to preferences, without leaving Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
@@ -23,6 +23,11 @@
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
same split MainWindow.axaml drew before the importer moved in here. same split MainWindow.axaml drew before the importer moved in here.
HetznerImportScreen joined it on identical terms. Two flags rather than one "which importer" value,
because they are two independent facts about the content column and every path that opens either closes
the other; what the Preferences page below asks is IsSettingsPreferencesContentShowing, which is written
against both.
--> -->
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}"> <Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
@@ -59,6 +64,11 @@
<views:ImportScreen DataContext="{Binding ImportScreen}" /> <views:ImportScreen DataContext="{Binding ImportScreen}" />
</Panel> </Panel>
<!-- The Hetzner wizard, on exactly the same terms. See MainWindowViewModel.IsHetznerImportOpen. -->
<Panel IsVisible="{Binding IsHetznerImportOpen}">
<views:HetznerImportScreen DataContext="{Binding HetznerImportScreen}" />
</Panel>
</Panel> </Panel>
</Grid> </Grid>
@@ -1,12 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<!-- <!--
Reading an OpenSSH client configuration and turning it into hosts this application can store. Turning somewhere an estate is already described into hosts this application can store. Two sources:
an OpenSSH client configuration on this machine, and a Hetzner Cloud project over its API.
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
different concern with different dependencies, and keeping them apart is what lets the whole of the different concern with different dependencies, and keeping them apart is what lets the whole of the
parsing be tested with no Avalonia, no SQLite and no disk. parsing be tested with no Avalonia, no SQLite and no disk.
The Hetzner reader is here rather than in a project of its own because it is the same concern arriving
from somewhere else: a candidate list, warnings about what could not be represented, and a HostSecret at
the end of it. It brings no package reference with it — HttpClient and System.Text.Json are the shared
framework — and it stays testable on the same terms, against a stubbed HttpMessageHandler rather than
against strings.
--> -->
<ItemGroup> <ItemGroup>
@@ -0,0 +1,476 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Import;
/// <summary>
/// Hetzner Cloud answered, and the answer was not a list of servers.
/// </summary>
/// <remarks>
/// One exception type with a sentence already fit to show somebody, rather than a status code every caller
/// would have to translate for itself. The status is kept beside it because "401" and "503" are different
/// problems with different next steps — one is the token, the other is nothing the user can do — and the
/// screen distinguishes them by <see cref="IsTokenProblem"/> rather than by matching on prose.
/// </remarks>
public sealed class HetznerCloudException : Exception
{
/// <summary>Creates a failure that has no underlying exception — a refusal Hetzner spelled out.</summary>
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
: this(statusCode, code, message, innerException: null)
{
}
/// <summary>Creates a failure wrapping the one the transport or the parser threw.</summary>
public HetznerCloudException(
HttpStatusCode? statusCode,
string? code,
string message,
Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code;
}
/// <summary>What Hetzner returned, or null where the request never got an answer at all.</summary>
public HttpStatusCode? StatusCode { get; }
/// <summary>Hetzner's own machine-readable error code, where the body carried one.</summary>
public string? Code { get; }
/// <summary>Whether the token is the thing to go and fix.</summary>
/// <remarks>
/// The wizard's first step is where a token gets typed, so a failure that is about the token belongs
/// back on that step and a failure that is about Hetzner's weather does not — sending somebody back to
/// re-type a token that was fine, because a datacentre had a bad minute, teaches them the token is
/// wrong.
/// </remarks>
public bool IsTokenProblem =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
}
/// <summary>
/// Reads the servers in one Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// <b>One token is one project.</b> Hetzner issues Cloud API tokens per project and the API offers no way
/// to ask which project a token belongs to, or to list the others — so this reads exactly the project whose
/// token it was handed, and the screen says as much rather than implying it found everything the account
/// owns. Somebody with four projects runs this four times.
/// </para>
/// <para>
/// <b>Read is all it does.</b> Only <c>GET /v1/servers</c> is ever called, which is why the screen asks for
/// a read-only token: a Read &amp; Write token would work identically here and would be a credential capable
/// of deleting the estate, pasted into a text box for no gain.
/// </para>
/// <para>
/// <b>The token is never stored.</b> It lives in the view model's field for as long as the wizard is open
/// and goes no further — not into the vault, not into settings, not into a log. The next import asks again,
/// which is the same reasoning <c>ImportViewModel.ImportsKeys</c> gives for not remembering its tick.
/// </para>
/// <para>
/// <b>Dedicated servers are not here.</b> Hetzner's Robot — the dedicated-server and Storage Box side of the
/// business — is a different API behind different credentials, and nothing in this file touches it. An
/// account with both gets its cloud servers from this wizard and adds the rest by hand.
/// </para>
/// </remarks>
public sealed class HetznerCloudClient
{
/// <summary>The Cloud API, and the one address in this file.</summary>
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
/// <summary>What Hetzner will return per page, which is also its maximum.</summary>
private const int PerPage = 50;
/// <summary>
/// How many pages this will follow before giving up.
/// </summary>
/// <remarks>
/// A backstop against a paginator that never says "no more pages" rather than a limit anybody's estate
/// will meet: at fifty a page this is five thousand servers, and the warning it emits on the way out
/// says the list is short rather than letting it look complete.
/// </remarks>
private const int MaxPages = 100;
private readonly HttpClient http;
/// <summary>Creates a reader over its own transport, which is how the application builds one.</summary>
public HetznerCloudClient()
: this(new HttpClient { BaseAddress = CloudApi })
{
}
/// <summary>Creates a reader with its transport supplied, which is how the tests reach it.</summary>
/// <remarks>
/// The base address is set here when the caller left it unset, so a stubbed <c>HttpMessageHandler</c>
/// can be handed over on its own without every test repeating the one URL this class knows.
/// </remarks>
public HetznerCloudClient(HttpClient http)
{
ArgumentNullException.ThrowIfNull(http);
http.BaseAddress ??= CloudApi;
this.http = http;
}
/// <summary>
/// Reads every server the token's project holds.
/// </summary>
/// <remarks>
/// Pages are followed rather than assumed away: twenty-six servers in a project is enough to meet the
/// second page, and an importer that quietly showed the first fifty would offer a list that looks
/// complete and is not.
/// </remarks>
/// <exception cref="HetznerCloudException">
/// The token was refused, the project could not be read, or Hetzner could not be reached. Every failure
/// arrives as this, with a sentence already fit to put on the screen.
/// </exception>
public async Task<HetznerImport> ListServersAsync(string token, CancellationToken cancellationToken)
{
var bearer = Sanitise(token);
var servers = new List<HetznerServer>();
var warnings = new List<string>();
var page = 1;
while (page <= MaxPages)
{
var read = await ReadPageAsync(bearer, page, cancellationToken).ConfigureAwait(false);
foreach (var server in read.Servers ?? [])
{
servers.Add(Convert(server, warnings));
}
if (read.Meta?.Pagination?.NextPage is not { } next || next <= page)
{
return new HetznerImport(servers, warnings);
}
page = next;
}
var counted = servers.Count.ToString(CultureInfo.InvariantCulture);
warnings.Add(
$"Stopped after {MaxPages.ToString(CultureInfo.InvariantCulture)} pages. This list is the first "
+ counted + " servers in the project and not all of them.");
return new HetznerImport(servers, warnings);
}
/// <summary>
/// Trims the pasted token and refuses one that could not be a header value.
/// </summary>
/// <remarks>
/// A token copied out of the Hetzner console arrives with a newline behind it often enough to be worth
/// handling, and a header value carrying one throws a <see cref="FormatException"/> from inside
/// <see cref="HttpClient"/> — a stack trace where the answer is "we trimmed it for you". Anything left
/// after the trim that still cannot be a header is refused here, with a sentence, rather than there.
/// </remarks>
private static string Sanitise(string token)
{
var trimmed = token?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
throw new HetznerCloudException(null, null, "Paste a Hetzner Cloud API token first.");
}
foreach (var character in trimmed)
{
if (char.IsWhiteSpace(character) || char.IsControl(character))
{
throw new HetznerCloudException(
null,
null,
"That does not look like a Hetzner Cloud API token — it has a space or a line break in "
+ "the middle of it. A token is one unbroken run of 64 letters and digits.");
}
}
return trimmed;
}
/// <summary>Fetches and parses one page, turning every way it can fail into one exception type.</summary>
private async Task<HetznerServersPage> ReadPageAsync(
string token,
int page,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"servers?page={page}&per_page={PerPage}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException failure)
{
throw new HetznerCloudException(
null,
null,
$"Could not reach {CloudApi.Host}: {failure.Message}",
failure);
}
catch (TaskCanceledException failure) when (!cancellationToken.IsCancellationRequested)
{
throw new HetznerCloudException(
null,
null,
$"{CloudApi.Host} did not answer in time.",
failure);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
throw await FailureAsync(response, cancellationToken).ConfigureAwait(false);
}
try
{
return await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerServersPage, cancellationToken)
.ConfigureAwait(false)
?? new HetznerServersPage(null, null);
}
catch (JsonException failure)
{
throw new HetznerCloudException(
response.StatusCode,
null,
$"{CloudApi.Host} answered with something that is not a server list. That usually means "
+ "a proxy answered instead of Hetzner.",
failure);
}
}
}
/// <summary>
/// Turns a refusal into the sentence the screen shows.
/// </summary>
/// <remarks>
/// Hetzner's own message is kept and something actionable is put in front of it, because "unable to
/// authenticate" on its own does not tell somebody where tokens come from — and the token step is
/// exactly where they will be standing when they read it.
/// </remarks>
private static async Task<HetznerCloudException> FailureAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
string? code = null;
string? detail = null;
try
{
var body = await response.Content
.ReadFromJsonAsync(HetznerJsonContext.Default.HetznerErrorEnvelope, cancellationToken)
.ConfigureAwait(false);
code = body?.Error?.Code;
detail = body?.Error?.Message;
}
catch (JsonException)
{
// Not Hetzner's error shape. The status still says what happened, which is what the switch below
// is written against — a proxy's HTML 502 must not lose the 502 while being parsed.
}
catch (NotSupportedException)
{
// A content type that is not JSON at all, for the same reason.
}
var said = detail is { Length: > 0 } ? $" Hetzner said: {detail}" : string.Empty;
var status = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
var summary = response.StatusCode switch
{
HttpStatusCode.Unauthorized =>
"Hetzner did not accept that token. Cloud API tokens are made per project, in the Hetzner "
+ "console under Security → API tokens, and the value is shown only once — so a token "
+ "that was written down half-copied fails exactly like this.",
HttpStatusCode.Forbidden =>
"That token is not allowed to read this project's servers. A read-only token is enough; a "
+ "token scoped to something narrower is not.",
HttpStatusCode.TooManyRequests =>
"Hetzner is rate-limiting this token. Give it a minute and start the scan again.",
HttpStatusCode.NotFound =>
$"{CloudApi.Host} has no server list at that address, which should not happen and probably "
+ "means something between here and Hetzner answered instead.",
_ when (int)response.StatusCode >= 500 =>
$"Hetzner Cloud returned {status}. That is a fault at their end rather than anything about "
+ "the token.",
_ => $"Hetzner Cloud returned {status}.",
};
return new HetznerCloudException(response.StatusCode, code, summary + said);
}
/// <summary>
/// Turns one server as the API describes it into one the import can offer.
/// </summary>
/// <remarks>
/// Everything the API leaves out is tolerated rather than fatal, and only the name is worth a warning: a
/// server with no plan or no image loses a phrase from a note, and a server with no name would arrive as
/// a bookmark called nothing.
/// </remarks>
private static HetznerServer Convert(HetznerServerDto server, List<string> warnings)
{
var name = server.Name;
if (string.IsNullOrWhiteSpace(name))
{
name = string.Create(CultureInfo.InvariantCulture, $"server-{server.Id}");
warnings.Add($"A server had no name in the project, so it is offered as {name}.");
}
var privateAddresses = (server.PrivateNet ?? [])
.Select(network => network.Ip)
.Where(ip => !string.IsNullOrWhiteSpace(ip))
.Select(ip => ip!)
.ToList();
var labels = (server.Labels ?? new Dictionary<string, string>(StringComparer.Ordinal))
.Select(label => label.Value is { Length: > 0 }
? $"{label.Key}={label.Value}"
: label.Key)
.Order(StringComparer.Ordinal)
.ToList();
return new HetznerServer(
server.Id,
name,
server.Status is { Length: > 0 } status ? status : "unknown",
Blank(server.PublicNet?.IPv4?.Ip),
Blank(server.PublicNet?.IPv6?.Ip),
privateAddresses,
Blank(server.ServerType?.Name),
Where(server.Datacenter),
Blank(server.Image?.Name),
labels);
}
/// <summary>Where a server runs, as a person would say it — or null where the API said nothing useful.</summary>
private static string? Where(HetznerDatacenterDto? datacenter)
{
if (datacenter is null)
{
return null;
}
var location = datacenter.Location;
var region = Blank(location?.Name) ?? Blank(datacenter.Name);
var place = (Blank(location?.City), Blank(location?.Country)) switch
{
({ } city, { } country) => $"{city}, {country}",
({ } city, null) => city,
(null, { } country) => country,
_ => null,
};
return (region, place) switch
{
({ } where, { } which) => $"{where} · {which}",
({ } where, null) => where,
(null, { } which) => which,
_ => null,
};
}
/// <summary>Folds an absent value and a present-but-empty one together, which JSON does not.</summary>
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>One page of <c>GET /v1/servers</c>.</summary>
internal sealed record HetznerServersPage(
[property: JsonPropertyName("servers")] IReadOnlyList<HetznerServerDto>? Servers,
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
/// <summary>The envelope every Hetzner list response carries its paging in.</summary>
internal sealed record HetznerMetaDto(
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
/// <summary>Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.</summary>
internal sealed record HetznerPaginationDto(
[property: JsonPropertyName("next_page")] int? NextPage);
/// <summary>One server, as the API describes it. Every field is optional because the API's are.</summary>
internal sealed record HetznerServerDto(
[property: JsonPropertyName("id")] long Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("public_net")] HetznerPublicNetDto? PublicNet,
[property: JsonPropertyName("private_net")] IReadOnlyList<HetznerPrivateNetDto>? PrivateNet,
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
[property: JsonPropertyName("labels")] IReadOnlyDictionary<string, string>? Labels);
/// <summary>A server's public addresses. Either half can be absent on a server that has only the other.</summary>
internal sealed record HetznerPublicNetDto(
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
/// <summary>
/// One public address.
/// </summary>
/// <remarks>
/// The v6 case puts a <em>network</em> in this field rather than an address — see
/// <see cref="HetznerServer.FirstIPv6Address"/>, which is where that is dealt with rather than papered over
/// here.
/// </remarks>
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>A server's attachment to one private network.</summary>
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
/// <summary>The shape shared by the server type and the image, of which only the name is read.</summary>
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
/// <summary>Where a server runs.</summary>
internal sealed record HetznerDatacenterDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
/// <inheritdoc cref="HetznerDatacenterDto" />
internal sealed record HetznerLocationDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("city")] string? City,
[property: JsonPropertyName("country")] string? Country);
/// <summary>How Hetzner wraps a refusal.</summary>
internal sealed record HetznerErrorEnvelope(
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
/// <inheritdoc cref="HetznerErrorEnvelope" />
internal sealed record HetznerErrorDto(
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("message")] string? Message);
/// <summary>
/// The serializer for everything above.
/// </summary>
/// <remarks>
/// Source-generated rather than reflective, for the reason every other JSON context in this repository is:
/// the Android head publishes trimmed, and a reflective deserializer in an assembly the phone links is a
/// trim warning at best and a runtime failure at worst.
/// </remarks>
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HetznerServersPage))]
[JsonSerializable(typeof(HetznerErrorEnvelope))]
internal sealed partial class HetznerJsonContext : JsonSerializerContext;
+237
View File
@@ -0,0 +1,237 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// Which of a Hetzner server's addresses an import should dial.
/// </summary>
/// <remarks>
/// One choice for the whole import rather than one per row, for the reason the ssh_config screen's key tick
/// is one control: an estate is reached the same way throughout — everything over the public internet, or
/// everything across the private network from a machine already inside it — and a per-server dropdown would
/// be forty answers to one question.
/// </remarks>
public enum HetznerAddressKind
{
/// <summary>The public IPv4 address, which is how all but the cheapest Hetzner servers are reached.</summary>
PublicIPv4 = 0,
/// <summary>The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all.</summary>
PublicIPv6 = 1,
/// <summary>The address on the first private network the server is attached to.</summary>
Private = 2,
}
/// <summary>
/// An address an import settled on, and what it had to assume to get there.
/// </summary>
/// <param name="Value">What the host will dial.</param>
/// <param name="Warning">
/// What is not simply a fact the API stated, or null where it is. Every one of these is a place the import
/// is deciding something on somebody's behalf, and a bookmark that dials the wrong machine is worse than one
/// that was never offered — so they are shown beside the row rather than folded away.
/// </param>
public sealed record HetznerAddress(string Value, string? Warning);
/// <summary>
/// One server a Hetzner Cloud project holds, as the import sees it.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not a <see cref="HostSecret"/>, on exactly the reasoning <see cref="ImportedHost"/> gives:
/// this is a candidate nobody has agreed to import, and it carries facts a stored host has no field for —
/// the server's own id, what it is running, where it runs, and which of its several addresses were on offer.
/// </para>
/// <para>
/// <b>There is no key here and there cannot be.</b> The Cloud API will name the SSH keys a project holds and
/// hand over their public halves; the private halves have never been in Hetzner's hands. So an imported host
/// authenticates with a key that was already in this keychain or it asks for a password, and the import
/// screen says so rather than leaving somebody to find out at connect time.
/// </para>
/// </remarks>
/// <param name="Id">The server's id in the project, which is the one name of it that never changes.</param>
/// <param name="Name">What the project calls it, which is what the user will recognise.</param>
/// <param name="Status">
/// <c>running</c>, <c>off</c>, <c>initializing</c> and the rest. Shown on the row and deliberately not
/// stored: it stays true for as long as it takes somebody to press a button in the Hetzner console.
/// </param>
/// <param name="PublicIPv4">The public IPv4 address, or null on a server that has none.</param>
/// <param name="PublicIPv6Network">
/// The public IPv6 <em>network</em> — Hetzner routes a whole /64 and the API reports the prefix, not an
/// address. See <see cref="FirstIPv6Address"/> for what this import does with that.
/// </param>
/// <param name="PrivateAddresses">The addresses it has on private networks, in the order the API gave them.</param>
/// <param name="ServerType">The plan — <c>cx22</c>, <c>ccx13</c> — or null where the API did not say.</param>
/// <param name="Location">Where it runs, as a person would say it: <c>fsn1 · Falkenstein, DE</c>.</param>
/// <param name="Image">What it was built from, which is the best available hint at which user logs in.</param>
/// <param name="Labels">The project's own labels, as <c>key=value</c>, sorted.</param>
public sealed record HetznerServer(
long Id,
string Name,
string Status,
string? PublicIPv4,
string? PublicIPv6Network,
IReadOnlyList<string> PrivateAddresses,
string? ServerType,
string? Location,
string? Image,
IReadOnlyList<string> Labels)
{
/// <summary>Whether the API gave this server a public IPv4 address at all.</summary>
/// <remarks>
/// Not a formality. Hetzner bills IPv4 separately now, and a project built since then can be entirely
/// IPv6-only — which is exactly the case an import that assumed a v4 would get silently wrong.
/// </remarks>
public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 };
/// <summary>
/// The address inside the routed /64 that a stock Hetzner image actually answers on.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a convention and not a fact the API stated</b>, which is why every use of it carries a
/// warning. What the API reports is <c>2a01:4f8:1c17:d00::/64</c> — a network, which nothing can dial.
/// The address Hetzner's own images configure is the first one in it, and the console shows the same
/// thing; a server whose operator moved it elsewhere in the /64 will not answer here.
/// </para>
/// <para>
/// Computed by setting the low byte rather than by string surgery on the prefix, so a network the API
/// spells some other way still yields an address rather than a malformed string.
/// </para>
/// </remarks>
public string? FirstIPv6Address
{
get
{
if (PublicIPv6Network is not { Length: > 0 } network)
{
return null;
}
var slash = network.IndexOf('/', StringComparison.Ordinal);
var prefix = slash < 0 ? network : network[..slash];
if (!IPAddress.TryParse(prefix, out var parsed)
|| parsed.AddressFamily != AddressFamily.InterNetworkV6)
{
return null;
}
var bytes = parsed.GetAddressBytes();
bytes[^1] = 1;
return new IPAddress(bytes).ToString();
}
}
/// <summary>
/// Settles on the address this server would be dialled at, or null where the choice has none to offer.
/// </summary>
/// <remarks>
/// The two public choices fall back to each other and the private one does not, and that asymmetry is
/// deliberate. Falling back between v4 and v6 reaches the same machine over the same internet, so the
/// worst case is a route somebody did not pick; falling back from a private address to a public one
/// would put an address on the open internet into a bookmark that was asked to stay off it. So a server
/// with no private address is simply not importable under that choice, and the screen says which.
/// </remarks>
public HetznerAddress? Resolve(HetznerAddressKind kind)
{
var six = FirstIPv6Address;
return kind switch
{
HetznerAddressKind.PublicIPv4 when HasPublicIPv4 => new(PublicIPv4!, null),
HetznerAddressKind.PublicIPv4 when six is not null => new(
six,
$"No public IPv4. This is the first address of {PublicIPv6Network}, which is where Hetzner's "
+ "own images put the server — the API reports the network, not an address."),
HetznerAddressKind.PublicIPv6 when six is not null => new(
six,
$"The API reports {PublicIPv6Network} rather than an address. This is the first address in "
+ "it, which is where Hetzner's own images put the server."),
HetznerAddressKind.PublicIPv6 when HasPublicIPv4 => new(
PublicIPv4!,
"No public IPv6 on this server, so this is its IPv4 address."),
HetznerAddressKind.Private when PrivateAddresses.Count > 1 => new(
PrivateAddresses[0],
$"On {PrivateAddresses.Count} private networks. This is the first the API listed."),
HetznerAddressKind.Private when PrivateAddresses.Count == 1 => new(PrivateAddresses[0], null),
_ => null,
};
}
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>Nothing here authenticates.</b> There is no key to bring — see the remark on this record — so the
/// host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. That is the same shape an <c>ssh_config</c> import has with its key tick off, and it is
/// stated on the screen rather than discovered at connect time.
/// </para>
/// <para>
/// <b>What goes in the notes is what does not go stale.</b> The id, the plan, the location and the image
/// survive; the power state does not, so it is a column on the import screen and never a note. A note
/// saying "running" about a machine somebody switched off last March is worse than no note.
/// </para>
/// </remarks>
/// <param name="address">The address to dial, from <see cref="Resolve"/>.</param>
/// <param name="username">The login user, or null and empty for none.</param>
/// <param name="port">The SSH port.</param>
public HostSecret ToSecret(HetznerAddress address, string? username, int port)
{
ArgumentNullException.ThrowIfNull(address);
return new HostSecret
{
Label = Name,
Hostname = address.Value,
Port = port,
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(),
Notes = Describe(address),
};
}
/// <summary>What the note says about where this came from, plus whatever the address had to assume.</summary>
private string Describe(HetznerAddress address)
{
var facts = new List<string> { string.Create(CultureInfo.InvariantCulture, $"server {Id}") };
if (ServerType is { Length: > 0 } plan)
{
facts.Add(plan);
}
if (Location is { Length: > 0 } where)
{
facts.Add(where);
}
if (Image is { Length: > 0 } image)
{
facts.Add($"image {image}");
}
if (Labels.Count > 0)
{
facts.Add($"labels {string.Join(", ", Labels)}");
}
var sentence = $"Imported from Hetzner Cloud: {string.Join(", ", facts)}.";
return address.Warning is { Length: > 0 } warning ? $"{sentence} {warning}" : sentence;
}
}
/// <summary>
/// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not.
/// </summary>
/// <param name="Servers">The importable candidates, in the order the API returned them.</param>
/// <param name="Warnings">
/// What the read could not represent, at project level — a page that came back malformed, a server the API
/// described without a name. The counterpart of <see cref="SshConfigImport.Warnings"/>.
/// </param>
public sealed record HetznerImport(IReadOnlyList<HetznerServer> Servers, IReadOnlyList<string> Warnings);
@@ -0,0 +1,646 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Which of the wizard's three steps is on screen.</summary>
/// <remarks>
/// A wizard rather than one screen, unlike <see cref="ImportViewModel"/>, and the reason is what each step
/// costs. Reading an <c>ssh_config</c> is free and local, so that screen scans the moment it opens and shows
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
/// and it then makes a request over the internet. Those are two different moments, and a screen that mixed
/// the token box in with the list would ask for the credential again every time somebody came back to look
/// at the list.
/// </remarks>
internal enum HetznerImportStep
{
/// <summary>Paste a token. Nothing has been sent yet.</summary>
Token = 0,
/// <summary>The project's servers, and what to do with them. Nothing has been stored yet.</summary>
Choose = 1,
/// <summary>What was stored.</summary>
Done = 2,
}
/// <summary>
/// One Hetzner server, as a row somebody decides about.
/// </summary>
/// <remarks>
/// The address is not a property of the server but of the server <em>and the choice above the list</em>, so
/// it is pushed down by <see cref="Refresh"/> rather than computed here — the same shape
/// <see cref="ImportRowViewModel.ImportsItsKey"/> has, and for the same reason: a control that decides one
/// thing for forty rows belongs above the forty rows.
/// </remarks>
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
{
private HetznerAddress? resolved;
internal HetznerServer Server => server;
internal string Name => server.Name;
/// <summary>Whether it is running, off, or somewhere in between — a fact of the moment, never stored.</summary>
internal string Status => server.Status;
/// <summary>The plan and where it runs, as one cell.</summary>
internal string Kind => (server.ServerType, server.Location) switch
{
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
({ Length: > 0 } plan, _) => plan,
(_, { Length: > 0 } place) => place,
_ => "—",
};
/// <summary>What this row would dial, or an em dash where the address choice leaves it nothing.</summary>
internal string Address => resolved?.Value ?? "—";
/// <summary>Whether the address choice above the list can reach this server at all.</summary>
/// <remarks>
/// False is the private-network case: a server on no private network has no private address, and this
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
/// an import that was asked to stay off the internet from putting a public address in the keychain.
/// </remarks>
internal bool CanImport => resolved is not null;
/// <summary>Whether the keychain already has a host pointing where this one would.</summary>
internal bool AlreadyPresent { get; private set; }
/// <summary>What had to be assumed to reach that address, if anything.</summary>
internal string Warning => resolved?.Warning ?? string.Empty;
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
/// <summary>
/// The row's own chip: the single most actionable thing true about it.
/// </summary>
/// <remarks>
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
/// here", which is only about whether it is worth ticking. Deliberately parallel to
/// <see cref="ImportRowViewModel.Meaning"/> — the same three states plus the one that screen cannot
/// have, because a resolved <c>ssh_config</c> entry always has a hostname.
/// </remarks>
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
{
(false, _, _) => "no address of that kind",
(_, true, _) => Warning,
(_, _, true) => "already here",
_ => "new host",
};
internal bool IsMeaningWarned => !CanImport || HasWarning;
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
/// <summary>The address, resolved, or null where there is none of the chosen kind.</summary>
internal HetznerAddress? Resolved => resolved;
[ObservableProperty]
private bool isSelected;
/// <summary>
/// Re-resolves this row against the address choice above the list.
/// </summary>
/// <param name="kind">Which address the import is dialling.</param>
/// <param name="isAlreadyPresent">Whether the keychain already holds a host at a given address.</param>
/// <param name="resetSelection">
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
/// question "which of these do you want" is being put again — and false when only the address choice
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
/// A row that has lost its address is unticked either way; there is nothing to import.
/// </param>
internal void Refresh(
HetznerAddressKind kind,
Func<HetznerAddress, bool> isAlreadyPresent,
bool resetSelection)
{
resolved = server.Resolve(kind);
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
if (resetSelection)
{
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
// want — but it should take a click rather than be the default.
IsSelected = CanImport && !AlreadyPresent;
}
else if (!CanImport)
{
IsSelected = false;
}
OnPropertyChanged(nameof(Address));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(AlreadyPresent));
OnPropertyChanged(nameof(Warning));
OnPropertyChanged(nameof(HasWarning));
OnPropertyChanged(nameof(Meaning));
OnPropertyChanged(nameof(IsMeaningWarned));
OnPropertyChanged(nameof(IsMeaningExisting));
OnPropertyChanged(nameof(IsMeaningNew));
}
}
/// <summary>
/// Importing the servers in a Hetzner Cloud project.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three steps, and the first two write nothing.</b> Step one takes a token and sends one request; step
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
/// the same argument <see cref="ImportViewModel"/> makes — a project with forty servers is forty questions,
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
/// import has and that one does not: the credential. It has to be fetched from another company's console
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
/// </para>
/// <para>
/// <b>The token is never stored.</b> It lives in <see cref="Token"/> while the wizard is open and is cleared
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
/// again. That is the same reasoning <see cref="ImportViewModel.ImportsKeys"/> gives for not remembering its
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
/// stored credential capable of listing somebody's whole estate.
/// </para>
/// <para>
/// <b>Nothing imported here authenticates.</b> Hetzner has never held the private half of an SSH key, so
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
/// time — see <see cref="AuthenticationNote"/>.
/// </para>
/// </remarks>
internal sealed partial class HetznerImportViewModel(
VaultViewModel vault,
HetznerCloudClient hetzner,
Action? onCancel = null) : ObservableObject
{
/// <summary>
/// The user a stock Hetzner Linux image logs in as.
/// </summary>
/// <remarks>
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
/// estate at once, which is why the box is above the list rather than on each row.
/// </remarks>
internal const string DefaultUsername = "root";
internal ObservableCollection<HetznerRowViewModel> Rows { get; } = [];
/// <summary>What the project could not be made to say, at project level.</summary>
internal ObservableCollection<string> Warnings { get; } = [];
[ObservableProperty]
private HetznerImportStep step = HetznerImportStep.Token;
/// <summary>
/// The Cloud API token, held only for as long as the wizard needs it.
/// </summary>
/// <remarks>
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
/// that outlives the process. <see cref="ScanAsync"/> clears it as soon as the read is finished.
/// </remarks>
[ObservableProperty]
private string token = string.Empty;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isBusy;
/// <summary>The login user every imported host gets.</summary>
[ObservableProperty]
private string username = DefaultUsername;
/// <summary>
/// The SSH port, as typed.
/// </summary>
/// <remarks>
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
/// <see cref="Port"/>, which is also what <see cref="IsPortValid"/> reports on.
/// </remarks>
[ObservableProperty]
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
private const int HetznerPort = 22;
[ObservableProperty]
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
internal bool IsTokenStep => Step is HetznerImportStep.Token;
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
internal bool IsDoneStep => Step is HetznerImportStep.Done;
/// <summary>"Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.</summary>
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
/// <summary>What this step is for, in the header, beside <see cref="StepLabel"/>.</summary>
internal string StepTitle => Step switch
{
HetznerImportStep.Token => "Connect to a project",
HetznerImportStep.Choose => "Choose what to import",
_ => "Imported",
};
/// <summary>
/// Which of the three address chips above the list is lit.
/// </summary>
/// <remarks>
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
/// control is a chip that runs <see cref="UseAddressCommand"/> — see <c>HetznerImportScreen.axaml</c>
/// for why chips rather than radio buttons, which this application has no style for.
/// </remarks>
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
/// <inheritdoc cref="UsesPublicIPv4" />
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
/// <inheritdoc cref="UsesPublicIPv4" />
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
/// <summary>What the chosen address means for the list, said once above it rather than per row.</summary>
internal string AddressNote => AddressKind switch
{
HetznerAddressKind.PublicIPv4 =>
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
HetznerAddressKind.PublicIPv6 =>
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
+ "than an address, so this is the first address in it — where Hetzner's own images put "
+ "the server, and not something the API states.",
_ =>
"The address on the server's own private network, which only reaches it from inside that "
+ "network. Servers on no private network are not importable under this choice, and this "
+ "will never quietly fall back to a public address.",
};
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
/// <summary>The port every imported host gets, or null where what is typed is not one.</summary>
internal int? Port =>
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
&& port is >= 1 and <= 65535
? port
: null;
internal bool IsPortValid => Port is not null;
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
/// <summary>How many rows the address choice can actually reach.</summary>
internal int ImportableCount => Rows.Count(row => row.CanImport);
/// <summary>Whether every importable row is ticked — what the header's tick-all box shows.</summary>
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
/// <summary>Whether IMPORT would do anything.</summary>
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
/// <summary>
/// The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.
/// </summary>
/// <remarks>
/// Fixed rather than offered as a picker, exactly as the ssh_config importer's is: the import goes
/// through the same active vault every other bulk write does, and there is no per-import target to bind.
/// Printed as a fact instead of drawn as a dropdown.
/// </remarks>
internal string VaultName => vault.VaultName;
/// <summary>How many ticked, out of how many, and where they land.</summary>
internal string SelectionSummary
{
get
{
var noun = Rows.Count == 1 ? "server" : "servers";
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
}
}
/// <summary>
/// The sentence that says an imported host will not connect on its own.
/// </summary>
/// <remarks>
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
/// for its own version: whether the credential comes with the host is the difference between a bookmark
/// that connects and one that asks for a password, and somebody who is not told will conclude the
/// import was broken.
/// </remarks>
internal static string AuthenticationNote =>
"Hetzner has never held the private half of an SSH key, so nothing here can bring one. A server "
+ "imported this way arrives asking for a password until you bind it to a key already in your "
+ "keychain — from the host's own editor, or in bulk from the Hosts screen.";
/// <summary>What the token step says about the token it is asking for.</summary>
/// <remarks>
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
/// say so would look like it had lost most of the estate.
/// </remarks>
internal static string TokenNote =>
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
+ "covered here.";
/// <summary>Reads the project. Writes nothing, and forgets the token on the way out.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
{
ClearRows();
IsBusy = true;
try
{
var import = await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true);
foreach (var server in import.Servers)
{
var row = new HetznerRowViewModel(server);
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound
// from the item template, because a template binding cannot reach the list it is inside
// without a parent binding — and the count is on the parent by definition.
row.PropertyChanged += OnRowChanged;
Rows.Add(row);
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
Refresh(resetSelection: true);
if (Rows.Count == 0)
{
// Deliberately not an error, and deliberately not a step forward: an empty project is a
// real answer to a correct token, and a list screen with nothing on it says less than this.
Status = "That project has no servers in it.";
return;
}
Status = Rows.Count == 1
? "Found 1 server. Nothing is stored until you press the button below."
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
Step = HetznerImportStep.Choose;
}
catch (HetznerCloudException failure)
{
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
// for why a fault at Hetzner's end is worded differently from a refused token even though both
// land here: one of them is something the person reading this can act on.
Status = failure.Message;
}
finally
{
// Cleared whatever happened, including on the way to a list somebody is about to import from:
// the read is over and the token has no further use. A failed attempt clears it too, so a
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
// against a credential sitting in a text box for the rest of the session.
Token = string.Empty;
IsBusy = false;
RaiseListState();
}
}
/// <summary>Stores the ticked servers as hosts.</summary>
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
if (Port is not { } port)
{
Status = "That is not a port. Give a number between 1 and 65535.";
return;
}
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
if (chosen.Count == 0)
{
Status = "Nothing is ticked.";
return;
}
IsBusy = true;
try
{
var requests = chosen
.Select(row => new ImportedHostRequest(
row.Server.ToSecret(row.Resolved!, Username, port),
Key: null,
KeyPath: null))
.ToList();
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
// which is what makes coming back to the list and importing the rest of the project harmless
// and visible rather than harmless and confusing.
Refresh(resetSelection: true);
// This screen's own sentence rather than the vault's, which the push that follows an import
// overwrites with a sync report — right on a screen somebody is about to leave, wrong on the one
// they are still standing on.
Status = outcome.Hosts == 1
? "Imported 1 server. It is on the Hosts screen, asking for a password until you bind it "
+ "to a key."
: $"Imported {outcome.Hosts} servers. They are on the Hosts screen, asking for a password "
+ "until you bind them to a key.";
Step = HetznerImportStep.Done;
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Back to the token step, with the list dropped.</summary>
/// <remarks>
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
/// means to have produced them would be showing a list it cannot stand behind.
/// </remarks>
[RelayCommand]
private void Restart()
{
ClearRows();
Status = string.Empty;
Step = HetznerImportStep.Token;
}
/// <summary>
/// Back from the last step to the list, with what was just stored marked as already here.
/// </summary>
/// <remarks>
/// Unlike <see cref="Restart"/> this keeps the rows, and it can: they were read before the token was
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
/// otherwise.
/// </remarks>
[RelayCommand]
private void Review()
{
if (Rows.Count > 0)
{
Step = HetznerImportStep.Choose;
}
}
/// <summary>Points the whole import at one of the three kinds of address.</summary>
[RelayCommand]
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
/// <summary>Ticks or unticks every importable row at once.</summary>
[RelayCommand]
private void ToggleAll()
{
var importable = Rows.Where(row => row.CanImport).ToList();
var target = SelectedCount < importable.Count;
foreach (var row in importable)
{
row.IsSelected = target;
}
RaiseListState();
}
/// <summary>The footer's Cancel button: back to the Preferences page, nothing stored.</summary>
/// <remarks>
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the reasoning
/// <see cref="ImportViewModel"/>'s own cancel gives: this type has no business knowing settings mode
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
/// no-op rather than a crash.
/// </remarks>
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
/// <summary>Drops the list, unsubscribing from what it held.</summary>
/// <remarks>
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
/// </remarks>
private void ClearRows()
{
foreach (var row in Rows)
{
row.PropertyChanged -= OnRowChanged;
}
Rows.Clear();
Warnings.Clear();
RaiseListState();
}
/// <remarks>
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
/// started, which already ends in <see cref="RaiseListState"/>.
/// </remarks>
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
{
RaiseListState();
}
}
/// <summary>Re-resolves every row against the current address choice.</summary>
private void Refresh(bool resetSelection)
{
foreach (var row in Rows)
{
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
}
RaiseListState();
}
/// <summary>
/// Whether the keychain already holds a host that would dial where this one does.
/// </summary>
/// <remarks>
/// Matched on address, port and user rather than on name, for the reason
/// <see cref="ImportViewModel.IsAlreadyPresent"/> gives at more length: a machine bookmarked under
/// another name is the same machine, and matching on the label would offer a duplicate of something
/// already stored. Compared against the <em>resolved</em> host so that a stored host inheriting its port
/// or user from a group still matches.
/// </remarks>
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
&& existing.Resolved.Port.Value == Port
&& string.Equals(
existing.Resolved.Username.Value,
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
StringComparison.OrdinalIgnoreCase));
partial void OnStepChanged(HetznerImportStep value)
{
OnPropertyChanged(nameof(IsTokenStep));
OnPropertyChanged(nameof(IsChooseStep));
OnPropertyChanged(nameof(IsDoneStep));
OnPropertyChanged(nameof(StepLabel));
OnPropertyChanged(nameof(StepTitle));
}
partial void OnAddressKindChanged(HetznerAddressKind value)
{
OnPropertyChanged(nameof(UsesPublicIPv4));
OnPropertyChanged(nameof(UsesPublicIPv6));
OnPropertyChanged(nameof(UsesPrivate));
OnPropertyChanged(nameof(AddressNote));
Refresh(resetSelection: false);
}
/// <remarks>
/// The user and the port are half of what "already here" is decided on, so changing either has to
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
/// would now store is different.
/// </remarks>
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
/// <inheritdoc cref="OnUsernameChanged" />
partial void OnPortTextChanged(string value)
{
OnPropertyChanged(nameof(IsPortValid));
Refresh(resetSelection: false);
}
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
private void RaiseListState()
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportableCount));
OnPropertyChanged(nameof(ImportLabel));
OnPropertyChanged(nameof(AllTicked));
OnPropertyChanged(nameof(CanImport));
OnPropertyChanged(nameof(SelectionSummary));
}
}
@@ -182,6 +182,17 @@ internal enum ShellScreen
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>. /// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
/// </remarks> /// </remarks>
Buckets = 10, Buckets = 10,
/// <summary>Importing hosts from a Hetzner Cloud project.</summary>
/// <remarks>
/// Reached from preferences and not from the nav rail, exactly as <see cref="Import"/> is, and for the
/// same reason: it is a task done once rather than a place to be. A member of its own rather than a mode
/// of <see cref="Import"/>, because the two read different things through different code and draw
/// different screens — a single "import" destination that remembered which kind you last opened would be
/// a destination whose meaning depended on history, which is the argument <see cref="Buckets"/> already
/// makes for not sharing a member with <see cref="Transfers"/>.
/// </remarks>
HetznerImport = 11,
} }
/// <summary> /// <summary>
@@ -1030,6 +1041,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty] [ObservableProperty]
private ImportViewModel? importScreen; private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private HetznerImportViewModel? hetznerImportScreen;
/// <inheritdoc cref="KnownHostsScreen" /> /// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty] [ObservableProperty]
private SnippetsViewModel? snippetsScreen; private SnippetsViewModel? snippetsScreen;
@@ -1531,6 +1546,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return; return;
} }
if (target is ShellScreen.HetznerImport)
{
OpenHetznerImport();
return;
}
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on // Any other screen leaves settings mode outright rather than restoring whatever was remembered on
// the way in — the caller named a destination, and that destination wins over "go back". // the way in — the caller named a destination, and that destination wins over "go back".
ActiveSettingsPage = null; ActiveSettingsPage = null;
@@ -1593,10 +1614,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private bool isImportOpen; private bool isImportOpen;
/// <summary> /// <summary>
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's /// Whether the Hetzner wizard, rather than the ssh_config importer, is the one drawn over Preferences.
/// </summary>
/// <remarks>
/// A second flag rather than a second value of one, because they are two independent facts about the
/// content column and a single "which importer" enum would have to carry a "neither" member that
/// <see cref="IsImportOpen"/> already expresses. What keeps them from both being true is that every
/// path that opens one closes the other — see <see cref="OpenImport"/> and
/// <see cref="OpenHetznerImport"/>.
/// </remarks>
[ObservableProperty]
private bool isHetznerImportOpen;
/// <summary>
/// Whether either importer is up, which is the one thing the titlebar's back button cares about.
/// </summary>
/// <remarks>
/// Both back out to the same place by the same command, so the bar asks this rather than asking twice.
/// See <c>SettingsTitleBar.axaml</c>.
/// </remarks>
internal bool IsImporterOpen => IsImportOpen || IsHetznerImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than an importer drawn over it, is what settings mode's
/// content column shows. /// content column shows.
/// </summary> /// </summary>
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen; internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImporterOpen;
partial void OnActiveSettingsPageChanged(SettingsPage? value) partial void OnActiveSettingsPageChanged(SettingsPage? value)
{ {
@@ -1611,8 +1654,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing)); OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
} }
partial void OnIsImportOpenChanged(bool value) => partial void OnIsImportOpenChanged(bool value) => RaiseImporterState();
/// <inheritdoc cref="OnIsImportOpenChanged" />
partial void OnIsHetznerImportOpenChanged(bool value) => RaiseImporterState();
private void RaiseImporterState()
{
OnPropertyChanged(nameof(IsImporterOpen));
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing)); OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary> /// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
/// <remarks> /// <remarks>
@@ -1635,6 +1686,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = page; ActiveSettingsPage = page;
IsImportOpen = false; IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = page switch Screen = page switch
{ {
@@ -1656,9 +1708,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IsImportOpen = true; IsImportOpen = true;
} }
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary> /// <summary>
/// Opens the Hetzner wizard over the Preferences page — the Preferences row's own button, and
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.HetznerImport"/>.
/// </summary>
/// <remarks>
/// Reopened from the first step each time rather than resumed where it was left, and that is a decision
/// rather than an omission: the wizard's first step holds a credential it deliberately does not keep, so
/// a "resumed" second step would be a list nothing on screen could refresh. See
/// <see cref="HetznerImportViewModel"/>.
/// </remarks>
private void OpenHetznerImport()
{
EnterSettings(SettingsPage.Preferences);
HetznerImportScreen?.RestartCommand.Execute(null);
IsHetznerImportOpen = true;
}
/// <summary>"Back to preferences": closes whichever importer is up, without leaving settings mode.</summary>
[RelayCommand] [RelayCommand]
private void CloseImport() => IsImportOpen = false; private void CloseImport()
{
IsImportOpen = false;
IsHetznerImportOpen = false;
}
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary> /// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
[RelayCommand] [RelayCommand]
@@ -1671,6 +1746,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = null; ActiveSettingsPage = null;
IsImportOpen = false; IsImportOpen = false;
IsHetznerImportOpen = false;
Screen = settingsReturnScreen; Screen = settingsReturnScreen;
Surface = settingsReturnSurface; Surface = settingsReturnSurface;
} }
@@ -3553,6 +3629,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
? null ? null
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport); : new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
// Its own transport, built once with the vault rather than per scan: an HttpClient is meant to be
// long-lived, and this one goes exactly one place — see HetznerCloudClient.CloudApi.
HetznerImportScreen = newValue is null
? null
: new HetznerImportViewModel(newValue, new HetznerCloudClient(), CloseImport);
SnippetsScreen?.Detach(); SnippetsScreen?.Detach();
SnippetsScreen = newValue is null SnippetsScreen = newValue is null
? null ? null
@@ -416,6 +416,32 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
await ScannedImportAsync()); await ScannedImportAsync());
} }
// ---- The Hetzner wizard ----
//
// Measured against the same SettingsContentWidth/SettingsContentHeight budget, because it is drawn in
// the same content column over the same Preferences page — see MainWindowViewModel.IsHetznerImportOpen.
// Two shapes, because the two steps are different layouts rather than one with a section hidden: the
// token step is a card, and the choose step is a table with an options card above it and a footer below.
[Fact]
public async Task TheHetznerWizardFitsOnTheStepItOpensOn()
{
await MeasureHetznerAsync(faults => faults.ShouldBeEmpty("the token step"));
}
/// <remarks>
/// The shape with something to decide about, and the two rows that must not be pushed off the bottom are
/// the sentence saying no key comes with these hosts and the IMPORT button under it — the same pair
/// <see cref="TheImportScreenFitsWithHostsToChooseFromAndWarnings"/> is written about.
/// </remarks>
[Fact]
public async Task TheHetznerWizardFitsWithServersToChooseFromAndWarnings()
{
await MeasureHetznerAsync(
faults => faults.ShouldBeEmpty("with a scanned project"),
await ScannedHetznerAsync());
}
// ---- Helpers ---- // ---- Helpers ----
/// <remarks> /// <remarks>
@@ -538,4 +564,93 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
Directory.Delete(directory, recursive: true); Directory.Delete(directory, recursive: true);
} }
} }
/// <inheritdoc cref="MeasureImportAsync" />
private Task MeasureHetznerAsync(
Action<IReadOnlyList<string>> assert,
HetznerImportViewModel? wizard = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new HetznerImportScreen
{
DataContext = wizard ?? new HetznerImportViewModel(vault, new HetznerCloudClient()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// A wizard that has read a project, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through the real client over a stubbed transport rather than by populating the rows directly, on the
/// reasoning <see cref="ScannedImportAsync"/> gives: a row carrying a warning is taller than one without,
/// and inventing the rows would measure a layout nothing generates. The fixture is chosen to produce all
/// three chip states plus a project-level warning — a plain server, one with no IPv4, and one the API
/// described without a name.
/// </remarks>
private async Task<HetznerImportViewModel> ScannedHetznerAsync()
{
var http = new HttpClient(new StubHetzner("""
{
"servers": [
{
"id": 1,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
},
{
"id": 2,
"name": "edge-v6",
"status": "off",
"public_net": { "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } },
"server_type": { "name": "cax11" },
"datacenter": { "location": { "name": "hel1", "city": "Helsinki", "country": "FI" } }
},
{ "id": 3, "status": "initializing" }
],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
"""));
var wizard = new HetznerImportViewModel(vault, new HetznerCloudClient(http))
{
Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
};
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasRows.ShouldBeTrue("the fixture has servers in it");
wizard.HasWarnings.ShouldBeTrue("the fixture has a server the API left unnamed");
return wizard;
}
/// <summary>Answers anything asked of it with one page. There is no sequence to model here.</summary>
private sealed class StubHetzner(string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
});
}
} }
@@ -0,0 +1,445 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The Hetzner Cloud wizard, from a token to hosts on the server.
/// </summary>
/// <remarks>
/// <para>
/// What this establishes beyond <c>HetznerCloudClientTests</c> is the half that suite cannot reach: that
/// reading a project stores nothing, that importing goes through the ordinary create-and-push path every
/// other write uses, that a server the keychain already points at arrives unticked rather than silently
/// duplicated, and that the token does not outlive the request it was typed for.
/// </para>
/// <para>
/// Hetzner is a stubbed <c>HttpMessageHandler</c>. Everything else — the vault, the cache, the server — is
/// this fixture's real one, which is the point: the assertion that matters most is that the imported hosts
/// reached the server without anything pressing Sync.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
{
private const string HetznerToken =
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
/// <summary>
/// The whole wizard.
/// </summary>
/// <remarks>
/// One server already in the keychain and one not, so the two halves of the default are both exercised:
/// the new one arrives ticked, the known one does not, and only the ticked one is stored.
/// </remarks>
[Fact]
public async Task ImportingFromHetzner_ShowsTheProjectFirstAndThenStoresWhatWasTicked()
{
await UnlockedAsync();
var vault = shell.Vault!;
// db.internal:deploy is what AddHostAsync creates, so the first server is one the keychain has.
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "already-here",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
""",
"""
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } },
"server_type": { "name": "cx22" },
"datacenter": { "name": "fsn1-dc14", "location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" } },
"image": { "name": "ubuntu-24.04" }
}
"""));
wizard.Step.ShouldBe(HetznerImportStep.Token, "it opens on the box, having sent nothing");
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
wizard.Rows.Count.ShouldBe(2);
vault.Hosts.Count.ShouldBe(1, "reading the project stores nothing");
var known = wizard.Rows.Single(row => string.Equals(row.Name, "already-here", StringComparison.Ordinal));
known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");
var fresh = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
fresh.IsSelected.ShouldBeTrue();
fresh.Address.ShouldBe("203.0.113.10");
await wizard.ImportCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Done);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.Address.ShouldBe("deploy@203.0.113.10:22");
imported.Host.SshKeyId.ShouldBeNull("Hetzner has no private key to hand over");
imported.Host.Notes.ShouldNotBeNull().ShouldContain("cx22");
vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");
// Through the ordinary path, which is the point of routing it through the vault: it reached the
// server without anything pressing Sync.
server.LiveRowCount.ShouldBe(2);
}
/// <summary>
/// The token does not outlive the request it was typed for.
/// </summary>
/// <remarks>
/// This is the whole of what makes pasting a credential capable of listing somebody's estate into a text
/// box acceptable: it is used once and dropped. Asserted on the success path deliberately — a wizard
/// that cleared the box only when something went wrong would leave the token sitting there for the
/// entire time somebody spends reading a list of forty servers.
/// </remarks>
[Fact]
public async Task TheTokenIsForgottenAsSoonAsTheProjectHasBeenRead()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Token.ShouldBeEmpty();
wizard.Rows.ShouldHaveSingleItem();
}
/// <remarks>
/// A refused token is the failure this meets most often, and the recovery is to type a different one —
/// so it stays on the step that has the box, with a message that says where tokens come from.
/// </remarks>
[Fact]
public async Task ARefusedToken_StaysOnTheTokenStepAndStoresNothing()
{
await UnlockedAsync();
var wizard = Wizard(new StubHetznerResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("API tokens");
wizard.Rows.ShouldBeEmpty();
wizard.Token.ShouldBeEmpty("a refused token is retyped rather than corrected in place");
shell.Vault!.Hosts.ShouldBeEmpty();
}
/// <remarks>
/// An empty project is a correct answer to a correct token, so it is not an error — and it is not a step
/// forward either, because a list screen with nothing on it says less than the sentence does.
/// </remarks>
[Fact]
public async Task AnEmptyProject_SaysSoWithoutMovingOn()
{
await UnlockedAsync();
var wizard = Wizard(Project());
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Status.ShouldContain("no servers");
}
/// <summary>
/// Choosing the private network does not quietly put a public address in the keychain.
/// </summary>
/// <remarks>
/// The one asymmetry in how addresses resolve, and the reason for it: falling back between v4 and v6
/// reaches the same machine over the same internet, where falling back from a private address to a
/// public one would put an address on the open internet into a bookmark that was asked to stay off it.
/// So the row loses its tick and cannot get it back while that choice stands.
/// </remarks>
[Fact]
public async Task ChoosingThePrivateNetwork_LeavesAServerWithoutOneUnimportable()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer, """
{
"id": 3,
"name": "db-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.20" } },
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.SelectedCount.ShouldBe(2);
wizard.UseAddressCommand.Execute(HetznerAddressKind.Private);
var public_ = wizard.Rows.Single(row => string.Equals(row.Name, "web-01", StringComparison.Ordinal));
var inside = wizard.Rows.Single(row => string.Equals(row.Name, "db-01", StringComparison.Ordinal));
public_.CanImport.ShouldBeFalse("it is on no private network");
public_.IsSelected.ShouldBeFalse();
public_.Address.ShouldBe("—");
inside.CanImport.ShouldBeTrue();
inside.Address.ShouldBe("10.0.0.4");
wizard.SelectedCount.ShouldBe(1);
// Neither direction of "tick everything" can reach it, which is what "not importable" has to mean.
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeFalse("that press was the untick, since everything reachable was ticked");
wizard.ToggleAllCommand.Execute(null);
public_.IsSelected.ShouldBeFalse();
inside.IsSelected.ShouldBeTrue();
await wizard.ImportCommand.ExecuteAsync(null);
shell.Vault!.Hosts.ShouldHaveSingleItem().Host.Hostname.ShouldBe("10.0.0.4");
}
/// <remarks>
/// Changing the address choice must not throw away ticks somebody set — but changing the login user has
/// to re-ask "is this already here", because the user is half of what that is decided on.
/// </remarks>
[Fact]
public async Task ChangingTheLoginUser_ReasksWhetherTheKeychainAlreadyHasIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var wizard = Wizard(Project("""
{
"id": 1,
"name": "db",
"status": "running",
"public_net": { "ipv4": { "ip": "db.internal" } }
}
"""));
wizard.Token = HetznerToken;
wizard.Username = "deploy";
await wizard.ScanCommand.ExecuteAsync(null);
wizard.Rows.ShouldHaveSingleItem().AlreadyPresent.ShouldBeTrue();
wizard.Username = "ubuntu";
wizard.Rows[0].AlreadyPresent.ShouldBeFalse("a different account on that machine is a different host");
}
/// <summary>
/// A server with no public IPv4 still imports, and the row says what was assumed to reach it.
/// </summary>
/// <remarks>
/// IPv6-only is a whole Hetzner price tier rather than an edge case. The address is the first in the
/// routed /64 — the API reports a network, which nothing can dial — and that is a convention of
/// Hetzner's own images rather than a fact, so it is on the row and in the stored note.
/// </remarks>
[Fact]
public async Task AnIPv6OnlyServer_ImportsWithWhatWasAssumedWrittenDown()
{
await UnlockedAsync();
var wizard = Wizard(Project("""
{
"id": 5,
"name": "v6-only",
"status": "running",
"public_net": { "ipv4": null, "ipv6": { "ip": "2a01:4f8:1c17:d00::/64" } }
}
"""));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
var row = wizard.Rows.ShouldHaveSingleItem();
row.Address.ShouldBe("2a01:4f8:1c17:d00::1");
row.HasWarning.ShouldBeTrue();
row.Meaning.ShouldContain("No public IPv4");
await wizard.ImportCommand.ExecuteAsync(null);
var host = shell.Vault!.Hosts.ShouldHaveSingleItem();
host.Host.Hostname.ShouldBe("2a01:4f8:1c17:d00::1");
host.Host.Notes.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// After an import the list is asked again rather than cleared, so a row that was just stored says
/// "already here" — which is what makes coming back and importing the rest of the project harmless and
/// visible rather than harmless and confusing.
/// </remarks>
[Fact]
public async Task AfterImporting_TheListSaysWhichOnesAreNowHere()
{
await UnlockedAsync();
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
await wizard.ImportCommand.ExecuteAsync(null);
wizard.ReviewCommand.Execute(null);
wizard.Step.ShouldBe(HetznerImportStep.Choose);
var row = wizard.Rows.ShouldHaveSingleItem();
row.AlreadyPresent.ShouldBeTrue();
row.IsSelected.ShouldBeFalse("importing it twice takes a click");
}
// ---- Where it lives in the shell ----
/// <remarks>
/// The wizard is drawn over the Preferences page inside settings mode, exactly as the ssh_config
/// importer is: the nav stays lit on Preferences and the titlebar offers "Back to preferences". What
/// would silently come apart is the content column — two importers and one page beneath them.
/// </remarks>
[Fact]
public async Task TheHetznerWizardOpensOverPreferencesAndBacksOutToIt()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.IsImporterOpen.ShouldBeTrue("the titlebar's back button asks this one");
shell.IsSettingsPreferencesPage.ShouldBeTrue("the nav stays lit on Preferences");
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the wizard is what the column draws");
shell.CloseImportCommand.Execute(null);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsSettingsMode.ShouldBeTrue("back to preferences, not out of settings");
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
}
/// <remarks>
/// Opening one closes the other. They share a content column, and two importers drawn over each other
/// would leave whichever was on top looking like the one the back button belongs to.
/// </remarks>
[Fact]
public async Task OpeningOneImporterClosesTheOther()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
shell.IsImportOpen.ShouldBeFalse();
shell.IsHetznerImportOpen.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsHetznerImportOpen.ShouldBeFalse();
shell.IsImportOpen.ShouldBeTrue();
}
/// <summary>
/// Reopening the wizard puts it back on step one.
/// </summary>
/// <remarks>
/// A decision rather than an omission. The first step holds a credential this deliberately does not
/// keep, so a "resumed" second step would be a list nothing on screen has the means to refresh — and it
/// would go on looking current for as long as somebody left the application open.
/// </remarks>
[Fact]
public async Task ReopeningTheWizard_StartsAtTheTokenAgain()
{
await UnlockedAsync();
var wizard = shell.HetznerImportScreen.ShouldNotBeNull();
// Driven through the shell's own view model rather than the one this file builds, because what is
// being checked is what OpenHetznerImport does to the screen the shell is holding.
wizard.Token = HetznerToken;
wizard.Step = HetznerImportStep.Choose;
shell.ShowScreenCommand.Execute(ShellScreen.HetznerImport);
wizard.Step.ShouldBe(HetznerImportStep.Token);
wizard.Rows.ShouldBeEmpty();
}
// ---- Helpers ----
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
private const string WebServer = """
{
"id": 2,
"name": "web-01",
"status": "running",
"public_net": { "ipv4": { "ip": "203.0.113.10" } }
}
""";
/// <summary>The wizard, over a Hetzner that answers with exactly this.</summary>
private HetznerImportViewModel Wizard(StubHetznerResponse response) =>
new(shell.Vault!, new HetznerCloudClient(new HttpClient(new StubHetzner(response))));
/// <summary>One page holding these servers and saying there are no more.</summary>
private static StubHetznerResponse Project(params string[] servers) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{string.Join(",", servers)}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": null } }
}
""");
private sealed record StubHetznerResponse(HttpStatusCode Status, string Body);
/// <summary>Answers every request the same way. What varies between these tests is the body, not the sequence.</summary>
private sealed class StubHetzner(StubHetznerResponse response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(response.Status)
{
Content = new StringContent(response.Body, Encoding.UTF8, "application/json"),
});
}
}
@@ -26,7 +26,12 @@ namespace DodoSSH.Client.App.Tests;
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test /// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
/// rather than by remembering to click through them. /// rather than by remembering to click through them.
/// </remarks> /// </remarks>
public sealed class ShellFlowTests : IAsyncLifetime /// <remarks>
/// Split across two files rather than one: the Hetzner wizard's own path is in
/// <c>ShellFlowTests.Hetzner.cs</c>, which needs this fixture's unlocked vault and nothing else of it. The
/// same reason <c>FakeVaultServer</c> keeps its team endpoints in a file of their own.
/// </remarks>
public sealed partial class ShellFlowTests : IAsyncLifetime
{ {
private const string Passphrase = "a sufficiently long passphrase"; private const string Passphrase = "a sufficiently long passphrase";
@@ -1,9 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<!-- <!--
The ssh_config reader, against strings. No disk, because the parser takes its include reader as a Both readers, each against a stand-in for the world it reads.
The ssh_config one runs against strings. No disk, because the parser takes its include reader as a
parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour, parameter — which is what lets Include, the one directive whose behaviour is a file-system behaviour,
be covered by ordinary unit tests. be covered by ordinary unit tests.
The Hetzner one runs against a stubbed HttpMessageHandler, which is the same trade: no account, no token
and no network, and every question about what the client decides from what Hetzner said still answerable.
What that leaves for a person is in docs/manual-checks.md, 2.11a onwards.
--> -->
<ItemGroup> <ItemGroup>
@@ -0,0 +1,416 @@
using System.Net;
using System.Text;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Import.Tests;
/// <summary>
/// Reading a Hetzner Cloud project, against a stubbed transport.
/// </summary>
/// <remarks>
/// <para>
/// No socket, for the reason <c>LinuxUpdateChannelTests</c> gives: every question here is what this client
/// decides from what Hetzner said, and a real request would add an account, a token and a network to the
/// assertions without adding anything to what is being checked.
/// </para>
/// <para>
/// The bodies are real shapes rather than minimal ones. An importer's whole value is agreeing with the
/// source about what it means — the IPv6 field holding a network rather than an address, and a server that
/// has no IPv4 at all, are both ordinary in a Hetzner project and both are exactly where an importer that
/// was nearly right would produce bookmarks that nearly connect.
/// </para>
/// </remarks>
public sealed class HetznerCloudClientTests
{
private const string Token = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
private static CancellationToken Cancellation => TestContext.Current.CancellationToken;
[Fact]
public async Task APageOfServers_ArrivesWithWhatTheProjectSaidAboutThem()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers.ShouldHaveSingleItem();
server.Id.ShouldBe(42);
server.Name.ShouldBe("web-01");
server.Status.ShouldBe("running");
server.PublicIPv4.ShouldBe("203.0.113.10");
server.ServerType.ShouldBe("cx22");
server.Location.ShouldBe("fsn1 · Falkenstein, DE");
server.Image.ShouldBe("ubuntu-24.04");
server.Labels.ShouldBe(["env=prod", "role=web"]);
import.Warnings.ShouldBeEmpty();
}
/// <remarks>
/// The token goes in an <c>Authorization: Bearer</c> header and nowhere else — never a query parameter,
/// which is the shape that ends up in somebody's proxy log.
/// </remarks>
[Fact]
public async Task TheTokenTravelsAsABearerHeader()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation);
var request = stub.Requests.ShouldHaveSingleItem();
request.Authorization.ShouldBe($"Bearer {Token}");
request.Uri.ShouldNotContain(Token);
request.Uri.ShouldContain("per_page=50");
}
/// <summary>
/// Every page is followed, which is what keeps a short list from looking like a complete one.
/// </summary>
/// <remarks>
/// Fifty-one servers in a project is enough to meet this, and an importer that showed the first fifty
/// would present a list nobody has any reason to doubt.
/// </remarks>
[Fact]
public async Task EveryPageIsFollowed()
{
var stub = new StubHetzner([
Page(nextPage: 2, """{ "id": 1, "name": "web-01" }"""),
Page(nextPage: 3, """{ "id": 2, "name": "web-02" }"""),
Page(nextPage: null, """{ "id": 3, "name": "web-03" }"""),
]);
var import = await new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync(Token, Cancellation);
import.Servers.Select(server => server.Name).ShouldBe(["web-01", "web-02", "web-03"]);
stub.Requests.Select(request => request.Uri).ShouldAllBe(uri => uri.Contains("page="));
stub.Requests.Count.ShouldBe(3);
}
/// <remarks>
/// A refused token is the failure this wizard meets most, because the value is shown once in the Hetzner
/// console and a half-copied one fails exactly like a wrong one. So the message says where tokens come
/// from, and <c>IsTokenProblem</c> is what sends the wizard back to the step that has the box.
/// </remarks>
[Fact]
public async Task ARefusedToken_SaysWhereTokensComeFromAndIsMarkedAsTheTokensFault()
{
var stub = new StubHetzner([
new StubResponse(
HttpStatusCode.Unauthorized,
"""{ "error": { "code": "unauthorized", "message": "unable to authenticate" } }"""),
]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeTrue();
failure.Code.ShouldBe("unauthorized");
failure.Message.ShouldContain("API tokens");
failure.Message.ShouldContain("unable to authenticate", Case.Insensitive);
}
/// <remarks>
/// The other half of that split: Hetzner having a bad minute is not a reason to send somebody back to
/// re-type a token that was fine.
/// </remarks>
[Fact]
public async Task AFaultAtHetznersEnd_IsNotTheTokensFault()
{
var stub = new StubHetzner([new StubResponse(HttpStatusCode.BadGateway, "<html>nope</html>")]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub)).ListServersAsync(Token, Cancellation));
failure.IsTokenProblem.ShouldBeFalse();
failure.Message.ShouldContain("502");
}
/// <remarks>
/// Refused before anything is sent, because a header value carrying a line break throws from inside
/// <c>HttpClient</c> — a stack trace where the honest answer is a sentence.
/// </remarks>
[Fact]
public async Task ATokenWithALineBreakInTheMiddle_IsRefusedWithoutAskingHetzner()
{
var stub = new StubHetzner([OnePage(OneServer)]);
var failure = await Should.ThrowAsync<HetznerCloudException>(
() => new HetznerCloudClient(new HttpClient(stub))
.ListServersAsync("abcdef\n0123456789", Cancellation));
failure.Message.ShouldContain("64 letters and digits");
stub.Requests.ShouldBeEmpty();
}
/// <remarks>Pasting out of the console brings a trailing newline often enough to be worth handling.</remarks>
[Fact]
public async Task ATokenPastedWithSurroundingWhitespace_IsTrimmedRatherThanRefused()
{
var stub = new StubHetzner([OnePage(OneServer)]);
await new HetznerCloudClient(new HttpClient(stub)).ListServersAsync($" {Token}\n", Cancellation);
stub.Requests.ShouldHaveSingleItem().Authorization.ShouldBe($"Bearer {Token}");
}
/// <summary>
/// A server with no IPv4 at all, which is a whole Hetzner price tier rather than an edge case.
/// </summary>
/// <remarks>
/// Two things are being checked and the second is the one that matters. The address is the first in the
/// routed /64, because what the API reports is a network and nothing can dial a network. And it carries
/// a warning, because that address is a convention of Hetzner's own images rather than a fact the API
/// stated — an import that silently guessed would produce a bookmark that fails to connect with no
/// indication of why.
/// </remarks>
[Fact]
public async Task AServerWithNoIPv4_DialsTheFirstAddressOfItsSixtyFourAndSaysSo()
{
var import = await ReadAsync(OnePage("""
{
"id": 7,
"name": "v6-only",
"status": "running",
"public_net": {
"ipv4": null,
"ipv6": { "ip": "2a01:4f8:1c17:d00::/64" }
}
}
"""));
var server = import.Servers.ShouldHaveSingleItem();
server.HasPublicIPv4.ShouldBeFalse();
server.FirstIPv6Address.ShouldBe("2a01:4f8:1c17:d00::1");
var address = server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("No public IPv4");
}
/// <remarks>
/// The v6 choice warns even where it is the one that was asked for, on the same reasoning: the address
/// is inferred from a network either way.
/// </remarks>
[Fact]
public async Task ChoosingIPv6_StillSaysTheAddressWasInferredFromANetwork()
{
var import = await ReadAsync(OnePage(OneServer));
var address = import.Servers[0].Resolve(HetznerAddressKind.PublicIPv6).ShouldNotBeNull();
address.Value.ShouldBe("2a01:4f8:1c17:d00::1");
address.Warning.ShouldNotBeNull().ShouldContain("2a01:4f8:1c17:d00::/64");
}
/// <summary>
/// The private choice does not fall back, and that asymmetry is the point.
/// </summary>
/// <remarks>
/// Falling back between v4 and v6 reaches the same machine over the same internet. Falling back from a
/// private address to a public one would put an address on the open internet into a bookmark that was
/// asked to stay off it, so a server with no private network is simply not importable under that choice.
/// </remarks>
[Fact]
public async Task AServerOnNoPrivateNetwork_HasNoPrivateAddressToOffer()
{
var import = await ReadAsync(OnePage(OneServer));
import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldBeNull();
}
[Fact]
public async Task AServerOnAPrivateNetwork_DialsItWithoutAWarning()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldBeNull();
}
/// <remarks>
/// Two private networks is a real shape and the first one is a choice this makes rather than a fact, so
/// it is said out loud — the same rule the IPv6 address follows.
/// </remarks>
[Fact]
public async Task AServerOnTwoPrivateNetworks_TakesTheFirstAndSaysThereWereTwo()
{
var import = await ReadAsync(OnePage("""
{
"id": 9,
"name": "db-01",
"private_net": [ { "ip": "10.0.0.4" }, { "ip": "10.1.0.4" } ]
}
"""));
var address = import.Servers[0].Resolve(HetznerAddressKind.Private).ShouldNotBeNull();
address.Value.ShouldBe("10.0.0.4");
address.Warning.ShouldNotBeNull().ShouldContain("2 private networks");
}
/// <summary>What is stored, and what is deliberately not.</summary>
/// <remarks>
/// The note keeps what does not go stale — the id, the plan, where it runs, what it was built from. The
/// power state is not in it: a note saying "running" about a machine somebody switched off last March is
/// worse than no note, so status is a column on the import screen and never a stored fact.
/// </remarks>
[Fact]
public async Task WhatIsStored_CarriesTheProjectsContextAndNotItsPowerState()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
var host = server.ToSecret(
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldNotBeNull(),
"root",
22);
host.Label.ShouldBe("web-01");
host.Hostname.ShouldBe("203.0.113.10");
host.Username.ShouldBe("root");
host.Port.ShouldBe(22);
host.SshKeyId.ShouldBeNull("the private half of a key was never Hetzner's to hand over");
var notes = host.Notes.ShouldNotBeNull();
notes.ShouldContain("server 42");
notes.ShouldContain("cx22");
notes.ShouldContain("fsn1 · Falkenstein, DE");
notes.ShouldContain("ubuntu-24.04");
notes.ShouldContain("env=prod");
notes.ShouldNotContain("running");
host.TryValidate(out _).ShouldBeTrue();
}
/// <remarks>
/// A blank login user stores none rather than storing an empty one, which is what the ssh_config import
/// does for an entry that named no <c>User</c>.
/// </remarks>
[Fact]
public async Task ABlankLoginUser_StoresNoUsername()
{
var import = await ReadAsync(OnePage(OneServer));
var server = import.Servers[0];
server.ToSecret(server.Resolve(HetznerAddressKind.PublicIPv4)!, " ", 22).Username.ShouldBeNull();
}
/// <remarks>
/// A server with no name would arrive as a bookmark called nothing, which <c>HostSecret.TryValidate</c>
/// refuses outright — so it is named for its id and the project-level warning says one was.
/// </remarks>
[Fact]
public async Task AServerWithNoName_IsOfferedUnderItsIdAndWarnedAbout()
{
var import = await ReadAsync(OnePage("""{ "id": 512, "status": "off" }"""));
import.Servers.ShouldHaveSingleItem().Name.ShouldBe("server-512");
import.Warnings.ShouldHaveSingleItem().ShouldContain("server-512");
}
/// <remarks>
/// Everything but the id is optional in practice — a server built from a snapshot has no image, and one
/// mid-creation has no datacentre yet. None of that is a reason to refuse the whole page.
/// </remarks>
[Fact]
public async Task AServerTheApiDescribedSparsely_StillImports()
{
var import = await ReadAsync(OnePage("""{ "id": 3, "name": "fresh", "status": "initializing" }"""));
var server = import.Servers.ShouldHaveSingleItem();
server.ServerType.ShouldBeNull();
server.Location.ShouldBeNull();
server.Image.ShouldBeNull();
server.Labels.ShouldBeEmpty();
server.Resolve(HetznerAddressKind.PublicIPv4).ShouldBeNull("it has no address of any kind yet");
}
// ---- Helpers ----
/// <summary>A server carrying everything the API can say about one, used wherever the shape is not the point.</summary>
private const string OneServer = """
{
"id": 42,
"name": "web-01",
"status": "running",
"public_net": {
"ipv4": { "id": 1, "ip": "203.0.113.10", "blocked": false },
"ipv6": { "id": 2, "ip": "2a01:4f8:1c17:d00::/64", "blocked": false }
},
"private_net": [],
"server_type": { "id": 22, "name": "cx22", "cores": 2 },
"datacenter": {
"id": 4,
"name": "fsn1-dc14",
"location": { "name": "fsn1", "city": "Falkenstein", "country": "DE" }
},
"image": { "id": 100, "name": "ubuntu-24.04", "os_flavor": "ubuntu" },
"labels": { "role": "web", "env": "prod" }
}
""";
private static Task<HetznerImport> ReadAsync(StubResponse page) =>
new HetznerCloudClient(new HttpClient(new StubHetzner([page]))).ListServersAsync(Token, Cancellation);
private static StubResponse OnePage(string server) => Page(nextPage: null, server);
private static StubResponse Page(int? nextPage, string server) =>
new(
HttpStatusCode.OK,
$$"""
{
"servers": [ {{server}} ],
"meta": { "pagination": { "page": 1, "per_page": 50, "next_page": {{Next(nextPage)}} } }
}
""");
private static string Next(int? page) =>
page is { } value ? value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "null";
private sealed record StubResponse(HttpStatusCode Status, string Body);
private sealed record SeenRequest(string Uri, string? Authorization);
/// <summary>
/// Answers each request with the next scripted response, and remembers what was asked.
/// </summary>
/// <remarks>
/// Scripted in order rather than keyed by URL, because the thing being checked in the paging test is the
/// sequence: a client that asked for page one three times would pass a lookup by address and produce the
/// same list three times over.
/// </remarks>
private sealed class StubHetzner(IReadOnlyList<StubResponse> responses) : HttpMessageHandler
{
private int served;
internal List<SeenRequest> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new SeenRequest(
request.RequestUri!.AbsoluteUri,
request.Headers.Authorization?.ToString()));
var scripted = responses[Math.Min(served++, responses.Count - 1)];
return Task.FromResult(new HttpResponseMessage(scripted.Status)
{
Content = new StringContent(scripted.Body, Encoding.UTF8, "application/json"),
});
}
}
}