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;