Skip to content

Commit 4671586

Browse files
feat: add ACME / Let's Encrypt auto-renewal for certificates
Implements a complete ACME client (RFC 8555) with HTTP-01 challenge handling, automatic renewal via Oban worker, and LiveView UI for ACME configuration. - ACME client behaviour + HTTP implementation (Req + JWS ES256) - Crypto module: EC P-256 account keys, RSA 2048 cert keys, CSR, JWK thumbprint - ETS-backed challenge store for HTTP-01 token serving - Renewal orchestration: account registration, order, authorize, finalize, download - Oban worker on maintenance queue (6-hour interval) with audit logging - LiveView: ACME config editing, status display, "Renew Now" button, ACME tab on new cert - 29 new tests (crypto, challenge store, renewal, worker, controller)
1 parent 9179590 commit 4671586

25 files changed

Lines changed: 1888 additions & 6 deletions

config/config.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ config :sentinel_cp, :github_webhook,
9191
secret: nil,
9292
default_branch: "main"
9393

94+
# ACME / Let's Encrypt configuration
95+
config :sentinel_cp, :acme,
96+
directory_url: "https://acme-v02.api.letsencrypt.org/directory"
97+
9498
# Import environment specific config. This must remain at the bottom
9599
# of this file so it overrides the configuration defined above.
96100
import_config "#{config_env()}.exs"

config/dev.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,7 @@ config :sentinel_cp, SentinelCp.Bundles.Storage,
9797
config :sentinel_cp, SentinelCp.Bundles.Compiler,
9898
sentinel_binary: System.get_env("SENTINEL_BINARY", "sentinel"),
9999
skip_validation: !System.get_env("SENTINEL_BINARY")
100+
101+
# Use Let's Encrypt staging in development
102+
config :sentinel_cp, :acme,
103+
directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory"

config/test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ config :sentinel_cp, :github_client, SentinelCp.Webhooks.GitHubClient.Mock
5454
# Use mock DNS resolver in tests
5555
config :sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Mock
5656

57+
# Use mock K8s resolver in tests
58+
config :sentinel_cp, :k8s_resolver, SentinelCp.Services.K8sResolver.Mock
59+
60+
# Use mock ACME client in tests
61+
config :sentinel_cp, :acme_client, SentinelCp.Services.Acme.Client.Mock
62+
5763
# Wallaby E2E test configuration
5864
config :wallaby,
5965
otp_app: :sentinel_cp,

lib/sentinel_cp/application.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ defmodule SentinelCp.Application do
1616
SentinelCp.PromEx,
1717
# API rate limiting (ETS-backed token bucket)
1818
SentinelCp.RateLimit,
19+
# ACME challenge token store (ETS-backed)
20+
SentinelCp.Services.Acme.ChallengeStore,
1921
# Background job processing
2022
{Oban, Application.fetch_env!(:sentinel_cp, Oban)},
2123
# Start to serve requests, typically the last entry
@@ -33,6 +35,7 @@ defmodule SentinelCp.Application do
3335
SentinelCp.Services.CertificateExpiryWorker.ensure_started()
3436
SentinelCp.Analytics.PruneWorker.ensure_started()
3537
SentinelCp.Services.DiscoverySyncWorker.ensure_started()
38+
SentinelCp.Services.CertificateRenewalWorker.ensure_started()
3639

3740
result
3841
end

lib/sentinel_cp/services.ex

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ defmodule SentinelCp.Services do
99
import Ecto.Query, warn: false
1010
alias SentinelCp.Repo
1111
alias SentinelCp.Services.{Service, ServiceTemplate, ProjectConfig, UpstreamGroup, UpstreamTarget, Certificate, AuthPolicy, OpenApiSpec, DiscoverySource, DiscoverySync, Middleware, ServiceMiddleware}
12+
alias SentinelCp.Secrets
1213

1314
## Services
1415

@@ -326,6 +327,36 @@ defmodule SentinelCp.Services do
326327
|> Repo.all()
327328
end
328329

330+
@doc """
331+
Lists certificates eligible for ACME auto-renewal.
332+
333+
Finds certificates with `auto_renew: true`, non-empty `acme_config`,
334+
and expiring within the configured threshold (default 30 days).
335+
"""
336+
def list_acme_renewal_candidates(days_ahead \\ 30) do
337+
now = DateTime.utc_now()
338+
threshold = DateTime.add(now, days_ahead * 86_400, :second)
339+
340+
from(c in Certificate,
341+
where: c.auto_renew == true,
342+
where: c.status in ["active", "expiring_soon"],
343+
where: c.not_after > ^now,
344+
where: c.not_after <= ^threshold,
345+
order_by: [asc: c.not_after]
346+
)
347+
|> Repo.all()
348+
|> Enum.filter(&((&1.acme_config || %{}) != %{}))
349+
end
350+
351+
@doc """
352+
Updates ACME-specific fields on a certificate (account key, renewal status).
353+
"""
354+
def update_certificate_acme(%Certificate{} = cert, attrs) do
355+
cert
356+
|> Certificate.acme_changeset(attrs)
357+
|> Repo.update()
358+
end
359+
329360
## Service Templates
330361

