using System.Globalization; using System.Net; using System.Net.Sockets; using DodoSSH.Client.Domain; namespace DodoSSH.Client.Import; /// /// Which of a Hetzner server's addresses an import should dial. /// /// /// 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. /// public enum HetznerAddressKind { /// The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. PublicIPv4 = 0, /// The public IPv6 address, for an estate that is v6 throughout — or a server with no v4 at all. PublicIPv6 = 1, /// The address on the first private network the server is attached to. Private = 2, } /// /// An address an import settled on, and what it had to assume to get there. /// /// What the host will dial. /// /// 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. /// public sealed record HetznerAddress(string Value, string? Warning); /// /// One server a Hetzner Cloud project holds, as the import sees it. /// /// /// /// Deliberately not a , on exactly the reasoning 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. /// /// /// There is no key here and there cannot be. 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. /// /// /// The server's id in the project, which is the one name of it that never changes. /// What the project calls it, which is what the user will recognise. /// /// running, off, initializing 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. /// /// The public IPv4 address, or null on a server that has none. /// /// The public IPv6 network — Hetzner routes a whole /64 and the API reports the prefix, not an /// address. See for what this import does with that. /// /// The addresses it has on private networks, in the order the API gave them. /// The plan — cx22, ccx13 — or null where the API did not say. /// Where it runs, as a person would say it: fsn1 · Falkenstein, DE. /// What it was built from, which is the best available hint at which user logs in. /// The project's own labels, as key=value, sorted. public sealed record HetznerServer( long Id, string Name, string Status, string? PublicIPv4, string? PublicIPv6Network, IReadOnlyList PrivateAddresses, string? ServerType, string? Location, string? Image, IReadOnlyList Labels) { /// Whether the API gave this server a public IPv4 address at all. /// /// 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. /// public bool HasPublicIPv4 => PublicIPv4 is { Length: > 0 }; /// /// The address inside the routed /64 that a stock Hetzner image actually answers on. /// /// /// /// This is a convention and not a fact the API stated, which is why every use of it carries a /// warning. What the API reports is 2a01:4f8:1c17:d00::/64 — 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. /// /// /// 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. /// /// 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(); } } /// /// Settles on the address this server would be dialled at, or null where the choice has none to offer. /// /// /// 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. /// 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, }; } /// Turns this into the host that would be stored. /// /// /// Nothing here authenticates. 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 ssh_config import has with its key tick off, and it is /// stated on the screen rather than discovered at connect time. /// /// /// What goes in the notes is what does not go stale. 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. /// /// /// The address to dial, from . /// The login user, or null and empty for none. /// The SSH port. 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), }; } /// What the note says about where this came from, plus whatever the address had to assume. private string Describe(HetznerAddress address) { var facts = new List { 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; } } /// /// Everything a Hetzner Cloud project yielded: the servers it can offer, and what it could not. /// /// The importable candidates, in the order the API returned them. /// /// 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 . /// public sealed record HetznerImport(IReadOnlyList Servers, IReadOnlyList Warnings);