Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions metadata-relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ The service is configured entirely via environment variables for maximum flexibi
| `SMTP_PORT` | No | `587` | SMTP relay port |
| `SMTP_USERNAME` | No | - | SMTP username. SMTP auth is enabled when both username and password are present |
| `SMTP_PASSWORD` | No | - | SMTP password. SMTP auth is enabled when both username and password are present |
| `P2P_ACCESS_BEARER_TOKENS` | No | - | Comma-separated list of bearer tokens the self-hosted iroh relay may present to `POST /p2p/access`. Multiple values are accepted so a token can be rotated by deploying the new one alongside the old and removing the old afterwards. Unset means the endpoint denies every relay client |

### Cache Configuration

Expand Down
16 changes: 15 additions & 1 deletion metadata-relay/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,21 @@ config :metadata_relay,
port: 4000,
# Ecto repository
ecto_repos: [MetadataRelay.Repo],
dashboard_auth: [username: "admin", password: "admin"]
dashboard_auth: [username: "admin", password: "admin"],
# Bearer tokens the iroh relay may present to POST /p2p/access.
# Populated from P2P_ACCESS_BEARER_TOKENS at runtime. A list, so a token can
# be rotated by deploying both values before removing the old one.
p2p_access_bearer_tokens: [],
# Hard cap on distinct endpoint IDs held in ETS, sized against the pod's
# 512Mi memory limit. Above this we keep allowing traffic but stop recording
# new identities.
p2p_max_sightings: 200_000,
# How often accumulated ETS sightings are written to the database.
p2p_flush_interval_ms: 30_000,
# How often stale sightings are pruned.
p2p_prune_interval_ms: 86_400_000,
# Sightings not seen within this window are pruned (30 days).
p2p_retention_seconds: 2_592_000

config :metadata_relay, MetadataRelay.Feedback.Notifier,
recipient: nil,
Expand Down
17 changes: 17 additions & 0 deletions metadata-relay/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ if config_env() != :test do
config :metadata_relay,
dashboard_auth: [username: dashboard_username, password: dashboard_password]

# Bearer tokens accepted on POST /p2p/access, presented by the iroh relay.
# Comma-separated so a token can be rotated by deploying both values before
# removing the old one. Unset means an empty list, which denies everything.
p2p_access_bearer_tokens =
case normalize_env.("P2P_ACCESS_BEARER_TOKENS") do
nil ->
[]

value ->
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end

config :metadata_relay, p2p_access_bearer_tokens: p2p_access_bearer_tokens

# Database configuration (all environments except test)
db_path = System.get_env("SQLITE_DB_PATH") || "./metadata_relay.db"

Expand Down
10 changes: 9 additions & 1 deletion metadata-relay/config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ config :metadata_relay, MetadataRelay.Repo,
pool: Ecto.Adapters.SQL.Sandbox

config :metadata_relay,
rendezvous_master_pepper: "test-pepper-not-for-production"
rendezvous_master_pepper: "test-pepper-not-for-production",
p2p_access_bearer_tokens: ["test-relay-bearer"],
# The P2pAccess.Store's timers would otherwise fire mid-suite and write to
# the database from a process that does not own the sandbox connection.
# Pin them well beyond any plausible suite runtime; tests drive the work
# explicitly through flush_now/0, prune_now/0 and a direct :reload_blocks.
p2p_flush_interval_ms: 3_600_000,
p2p_prune_interval_ms: 3_600_000,
p2p_reload_retry_interval_ms: 3_600_000

config :metadata_relay, MetadataRelay.Feedback.Notifier,
recipient: "maintainer@example.com",
Expand Down
2 changes: 2 additions & 0 deletions metadata-relay/lib/metadata_relay/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ defmodule MetadataRelay.Application do
{cache_adapter, cache_opts},
# Long-lived ETS owner for pairing fallback storage
MetadataRelay.PairingStore,
# Long-lived ETS owner for p2p relay access control
MetadataRelay.P2pAccess.Store,
# Rate limiter for crash reports and pairing
MetadataRelay.RateLimiter,
# Metrics collector
Expand Down
129 changes: 129 additions & 0 deletions metadata-relay/lib/metadata_relay/p2p_access.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
defmodule MetadataRelay.P2pAccess do
@moduledoc """
Access control policy for the self-hosted iroh relay.

The relay POSTs to `/p2p/access` before accepting an endpoint. The endpoint
ID it sends is proven by the relay handshake, so it is a trustworthy
identifier and needs no further authentication from the client.

This does not verify that a caller is a genuine Mydia instance. Mydia is open
source and self-hosted with no user accounts, so there is no per-user secret
to check. What this provides is attribution, a size-capped record of who used
the relay, and the ability to revoke a specific endpoint.

Phase 1 policy: allow everyone except explicitly blocked endpoints.
"""

