merge: pr-12-featedge-per-site-config-bundle-pure-edge-deploy-p

This commit is contained in:
maxfield 2026-08-02 01:54:37 -04:00
commit cc026dddcf
26 changed files with 1781 additions and 3 deletions

View file

@ -0,0 +1,114 @@
defmodule Elektrine.Repo.Migrations.CreateDnsEdgeSites do
use Ecto.Migration
@moduledoc """
Edge site registry for per-site config tokens (KD-15).
Idempotent against a partial/legacy table that may already exist from a
shared test DB or an earlier sites slice: creates when missing, otherwise
adds columns required for config pull (hmac secret, revoked_at, token
uniqueness).
"""
def up do
if table_exists?(:dns_edge_sites) do
alter_existing_table()
else
create_fresh_table()
end
end
def down do
# Only drop columns we may have added; do not drop a shared sites table
# that other PRs own if present before this migration ran as create.
if table_exists?(:dns_edge_sites) and column_exists?(:dns_edge_sites, :hmac_secret) do
alter table(:dns_edge_sites) do
remove_if_exists :hmac_secret, :text
remove_if_exists :revoked_at, :utc_datetime
end
end
end
defp create_fresh_table do
create table(:dns_edge_sites) do
add :name, :string, null: false
add :ipv4, {:array, :string}, null: false, default: []
add :ipv6, {:array, :string}, null: false, default: []
add :roles, {:array, :string}, null: false, default: ["proxy"]
add :status, :string, null: false, default: "unknown"
add :last_heartbeat_at, :utc_datetime
add :token_hash, :string, null: false
add :token_prefix, :string, null: false
add :hmac_secret, :text
add :metadata, :map, null: false, default: %{}
add :revoked_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:dns_edge_sites, [:name])
create unique_index(:dns_edge_sites, [:token_hash])
create index(:dns_edge_sites, [:status])
end
defp alter_existing_table do
alter table(:dns_edge_sites) do
add_if_not_exists :hmac_secret, :text
add_if_not_exists :revoked_at, :utc_datetime
add_if_not_exists :token_hash, :string
add_if_not_exists :token_prefix, :string
add_if_not_exists :ipv4, {:array, :string}, null: false, default: []
add_if_not_exists :ipv6, {:array, :string}, null: false, default: []
add_if_not_exists :roles, {:array, :string}, null: false, default: ["proxy"]
add_if_not_exists :status, :string, null: false, default: "unknown"
add_if_not_exists :last_heartbeat_at, :utc_datetime
add_if_not_exists :metadata, :map, null: false, default: %{}
end
# Ensure unique token index exists (name may differ on legacy tables).
execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'dns_edge_sites' AND indexname = 'dns_edge_sites_token_hash_index'
) AND NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'dns_edge_sites' AND indexdef ILIKE '%token_hash%'
) THEN
CREATE UNIQUE INDEX dns_edge_sites_token_hash_index ON dns_edge_sites (token_hash);
END IF;
END $$;
""")
end
defp table_exists?(table) do
query = """
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '#{table}'
)
"""
case repo().query(query) do
{:ok, %{rows: [[true]]}} -> true
_ -> false
end
end
defp column_exists?(table, column) do
query = """
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = '#{table}'
AND column_name = '#{column}'
)
"""
case repo().query(query) do
{:ok, %{rows: [[true]]}} -> true
_ -> false
end
end
end

View file

@ -812,6 +812,17 @@ alias Elektrine.DNS.SecondaryStore
defdelegate public_tunnel(tunnel), to: Tunnels
def public_tunnel(tunnel, raw_token), do: Tunnels.public_tunnel(tunnel, raw_token)
# Edge sites (instance-admin; per-site config bearer). Soft dep for multi-node.
defdelegate list_edge_sites(), to: EdgeSites, as: :list_sites
defdelegate get_edge_site(id), to: EdgeSites, as: :get_site
defdelegate create_edge_site(attrs), to: EdgeSites, as: :create_site
defdelegate update_edge_site(site, attrs), to: EdgeSites, as: :update_site
defdelegate rotate_edge_site_token(site), to: EdgeSites, as: :rotate_token
defdelegate revoke_edge_site(site), to: EdgeSites, as: :revoke_site
defdelegate public_edge_site(site), to: EdgeSites, as: :public_site
def public_edge_site(site, raw_token), do: EdgeSites.public_site(site, raw_token)
defdelegate edge_pool_ips(), to: EdgeSites
def scan_existing_zone(domain) when is_binary(domain) do
normalized_domain = domain |> String.trim() |> String.downcase() |> String.trim_trailing(".")

View file

@ -0,0 +1,326 @@
defmodule Elektrine.DNS.ConfigBundle do
@moduledoc """
Builds the multi-node edge config bundle (KD-15).
Bundle is served at `GET /_edge/config/v1/bundle` with per-site bearer auth.
Edge rules are a soft dependency: when `Elektrine.DNS.EdgeRules` is not present
(PR 5 not merged), `rules` is an empty list.
"""
import Ecto.Query, warn: false
alias Elektrine.DNS.EdgeSite
alias Elektrine.DNS.EdgeSites
alias Elektrine.DNS.Record
alias Elektrine.DNS.Zone
alias Elektrine.Repo
@bundle_ttl_seconds 300
@doc """
Build a full config bundle for `site`.
Returns `{:ok, bundle_map}` where the map is JSON-ready and includes:
`site_id`, `generated_at`, `exp`, `etag`, `content_updated_at`, `origins`,
`rules`, `edge_ips`.
"""
def build(%EdgeSite{} = site) do
{origins, origin_mtime} = list_proxied_origins()
rules = list_edge_rules()
edge_ips = EdgeSites.edge_pool_ips()
generated_at = DateTime.utc_now() |> DateTime.truncate(:second)
exp = DateTime.add(generated_at, bundle_ttl_seconds(), :second)
stable = %{
origins: origins,
rules: rules,
edge_ips: edge_ips
}
etag = etag_for(stable)
# Revision covers the same stable payload as etag: origin mtimes, site pool
# rows (edge_ips composition), and rule timestamps when present — never wall
# clock alone (that broke empty-catalog since 304s).
content_updated_at =
latest_dt([
origin_mtime,
site_pool_mtime(),
rules_mtime(rules)
]) || epoch()
bundle = %{
site_id: site.id,
generated_at: DateTime.to_iso8601(generated_at),
exp: DateTime.to_iso8601(exp),
etag: etag,
content_updated_at: datetime_to_iso(content_updated_at),
origins: origins,
rules: rules,
edge_ips: edge_ips
}
{:ok, bundle}
end
@doc """
True when the client already has this content revision.
When `If-None-Match` is present it is authoritative: a miss returns false
even if `since` would still look fresh (avoids 304 after rules/edge_ips
change while origin mtimes are unchanged). `since` is only used when no
etag validator is supplied.
"""
def unchanged?(%{etag: etag, content_updated_at: content_updated_at}, opts)
when is_list(opts) do
if_none_match = Keyword.get(opts, :if_none_match)
since = Keyword.get(opts, :since)
cond do
present_header?(if_none_match) ->
etag_match?(etag, if_none_match)
present_header?(since) ->
since_not_stale?(content_updated_at, since)
true ->
false
end
end
def sign_bundle(%EdgeSite{} = site, %{etag: etag, exp: exp, site_id: site_id}) do
case site.hmac_secret do
secret when is_binary(secret) and secret != "" ->
payload = "#{etag}|#{exp}|#{site_id}"
mac = :crypto.mac(:hmac, :sha256, secret, payload)
"v1=" <> Base.encode16(mac, case: :lower)
_ ->
nil
end
end
def bundle_ttl_seconds do
Application.get_env(:elektrine, :dns, [])
|> Keyword.get(:edge_config_bundle_ttl_seconds, @bundle_ttl_seconds)
end
defp present_header?(nil), do: false
defp present_header?(""), do: false
defp present_header?(value) when is_binary(value), do: String.trim(value) != ""
defp present_header?(_), do: false
defp etag_match?(etag, if_none_match) when is_binary(if_none_match) do
candidates =
if_none_match
|> String.split(",", trim: true)
|> Enum.map(fn part ->
part
|> String.trim()
|> String.trim_leading("W/")
|> String.trim("\"")
end)
etag in candidates
end
defp since_not_stale?(content_updated_at, since) when is_binary(since) do
with {:ok, since_dt, _} <- DateTime.from_iso8601(String.trim(since)),
%DateTime{} = content_dt <- parse_content_updated_at(content_updated_at) do
DateTime.compare(content_dt, since_dt) != :gt
else
_ -> false
end
end
defp parse_content_updated_at(%DateTime{} = dt), do: dt
defp parse_content_updated_at(iso) when is_binary(iso) do
case DateTime.from_iso8601(iso) do
{:ok, dt, _} -> dt
_ -> nil
end
end
defp parse_content_updated_at(_), do: nil
defp etag_for(stable) do
canonical = Jason.encode!(stable)
"sha256-" <>
(:crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower))
end
defp list_proxied_origins do
rows =
from(r in Record,
join: z in Zone,
on: z.id == r.zone_id,
where: z.status == "verified",
where: fragment("coalesce((? -> 'proxy' ->> 'enabled'), 'false') = 'true'", r.metadata),
select: {r, z},
order_by: [asc: z.domain, asc: r.name, asc: r.type]
)
|> Repo.all()
origins =
Enum.map(rows, fn {record, zone} ->
origin_entry(zone, record)
end)
origin_mtime =
rows
|> Enum.map(fn {record, zone} ->
latest_dt([record.updated_at, zone.updated_at, zone.last_published_at])
end)
|> latest_dt()
{origins, origin_mtime}
end
defp site_pool_mtime do
EdgeSites.list_sites()
|> Enum.map(fn site ->
latest_dt([site.updated_at, site.last_heartbeat_at, site.inserted_at])
end)
|> latest_dt()
end
defp rules_mtime(rules) when is_list(rules) do
rules
|> Enum.map(fn
%{updated_at: %DateTime{} = dt} ->
dt
%{"updated_at" => iso} when is_binary(iso) ->
parse_content_updated_at(iso)
rule when is_map(rule) ->
parse_content_updated_at(Map.get(rule, :updated_at) || Map.get(rule, "updated_at"))
_ ->
nil
end)
|> latest_dt()
end
defp rules_mtime(_), do: nil
defp epoch, do: ~U[1970-01-01 00:00:00Z]
defp origin_entry(%Zone{} = zone, %Record{} = record) do
host = record_fqdn(zone, record)
host_header = Record.proxy_origin_host_header(record) || host
atomine_gate = Record.proxy_atomine_gate?(record)
base = %{
zone_id: zone.id,
zone_domain: zone.domain,
record_id: record.id,
host: host,
origin_host_header: host_header,
atomine_gate: atomine_gate
}
if Record.tunnel_origin?(record) do
Map.merge(base, %{
type: "tunnel",
tunnel_id: Record.proxy_tunnel_id(record)
})
else
scheme = Record.proxy_origin_scheme(record)
port = Record.proxy_origin_port(record)
origin_host =
record.content |> String.trim() |> String.trim_trailing(".") |> String.downcase()
Map.merge(base, %{
type: "public",
origin_scheme: scheme,
origin_host: origin_host,
origin_port: port,
origin_url: origin_url(scheme, origin_host, port)
})
end
end
defp list_edge_rules do
# Soft dep on PR 5 (EdgeRules may not be on this branch).
export_fun = soft_edge_rules_fun(:export_for_bundle, 0)
list_fun = soft_edge_rules_fun(:list_all_enabled, 0)
cond do
is_function(export_fun, 0) ->
export_fun.()
is_function(list_fun, 0) ->
list_fun.() |> Enum.map(&rule_export/1)
true ->
[]
end
rescue
_ -> []
end
defp soft_edge_rules_fun(name, arity) do
rules_mod = Module.concat([Elektrine, DNS, EdgeRules])
if Code.ensure_loaded?(rules_mod) and function_exported?(rules_mod, name, arity) do
Function.capture(rules_mod, name, arity)
else
nil
end
end
defp rule_export(rule) when is_struct(rule) do
%{
id: Map.get(rule, :id),
zone_id: Map.get(rule, :zone_id),
name: Map.get(rule, :name),
enabled: Map.get(rule, :enabled, true),
priority: Map.get(rule, :priority, 1000),
match: Map.get(rule, :match, %{}),
action: Map.get(rule, :action, %{})
}
end
defp rule_export(%{} = rule), do: rule
defp rule_export(_), do: %{}
defp origin_url("http", host, 80), do: "http://#{host}"
defp origin_url("https", host, 443), do: "https://#{host}"
defp origin_url(scheme, host, port), do: "#{scheme}://#{host}:#{port}"
defp record_fqdn(%Zone{} = zone, %Record{name: name}) when is_binary(name) do
zone_domain = normalize_host(zone.domain)
normalized_name = normalize_host(name)
cond do
normalized_name in ["", "@"] -> zone_domain
normalized_name == zone_domain -> zone_domain
String.ends_with?(normalized_name, "." <> zone_domain) -> normalized_name
true -> normalized_name <> "." <> zone_domain
end
end
defp normalize_host(host) do
host
|> String.trim()
|> String.trim_trailing(".")
|> String.downcase()
end
defp latest_dt(list) do
list
|> Enum.reject(&is_nil/1)
|> case do
[] -> nil
dts -> Enum.max_by(dts, &DateTime.to_unix/1)
end
end
defp datetime_to_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp datetime_to_iso(_), do: nil
end

View file

@ -9,6 +9,10 @@ defmodule Elektrine.DNS.EdgeSite do
Distinct from `Elektrine.DNS.HealthMonitor`, which probes **origin**
record content not the edge pool.
Operator-level edge site row for multi-node config pull and heartbeats.
Per-site bearer tokens (`ess_...`) are stored hashed and shown once at mint.
Instance-admin only never zone PAT scopes.
"""
use Ecto.Schema
@ -17,6 +21,12 @@ defmodule Elektrine.DNS.EdgeSite do
@statuses ~w(up down unknown)
@roles ~w(proxy dns)
@token_prefix "eds_"
alias Elektrine.Secrets.EncryptedString
@statuses ~w(up down unknown revoked)
@roles ~w(proxy dns)
@max_name_length 100
@max_ips 32
schema "dns_edge_sites" do
field :name, :string
@ -31,6 +41,13 @@ defmodule Elektrine.DNS.EdgeSite do
# Raw token is only available immediately after mint/rotate.
field :token, :string, virtual: true
field :hmac_secret, EncryptedString, redact: true
field :metadata, :map, default: %{}
field :revoked_at, :utc_datetime
# Virtual: raw token / hmac secret returned once on mint or rotate
field :token, :string, virtual: true
field :hmac_secret_plain, :string, virtual: true
timestamps(type: :utc_datetime)
end
@ -77,6 +94,59 @@ defmodule Elektrine.DNS.EdgeSite do
def generate_token do
raw = @token_prefix <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
{raw, hash_token(raw), String.slice(raw, 0, 12)}
def changeset(site, attrs) do
site
|> cast(attrs, [
:name,
:ipv4,
:ipv6,
:roles,
:status,
:last_heartbeat_at,
:token_hash,
:token_prefix,
:hmac_secret,
:metadata,
:revoked_at
])
|> update_change(:name, &normalize_name/1)
|> update_change(:ipv4, &normalize_ipv4_list/1)
|> update_change(:ipv6, &normalize_ipv6_list/1)
|> update_change(:roles, &normalize_roles/1)
|> validate_required([:name, :status, :token_hash, :token_prefix])
|> validate_inclusion(:status, @statuses)
|> validate_length(:name, min: 1, max: @max_name_length)
|> validate_length(:ipv4, max: @max_ips)
|> validate_length(:ipv6, max: @max_ips)
|> validate_length(:roles, min: 1, max: 4)
|> validate_subset(:roles, @roles)
|> unique_constraint(:name)
|> unique_constraint(:token_hash)
end
def revoke_changeset(site) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
site
|> change(%{status: "revoked", revoked_at: now})
end
def heartbeat_changeset(site) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
site
|> change(%{status: "up", last_heartbeat_at: now})
end
def generate_token do
raw = "ess_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
hash = hash_token(raw)
prefix = String.slice(raw, 0, 12)
{raw, hash, prefix}
end
def generate_hmac_secret do
Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
end
def hash_token(token) when is_binary(token) do
@ -125,6 +195,44 @@ defmodule Elektrine.DNS.EdgeSite do
nil
end
end
def revoked?(%__MODULE__{status: "revoked"}), do: true
def revoked?(%__MODULE__{revoked_at: %DateTime{}}), do: true
def revoked?(_), do: false
def active?(%__MODULE__{} = site), do: not revoked?(site)
defp normalize_name(nil), do: nil
defp normalize_name(name) when is_binary(name) do
name |> String.trim() |> String.slice(0, @max_name_length)
end
defp normalize_name(other), do: other
defp normalize_ipv4_list(nil), do: []
defp normalize_ipv4_list(list) when is_list(list) do
list
|> Enum.map(&normalize_ip/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
end
defp normalize_ipv4_list(_), do: []
defp normalize_ipv6_list(nil), do: []
defp normalize_ipv6_list(list) when is_list(list) do
list
|> Enum.map(&normalize_ip/1)
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
end
defp normalize_ipv6_list(_), do: []
defp normalize_ip(ip) when is_binary(ip), do: ip |> String.trim() |> String.downcase()
defp normalize_ip(_), do: nil
defp normalize_roles(nil), do: ["proxy"]
@ -133,6 +241,11 @@ defmodule Elektrine.DNS.EdgeSite do
|> Enum.map(&to_string/1)
|> Enum.map(&String.trim/1)
|> Enum.map(&String.downcase/1)
|> Enum.map(fn
r when is_atom(r) -> Atom.to_string(r)
r when is_binary(r) -> String.trim(r) |> String.downcase()
_ -> nil
end)
|> Enum.filter(&(&1 in @roles))
|> Enum.uniq()
|> case do
@ -192,4 +305,5 @@ defmodule Elektrine.DNS.EdgeSite do
changeset
end
end
defp normalize_roles(_), do: ["proxy"]
end

View file

@ -27,6 +27,10 @@ defmodule Elektrine.DNS.EdgeSites do
set because of a control-plane/heartbeat outage. Sites with only the `dns`
role are excluded from proxied answers (authority / multi-NS use). Origin
failover remains `Elektrine.DNS.HealthMonitor` and is intentionally separate.
Instance-admin operations for edge site registry and per-site bearer auth.
Soft dependency for multi-node config distribution (KD-15). Sites are not
zone-owned; mint/rotate is never available via user PAT scopes.
"""
import Ecto.Query, warn: false
@ -268,4 +272,200 @@ defmodule Elektrine.DNS.EdgeSites do
end
defp tap_ok(other, _fun), do: other
alias Elektrine.Repo
@doc """
List non-revoked edge sites (newest first by name).
"""
def list_sites do
EdgeSite
|> where([s], s.status != "revoked")
|> order_by([s], asc: s.name)
|> Repo.all()
end
def get_site(id) when is_integer(id), do: Repo.get(EdgeSite, id)
def get_site(id) when is_binary(id) do
case Integer.parse(id) do
{int, ""} -> Repo.get(EdgeSite, int)
_ -> nil
end
end
def get_site(_), do: nil
@doc """
Create a site and return the raw bearer token + HMAC secret once.
"""
def create_site(attrs) when is_map(attrs) do
{raw, hash, prefix} = EdgeSite.generate_token()
hmac = EdgeSite.generate_hmac_secret()
attrs =
attrs
|> stringify_keys()
|> Map.put("token_hash", hash)
|> Map.put("token_prefix", prefix)
|> Map.put("hmac_secret", hmac)
|> Map.put_new("status", "unknown")
%EdgeSite{}
|> EdgeSite.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, site} ->
{:ok, %{site | token: raw, hmac_secret_plain: hmac}}
other ->
other
end
end
def update_site(%EdgeSite{} = site, attrs) when is_map(attrs) do
if EdgeSite.revoked?(site) do
{:error, :revoked}
else
site
|> EdgeSite.changeset(stringify_keys(attrs))
|> Repo.update()
end
end
@doc """
Rotate the site bearer (and HMAC secret). Returns raw secrets once.
"""
def rotate_token(%EdgeSite{} = site) do
if EdgeSite.revoked?(site) do
{:error, :revoked}
else
{raw, hash, prefix} = EdgeSite.generate_token()
hmac = EdgeSite.generate_hmac_secret()
site
|> EdgeSite.changeset(%{
"token_hash" => hash,
"token_prefix" => prefix,
"hmac_secret" => hmac
})
|> Repo.update()
|> case do
{:ok, updated} ->
{:ok, %{updated | token: raw, hmac_secret_plain: hmac}}
other ->
other
end
end
end
def revoke_site(%EdgeSite{} = site) do
site
|> EdgeSite.revoke_changeset()
|> Repo.update()
end
@doc """
Resolve a long-lived site bearer (`ess_...`) to an active site row.
"""
def get_site_by_token(raw_token) when is_binary(raw_token) do
hash = EdgeSite.hash_token(String.trim(raw_token))
case Repo.get_by(EdgeSite, token_hash: hash) do
%EdgeSite{} = site ->
if EdgeSite.revoked?(site), do: {:error, :revoked}, else: {:ok, site}
nil ->
{:error, :not_found}
end
end
def get_site_by_token(_), do: {:error, :not_found}
def record_heartbeat(%EdgeSite{} = site) do
site
|> EdgeSite.heartbeat_changeset()
|> Repo.update()
end
@doc """
Public representation without secrets.
"""
def public_site(%EdgeSite{} = site) do
%{
id: site.id,
name: site.name,
ipv4: site.ipv4 || [],
ipv6: site.ipv6 || [],
roles: site.roles || ["proxy"],
status: site.status,
last_heartbeat_at: site.last_heartbeat_at,
token_prefix: site.token_prefix,
metadata: site.metadata || %{},
revoked_at: site.revoked_at,
inserted_at: site.inserted_at,
updated_at: site.updated_at
}
end
def public_site(%EdgeSite{} = site, raw_token) when is_binary(raw_token) do
site
|> public_site()
|> Map.put(:token, raw_token)
|> Map.put(:hmac_secret, site.hmac_secret_plain)
end
@doc """
Edge IPs for the pool: union of up sites, or env singleton when no rows.
Fail-open: if every registered site is down/unknown, return all site IPs plus
env addresses so proxied answers do not empty.
"""
def edge_pool_ips do
sites = list_sites()
case sites do
[] ->
env_edge_ips()
list ->
up = Enum.filter(list, &(&1.status == "up"))
chosen = if up == [], do: list, else: up
%{
ipv4: chosen |> Enum.flat_map(&(&1.ipv4 || [])) |> Enum.uniq(),
ipv6: chosen |> Enum.flat_map(&(&1.ipv6 || [])) |> Enum.uniq()
}
|> merge_env_if_empty()
end
end
def env_edge_ips do
dns = Application.get_env(:elektrine, :dns, [])
%{
ipv4: Keyword.get(dns, :edge_proxy_ipv4_addresses, []) || [],
ipv6: Keyword.get(dns, :edge_proxy_ipv6_addresses, []) || []
}
end
def stale_after_ms do
Application.get_env(:elektrine, :dns, [])
|> Keyword.get(:edge_site_stale_after_ms, 45_000)
end
def heartbeat_interval_ms do
Application.get_env(:elektrine, :dns, [])
|> Keyword.get(:edge_site_heartbeat_interval_ms, 15_000)
end
defp merge_env_if_empty(%{ipv4: [], ipv6: []} = _pool), do: env_edge_ips()
defp merge_env_if_empty(pool), do: pool
defp stringify_keys(attrs) when is_map(attrs) do
Map.new(attrs, fn
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
{k, v} -> {k, v}
end)
end
end

View file

@ -0,0 +1,118 @@
defmodule Mix.Tasks.Elektrine.Dns.EdgeSite do
@moduledoc """
Instance-admin helper for edge site registry (per-site config tokens).
mix elektrine.dns.edge_site list
mix elektrine.dns.edge_site create --name edge-b --ipv4 203.0.113.10
mix elektrine.dns.edge_site rotate --id <site-id>
mix elektrine.dns.edge_site revoke --id <site-id>
Tokens (`ess_...`) and HMAC secrets are printed **once** on create/rotate.
Store them on the remote edge node; they are never recoverable from the DB.
"""
use Mix.Task
@shortdoc "Manage DNS edge sites (mint/list/rotate/revoke)"
@impl Mix.Task
def run(args) do
Mix.Task.run("app.start")
{opts, rest, _invalid} =
OptionParser.parse(args,
strict: [name: :string, id: :string, ipv4: :string, ipv6: :string, roles: :string]
)
case rest do
["list"] -> list_sites()
["create"] -> create_site(opts)
["rotate"] -> rotate_site(opts)
["revoke"] -> revoke_site(opts)
_ -> usage()
end
end
defp list_sites do
Elektrine.DNS.EdgeSites.list_sites()
|> Enum.each(fn site ->
Mix.shell().info(
"#{site.id} name=#{site.name} status=#{site.status} " <>
"ipv4=#{Enum.join(site.ipv4 || [], ",")} prefix=#{site.token_prefix}"
)
end)
end
defp create_site(opts) do
name = Keyword.get(opts, :name) || Mix.raise("--name is required")
attrs = %{
"name" => name,
"ipv4" => split_csv(Keyword.get(opts, :ipv4)),
"ipv6" => split_csv(Keyword.get(opts, :ipv6)),
"roles" => split_csv(Keyword.get(opts, :roles)) |> default_roles()
}
case Elektrine.DNS.EdgeSites.create_site(attrs) do
{:ok, site} ->
Mix.shell().info("created site id=#{site.id} name=#{site.name}")
Mix.shell().info("token (store once): #{site.token}")
Mix.shell().info("hmac_secret (store once): #{site.hmac_secret_plain}")
{:error, changeset} ->
Mix.raise("create failed: #{inspect(changeset.errors)}")
end
end
defp rotate_site(opts) do
site = fetch_site!(opts)
case Elektrine.DNS.EdgeSites.rotate_token(site) do
{:ok, site} ->
Mix.shell().info("rotated site id=#{site.id}")
Mix.shell().info("token (store once): #{site.token}")
Mix.shell().info("hmac_secret (store once): #{site.hmac_secret_plain}")
{:error, reason} ->
Mix.raise("rotate failed: #{inspect(reason)}")
end
end
defp revoke_site(opts) do
site = fetch_site!(opts)
case Elektrine.DNS.EdgeSites.revoke_site(site) do
{:ok, site} ->
Mix.shell().info("revoked site id=#{site.id}")
{:error, reason} ->
Mix.raise("revoke failed: #{inspect(reason)}")
end
end
defp fetch_site!(opts) do
id = Keyword.get(opts, :id) || Mix.raise("--id is required")
case Elektrine.DNS.EdgeSites.get_site(id) do
nil -> Mix.raise("site not found: #{id}")
site -> site
end
end
defp split_csv(nil), do: []
defp split_csv(""), do: []
defp split_csv(value) when is_binary(value) do
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp default_roles([]), do: ["proxy"]
defp default_roles(roles), do: roles
defp usage do
Mix.shell().info(@moduledoc)
end
end

View file

@ -0,0 +1,108 @@
defmodule Elektrine.DNS.ConfigBundleTest do
use Elektrine.DataCase, async: true
alias Elektrine.AccountsFixtures
alias Elektrine.DNS
alias Elektrine.DNS.ConfigBundle
alias Elektrine.DNS.EdgeSites
setup do
user = AccountsFixtures.user_fixture()
{:ok, zone} =
DNS.create_zone(user, %{"domain" => "bundle-#{System.unique_integer([:positive])}.test"})
{:ok, zone} =
zone
|> Ecto.Changeset.change(%{
status: "verified",
verified_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Elektrine.Repo.update()
{:ok, record} =
DNS.create_record(zone, %{
"name" => "app",
"type" => "A",
"ttl" => 300,
"content" => "198.51.100.20",
"proxied" => "true",
"proxy_origin_scheme" => "https",
"proxy_origin_port" => 443
})
{:ok, site} =
EdgeSites.create_site(%{
"name" => "bundle-site-#{System.unique_integer([:positive])}",
"ipv4" => ["203.0.113.50"]
})
%{user: user, zone: zone, record: record, site: site}
end
test "build includes proxied origins and etag", %{site: site, zone: zone} do
assert {:ok, bundle} = ConfigBundle.build(site)
assert bundle.site_id == site.id
assert is_binary(bundle.etag)
assert String.starts_with?(bundle.etag, "sha256-")
assert is_list(bundle.origins)
assert is_list(bundle.rules)
assert bundle.rules == []
host = "app.#{zone.domain}"
assert Enum.any?(bundle.origins, fn o -> o.host == host and o.type == "public" end)
assert is_binary(bundle.generated_at)
assert is_binary(bundle.exp)
end
test "unchanged? honors If-None-Match and since", %{site: site} do
assert {:ok, bundle} = ConfigBundle.build(site)
assert ConfigBundle.unchanged?(bundle, if_none_match: ~s("#{bundle.etag}"))
assert ConfigBundle.unchanged?(bundle, if_none_match: bundle.etag)
refute ConfigBundle.unchanged?(bundle, if_none_match: "sha256-other")
assert ConfigBundle.unchanged?(bundle, since: bundle.content_updated_at)
refute ConfigBundle.unchanged?(bundle, since: "2000-01-01T00:00:00Z")
# If-None-Match is authoritative: a miss must not 304 even when since is current
refute ConfigBundle.unchanged?(bundle,
if_none_match: "sha256-stale",
since: bundle.content_updated_at
)
end
test "content_updated_at advances when site pool changes", %{site: site} do
assert {:ok, first} = ConfigBundle.build(site)
first_at = first.content_updated_at
future =
DateTime.utc_now()
|> DateTime.add(5, :second)
|> DateTime.truncate(:second)
{1, _} =
Elektrine.Repo.update_all(
from(s in Elektrine.DNS.EdgeSite, where: s.id == ^site.id),
set: [ipv4: ["203.0.113.99"], updated_at: future]
)
updated = EdgeSites.get_site(site.id)
assert updated.ipv4 == ["203.0.113.99"]
assert {:ok, second} = ConfigBundle.build(updated)
assert second.etag != first.etag
assert second.content_updated_at > first_at
refute ConfigBundle.unchanged?(second, since: first_at)
end
test "sign_bundle produces v1 hmac when secret present", %{site: site} do
assert {:ok, bundle} = ConfigBundle.build(site)
# Reload so EncryptedString decrypts hmac_secret from DB
reloaded = EdgeSites.get_site(site.id)
sig = ConfigBundle.sign_bundle(reloaded, bundle)
assert is_binary(sig)
assert String.starts_with?(sig, "v1=")
end
end

View file

@ -149,6 +149,59 @@ defmodule Elektrine.DNS.EdgeSitesTest do
end
test "env keys override heartbeat/stale defaults" do
use Elektrine.DataCase, async: true
alias Elektrine.DNS.EdgeSites
test "create site mints ess token once and resolves by token" do
assert {:ok, site} =
EdgeSites.create_site(%{
"name" => "edge-a-#{System.unique_integer([:positive])}",
"ipv4" => ["203.0.113.10"]
})
assert String.starts_with?(site.token, "ess_")
assert is_binary(site.hmac_secret_plain)
assert site.token_prefix == String.slice(site.token, 0, 12)
assert site.ipv4 == ["203.0.113.10"]
assert {:ok, found} = EdgeSites.get_site_by_token(site.token)
assert found.id == site.id
assert {:error, :not_found} = EdgeSites.get_site_by_token("ess_invalid")
end
test "rotate invalidates previous token" do
{:ok, site} =
EdgeSites.create_site(%{"name" => "edge-rot-#{System.unique_integer([:positive])}"})
old = site.token
assert {:ok, rotated} = EdgeSites.rotate_token(site)
assert rotated.token != old
assert {:error, :not_found} = EdgeSites.get_site_by_token(old)
assert {:ok, _} = EdgeSites.get_site_by_token(rotated.token)
end
test "revoke rejects token" do
{:ok, site} =
EdgeSites.create_site(%{"name" => "edge-rev-#{System.unique_integer([:positive])}"})
token = site.token
assert {:ok, _} = EdgeSites.revoke_site(site)
assert {:error, :revoked} = EdgeSites.get_site_by_token(token)
end
test "heartbeat marks site up" do
{:ok, site} =
EdgeSites.create_site(%{"name" => "edge-hb-#{System.unique_integer([:positive])}"})
assert site.status == "unknown"
assert {:ok, updated} = EdgeSites.record_heartbeat(site)
assert updated.status == "up"
assert %DateTime{} = updated.last_heartbeat_at
end
test "edge_pool_ips falls back to env when no sites" do
previous = Application.get_env(:elektrine, :dns, [])
Application.put_env(
@ -220,5 +273,17 @@ defmodule Elektrine.DNS.EdgeSitesTest do
EdgeSites.invalidate_cache()
assert DNS.edge_proxy_ipv4_addresses() == ["198.51.100.200"]
edge_proxy_ipv4_addresses: ["198.51.100.1"],
edge_proxy_ipv6_addresses: ["2001:db8::1"]
)
)
on_exit(fn -> Application.put_env(:elektrine, :dns, previous) end)
# May include other sites created in parallel tests if not sandboxed fully;
# when list is empty, env applies.
pool = EdgeSites.env_edge_ips()
assert pool.ipv4 == ["198.51.100.1"]
assert pool.ipv6 == ["2001:db8::1"]
end
end

View file

@ -0,0 +1,101 @@
defmodule ElektrineWeb.EdgeConfigController do
@moduledoc """
Per-site config bundle and site heartbeat under `/_edge/config` and `/_edge/site`.
Auth is **per-site bearer** (`ess_...`), not the shared `CADDY_EDGE_API_KEY`.
"""
use ElektrineWeb, :controller
alias Elektrine.DNS.ConfigBundle
alias Elektrine.DNS.EdgeSites
def bundle(conn, params) do
with {:ok, site} <- site_from_conn(conn),
{:ok, bundle} <- ConfigBundle.build(site) do
if ConfigBundle.unchanged?(bundle,
if_none_match: first_header(conn, "if-none-match"),
since: Map.get(params, "since")
) do
conn
|> put_resp_header("etag", quote_etag(bundle.etag))
|> send_resp(:not_modified, "")
else
conn =
conn
|> put_resp_header("etag", quote_etag(bundle.etag))
|> put_resp_header("cache-control", "private, no-store")
conn =
case ConfigBundle.sign_bundle(site, bundle) do
sig when is_binary(sig) ->
put_resp_header(conn, "x-elektrine-edge-sig", sig)
_ ->
conn
end
json(conn, bundle)
end
else
{:error, :missing_token} -> unauthorized(conn, "missing_token")
{:error, :not_found} -> unauthorized(conn, "invalid_token")
{:error, :revoked} -> unauthorized(conn, "revoked")
{:error, _} -> error(conn, :internal_server_error, "bundle_failed")
end
end
def heartbeat(conn, _params) do
with {:ok, site} <- site_from_conn(conn),
{:ok, updated} <- EdgeSites.record_heartbeat(site) do
json(conn, %{
ok: true,
site: EdgeSites.public_site(updated),
next_heartbeat_ms: EdgeSites.heartbeat_interval_ms(),
stale_after_ms: EdgeSites.stale_after_ms()
})
else
{:error, :missing_token} -> unauthorized(conn, "missing_token")
{:error, :not_found} -> unauthorized(conn, "invalid_token")
{:error, :revoked} -> unauthorized(conn, "revoked")
{:error, _} -> error(conn, :internal_server_error, "heartbeat_failed")
end
end
defp site_from_conn(conn) do
case bearer_token(conn) do
nil -> {:error, :missing_token}
token -> EdgeSites.get_site_by_token(token)
end
end
defp bearer_token(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] -> String.trim(token)
["bearer " <> token] -> String.trim(token)
_ -> nil
end
end
defp first_header(conn, name) do
case get_req_header(conn, name) do
[value | _] -> value
_ -> nil
end
end
defp quote_etag(etag) when is_binary(etag), do: ~s("#{etag}")
defp quote_etag(_), do: ~s("")
defp unauthorized(conn, code) do
conn
|> put_status(:unauthorized)
|> json(%{error: code})
end
defp error(conn, status, code) do
conn
|> put_status(status)
|> json(%{error: code})
end
end

View file

@ -461,6 +461,19 @@ defmodule ElektrineWeb.Router do
post("/heartbeat", EdgeSiteHeartbeatController, :create)
end
# Per-site config pull + heartbeat (auth via ess_ site bearer — not CADDY_EDGE_API_KEY).
scope "/_edge/config/v1", ElektrineWeb do
pipe_through([:api, :api_rate_limited])
get("/bundle", EdgeConfigController, :bundle)
end
scope "/_edge/site/v1", ElektrineWeb do
pipe_through([:api, :api_rate_limited])
post("/heartbeat", EdgeConfigController, :heartbeat)
end
# Media proxy for federation privacy (no auth required)
scope "/media_proxy", alias: false do
pipe_through([:api, :api_rate_limited])

View file

@ -71,6 +71,9 @@ defmodule ElektrineWeb.MixProject do
ElektrineDNSWeb.DNSLive.Index,
Elektrine.DNS.Tunnels,
Elektrine.DNS.Tunnel,
Elektrine.DNS.EdgeSites,
Elektrine.DNS.EdgeSite,
Elektrine.DNS.ConfigBundle,
ElektrineSocialWeb.DiscussionsLive.Community,
ElektrineSocialWeb.DiscussionsLive.Index,
ElektrineSocialWeb.DiscussionsLive.Post,

View file

@ -0,0 +1,129 @@
defmodule ElektrineWeb.EdgeConfigControllerTest do
use ElektrineWeb.ConnCase, async: false
alias Elektrine.AccountsFixtures
alias Elektrine.DNS
alias Elektrine.DNS.EdgeSites
setup do
user = AccountsFixtures.user_fixture()
{:ok, zone} =
DNS.create_zone(user, %{"domain" => "ecfg-#{System.unique_integer([:positive])}.test"})
{:ok, zone} =
zone
|> Ecto.Changeset.change(%{
status: "verified",
verified_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
|> Elektrine.Repo.update()
{:ok, _record} =
DNS.create_record(zone, %{
"name" => "www",
"type" => "A",
"ttl" => 300,
"content" => "198.51.100.21",
"proxied" => "true"
})
{:ok, site} =
EdgeSites.create_site(%{
"name" => "ecfg-site-#{System.unique_integer([:positive])}",
"ipv4" => ["203.0.113.9"]
})
%{site: site, token: site.token}
end
test "bundle requires site bearer", %{conn: conn} do
conn = get(conn, "/_edge/config/v1/bundle")
assert json_response(conn, 401)["error"] == "missing_token"
end
test "bundle rejects shared-style invalid token", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer not-a-site-token")
|> get("/_edge/config/v1/bundle")
assert json_response(conn, 401)["error"] == "invalid_token"
end
test "bundle returns origins without CADDY_EDGE_API_KEY", %{
conn: conn,
token: token,
site: site
} do
# Ensure shared key is not required / not used
previous = System.get_env("CADDY_EDGE_API_KEY")
System.delete_env("CADDY_EDGE_API_KEY")
on_exit(fn ->
if previous, do: System.put_env("CADDY_EDGE_API_KEY", previous)
end)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/_edge/config/v1/bundle")
assert %{
"site_id" => site_id,
"etag" => etag,
"origins" => origins,
"rules" => rules
} = json_response(conn, 200)
assert site_id == site.id
assert String.starts_with?(etag, "sha256-")
assert is_list(origins)
assert rules == []
assert get_resp_header(conn, "etag") == [~s("#{etag}")]
assert get_resp_header(conn, "x-elektrine-edge-sig") != []
end
test "bundle returns 304 when If-None-Match matches", %{conn: conn, token: token} do
first =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/_edge/config/v1/bundle")
assert %{"etag" => etag} = json_response(first, 200)
second =
build_conn()
|> put_req_header("authorization", "Bearer #{token}")
|> put_req_header("if-none-match", ~s("#{etag}"))
|> get("/_edge/config/v1/bundle")
assert second.status == 304
end
test "bundle returns 304 when since is current", %{conn: conn, token: token} do
first =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get("/_edge/config/v1/bundle")
assert %{"content_updated_at" => since} = json_response(first, 200)
second =
build_conn()
|> put_req_header("authorization", "Bearer #{token}")
|> get("/_edge/config/v1/bundle?since=#{URI.encode_www_form(since)}")
assert second.status == 304
end
test "heartbeat updates site", %{conn: conn, token: token, site: site} do
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post("/_edge/site/v1/heartbeat", %{})
assert %{"ok" => true, "site" => %{"id" => id, "status" => "up"}} = json_response(conn, 200)
assert id == site.id
end
end

View file

@ -357,6 +357,9 @@ cache_enabled: false,
edge_cache_ttl_cap_seconds: 3_600,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_stale_after_ms: 45_000,
edge_site_heartbeat_interval_ms: 15_000,
edge_site_stale_after_ms: 45_000,
edge_config_bundle_ttl_seconds: 300,
recursive_cache_max_entries: 10_000,
recursive_cache_cleanup_interval_ms: 60_000,
recursive_root_hints: [

View file

@ -320,6 +320,11 @@ edge_site_heartbeat_interval_ms:
"DNS_EDGE_SITE_STALE_AFTER_MS",
Keyword.get(dns_config, :edge_site_stale_after_ms, 45_000)
),
edge_config_bundle_ttl_seconds:
parse_int_env.(
"DNS_EDGE_CONFIG_BUNDLE_TTL_SECONDS",
Keyword.get(dns_config, :edge_config_bundle_ttl_seconds, 300)
),
max_udp_payload:
parse_int_env.(
"DNS_MAX_UDP_PAYLOAD",

View file

@ -0,0 +1,60 @@
# Pure-edge Caddyfile — reverse-proxies to an edge-facing Elektrine node.
#
# Unlike the product Caddyfiles, this variant intentionally forwards /_edge/*
# (config pull target is usually the control plane; tunnel WSS + proxy live
# here). Use with ELEKTRINE_RUNTIME_ROLE=edge (or app + ELEKTRINE_ENABLE_WEB=true).
#
# Optional multi-node HTTP cache: stock Caddy 2.11.x has no cache module.
# Named choice when operators build a custom image (not enabled by default):
# github.com/caddyserver/cache-handler (Souin-backed, v0.16.x line for Caddy 2.8+)
# Pin a release and rebuild via xcaddy; do not claim multi-node cache until
# purge fan-out is configured against each site bearer.
#
# Env:
# ACME_EMAIL, CADDY_ASK_URL (control-plane or local /_edge/tls/v1/allow)
# ORIGIN_UPSTREAM — edge app, e.g. edge:8080
# CADDY_TRUSTED_PROXY_CIDRS, NETBIRD_ALLOWED_CIDRS
{
email {$ACME_EMAIL:admin@example.com}
storage file_system /data/caddy
on_demand_tls {
ask {$CADDY_ASK_URL}
}
auto_https disable_redirects
servers :443 {
trusted_proxies static 192.0.2.0/32 {$CADDY_TRUSTED_PROXY_CIDRS:}
trusted_proxies_strict
client_ip_headers X-Forwarded-For X-Real-IP
}
servers :80 {
trusted_proxies static 192.0.2.0/32 {$CADDY_TRUSTED_PROXY_CIDRS:}
trusted_proxies_strict
client_ip_headers X-Forwarded-For X-Real-IP
}
}
https:// {
tls {
on_demand
}
header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
# Pure-edge: do NOT 404 /_edge/* — tunnel WSS, access complete, and local
# edge APIs must reach the Phoenix endpoint.
reverse_proxy {$ORIGIN_UPSTREAM:edge:8080} {
header_up Host {host}
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {client_ip}
header_up X-Real-IP {client_ip}
}
}
:80 {
redir https://{host}{uri} 308
}

View file

@ -1 +1,6 @@
# Stock edge image: official Caddy without a cache module.
# Optional multi-node HTTP cache (not enabled here): build with xcaddy and
# github.com/caddyserver/cache-handler (Souin-backed; pin a release matching
# your Caddy minor). See docs/architecture/edge-platform.md and
# deploy/caddy/Caddyfile.pure-edge.
FROM caddy:2.11.2@sha256:25cdc846626b62d05f6b633b9b40c2c9f6ef89b515dc76133cefd920f7dbe562

View file

@ -0,0 +1,87 @@
# Example pure-edge node — not rendered by default deploy tooling.
# Copy and adapt for a second site that terminates TLS and runs the HTTP edge
# data plane (proxy + /_edge/* + optional tunnels). Shares Postgres with the
# control plane, or uses a reachable DATABASE_URL to the same cluster.
#
# Role matrix (see docs/architecture/edge-platform.md):
# authority-only : worker + DNS_AUTHORITY_ENABLED=true + ELEKTRINE_ENABLE_WEB=false
# pure-edge : edge (or app) + ELEKTRINE_ENABLE_WEB=true (+ authority usually false)
# combined : app/all on a single host
#
# Config pull uses a per-site bearer (ess_...), not CADDY_EDGE_API_KEY:
# curl -H "Authorization: Bearer ess_..." \
# "$CONTROL_PLANE_URL/_edge/config/v1/bundle"
version: "3.8"
services:
edge:
image: ${ELEKTRINE_IMAGE:-elektrine:local}
container_name: elektrine_edge
env_file:
- ../../.env.production
environment:
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to control-plane Postgres}
PHX_HOST: ${EDGE_PHX_HOST:-edge.example.com}
PHX_SERVER: "true"
PORT: ${PORT:-8080}
PHX_HTTP_IP: 0.0.0.0
MIX_ENV: prod
ELEKTRINE_RUNTIME_ROLE: edge
ELEKTRINE_ENABLE_WEB: "true"
ELEKTRINE_ENABLE_JOBS: "false"
ELEKTRINE_ENABLE_MAIL: "false"
ELEKTRINE_ENABLE_TOR: "false"
DNS_AUTHORITY_ENABLED: "false"
DNS_EDGE_PROXY_ENABLED: "true"
DNS_TUNNEL_ENABLED: ${DNS_TUNNEL_ENABLED:-true}
DNS_EDGE_RULES_ENABLED: ${DNS_EDGE_RULES_ENABLED:-false}
# Site token is used by the local puller/agent — not injected into the app
# process for config serve (serve lives on the control plane).
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-172.30.0.0/24}
networks:
- elektrine_edge
restart: unless-stopped
security_opt:
- no-new-privileges:true
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://127.0.0.1:$${PORT:-8080}/health >/dev/null || exit 1"
]
interval: 15s
timeout: 5s
retries: 5
caddy_edge:
build:
context: ../..
dockerfile: deploy/caddy/Dockerfile
container_name: elektrine_caddy_pure_edge
depends_on:
edge:
condition: service_healthy
environment:
ACME_EMAIL: ${ACME_EMAIL:-admin@example.com}
# Prefer control-plane TLS ask when this node is not the CP:
CADDY_ASK_URL: ${CADDY_ASK_URL:-http://edge:8080/_edge/tls/v1/allow}
ORIGIN_UPSTREAM: edge:8080
CADDY_TRUSTED_PROXY_CIDRS: ${CADDY_TRUSTED_PROXY_CIDRS:-}
ports:
- "${CADDY_HTTP_BIND:-80:80}"
- "${CADDY_HTTPS_BIND:-443:443}"
volumes:
- ../caddy/Caddyfile.pure-edge:/etc/caddy/Caddyfile:ro
- caddy_edge_data:/data
networks:
- elektrine_edge
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
elektrine_edge:
volumes:
caddy_edge_data:

View file

@ -9,7 +9,7 @@ TLS_RUNTIME_DIR="$CERTS_DIR/runtime"
ROLE="${1:-${ELEKTRINE_RUNTIME_ROLE:-all}}"
case "$ROLE" in
all|app|worker|mail|dns|vpn) ;;
all|app|edge|worker|mail|dns|vpn) ;;
*)
echo "Invalid runtime role: $ROLE" >&2
exit 1

View file

@ -31,13 +31,23 @@ configure_role() {
local jobs_default="true"
local mail_default="true"
local tor_default="false"
local authority_default=""
case "$role" in
all)
;;
app|edge)
app)
jobs_default="true"
;;
edge)
# Pure-edge / edge-facing data plane: web required for DNSEdgeProxy,
# /_edge/*, and tunnel WSS. Mail off; jobs optional; authority off by default.
jobs_default="false"
mail_default="false"
tor_default="false"
authority_default="false"
web_default="true"
;;
web)
jobs_default="false"
mail_default="false"
@ -70,6 +80,10 @@ configure_role() {
export ELEKTRINE_ENABLE_JOBS="${ELEKTRINE_ENABLE_JOBS:-$jobs_default}"
export ELEKTRINE_ENABLE_MAIL="${ELEKTRINE_ENABLE_MAIL:-$mail_default}"
export ELEKTRINE_ENABLE_TOR="${ELEKTRINE_ENABLE_TOR:-$tor_default}"
if [ -n "$authority_default" ]; then
export DNS_AUTHORITY_ENABLED="${DNS_AUTHORITY_ENABLED:-$authority_default}"
fi
}
derive_vpn_public_key() {

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Lightweight assertions for the authority-only vs pure-edge role matrix.
# Run from repo root: bash deploy/docker/test_edge_role_matrix.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
START="$ROOT/deploy/docker/start.sh"
ENTRY="$ROOT/deploy/docker/docker-entrypoint.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
# entrypoint allow-list includes edge
grep -q 'all|app|edge|worker|mail|dns|vpn' "$ENTRY" || fail "entrypoint missing edge role"
# start.sh has dedicated edge case with web true defaults documented in script
grep -q 'edge)' "$START" || fail "start.sh missing edge case"
# Simulate configure_role by sourcing the function in a subshell
eval_role() {
local role="$1"
bash -c '
set -euo pipefail
source /dev/stdin
configure_role "'"$role"'"
printf "ROLE=%s WEB=%s MAIL=%s JOBS=%s AUTH=%s\n" \
"${ELEKTRINE_RUNTIME_ROLE}" \
"${ELEKTRINE_ENABLE_WEB}" \
"${ELEKTRINE_ENABLE_MAIL}" \
"${ELEKTRINE_ENABLE_JOBS}" \
"${DNS_AUTHORITY_ENABLED:-}"
' < <(sed -n "/^configure_role()/,/^}/p" "$START"; echo 'is_truthy(){ case "${1:-}" in 1|true|TRUE|yes|YES|on|ON) return 0;; *) return 1;; esac; }')
}
worker_out="$(eval_role worker)"
echo "$worker_out" | grep -q 'ROLE=worker' || fail "worker role: $worker_out"
echo "$worker_out" | grep -q 'WEB=false' || fail "authority-only must default web false: $worker_out"
echo "$worker_out" | grep -q 'MAIL=false' || fail "worker mail false: $worker_out"
edge_out="$(eval_role edge)"
echo "$edge_out" | grep -q 'ROLE=edge' || fail "edge role: $edge_out"
echo "$edge_out" | grep -q 'WEB=true' || fail "pure-edge must default web true: $edge_out"
echo "$edge_out" | grep -q 'MAIL=false' || fail "edge mail false: $edge_out"
echo "$edge_out" | grep -q 'AUTH=false' || fail "edge authority default false: $edge_out"
app_out="$(eval_role app)"
echo "$app_out" | grep -q 'WEB=true' || fail "app web true: $app_out"
echo "role matrix checks passed"
echo " worker: $worker_out"
echo " edge: $edge_out"
echo " app: $app_out"

View file

@ -187,6 +187,15 @@ Internal endpoints:
- `POST /_edge/tunnel/v1/refresh` rotates the session ticket
- `POST /_edge/tunnel/v1/disconnect` tears down the agent session
- `GET /_edge/tunnel/v1/ws` WSS upgrade to `ElektrineWeb.Edge.Connector` (session ticket)
- `GET /_edge/config/v1/bundle` per-site config pull (`ess_...` bearer; ETag/304)
- `POST /_edge/site/v1/heartbeat` per-site heartbeat (`ess_...` bearer)
### Edge config bundle + pure-edge role
Multi-node edges pull origins/rules/edge IPs with a **per-site** token — not the
shared `CADDY_EDGE_API_KEY`. See `docs/architecture/edge-platform.md` for the
role matrix (`worker` authority-only vs `edge`/`app` pure-edge with web on),
compose example, reload policy, and optional Caddy cache module name.
### Private origin tunnels (feature-flagged)

View file

@ -472,3 +472,140 @@ true is unchanged.
`deploy/docker/docker-entrypoint.sh`
- `deploy/caddy/Caddyfile`
- `scripts/vpn/elektrine-vpn-agent.sh`
# Edge platform (multi-node config + roles)
This document covers the **config distribution** path (KD-15), the **node role
matrix**, and the pure-edge deploy path. Tunnel agent packaging lives under
`scripts/edge/` and `docs/architecture/dns-module.md`.
## Node role matrix
| Node purpose | `ELEKTRINE_RUNTIME_ROLE` | `ELEKTRINE_ENABLE_WEB` | `DNS_AUTHORITY_ENABLED` | Notes |
| --- | --- | --- | --- | --- |
| **Authority-only** (multi-NS) | `worker` | **false** | **true** | Stock `dns` Compose service pattern. No Phoenix HTTP edge. |
| **Combined** (default self-host) | `app` or `all` | **true** | optional | Caddy → app; connector + proxy live here. |
| **Edge-facing / pure proxy** | `edge` (or `app`) | **true (required)** | usually **false** | Must **not** use stock worker web-off. |
| **Control plane only** | `app` | true | false | UI/API; remote sites heartbeat + pull config. |
### First-class `edge` role
Both `deploy/docker/docker-entrypoint.sh` and `deploy/docker/start.sh` accept
`ELEKTRINE_RUNTIME_ROLE=edge`:
- web **on** (required for `DNSEdgeProxy`, `/_edge/*`, tunnel WSS)
- mail **off**
- jobs **off** by default (override with `ELEKTRINE_ENABLE_JOBS=true`)
- `DNS_AUTHORITY_ENABLED` defaults to **false** when unset
Before this change, `start.sh` had a dead `app|edge` branch that entrypoint
rejected. Entrypoint now allows `edge`.
### Compose example
See `deploy/docker/compose.edge.example.yml` and
`deploy/caddy/Caddyfile.pure-edge` (forwards `/_edge/*` to the edge app;
product Caddyfiles intentionally `respond /_edge/* 404` on public managed sites).
## Config bundle (KD-15)
Remote edge nodes pull configuration with a **per-site bearer**, not the shared
`CADDY_EDGE_API_KEY` (that key remains for legacy ACME / TLS-ask only).
| Method | Path | Auth |
| --- | --- | --- |
| GET | `/_edge/config/v1/bundle` | `Authorization: Bearer ess_...` |
| POST | `/_edge/site/v1/heartbeat` | same site bearer |
### Response shape
```json
{
"site_id": "...",
"generated_at": "...",
"exp": "...",
"etag": "sha256-...",
"content_updated_at": "...",
"origins": [],
"rules": [],
"edge_ips": { "ipv4": [], "ipv6": [] }
}
```
- Clients send `If-None-Match: "<etag>"` and/or `?since=<ISO8601>`; server
returns **304 Not Modified** when content is unchanged.
- When `If-None-Match` is present it is **authoritative**: a miss yields 200
even if `since` still looks current (so rules/edge-pool edits cannot hide
behind origin-only mtimes). Prefer ETag for agents (`pull-config-bundle.sh`).
- `content_updated_at` is the max of proxied origin/zone mtimes, edge-site pool
row times, and rule timestamps (when rules export them) — not wall-clock on
each empty build.
- Optional response header `X-Elektrine-Edge-Sig: v1=<hex>` is HMAC-SHA256 over
`etag|exp|site_id` using the per-site HMAC secret (minted with the token).
- Revocation: `mix elektrine.dns.edge_site revoke --id ...` or rotate; old bearer
gets 401.
### Mint a site (instance-admin)
```bash
mix elektrine.dns.edge_site create --name edge-b --ipv4 203.0.113.10
# prints ess_... and hmac_secret once
```
Site CRUD is **not** available via user PAT (`write:dns` / `write:edge`). Full
admin LiveView may land with the broader edge-sites registry PR; the mix task is
the supported automation path on this branch.
### Soft dependencies
| Feature in bundle | Module | If missing |
| --- | --- | --- |
| Origins | `Elektrine.DNS` proxied records | always included when zones verified |
| Rules | `Elektrine.DNS.EdgeRules` (PR 5) | `rules: []` |
| Tunnel origin rows | tunnels (PR 7a/7b) | `type: "tunnel"` entries when records exist |
| Site registry heartbeats | this PR (`dns_edge_sites`) | env singleton IPs when no rows |
### Pull script + reload
```bash
export CONTROL_PLANE_URL=https://cp.example.com # must reach Phoenix (not a
# product Caddy that 404s /_edge)
export EDGE_SITE_TOKEN=ess_...
scripts/edge/pull-config-bundle.sh
```
**Reload policy (v1):**
1. **Phoenix edge process** — no restart required for origins already resolved
live via `DNS.proxied_origin_for_host/1`. The bundle is for remote data-plane
agents and for applying optional static fragments.
2. **Cached JSON agents** — rewrite `bundle.json`, then run
`EDGE_BUNDLE_RELOAD_CMD` if set.
3. **Caddy** — if you render static `handle` / reverse_proxy snippets from the
bundle (experimental; not a hard commit), reload with
`caddy reload --config /etc/caddy/Caddyfile` or SIGHUP. Do **not** depend on
the remote Caddy admin API in v1.
4. **Cadence** — poll every 3060s with ETag; heartbeat every 15s
(`DNS_EDGE_SITE_HEARTBEAT_INTERVAL_MS`, stale after 45s).
## Caddy cache module (named, optional)
Stock `deploy/caddy/Dockerfile` uses official Caddy **without** a cache plugin.
When operators need edge HTTP cache outside Phoenix ETS:
| Choice | Module | Notes |
| --- | --- | --- |
| **Named default** | `github.com/caddyserver/cache-handler` (Souin) | Build with `xcaddy`; pin a release that matches Caddy 2.8+/2.11.x |
Multi-node purge fan-out (`POST /_edge/cache/v1/purge` with site bearer) is
deferred until the single-node cache PR and site registry are both live.
## Related env
| Env | Default | Meaning |
| --- | --- | --- |
| `DNS_EDGE_SITE_HEARTBEAT_INTERVAL_MS` | 15000 | site agent cadence |
| `DNS_EDGE_SITE_STALE_AFTER_MS` | 45000 | mark site down without heartbeat |
| `DNS_EDGE_CONFIG_BUNDLE_TTL_SECONDS` | 300 | bundle `exp` skew |
| `DNS_EDGE_PROXY_*` | existing | env singleton edge IPs when no site rows |
| `CADDY_EDGE_API_KEY` | required for ACME/TLS-ask | **not** used for config pull |

View file

@ -15,6 +15,12 @@ planned Edge Platform (rules, tunnels, cache), see
[../architecture/dns-module.md](../architecture/dns-module.md). Pure edge
nodes need a web-enabled role (`app` with `ELEKTRINE_ENABLE_WEB=true` today) —
not the stock DNS `worker` + `DNS_AUTHORITY_ENABLED` authority container.
For a **pure-edge** second site, use `deploy/caddy/Caddyfile.pure-edge` with
`deploy/docker/compose.edge.example.yml`. That variant **forwards** `/_edge/*`
to the edge app (product Caddyfiles return 404 on `/_edge/*` for public managed
sites). Optional HTTP cache module name: `github.com/caddyserver/cache-handler`
(Souin) via xcaddy — stock `deploy/caddy/Dockerfile` has no cache. Per-site
config pull uses `ess_...` bearers; see `docs/architecture/edge-platform.md`.
## What it manages

View file

@ -20,6 +20,8 @@ all-modules/all-profiles behavior.
- optional `caddy` edge when the `caddy` profile is enabled
- optional `bluesky` PDS when the `bluesky` profile is enabled
- optional onion hosting inside the `app` container when the `tor` profile is enabled
- optional pure-edge second site via `deploy/docker/compose.edge.example.yml`
(`ELEKTRINE_RUNTIME_ROLE=edge`, web on; see `docs/architecture/edge-platform.md`)
See `docs/self-hosting/caddy.md` for Caddy details.

View file

@ -1,4 +1,20 @@
# Edge tunnel agent
# Edge scripts
## Config bundle pull
Remote sites pull origins/rules without the shared `CADDY_EDGE_API_KEY`:
```bash
export CONTROL_PLANE_URL=https://cp.example.com
export EDGE_SITE_TOKEN=ess_... # mix elektrine.dns.edge_site create
scripts/edge/pull-config-bundle.sh
```
304 responses keep the last `bundle.json`. Optional `EDGE_BUNDLE_RELOAD_CMD`
runs after a successful pull. Role matrix and reload policy:
`docs/architecture/edge-platform.md`.
## Tunnel agent
Outbound agent that publishes a private HTTP origin through Elektrine's edge
proxy. The edge never dials the origin; the agent dials only `ORIGIN_URL`.

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Pull the per-site edge config bundle from the control plane (KD-15).
#
# Auth is the site bearer (ess_...), never CADDY_EDGE_API_KEY.
# Supports ETag / If-None-Match (HTTP 304) and optional ?since= ISO8601.
#
# Env:
# CONTROL_PLANE_URL e.g. https://cp.example.com or http://app:8080
# EDGE_SITE_TOKEN ess_... minted via mix elektrine.dns.edge_site create
# BUNDLE_CACHE_DIR default /var/lib/elektrine/edge
# SINCE optional ISO8601 content_updated_at from last bundle
#
# Reload (after a non-304 pull):
# - Phoenix DNSEdgeProxy / connector: no process restart required; next
# request can re-read the cached JSON if your local agent applies it.
# - Caddy static fragments (experimental): write file then
# caddy reload --config /etc/caddy/Caddyfile
# or SIGHUP the caddy process. Do not depend on remote Caddy admin API in v1.
set -euo pipefail
CONTROL_PLANE_URL="${CONTROL_PLANE_URL:?set CONTROL_PLANE_URL}"
EDGE_SITE_TOKEN="${EDGE_SITE_TOKEN:?set EDGE_SITE_TOKEN}"
BUNDLE_CACHE_DIR="${BUNDLE_CACHE_DIR:-/var/lib/elektrine/edge}"
ETAG_FILE="${BUNDLE_CACHE_DIR}/bundle.etag"
BUNDLE_FILE="${BUNDLE_CACHE_DIR}/bundle.json"
mkdir -p "$BUNDLE_CACHE_DIR"
url="${CONTROL_PLANE_URL%/}/_edge/config/v1/bundle"
if [[ -n "${SINCE:-}" ]]; then
url="${url}?since=$(printf '%s' "$SINCE" | jq -sRr @uri 2>/dev/null || python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$SINCE")"
fi
headers=(-H "Authorization: Bearer ${EDGE_SITE_TOKEN}" -H "Accept: application/json")
if [[ -f "$ETAG_FILE" ]]; then
etag="$(tr -d '\r\n' <"$ETAG_FILE")"
if [[ -n "$etag" ]]; then
headers+=(-H "If-None-Match: \"${etag}\"")
fi
fi
tmp="$(mktemp)"
code="$(
curl -sS -o "$tmp" -w '%{http_code}' "${headers[@]}" "$url" || true
)"
case "$code" in
304)
echo "bundle unchanged (304)"
rm -f "$tmp"
exit 0
;;
200)
if command -v jq >/dev/null 2>&1; then
etag="$(jq -r '.etag // empty' <"$tmp")"
else
etag="$(sed -n 's/.*"etag"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <"$tmp" | head -1)"
fi
mv "$tmp" "$BUNDLE_FILE"
if [[ -n "${etag:-}" ]]; then
printf '%s\n' "$etag" >"$ETAG_FILE"
fi
echo "bundle written to $BUNDLE_FILE (etag=${etag:-none})"
# Hook for operators: EDGE_BUNDLE_RELOAD_CMD='caddy reload --config ...'
if [[ -n "${EDGE_BUNDLE_RELOAD_CMD:-}" ]]; then
# shellcheck disable=SC2086
eval $EDGE_BUNDLE_RELOAD_CMD
fi
exit 0
;;
*)
echo "config pull failed: HTTP $code" >&2
cat "$tmp" >&2 || true
rm -f "$tmp"
exit 1
;;
esac