Public Access
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
167 lines
6.4 KiB
C#
167 lines
6.4 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
|
|
namespace DodoSSH.Client.Domain;
|
|
|
|
/// <summary>Which way a saved forward carries connections.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public enum PortForwardKind
|
|
{
|
|
/// <summary>A port on this machine, carried to a target the remote can reach. <c>ssh -L</c>.</summary>
|
|
Local = 0,
|
|
|
|
/// <summary>A port on the remote, carried back to a target this machine can reach. <c>ssh -R</c>.</summary>
|
|
Remote = 1,
|
|
|
|
/// <summary>A SOCKS proxy on this machine, whose connections leave from the remote. <c>ssh -D</c>.</summary>
|
|
Dynamic = 2,
|
|
}
|
|
|
|
/// <summary>
|
|
/// A port forward saved on a host, started with every terminal opened to it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Part of the host's encrypted payload rather than an item of its own</b>, which is what
|
|
/// <c>RelayTicketRequest.PortForwardId</c> 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. <c>SyncEntityType.PortForward</c> stays reserved,
|
|
/// the way <c>HostTag</c> does for the same reason.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>There is no bind address.</b> Every listener binds loopback — see <c>SshPortForwardSpec</c> for why a
|
|
/// field another member of the keychain can fill in must not be able to say otherwise.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="Id"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed record PortForwardRule
|
|
{
|
|
/// <summary>The longest a target host may be, which is the longest a DNS name can be.</summary>
|
|
public const int MaxTargetHostLength = 253;
|
|
|
|
/// <summary>Identifies this forward within its host, for the merge.</summary>
|
|
public required Guid Id { get; init; }
|
|
|
|
/// <summary>Which way it carries connections.</summary>
|
|
public required PortForwardKind Kind { get; init; }
|
|
|
|
/// <summary>
|
|
/// The port to listen on: on this machine for a local or dynamic forward, on the remote for a remote one.
|
|
/// </summary>
|
|
public required int BindPort { get; init; }
|
|
|
|
/// <summary>Where each connection goes, or null for a dynamic forward.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public string? TargetHost { get; init; }
|
|
|
|
/// <summary>The port on <see cref="TargetHost"/>, or null for a dynamic forward.</summary>
|
|
public int? TargetPort { get; init; }
|
|
|
|
/// <summary>
|
|
/// The forward in OpenSSH's own notation, which is what anybody who has used one will read fastest.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>-L 5432:db.internal:5432</c>, <c>-R 8080:localhost:3000</c>, <c>-D 1080</c>. No bind address, because
|
|
/// there is never one to show.
|
|
/// </remarks>
|
|
public string Notation => Kind switch
|
|
{
|
|
PortForwardKind.Local => $"-L {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}",
|
|
PortForwardKind.Remote => $"-R {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}",
|
|
_ => $"-D {Port(BindPort)}",
|
|
};
|
|
|
|
/// <summary>Checks the fields that must hold before this can be stored.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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) ?? "?";
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|