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;
///
/// Hetzner Cloud answered, and the answer was not a list of servers.
///
///
/// 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 rather than by matching on prose.
///
public sealed class HetznerCloudException : Exception
{
/// Creates a failure that has no underlying exception — a refusal Hetzner spelled out.
public HetznerCloudException(HttpStatusCode? statusCode, string? code, string message)
: this(statusCode, code, message, innerException: null)
{
}
/// Creates a failure wrapping the one the transport or the parser threw.
public HetznerCloudException(
HttpStatusCode? statusCode,
string? code,
string message,
Exception? innerException)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code;
}
/// What Hetzner returned, or null where the request never got an answer at all.
public HttpStatusCode? StatusCode { get; }
/// Hetzner's own machine-readable error code, where the body carried one.
public string? Code { get; }
/// Whether the token is the thing to go and fix.
///
/// 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.
///
public bool IsTokenProblem =>
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
}
///
/// Reads the servers in one Hetzner Cloud project.
///
///
///
/// One token is one project. 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.
///
///
/// Read is all it does. Only GET /v1/servers is ever called, which is why the screen asks for
/// a read-only token: a Read & Write token would work identically here and would be a credential capable
/// of deleting the estate, pasted into a text box for no gain.
///
///
/// The token is never stored. 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 ImportViewModel.ImportsKeys gives for not remembering its tick.
///
///
/// Dedicated servers are not here. 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.
///
///
public sealed class HetznerCloudClient
{
/// The Cloud API, and the one address in this file.
public static readonly Uri CloudApi = new("https://api.hetzner.cloud/v1/", UriKind.Absolute);
/// What Hetzner will return per page, which is also its maximum.
private const int PerPage = 50;
///
/// How many pages this will follow before giving up.
///
///
/// 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.
///
private const int MaxPages = 100;
private readonly HttpClient http;
/// Creates a reader over its own transport, which is how the application builds one.
public HetznerCloudClient()
: this(new HttpClient { BaseAddress = CloudApi })
{
}
/// Creates a reader with its transport supplied, which is how the tests reach it.
///
/// The base address is set here when the caller left it unset, so a stubbed HttpMessageHandler
/// can be handed over on its own without every test repeating the one URL this class knows.
///
public HetznerCloudClient(HttpClient http)
{
ArgumentNullException.ThrowIfNull(http);
http.BaseAddress ??= CloudApi;
this.http = http;
}
///
/// Reads every server the token's project holds.
///
///
/// 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.
///
///
/// 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.
///
public async Task ListServersAsync(string token, CancellationToken cancellationToken)
{
var bearer = Sanitise(token);
var servers = new List();
var warnings = new List();
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);
}
///
/// Trims the pasted token and refuses one that could not be a header value.
///
///
/// 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 from inside
/// — 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.
///
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;
}
/// Fetches and parses one page, turning every way it can fail into one exception type.
private async Task 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);
}
}
}
///
/// Turns a refusal into the sentence the screen shows.
///
///
/// 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.
///
private static async Task 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);
}
///
/// Turns one server as the API describes it into one the import can offer.
///
///
/// 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.
///
private static HetznerServer Convert(HetznerServerDto server, List 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(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);
}
/// Where a server runs, as a person would say it — or null where the API said nothing useful.
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,
};
}
/// Folds an absent value and a present-but-empty one together, which JSON does not.
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
}
/// One page of GET /v1/servers.
internal sealed record HetznerServersPage(
[property: JsonPropertyName("servers")] IReadOnlyList? Servers,
[property: JsonPropertyName("meta")] HetznerMetaDto? Meta);
/// The envelope every Hetzner list response carries its paging in.
internal sealed record HetznerMetaDto(
[property: JsonPropertyName("pagination")] HetznerPaginationDto? Pagination);
/// Only the forward link is read; the rest of Hetzner's paging block is not needed to walk it.
internal sealed record HetznerPaginationDto(
[property: JsonPropertyName("next_page")] int? NextPage);
/// One server, as the API describes it. Every field is optional because the API's are.
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? PrivateNet,
[property: JsonPropertyName("server_type")] HetznerNamedDto? ServerType,
[property: JsonPropertyName("datacenter")] HetznerDatacenterDto? Datacenter,
[property: JsonPropertyName("image")] HetznerNamedDto? Image,
[property: JsonPropertyName("labels")] IReadOnlyDictionary? Labels);
/// A server's public addresses. Either half can be absent on a server that has only the other.
internal sealed record HetznerPublicNetDto(
[property: JsonPropertyName("ipv4")] HetznerIpDto? IPv4,
[property: JsonPropertyName("ipv6")] HetznerIpDto? IPv6);
///
/// One public address.
///
///
/// The v6 case puts a network in this field rather than an address — see
/// , which is where that is dealt with rather than papered over
/// here.
///
internal sealed record HetznerIpDto([property: JsonPropertyName("ip")] string? Ip);
/// A server's attachment to one private network.
internal sealed record HetznerPrivateNetDto([property: JsonPropertyName("ip")] string? Ip);
/// The shape shared by the server type and the image, of which only the name is read.
internal sealed record HetznerNamedDto([property: JsonPropertyName("name")] string? Name);
/// Where a server runs.
internal sealed record HetznerDatacenterDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("location")] HetznerLocationDto? Location);
///
internal sealed record HetznerLocationDto(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("city")] string? City,
[property: JsonPropertyName("country")] string? Country);
/// How Hetzner wraps a refusal.
internal sealed record HetznerErrorEnvelope(
[property: JsonPropertyName("error")] HetznerErrorDto? Error);
///
internal sealed record HetznerErrorDto(
[property: JsonPropertyName("code")] string? Code,
[property: JsonPropertyName("message")] string? Message);
///
/// The serializer for everything above.
///
///
/// 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.
///
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HetznerServersPage))]
[JsonSerializable(typeof(HetznerErrorEnvelope))]
internal sealed partial class HetznerJsonContext : JsonSerializerContext;