alias MetadataRelay.P2pAccess.Store

@endpoint_id_length 64

@doc """
The authorization decision for an endpoint. ETS only.

Case-normalizes the endpoint ID before recording the sighting and checking
the blocklist, since `block/2` and `unblock/1` store and match on the
downcased form and a block must not be bypassable by changing case. This
does **not** validate the ID: malformed input is still recorded and
checked (and will simply never match a block). Callers handling untrusted
input who need to distinguish a malformed ID from a denied one should call
`normalize_endpoint_id/1` first.
"""
def authorize(endpoint_id) when is_binary(endpoint_id) do
endpoint_id = String.downcase(endpoint_id)

Store.record_sighting(endpoint_id)

if Store.blocked?(endpoint_id) do
MetadataRelay.Metrics.inc("metadata_relay_p2p_access_total", result: "deny")
:deny
else
MetadataRelay.Metrics.inc("metadata_relay_p2p_access_total", result: "allow")
:allow
end
end

@doc """
Validates and downcases an endpoint ID.

iroh endpoint IDs are 32-byte ed25519 public keys, hex-encoded to 64
characters.
"""
def normalize_endpoint_id(endpoint_id) when is_binary(endpoint_id) do
normalized = String.downcase(endpoint_id)

if String.length(normalized) == @endpoint_id_length and
String.match?(normalized, ~r/\A[0-9a-f]+\z/) do
{:ok, normalized}
else
:error
end
end

def normalize_endpoint_id(_), do: :error

@doc """
Whether a bearer token presented by the relay is one we accept.

The configured value is a list so a token can be rotated by deploying both
the old and new value before removing the old one. An empty list rejects
everything, which fails closed if the deployment forgets the secret.
"""
def valid_bearer?(token) when is_binary(token) do
Enum.any?(bearer_tokens(), fn configured ->
Plug.Crypto.secure_compare(configured, token)
end)
end

def valid_bearer?(_), do: false

@doc """
Denies an endpoint relay access. Callable over rpc.

MetadataRelay.P2pAccess.block("abcd...", "bandwidth abuse")
"""
def block(endpoint_id, reason) when is_binary(reason) do
case normalize_endpoint_id(endpoint_id) do
{:ok, normalized} -> Store.put_block(normalized, reason)
:error -> {:error, :invalid_endpoint_id}
end
end

@doc """
Restores relay access for an endpoint. Callable over rpc.
"""
def unblock(endpoint_id) do
case normalize_endpoint_id(endpoint_id) do
{:ok, normalized} -> Store.delete_block(normalized)
:error -> {:error, :invalid_endpoint_id}
end
end

@doc """
The most recently active endpoints, newest first. Callable over rpc.

ETS only, deliberately: this is what an operator reaches for mid-incident,
and it must not depend on the database being responsive. It still survives a
restart, because `Store.seed_sightings/0` repopulates ETS from the durable
table at boot.
"""
def list_recent(limit \\ 50) when is_integer(limit) and limit > 0 do
:p2p_sightings
|> :ets.tab2list()
|> Enum.sort_by(fn {_id, _first, last_seen, _count} -> last_seen end, :desc)
|> Enum.take(limit)
|> Enum.map(fn {endpoint_id, first_seen, last_seen, conn_count} ->
%{
endpoint_id: endpoint_id,
first_seen: DateTime.from_unix!(first_seen),
last_seen: DateTime.from_unix!(last_seen),
conn_count: conn_count,
blocked: Store.blocked?(endpoint_id)
}
end)
end

defp bearer_tokens do
Application.get_env(:metadata_relay, :p2p_access_bearer_tokens, [])
end
end
16 changes: 16 additions & 0 deletions metadata-relay/lib/metadata_relay/p2p_access/block.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule MetadataRelay.P2pAccess.Block do
@moduledoc """
An endpoint that is denied relay access.

Written synchronously on admin action, and loaded into ETS at boot.
"""

use Ecto.Schema

@primary_key {:endpoint_id, :string, autogenerate: false}

schema "p2p_blocked_endpoints" do
field(:reason, :string)
field(:blocked_at, :utc_datetime)
end
end
18 changes: 18 additions & 0 deletions metadata-relay/lib/metadata_relay/p2p_access/sighting.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule MetadataRelay.P2pAccess.Sighting do
@moduledoc """
A p2p endpoint the relay has asked us about.

Written only by the periodic flush in `MetadataRelay.P2pAccess.Store`.
Never read or written on the authorization hot path.
"""

use Ecto.Schema

@primary_key {:endpoint_id, :string, autogenerate: false}

schema "p2p_endpoint_sightings" do
field(:first_seen, :utc_datetime)
field(:last_seen, :utc_datetime)
field(:conn_count, :integer, default: 0)
end
end
Loading
Loading