Public Access
Let a host marked for the relay actually be reached through it
The relay checkbox said it was not wired up, and the plan said the server half was built and shipped. It was not: there was ADR 0004, the contract records, RelayOptions and a CHECK constraint, and RelayOptions said in its own remarks that there was no endpoint and no dialler. So both halves are built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md. Server, in Features/Relay: - POST /api/v1/relay/tickets makes every decision: the host, Connect on its vault, the relay switched on, and the stored name resolved once and judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8, multicast and the metadata endpoints are refused with no configuration escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the operator's denials, allow-list and private-network switch. What passes goes into a Data Protection ticket, and nothing resolves the name again. Every target refusal is the same 403, so the endpoint cannot be used to map the operator's DNS; the real reason is logged. - GET /api/v1/relay/connect takes the ticket as a second subprotocol, checks its expiry against TimeProvider and spends it with a unique insert into relay_ticket_use before accepting the upgrade. After the upgrade a refusal is a close code: 4502 for a host the server could not reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain. - relay_session is the audit row, written from short-lived scopes. Client: - RelayBridge is a loopback SOCKS5 listener that accepts once and pipes to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so the connection disposes it on every way a session ends and a failed connect disposes it before throwing. - ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for the ticket, opens the socket, starts the bridge and turns every refusal into a sentence. - VaultViewModel and TransfersViewModel reach a relayed host through it, never fall back to dialling it directly, and replace SSH.NET's transport error with the relay's own reason when it gave one. Neither side ever cancels a WebSocket operation. Cancelling one aborts the socket, and an aborted socket sends no close frame, which is the only way the client learns the server could not reach the host. AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives. Three departures from ADR 0004, recorded in it: awaited copy loops rather than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket= fallback, and no device id in the ticket. Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a real login through the bridge, API client 30, contracts 25. Not run: the Android build (its change is copy only) and the Alpine layout oracle.
This commit is contained in:
1 parent
54bc142dce
commit
b20f7cae04
51 files changed
+6455
-88
No files matched your search
@@ -11671,7 +11671,10 @@ internal sealed partial class VaultViewModel(
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching)
|
||||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
||||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
||||
// they cannot use — and report on that instead.
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
@@ -11776,9 +11779,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||
@@ -11823,6 +11824,90 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a relay to a host set to connect through the server, or says why there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>There is no falling back to a direct connection.</b> A user who ticked the relay did so because
|
||||
/// the direct path does not work, or goes somewhere they do not mean — and dialling it anyway on a
|
||||
/// failure would be the old defect in reverse: the box would say one thing and the connection do
|
||||
/// another. So a relay that cannot be opened is the failure, stated as such.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server is resolved the way a synchronisation resolves it, which comes back online quietly when a
|
||||
/// sign-in is remembered. Offline is its own sentence, because it is the one case the user can fix from
|
||||
/// this machine without touching the host.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
||||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
throw new ServerRelayException(
|
||||
$"{label} connects through the server relay, and this machine is not connected to the "
|
||||
+ "server right now. Try again once it is back online.");
|
||||
}
|
||||
|
||||
return await server.Relay.OpenAsync(hostId, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
||||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
||||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||||
/// bridge listening until its own timeout; disposing twice is safe.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<uint> OpenThroughAsync(
|
||||
ConnectionTarget target,
|
||||
SshConnectionRequest request,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||||
var relay = target.Relayed
|
||||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
return await workspace
|
||||
.OpenSessionAsync(
|
||||
relay is null ? request : request with { Proxy = relay.Proxy },
|
||||
TerminalSize.Default,
|
||||
reporter,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (relay is not null)
|
||||
{
|
||||
await relay.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
if (relay.Failure is { } failure
|
||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||
{
|
||||
throw new ServerRelayException(failure, exception);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the password that just worked into a keychain credential bound to this host.
|
||||
/// </summary>
|
||||
@@ -12041,6 +12126,15 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is reached through the server relay rather than dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
||||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
||||
/// </remarks>
|
||||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in new issue
Block a user