Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs
jaap-jan f05deb4fbc
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s
Add port forwarding: local, remote and dynamic, per terminal session
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.
2026-09-28 20:47:02 +02:00

109 lines
4.4 KiB
C#

namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Builds hosts, and the groups they are filed under, so each test varies only what it is about.
/// </summary>
internal static class HostFactory
{
internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01");
internal static Guid Relay { get; } = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e02");
/// <summary>A vault SSH key id, for the hosts that bind one.</summary>
internal static Guid DeployKey { get; } = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e03");
/// <summary>A group id, for the hosts that are filed under one.</summary>
internal static Guid Production { get; } = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04");
/// <summary>A tag id, for the hosts that wear one.</summary>
internal static Guid Pci { get; } = Guid.Parse("0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08");
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
/// <summary>A forward to the database, as the host would carry it.</summary>
internal static PortForwardRule Postgres { get; } = new()
{
Id = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"),
Kind = PortForwardKind.Local,
BindPort = 5432,
TargetHost = "db.internal",
TargetPort = 5432,
};
/// <summary>A SOCKS forward, for the tests about two forwards on one host.</summary>
internal static PortForwardRule Socks { get; } = new()
{
Id = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
Kind = PortForwardKind.Dynamic,
BindPort = 1080,
};
/// <remarks>
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
/// suite here is still about. Passing <see langword="null"/> is how a test asks for the new one.
/// </remarks>
internal static HostSecret Host(
string label = "prod-db",
string hostname = "db.internal",
int? port = 22,
string? username = "deploy",
string? notes = null,
Guid[]? jumps = null,
(string Name, string Value)[]? options = null,
bool relayEnabled = false,
Guid? sshKeyId = null,
Guid? credentialId = null,
bool? asksForPassword = null,
Guid? groupId = null,
Guid[]? tags = null,
string[]? pinnedPaths = null,
PortForwardRule[]? portForwards = null) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = username,
Notes = notes,
JumpHostIds = jumps is null ? JumpChain.Empty : JumpChain.Create(jumps),
Options = options is null
? HostOptions.Empty
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
RelayEnabled = relayEnabled,
SshKeyId = sshKeyId,
CredentialId = credentialId,
AsksForPassword = asksForPassword,
GroupId = groupId,
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
};
/// <summary>
/// A group, flat and defaulting nothing unless the test says otherwise.
/// </summary>
/// <remarks>
/// The defaults of this builder are the shape a group had before it could nest or default anything,
/// which is what most suites still want: a heading with a name. A test that passes nothing here is
/// asserting about the old shape on purpose.
/// </remarks>
internal static HostGroupSecret Group(
string label = "production",
Guid? parentId = null,
int? defaultPort = null,
string? defaultUsername = null,
Guid? defaultSshKeyId = null,
Guid? defaultCredentialId = null) =>
new()
{
Label = label,
ParentId = parentId,
DefaultPort = defaultPort,
DefaultUsername = defaultUsername,
DefaultSshKeyId = defaultSshKeyId,
DefaultCredentialId = defaultCredentialId,
};
}