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
@@ -0,0 +1,61 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DodoSSH.Infrastructure.Configurations;
|
||||
|
||||
/// <summary>Maps <see cref="RelayTicketUse"/>.</summary>
|
||||
public sealed class RelayTicketUseConfiguration : IEntityTypeConfiguration<RelayTicketUse>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<RelayTicketUse> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("relay_ticket_use");
|
||||
|
||||
// The key is the guard. A second redemption of the same ticket is a unique violation, on whichever
|
||||
// node it reaches, and nothing weaker — a lookup followed by an insert — would be one.
|
||||
builder.HasKey(u => u.TicketId);
|
||||
builder.Property(u => u.TicketId).ValueGeneratedNever();
|
||||
|
||||
// For the sweep, which deletes by expiry.
|
||||
builder.HasIndex(u => u.ExpiresAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="RelaySession"/>.</summary>
|
||||
public sealed class RelaySessionConfiguration : IEntityTypeConfiguration<RelaySession>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<RelaySession> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("relay_session");
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// Minted with the ticket and handed to the client before the row exists, so the client can name
|
||||
// the session it opened.
|
||||
builder.Property(s => s.Id).ValueGeneratedNever();
|
||||
|
||||
builder.Property(s => s.TargetHost).HasMaxLength(255).IsRequired();
|
||||
|
||||
// An address's text form: 45 characters is the longest IPv6 spelling with an embedded IPv4.
|
||||
builder.Property(s => s.TargetAddress).HasMaxLength(45);
|
||||
builder.Property(s => s.ClientAddress).HasMaxLength(45);
|
||||
|
||||
builder.Property(s => s.CloseReason).HasConversion<int>();
|
||||
|
||||
builder.HasIndex(s => new { s.UserId, s.StartedAtUtc });
|
||||
builder.HasIndex(s => new { s.HostId, s.StartedAtUtc });
|
||||
|
||||
builder.ToTable(t => t.HasCheckConstraint(
|
||||
"ck_relay_session_port_range",
|
||||
"target_port BETWEEN 1 AND 65535"));
|
||||
|
||||
builder.ToTable(t => t.HasCheckConstraint(
|
||||
"ck_relay_session_bytes",
|
||||
"bytes_client_to_target >= 0 AND bytes_target_to_client >= 0"));
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,12 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
/// <summary>Applied-operation receipts, for exactly-once retries.</summary>
|
||||
public DbSet<SyncOperationReceipt> SyncOperationReceipts => Set<SyncOperationReceipt>();
|
||||
|
||||
/// <summary>Redeemed relay tickets: the replay guard ADR 0004 shares between nodes.</summary>
|
||||
public DbSet<RelayTicketUse> RelayTicketUses => Set<RelayTicketUse>();
|
||||
|
||||
/// <summary>The relay's audit record, one row per session.</summary>
|
||||
public DbSet<RelaySession> RelaySessions => Set<RelaySession>();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
+1907
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the relay's two tables: the replay guard for its tickets, and its audit record.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both are new and empty, so this is additive and safe to roll back. Neither table references a
|
||||
/// host or a vault by foreign key — an audit row has to outlive what it is about — and neither holds
|
||||
/// anything the host row did not already hold in plaintext for a relay-enabled host. See ADR 0004.
|
||||
/// </remarks>
|
||||
public partial class AddRelaySessions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "relay_session",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
host_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
target_host = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
target_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||
target_port = table.Column<int>(type: "integer", nullable: false),
|
||||
client_address = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||
started_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ended_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
bytes_client_to_target = table.Column<long>(type: "bigint", nullable: false),
|
||||
bytes_target_to_client = table.Column<long>(type: "bigint", nullable: false),
|
||||
close_reason = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_relay_session", x => x.id);
|
||||
table.CheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0");
|
||||
table.CheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "relay_ticket_use",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
ticket_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
used_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_relay_ticket_use", x => x.ticket_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_session_host_id_started_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_session",
|
||||
columns: new[] { "host_id", "started_at_utc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_session_user_id_started_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_session",
|
||||
columns: new[] { "user_id", "started_at_utc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_relay_ticket_use_expires_at_utc",
|
||||
schema: "dodo",
|
||||
table: "relay_ticket_use",
|
||||
column: "expires_at_utc");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "relay_session",
|
||||
schema: "dodo");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "relay_ticket_use",
|
||||
schema: "dodo");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,104 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.ToTable("key_log", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.RelaySession", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("BytesClientToTarget")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("bytes_client_to_target");
|
||||
|
||||
b.Property<long>("BytesTargetToClient")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("bytes_target_to_client");
|
||||
|
||||
b.Property<string>("ClientAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("client_address");
|
||||
|
||||
b.Property<int>("CloseReason")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("close_reason");
|
||||
|
||||
b.Property<DateTimeOffset?>("EndedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at_utc");
|
||||
|
||||
b.Property<Guid>("HostId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("host_id");
|
||||
|
||||
b.Property<DateTimeOffset>("StartedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at_utc");
|
||||
|
||||
b.Property<string>("TargetAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("target_address");
|
||||
|
||||
b.Property<string>("TargetHost")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("target_host");
|
||||
|
||||
b.Property<int>("TargetPort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("target_port");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_relay_session");
|
||||
|
||||
b.HasIndex("HostId", "StartedAtUtc")
|
||||
.HasDatabaseName("ix_relay_session_host_id_started_at_utc");
|
||||
|
||||
b.HasIndex("UserId", "StartedAtUtc")
|
||||
.HasDatabaseName("ix_relay_session_user_id_started_at_utc");
|
||||
|
||||
b.ToTable("relay_session", "dodo", t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0");
|
||||
|
||||
t.HasCheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.RelayTicketUse", b =>
|
||||
{
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("ticket_id");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at_utc");
|
||||
|
||||
b.Property<DateTimeOffset>("UsedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("used_at_utc");
|
||||
|
||||
b.HasKey("TicketId")
|
||||
.HasName("pk_relay_ticket_use");
|
||||
|
||||
b.HasIndex("ExpiresAtUtc")
|
||||
.HasDatabaseName("ix_relay_ticket_use_expires_at_utc");
|
||||
|
||||
b.ToTable("relay_ticket_use", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
Reference in new issue
Block a user