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);