using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// A host as the user sees it: everything the server never gets to read. /// /// /// /// The whole of this record lives inside the item's encrypted payload. In particular there is no /// plaintext label anywhere in the system — access-control administration runs on the client, which /// can decrypt names, so the server never needs a searchable title. /// /// /// and are here and may additionally appear as /// plaintext columns on the server, but only for a host the user has opted into the relay. That is /// the one deliberate privacy concession in the design: the relay must resolve its target /// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The /// copy in here is the authoritative one; the plaintext column is a derived duplicate the client /// supplies only when relay is enabled. See ADR 0004. /// /// /// Structural equality holds across every field, including the collections, which is what the merge /// relies on to tell "unchanged" from "changed to the same thing" from "changed differently". /// /// public sealed record HostSecret { /// The default SSH port, used when a host does not say otherwise. public const int DefaultPort = 22; /// Display name. The only name this host has anywhere. public required string Label { get; init; } /// Hostname or address to connect to. public required string Hostname { get; init; } /// TCP port. public int Port { get; init; } = DefaultPort; /// Login user, when the host pins one. public string? Username { get; init; } /// Free-text notes. public string? Notes { get; init; } /// /// The jump chain, nearest hop first, as host item ids. /// /// /// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a /// chain changes which machine is reached through which, and a set union of two different chains /// would produce a route neither user asked for. /// public JumpChain JumpHostIds { get; init; } = JumpChain.Empty; /// SSH directives, unique by name. public HostOptions Options { get; init; } = HostOptions.Empty; /// /// Whether this host may be dialled through the server relay. /// /// /// /// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server /// keeps. It has to: it is the flag that decides whether and /// are copied out into those columns, and a setting the merge cannot see is a /// setting two clients can silently disagree about — one of them re-exposing an address the other /// had just withdrawn. /// /// /// The plaintext copy is derived from this, in one place, so the address can only ever leave the /// payload as a consequence of the user turning this on. See ADR 0004. /// /// public bool RelayEnabled { get; init; } /// /// Checks the fields that must hold before this can be stored. /// /// /// Separate from construction on purpose. A view model binds directly to these properties and /// passes through empty and half-typed states on the way to a valid one; a constructor that threw /// would make the editor unusable. The sync layer validates before sealing, and the codec /// validates on decode, which are the two points where an invalid host would become durable. /// public bool TryValidate([NotNullWhen(false)] out string? error) { if (string.IsNullOrWhiteSpace(Label)) { error = "A host needs a name."; return false; } if (string.IsNullOrWhiteSpace(Hostname)) { error = "A host needs a hostname or address."; return false; } if (Port is < 1 or > 65535) { error = $"Port must be between 1 and 65535, not {Port}."; return false; } if (JumpHostIds.AsSpan().Contains(Guid.Empty)) { error = "A jump chain cannot contain an empty host id."; return false; } error = null; return true; } }