Files
DodoSSH/src/DodoSSH.Client.Import/HetznerCloudClient.cs
T
jaap-janandClaude Opus 5 c270f3fe39
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
Import a Hetzner Cloud project's servers, and forget the token that read it
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>
2026-08-26 10:51:11 +02:00

477 lines
20 KiB
C#

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;