Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.0 KiB
C#
120 lines
5.0 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,
|
|
};
|
|
|
|
/// <summary>A server's id in Spelunk.</summary>
|
|
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
|
|
|
/// <summary>
|
|
/// A route to <see cref="SpelunkServer"/> — signing in through Spelunk when <paramref name="signIn"/> says so.
|
|
/// </summary>
|
|
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
|
new() { ApiBase = apiBase, ServerId = SpelunkServer, SignInThroughSpelunk = signIn };
|
|
|
|
/// <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,
|
|
SpelunkRoute? spelunk = 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),
|
|
Spelunk = spelunk,
|
|
};
|
|
|
|
/// <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,
|
|
};
|
|
}
|