using System.Diagnostics.CodeAnalysis; using System.Globalization; namespace DodoSSH.Client.Domain; /// Which way a saved forward carries connections. /// /// Stored as its number, so the values are part of the payload format and must never be renumbered. The /// SSH layer has its own enum with the same three members; the connect path maps one to the other, which /// keeps this assembly free of any reference to it. /// public enum PortForwardKind { /// A port on this machine, carried to a target the remote can reach. ssh -L. Local = 0, /// A port on the remote, carried back to a target this machine can reach. ssh -R. Remote = 1, /// A SOCKS proxy on this machine, whose connections leave from the remote. ssh -D. Dynamic = 2, } /// /// A port forward saved on a host, started with every terminal opened to it. /// /// /// /// Part of the host's encrypted payload rather than an item of its own, which is what /// RelayTicketRequest.PortForwardId has said since the contract was frozen. A forward means nothing /// without the host it runs over, it is edited in the host's own editor, and a separate item kind would be a /// table, a server kind, a cipher and a repository to buy one thing the host's per-id merge already gives: /// two machines each adding a forward both keep theirs. SyncEntityType.PortForward stays reserved, /// the way HostTag does for the same reason. /// /// /// There is no bind address. Every listener binds loopback — see SshPortForwardSpec for why a /// field another member of the keychain can fill in must not be able to say otherwise. /// /// /// is what the merge keys on, so editing a forward's port on one machine and its target on /// another is one forward changed twice — a conflict the merge records — rather than two forwards. /// /// public sealed record PortForwardRule { /// The longest a target host may be, which is the longest a DNS name can be. public const int MaxTargetHostLength = 253; /// Identifies this forward within its host, for the merge. public required Guid Id { get; init; } /// Which way it carries connections. public required PortForwardKind Kind { get; init; } /// /// The port to listen on: on this machine for a local or dynamic forward, on the remote for a remote one. /// public required int BindPort { get; init; } /// Where each connection goes, or null for a dynamic forward. /// /// Resolved by the remote for a local forward — so it may name a machine only the remote can see — and /// by this machine for a remote one. /// public string? TargetHost { get; init; } /// The port on , or null for a dynamic forward. public int? TargetPort { get; init; } /// /// The forward in OpenSSH's own notation, which is what anybody who has used one will read fastest. /// /// /// -L 5432:db.internal:5432, -R 8080:localhost:3000, -D 1080. No bind address, because /// there is never one to show. /// public string Notation => Kind switch { PortForwardKind.Local => $"-L {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}", PortForwardKind.Remote => $"-R {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}", _ => $"-D {Port(BindPort)}", }; /// Checks the fields that must hold before this can be stored. /// /// Deliberately silent about two forwards on one port. Two machines each adding one is exactly what the /// merge produces, and a host that failed validation after a merge could not be written back at all; the /// second listener fails when it starts instead, and says so. The editor refuses the duplicate up front. /// public bool TryValidate([NotNullWhen(false)] out string? reason) { if (Id == Guid.Empty) { reason = "A port forward cannot have an empty id."; return false; } if (!Enum.IsDefined(Kind)) { reason = $"'{(int)Kind}' is not a kind of port forward."; return false; } if (BindPort is < 1 or > 65535) { reason = $"A forward's listening port must be between 1 and 65535, not {BindPort}."; return false; } if (Kind is PortForwardKind.Dynamic) { if (TargetHost is not null || TargetPort is not null) { reason = "A dynamic forward has no target: each connection through it names its own."; return false; } reason = null; return true; } return TargetIsStorable(out reason); } private static string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "?"; /// /// Whitespace and control characters are refused as well as blanks: neither is part of any host name, /// both are what a corrupted payload looks like, and this string is drawn on screen as it stands. /// private bool TargetIsStorable([NotNullWhen(false)] out string? reason) { if (string.IsNullOrWhiteSpace(TargetHost)) { reason = "A forward needs a host to forward to."; return false; } if (TargetHost.Length > MaxTargetHostLength) { reason = $"A forward's target cannot be longer than {MaxTargetHostLength} characters."; return false; } foreach (var ch in TargetHost) { if (char.IsControl(ch) || char.IsWhiteSpace(ch)) { reason = "A forward's target cannot contain spaces or control characters."; return false; } } if (TargetPort is not (>= 1 and <= 65535)) { reason = TargetPort is { } port ? $"A forward's target port must be between 1 and 65535, not {port}." : "A forward needs a port to forward to."; return false; } reason = null; return true; } }