using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Setup;
namespace DodoSSH.Api.Features.Relay;
///
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
///
///
///
/// This runs on addresses, 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.
///
///
/// Three layers, and the order is the security property. The 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.
///
///
/// An address that embeds another is judged as the one it embeds. ::ffff:127.0.0.1 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 64:ff9b::a9fe:a9fe is the metadata endpoint.
///
///
internal sealed class RelayAddressPolicy
{
///
/// Addresses the relay never dials, whatever it is configured to do.
///
///
/// Broader than the ADR's list in two places, both deliberately. 240.0.0.0/4 — reserved, and
/// containing the limited broadcast address — has no host worth reaching. And IPv6 ::/96, 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.
///
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"),
];
///
/// Ranges governs.
///
///
/// 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
/// fec0::/10 is deprecated and was private while it lived.
///
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"),
];
/// The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
///
/// Ports the relay never dials.
///
///
/// 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.
///
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);
}
/// Whether a port may be dialled at all.
internal static bool PermitsPort(int port) =>
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
///
/// Whether every entry is a network this policy can read.
///
///
/// 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.
///
/// Host bits are refused as well, although
/// would mask them off quietly. 10.20.5.7/16 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.
///
///
internal static bool AreNetworks(IEnumerable 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));
///
/// The address the relay would actually be talking to, with any embedding unwrapped.
///
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;
}
/// Whether the relay may dial this address.
///
/// Judges the normalised address. Callers should dial '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.
///
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));
}
///
/// 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.
///
private static bool Within(IPNetwork network, IPAddress address) =>
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
private static IPNetwork[] ParseAll(IEnumerable entries) =>
[.. entries.Select(entry => IPNetwork.Parse(entry))];
}