331362
@doc """
@@ -560,7 +591,7 @@ defmodule SentinelCp.Services do
560591
|> DiscoverySource.sync_changeset(%{last_sync_status: "syncing"})
561592
|> Repo.update()
562593

563-
case dns_resolver().resolve_srv(source.hostname) do
594+
case resolve_records(source) do
564595
{:ok, records} ->
565596
group = get_upstream_group!(source.upstream_group_id)
566597
current_targets = group.targets || []
@@ -887,10 +918,28 @@ defmodule SentinelCp.Services do
887918
service_edges ++ target_edges
888919
end
889920

921+
defp resolve_records(%DiscoverySource{source_type: "kubernetes"} = source) do
922+
case Secrets.resolve_references(source.config || %{}, source.project_id) do
923+
{:ok, resolved_config} ->
924+
k8s_resolver().resolve_endpoints(resolved_config)
925+
926+
{:error, reason} ->
927+
{:error, "Secret resolution failed: #{inspect(reason)}"}
928+
end
929+
end
930+
931+
defp resolve_records(%DiscoverySource{} = source) do
932+
dns_resolver().resolve_srv(source.hostname)
933+
end
934+
890935
defp dns_resolver do
891936
Application.get_env(:sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Inet)
892937
end
893938

939+
defp k8s_resolver do
940+
Application.get_env(:sentinel_cp, :k8s_resolver, SentinelCp.Services.K8sResolver.HTTP)
941+
end
942+
894943
defp resolve_auth_policy_id(%{security_refs: refs}, policy_map) when is_list(refs) do
895944
Enum.find_value(refs, fn ref -> Map.get(policy_map, ref) end)
896945
end
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
defmodule SentinelCp.Services.Acme.ChallengeStore do
2+
@moduledoc """
3+
ETS-backed GenServer for storing ephemeral ACME HTTP-01 challenge tokens.
4+
5+
Tokens are stored with a TTL and cleaned up periodically.
6+
"""
7+
8+
use GenServer
9+
10+
@table :acme_challenge_tokens
11+
@cleanup_interval :timer.minutes(5)
12+
@ttl_seconds 600
13+
14+
## Client API
15+
16+
def start_link(opts \\ []) do
17+
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
18+
end
19+
20+
@doc """
21+
Stores a challenge token and its key authorization.
22+
"""
23+
def put(token, key_authorization) do
24+
expires_at = System.system_time(:second) + @ttl_seconds
25+
:ets.insert(@table, {token, key_authorization, expires_at})
26+
:ok
27+
end
28+
29+
@doc """
30+
Retrieves the key authorization for a challenge token.
31+
"""
32+
def get(token) do
33+
case :ets.lookup(@table, token) do
34+
[{^token, key_auth, expires_at}] ->
35+
if System.system_time(:second) < expires_at do
36+
{:ok, key_auth}
37+
else
38+
:ets.delete(@table, token)
39+
:error
40+
end
41+
42+
[] ->
43+
:error
44+
end
45+
end
46+
47+
@doc """
48+
Removes a challenge token.
49+
"""
50+
def delete(token) do
51+
:ets.delete(@table, token)
52+
:ok
53+
end
54+
55+
## Server
56+
57+
@impl true
58+
def init(_opts) do
59+
table = :ets.new(@table, [:named_table, :public, :set, {:write_concurrency, true}])
60+
schedule_cleanup()
61+
{:ok, %{table: table}}
62+
end
63+
64+
@impl true
65+
def handle_info(:cleanup, state) do
66+
cleanup_expired()
67+
schedule_cleanup()
68+
{:noreply, state}
69+
end
70+
71+
defp schedule_cleanup do
72+
Process.send_after(self(), :cleanup, @cleanup_interval)
73+
end
74+
75+
defp cleanup_expired do
76+
now = System.system_time(:second)
77+
match_spec = [{{:_, :_, :"$1"}, [{:<, :"$1", now}], [true]}]
78+
:ets.select_delete(@table, match_spec)
79+
end
80+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule SentinelCp.Services.Acme.Client do
2+
@moduledoc """
3+
Behaviour for ACME (RFC 8555) client operations.
4+
5+
Covers the full certificate issuance flow: directory discovery,
6+
account registration, order creation, authorization, challenge
7+
response, finalization, and certificate download.
8+
"""
9+
10+
@type account_key :: tuple()
11+
@type directory :: map()
12+
@type nonce :: String.t()
13+
@type url :: String.t()
14+
@type kid :: String.t()
15+
16+
@callback get_directory(url()) :: {:ok, directory()} | {:error, term()}
17+
18+
@callback new_nonce(url()) :: {:ok, nonce()} | {:error, term()}
19+
20+
@callback new_account(url(), account_key(), nonce(), map()) ::
21+
{:ok, %{kid: kid(), nonce: nonce()}} | {:error, term()}
22+
23+
@callback new_order(url(), kid(), account_key(), nonce(), [String.t()]) ::
24+
{:ok, %{order_url: url(), authorizations: [url()], finalize_url: url(), nonce: nonce()}}
25+
| {:error, term()}
26+
27+
@callback get_authorization(url(), kid(), account_key(), nonce()) ::
28+
{:ok, %{status: String.t(), challenges: [map()], nonce: nonce()}}
29+
| {:error, term()}
30+
31+
@callback respond_challenge(url(), kid(), account_key(), nonce()) ::
32+
{:ok, %{nonce: nonce()}} | {:error, term()}
33+
34+
@callback poll_authorization(url(), kid(), account_key(), nonce(), keyword()) ::
35+
{:ok, %{status: String.t(), nonce: nonce()}} | {:error, term()}
36+
37+
@callback finalize_order(url(), kid(), account_key(), nonce(), binary()) ::
38+
{:ok, %{nonce: nonce()}} | {:error, term()}
39+
40+
@callback poll_order(url(), kid(), account_key(), nonce(), keyword()) ::
41+
{:ok, %{status: String.t(), certificate_url: url() | nil, nonce: nonce()}}
42+
| {:error, term()}
43+
44+
@callback download_certificate(url(), kid(), account_key(), nonce()) ::
45+
{:ok, %{cert_pem: String.t(), nonce: nonce()}} | {:error, term()}
46+
end

0 commit comments

Comments
 (0)