merge: pr-12-featedge-per-site-config-bundle-pure-edge-deploy-p
This commit is contained in:
commit
cc026dddcf
26 changed files with 1781 additions and 3 deletions
|
|
@ -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
|
||||
|
|
@ -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(".")
|
||||
|
||||
|
|
|
|||
326
apps/elektrine_dns/lib/elektrine/dns/config_bundle.ex
Normal file
326
apps/elektrine_dns/lib/elektrine/dns/config_bundle.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
118
apps/elektrine_dns/lib/mix/tasks/elektrine.dns.edge_site.ex
Normal file
118
apps/elektrine_dns/lib/mix/tasks/elektrine.dns.edge_site.ex
Normal 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
|
||||
108
apps/elektrine_dns/test/elektrine/dns/config_bundle_test.exs
Normal file
108
apps/elektrine_dns/test/elektrine/dns/config_bundle_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
60
deploy/caddy/Caddyfile.pure-edge
Normal file
60
deploy/caddy/Caddyfile.pure-edge
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
87
deploy/docker/compose.edge.example.yml
Normal file
87
deploy/docker/compose.edge.example.yml
Normal 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:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
54
deploy/docker/test_edge_role_matrix.sh
Executable file
54
deploy/docker/test_edge_role_matrix.sh
Executable 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"
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 30–60s 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
78
scripts/edge/pull-config-bundle.sh
Executable file
78
scripts/edge/pull-config-bundle.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue