Public Access
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.
193 lines
8.2 KiB
C#
193 lines
8.2 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using DodoSSH.Api.Setup;
|
|
|
|
namespace DodoSSH.Api.Features.Relay;
|
|
|
|
/// <summary>
|
|
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
|
|
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
|
|
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
|
|
/// one for the dial — which is the attack ADR 0004 is built around.
|
|
/// </para>
|
|
/// <para>
|
|
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
|
|
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
|
|
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
|
|
/// then their allow-list, then the private-network switch.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
|
|
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
|
|
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
|
|
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class RelayAddressPolicy
|
|
{
|
|
/// <summary>
|
|
/// Addresses the relay never dials, whatever it is configured to do.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
|
|
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
|
|
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
|
|
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
|
|
/// </remarks>
|
|
private static readonly IPNetwork[] Refused =
|
|
[
|
|
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
|
|
IPNetwork.Parse("0.0.0.0/8"),
|
|
IPNetwork.Parse("127.0.0.0/8"),
|
|
IPNetwork.Parse("169.254.0.0/16"),
|
|
IPNetwork.Parse("224.0.0.0/4"),
|
|
IPNetwork.Parse("240.0.0.0/4"),
|
|
|
|
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
|
|
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
|
|
// block cannot simply be refused.
|
|
IPNetwork.Parse("100.100.100.200/32"),
|
|
|
|
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
|
|
// metadata endpoint's IPv6 address.
|
|
IPNetwork.Parse("::/96"),
|
|
IPNetwork.Parse("fe80::/10"),
|
|
IPNetwork.Parse("ff00::/8"),
|
|
IPNetwork.Parse("fd00:ec2::254/128"),
|
|
];
|
|
|
|
/// <summary>
|
|
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
|
|
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
|
|
/// <c>fec0::/10</c> is deprecated and was private while it lived.
|
|
/// </remarks>
|
|
private static readonly IPNetwork[] Private =
|
|
[
|
|
IPNetwork.Parse("10.0.0.0/8"),
|
|
IPNetwork.Parse("172.16.0.0/12"),
|
|
IPNetwork.Parse("192.168.0.0/16"),
|
|
IPNetwork.Parse("100.64.0.0/10"),
|
|
IPNetwork.Parse("fc00::/7"),
|
|
IPNetwork.Parse("fec0::/10"),
|
|
];
|
|
|
|
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
|
|
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
|
|
|
|
/// <summary>
|
|
/// Ports the relay never dials.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
|
|
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
|
|
/// and the control that matters there is who may dial the host at all.
|
|
/// </remarks>
|
|
private static readonly int[] RefusedPorts = [25, 465, 587];
|
|
|
|
private readonly bool allowPrivate;
|
|
private readonly IPNetwork[] allowed;
|
|
private readonly IPNetwork[] denied;
|
|
|
|
public RelayAddressPolicy(RelayOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
allowPrivate = options.AllowPrivateNetworks;
|
|
allowed = ParseAll(options.AllowedNetworks);
|
|
denied = ParseAll(options.DeniedNetworks);
|
|
}
|
|
|
|
/// <summary>Whether a port may be dialled at all.</summary>
|
|
internal static bool PermitsPort(int port) =>
|
|
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
|
|
|
|
/// <summary>
|
|
/// Whether every entry is a network this policy can read.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
|
|
/// operator believes is closed, so an unreadable entry stops the server instead.
|
|
/// <para>
|
|
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
|
|
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
|
|
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static bool AreNetworks(IEnumerable<string> entries) =>
|
|
entries.All(entry =>
|
|
IPNetwork.TryParse(entry, out var network)
|
|
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
|
|
&& written.Equals(network.BaseAddress));
|
|
|
|
/// <summary>
|
|
/// The address the relay would actually be talking to, with any embedding unwrapped.
|
|
/// </summary>
|
|
internal static IPAddress Normalise(IPAddress address)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(address);
|
|
|
|
if (address.IsIPv4MappedToIPv6)
|
|
{
|
|
return address.MapToIPv4();
|
|
}
|
|
|
|
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
|
|
{
|
|
var bytes = address.GetAddressBytes();
|
|
return new IPAddress(bytes.AsSpan(12, 4));
|
|
}
|
|
|
|
// A scope id is how an address names an interface on this machine, which is not something a
|
|
// host row gets to choose. Dropped, so what is judged is what is dialled.
|
|
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
|
|
? new IPAddress(address.GetAddressBytes())
|
|
: address;
|
|
}
|
|
|
|
/// <summary>Whether the relay may dial this address.</summary>
|
|
/// <remarks>
|
|
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
|
|
/// the address checked and the address connected to are the same object rather than two spellings
|
|
/// the operating system is trusted to agree about.
|
|
/// </remarks>
|
|
internal bool Permits(IPAddress address)
|
|
{
|
|
var target = Normalise(address);
|
|
|
|
if (Refused.Any(network => Within(network, target)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (denied.Any(network => Within(network, target)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (allowed.Length > 0)
|
|
{
|
|
return allowed.Any(network => Within(network, target));
|
|
}
|
|
|
|
return allowPrivate || !Private.Any(network => Within(network, target));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
|
|
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
|
|
/// alternative reads as though it compared across families.
|
|
/// </remarks>
|
|
private static bool Within(IPNetwork network, IPAddress address) =>
|
|
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
|
|
|
|
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
|
|
[.. entries.Select(entry => IPNetwork.Parse(entry))];
|
|
}
|