diff --git a/go.mod b/go.mod index ea0175c78..562ac15a5 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/bluenviron/gortmplib v0.1.2 github.com/bluenviron/gortsplib/v5 v5.2.1 github.com/bluesky-social/indigo v0.0.0-20260629160527-dfe5578fd537 + github.com/caddyserver/certmagic v0.25.4 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v5 v5.0.2 github.com/decred/dcrd/dcrec/secp256k1 v1.0.4 @@ -85,6 +86,7 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.27.1 golang.org/x/image v0.30.0 golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 @@ -197,6 +199,7 @@ require ( github.com/breml/errchkjson v0.4.1 // indirect github.com/butuzov/ireturn v0.4.0 // indirect github.com/butuzov/mirror v1.3.0 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect github.com/carlmjohnson/versioninfo v0.22.5 // indirect github.com/catenacyber/perfsprint v0.9.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.2 // indirect @@ -378,6 +381,7 @@ require ( github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect + github.com/libdns/libdns v1.1.1 // indirect github.com/livepeer/go-tools v0.3.6-0.20240130205227-92479de8531b // indirect github.com/livepeer/joy4 v0.1.2-0.20191121080656-b2fea45cbded // indirect github.com/livepeer/livepeer-data v0.7.5-0.20231004073737-06f1f383fb18 // indirect @@ -395,6 +399,8 @@ require ( github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgechev/revive v1.9.0 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/minio-go/v7 v7.0.66 // indirect @@ -532,6 +538,7 @@ require ( github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect @@ -551,7 +558,7 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect diff --git a/go.sum b/go.sum index d273e64dc..86a9c7db1 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDl cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= codeberg.org/go-fonts/dejavu v0.4.0 h1:2yn58Vkh4CFK3ipacWUAIE3XVBGNa0y1bc95Bmfx91I= codeberg.org/go-fonts/dejavu v0.4.0/go.mod h1:abni088lmhQJvso2Lsb7azCKzwkfcnttl6tL1UTWKzg= codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0= @@ -275,6 +277,10 @@ github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQU github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= +github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= @@ -966,6 +972,12 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= @@ -1028,8 +1040,12 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM= github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -1520,6 +1536,12 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= gitlab.com/gitlab-org/release-cli v0.18.0 h1:vVNxGRIy4w4FIo0ucO8ZyYecSH+jwV+vdZygpBt+E/0= @@ -1587,8 +1609,10 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/js/docs/src/content/docs/guides/installing/automatic-https.md b/js/docs/src/content/docs/guides/installing/automatic-https.md new file mode 100644 index 000000000..a65a00ba9 --- /dev/null +++ b/js/docs/src/content/docs/guides/installing/automatic-https.md @@ -0,0 +1,79 @@ +--- +title: Automatic HTTPS +description: Let your node obtain and renew its own TLS certificates from Let's Encrypt. +--- + +A node running with `SP_SECURE=true` terminates TLS itself. Instead of +supplying a certificate and key with `SP_TLS_CERT` and `SP_TLS_KEY`, you can +let the node get them from an ACME certificate authority. Let's Encrypt is the +default. + +```bash +SP_SECURE=true \ +SP_ACME=true \ +SP_ACME_EMAIL=ops@example.com \ +SP_BROADCASTER_HOST=example.com \ +streamplace +``` + +By turning this on you agree to the CA's terms of service. + +## What the node needs + +The CA validates that you control each hostname by connecting to it. The node +answers either challenge itself: + +- **HTTP-01** on the plain HTTP listener (port 80). The listener otherwise + only redirects to HTTPS, but it serves `/.well-known/acme-challenge/` first. +- **TLS-ALPN-01** on the HTTPS listener (port 443). + +So DNS for every managed hostname must point at the node (or at the station's +load balancer, see below), and port 80 or 443 must be reachable from the +internet. If you run on other ports, put the node behind a proxy that +terminates TLS instead and use `SP_BEHIND_HTTPS_PROXY=true`. + +## Which hostnames + +The node manages a certificate for `SP_BROADCASTER_HOST`, the station's +identity. When `SP_SERVER_HOST` is set to a different name (for example +`prod-node1.example.com` alongside `example.com`), it manages a second +certificate for that name too. Add more with `SP_ACME_DOMAINS`, comma +separated, for hostnames such as a dedicated RTMPS endpoint. Every TLS +listener (HTTPS, RTMPS, the RTMPS addon) serves whichever certificate +matches the name the client asked for. + +## Stations with several nodes + +Certificates, the ACME account key and in-flight challenge state all live in +the state database, not on disk. With a shared Postgres `SP_DB_URL`: + +- Every node serves the same certificates, and a certificate any node + obtains is available to the rest within seconds. +- Renewal is coordinated with database locks, so only one node renews a given + certificate at a time and the others pick up the result. +- Any node can answer a challenge another node started, so it does not + matter which node the CA's request lands on behind a load balancer. + +A node with the default sqlite database keeps its certificates to itself. + +## Testing against staging + +Let's Encrypt rate-limits production issuance. While you get DNS and ports +right, use the staging environment, whose certificates browsers do not trust: + +```bash +SP_ACME_CA=letsencrypt-staging +``` + +Any other ACME directory URL works there too. Certificates obtained from +staging are stored separately from production ones, so switching back does +not require cleanup. + +## Troubleshooting + +Certificate management runs in the background. If a certificate cannot be +obtained, the node still starts and logs the CA's error; TLS connections for +that hostname fail until it succeeds, and the node keeps retrying with +backoff. The usual causes are DNS not pointing at the node yet, port 80 and +443 both unreachable, or a hostname that is not public (IP addresses and +`localhost` are rejected at startup). diff --git a/pkg/acme/acme.go b/pkg/acme/acme.go new file mode 100644 index 000000000..68ba524eb --- /dev/null +++ b/pkg/acme/acme.go @@ -0,0 +1,142 @@ +package acme + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "strings" + + "github.com/caddyserver/certmagic" + "go.uber.org/zap" + + "stream.place/streamplace/pkg/config" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/statedb" +) + +// CA shorthands accepted by --acme-ca. +const ( + CALetsEncrypt = "letsencrypt" + CALetsEncryptStaging = "letsencrypt-staging" +) + +// ResolveCA turns a shorthand or URL into an ACME directory URL. +func ResolveCA(ca string) string { + switch strings.ToLower(strings.TrimSpace(ca)) { + case "", CALetsEncrypt, "production", "prod": + return certmagic.LetsEncryptProductionCA + case CALetsEncryptStaging, "staging": + return certmagic.LetsEncryptStagingCA + } + return ca +} + +// Manager owns the certmagic configuration for this node. +type Manager struct { + cfg *certmagic.Config + issuer *certmagic.ACMEIssuer + domains []string +} + +// Domains lists the hostnames a node must hold certificates for: the +// broadcaster (station) host, the server host when it is a distinct +// identity, and any extra names the operator asked for. Ports are stripped. +func Domains(cli *config.CLI) []string { + var out []string + seen := map[string]struct{}{} + add := func(h string) { + h = strings.ToLower(strings.TrimSpace(h)) + if host, _, err := net.SplitHostPort(h); err == nil { + h = host + } + if h == "" { + return + } + if _, dup := seen[h]; dup { + return + } + seen[h] = struct{}{} + out = append(out, h) + } + add(cli.BroadcasterHost) + add(cli.ServerHost) + for _, d := range cli.ACMEDomains { + add(d) + } + return out +} + +// New builds the manager. It does not talk to the CA; call Manage for that. +func New(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB) (*Manager, error) { + domains := Domains(cli) + if len(domains) == 0 { + return nil, fmt.Errorf("acme: no domains to manage; set --broadcaster-host") + } + for _, d := range domains { + if ip := net.ParseIP(d); ip != nil || d == "localhost" { + return nil, fmt.Errorf("acme: %q is not a public hostname a CA can validate", d) + } + } + + logger, err := zap.NewProduction() + if err != nil { + return nil, fmt.Errorf("acme: build logger: %w", err) + } + storage := NewStorage(state) + + m := &Manager{domains: domains} + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { + return m.cfg, nil + }, + Logger: logger, + }) + m.cfg = certmagic.New(cache, certmagic.Config{ + Storage: storage, + Logger: logger, + DefaultServerName: domains[0], + }) + m.issuer = certmagic.NewACMEIssuer(m.cfg, certmagic.ACMEIssuer{ + CA: ResolveCA(cli.ACMECA), + Email: cli.ACMEEmail, + Agreed: true, + Logger: logger, + }) + m.cfg.Issuers = []certmagic.Issuer{m.issuer} + return m, nil +} + +// Domains returns the managed hostnames. +func (m *Manager) Domains() []string { + return append([]string(nil), m.domains...) +} + +// Manage obtains any missing certificates in the background and keeps them +// renewed until ctx ends. Failures are retried by certmagic; the node keeps +// serving meanwhile, so a misconfigured DNS record shows up as TLS handshake +// errors and log lines rather than a node that refuses to start. +func (m *Manager) Manage(ctx context.Context) error { + log.Log(ctx, "acme: managing certificates", "domains", strings.Join(m.domains, ","), "ca", m.issuer.CA) + if err := m.cfg.ManageAsync(ctx, m.domains); err != nil { + return fmt.Errorf("acme: manage %v: %w", m.domains, err) + } + <-ctx.Done() + return nil +} + +// TLSConfig serves the managed certificates and answers the TLS-ALPN-01 +// challenge. Callers append their own application protocols. +func (m *Manager) TLSConfig() *tls.Config { + cfg := m.cfg.TLSConfig() + cfg.MinVersion = tls.VersionTLS12 + return cfg +} + +// HTTPChallengeHandler answers HTTP-01 challenges (for this node or any node +// sharing its statedb) on the plain-HTTP listener, passing everything else +// to next. +func (m *Manager) HTTPChallengeHandler(next http.Handler) http.Handler { + return m.issuer.HTTPChallengeHandler(next) +} diff --git a/pkg/acme/storage.go b/pkg/acme/storage.go new file mode 100644 index 000000000..ff0739dca --- /dev/null +++ b/pkg/acme/storage.go @@ -0,0 +1,179 @@ +// Package acme obtains and renews the node's TLS certificates from an ACME +// CA (Let's Encrypt by default) with certmagic, keeping everything in +// statedb so a station's nodes share one set of certificates. +package acme + +import ( + "context" + "errors" + "fmt" + "io/fs" + "sort" + "strings" + "sync" + "time" + + "github.com/caddyserver/certmagic" + + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/statedb" +) + +// Storage is certmagic.Storage on top of statedb. Values live in the +// certmagic_items table; locks are statedb named locks, which on Postgres +// are session-scoped advisory locks (released automatically if the holder +// dies, so there is no stale-lock bookkeeping) and on sqlite are in-process +// mutexes (a sqlite statedb is a single-node deployment). +type Storage struct { + state *statedb.StatefulDB + + mu sync.Mutex + locks map[string]func() +} + +var _ certmagic.Storage = (*Storage)(nil) + +// NewStorage wraps state. +func NewStorage(state *statedb.StatefulDB) *Storage { + return &Storage{state: state, locks: map[string]func(){}} +} + +const lockPrefix = "certmagic/" + +// lockRetryDelay is how long Lock waits between attempts once the statedb +// backoff has given up on a contended lock. +var lockRetryDelay = 2 * time.Second + +// Lock implements certmagic.Locker. It blocks until the lock is held or ctx +// ends. A lock held by another node in the station (Postgres) or another +// goroutine (sqlite) makes the call wait, as certmagic expects. +func (s *Storage) Lock(ctx context.Context, name string) error { + for { + unlock, err := s.state.GetNamedLock(lockPrefix + name) + if err == nil { + s.mu.Lock() + s.locks[name] = unlock + s.mu.Unlock() + return nil + } + if !errors.Is(err, statedb.ErrNoLock) { + return fmt.Errorf("acme lock %q: %w", name, err) + } + log.Debug(ctx, "acme: lock held elsewhere, waiting", "name", name) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(lockRetryDelay): + } + } +} + +// Unlock implements certmagic.Locker. +func (s *Storage) Unlock(ctx context.Context, name string) error { + s.mu.Lock() + unlock, ok := s.locks[name] + delete(s.locks, name) + s.mu.Unlock() + if !ok { + return fmt.Errorf("acme unlock %q: not held", name) + } + unlock() + return nil +} + +// Store implements certmagic.Storage. +func (s *Storage) Store(ctx context.Context, key string, value []byte) error { + return s.state.CertmagicPut(ctx, key, value) +} + +// Load implements certmagic.Storage. +func (s *Storage) Load(ctx context.Context, key string) ([]byte, error) { + item, err := s.state.CertmagicGet(ctx, key) + if err != nil { + return nil, err + } + if item == nil { + return nil, fs.ErrNotExist + } + return item.Value, nil +} + +// Delete implements certmagic.Storage: key and everything under it. +func (s *Storage) Delete(ctx context.Context, key string) error { + _, err := s.state.CertmagicDelete(ctx, key) + return err +} + +// Exists implements certmagic.Storage. +func (s *Storage) Exists(ctx context.Context, key string) bool { + rows, err := s.state.CertmagicList(ctx, key) + return err == nil && len(rows) > 0 +} + +// List implements certmagic.Storage with the same shape as certmagic's own +// FileStorage: every key below prefix when recursive (implicit directories +// included), else only the immediate children. +func (s *Storage) List(ctx context.Context, prefix string, recursive bool) ([]string, error) { + rows, err := s.state.CertmagicList(ctx, prefix) + if err != nil { + return nil, err + } + base := prefix + if base != "" { + base += "/" + } + seen := map[string]struct{}{} + var keys []string + add := func(k string) { + if _, dup := seen[k]; dup { + return + } + seen[k] = struct{}{} + keys = append(keys, k) + } + for _, row := range rows { + if row.Key == prefix { + continue + } + rel := strings.TrimPrefix(row.Key, base) + parts := strings.Split(rel, "/") + if !recursive { + add(base + parts[0]) + continue + } + for i := range parts { + add(base + strings.Join(parts[:i+1], "/")) + } + } + sort.Strings(keys) + return keys, nil +} + +// Stat implements certmagic.Storage. +func (s *Storage) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error) { + rows, err := s.state.CertmagicList(ctx, key) + if err != nil { + return certmagic.KeyInfo{}, err + } + if len(rows) == 0 { + return certmagic.KeyInfo{}, fs.ErrNotExist + } + info := certmagic.KeyInfo{Key: key} + for _, row := range rows { + if row.Key == key { + info.IsTerminal = true + info.Size = row.Size + info.Modified = row.UpdatedAt + return info, nil + } + if row.UpdatedAt.After(info.Modified) { + info.Modified = row.UpdatedAt + } + } + return info, nil +} + +// String makes storage identifiable in certmagic's logs. +func (s *Storage) String() string { + return "statedb" +} diff --git a/pkg/acme/storage_test.go b/pkg/acme/storage_test.go new file mode 100644 index 000000000..7a380b146 --- /dev/null +++ b/pkg/acme/storage_test.go @@ -0,0 +1,161 @@ +package acme + +import ( + "context" + "io/fs" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "stream.place/streamplace/pkg/config" + "stream.place/streamplace/pkg/model" + "stream.place/streamplace/pkg/statedb" +) + +func newStorage(t *testing.T) *Storage { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + cli := &config.CLI{BroadcasterHost: "node.example", DBURL: ":memory:"} + cli.DataDir = t.TempDir() + mod, err := model.MakeDB(":memory:") + require.NoError(t, err) + state, err := statedb.MakeDB(ctx, cli, nil, mod) + require.NoError(t, err) + return NewStorage(state) +} + +func TestStorageRoundTrip(t *testing.T) { + ctx := context.Background() + s := newStorage(t) + + _, err := s.Load(ctx, "missing") + require.ErrorIs(t, err, fs.ErrNotExist) + require.False(t, s.Exists(ctx, "missing")) + _, err = s.Stat(ctx, "missing") + require.ErrorIs(t, err, fs.ErrNotExist) + + require.NoError(t, s.Store(ctx, "certificates/ca/example.com/example.com.crt", []byte("cert"))) + require.NoError(t, s.Store(ctx, "certificates/ca/example.com/example.com.key", []byte("key"))) + require.NoError(t, s.Store(ctx, "certificates/ca/other.example/other.example.crt", []byte("cert2"))) + require.NoError(t, s.Store(ctx, "acme/ca/users/me/me.json", []byte("{}"))) + + v, err := s.Load(ctx, "certificates/ca/example.com/example.com.crt") + require.NoError(t, err) + require.Equal(t, "cert", string(v)) + + // overwrite + require.NoError(t, s.Store(ctx, "certificates/ca/example.com/example.com.crt", []byte("cert-v2"))) + v, err = s.Load(ctx, "certificates/ca/example.com/example.com.crt") + require.NoError(t, err) + require.Equal(t, "cert-v2", string(v)) + + // directories exist implicitly + require.True(t, s.Exists(ctx, "certificates")) + require.True(t, s.Exists(ctx, "certificates/ca/example.com")) + require.False(t, s.Exists(ctx, "certificates/ca/example"), "prefix must end at a path boundary") + + // Stat: terminal vs directory + info, err := s.Stat(ctx, "certificates/ca/example.com/example.com.crt") + require.NoError(t, err) + require.True(t, info.IsTerminal) + require.Equal(t, int64(len("cert-v2")), info.Size) + require.WithinDuration(t, time.Now(), info.Modified, time.Minute) + info, err = s.Stat(ctx, "certificates/ca") + require.NoError(t, err) + require.False(t, info.IsTerminal) + require.WithinDuration(t, time.Now(), info.Modified, time.Minute) + + // List, non-recursive: immediate children only, directories included + keys, err := s.List(ctx, "certificates/ca", false) + require.NoError(t, err) + require.Equal(t, []string{"certificates/ca/example.com", "certificates/ca/other.example"}, keys) + keys, err = s.List(ctx, "certificates/ca/example.com", false) + require.NoError(t, err) + require.Equal(t, []string{"certificates/ca/example.com/example.com.crt", "certificates/ca/example.com/example.com.key"}, keys) + + // List, recursive: everything below, with the implicit directories + keys, err = s.List(ctx, "certificates", true) + require.NoError(t, err) + require.Equal(t, []string{ + "certificates/ca", + "certificates/ca/example.com", + "certificates/ca/example.com/example.com.crt", + "certificates/ca/example.com/example.com.key", + "certificates/ca/other.example", + "certificates/ca/other.example/other.example.crt", + }, keys) + + // Root listing + keys, err = s.List(ctx, "", false) + require.NoError(t, err) + require.Equal(t, []string{"acme", "certificates"}, keys) + + // Delete a directory removes the subtree and nothing else + require.NoError(t, s.Delete(ctx, "certificates/ca/example.com")) + require.False(t, s.Exists(ctx, "certificates/ca/example.com")) + require.True(t, s.Exists(ctx, "certificates/ca/other.example/other.example.crt")) + require.NoError(t, s.Delete(ctx, "certificates/ca/example.com"), "deleting what is already gone is not an error") + + // LIKE metacharacters in keys are literal + require.NoError(t, s.Store(ctx, "weird/a_b%c", []byte("x"))) + require.True(t, s.Exists(ctx, "weird/a_b%c")) + require.False(t, s.Exists(ctx, "weird/aXb")) +} + +func TestStorageLock(t *testing.T) { + ctx := context.Background() + s := newStorage(t) + + require.NoError(t, s.Lock(ctx, "issue_cert_example.com")) + require.Error(t, s.Unlock(ctx, "never-locked")) + + // A second holder waits until the first releases. + acquired := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + require.NoError(t, s.Lock(ctx, "issue_cert_example.com")) + close(acquired) + require.NoError(t, s.Unlock(ctx, "issue_cert_example.com")) + }() + select { + case <-acquired: + t.Fatal("lock acquired while still held") + case <-time.After(200 * time.Millisecond): + } + require.NoError(t, s.Unlock(ctx, "issue_cert_example.com")) + select { + case <-acquired: + case <-time.After(5 * time.Second): + t.Fatal("waiter never acquired the lock") + } + wg.Wait() + + // Locks are independent per name. + require.NoError(t, s.Lock(ctx, "a")) + require.NoError(t, s.Lock(ctx, "b")) + require.NoError(t, s.Unlock(ctx, "a")) + require.NoError(t, s.Unlock(ctx, "b")) +} + +func TestDomainsAndCA(t *testing.T) { + cli := &config.CLI{BroadcasterHost: "Example.com", ServerHost: "node1.example.com:443", ACMEDomains: []string{" rtmp.example.com ", "example.com"}} + require.Equal(t, []string{"example.com", "node1.example.com", "rtmp.example.com"}, Domains(cli)) + + cli = &config.CLI{BroadcasterHost: "example.com", ServerHost: "example.com"} + require.Equal(t, []string{"example.com"}, Domains(cli), "one cert when station and server identities coincide") + + require.Equal(t, "https://acme-v02.api.letsencrypt.org/directory", ResolveCA("")) + require.Equal(t, "https://acme-v02.api.letsencrypt.org/directory", ResolveCA("letsencrypt")) + require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", ResolveCA("staging")) + require.Equal(t, "https://ca.internal/dir", ResolveCA("https://ca.internal/dir")) + + _, err := New(context.Background(), &config.CLI{BroadcasterHost: "127.0.0.1"}, nil) + require.Error(t, err, "IPs cannot get public certificates") + _, err = New(context.Background(), &config.CLI{}, nil) + require.Error(t, err, "nothing to manage") +} diff --git a/pkg/api/api.go b/pkg/api/api.go index d95cb94f4..9fb63c27c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -32,6 +32,7 @@ import ( "github.com/streamplace/oatproxy/pkg/oatproxy" "stream.place/streamplace/js/app" web "stream.place/streamplace/js/web" + "stream.place/streamplace/pkg/acme" "stream.place/streamplace/pkg/atproto" "stream.place/streamplace/pkg/blob" "stream.place/streamplace/pkg/bus" @@ -74,6 +75,9 @@ type StreamplaceAPI struct { PlaybackStore blob.Store ViewLog *viewlog.Writer XRPCServer *spxrpc.Server + // ACME, when set, supplies TLS certificates for every TLS listener and + // answers HTTP-01 challenges on the redirect listener. + ACME *acme.Manager // not thread-safe yet Aliases map[string]string Bus *bus.Bus @@ -1006,6 +1010,9 @@ func (a *StreamplaceAPI) ServeHTTPRedirect(ctx context.Context) error { if err != nil { return err } + if a.ACME != nil { + handler = a.ACME.HTTPChallengeHandler(handler) + } return a.ServerWithShutdown(ctx, handler, func(s *http.Server) error { ln, err := getListenerFromFD("http") if err != nil { @@ -1045,6 +1052,15 @@ func (a *StreamplaceAPI) ServeHTTPS(ctx context.Context) error { a.HTTPRedirectTLSPort = &port443 log.Warn(ctx, "https server listening for https over systemd socket", "addr", ln.Addr()) } + if a.ACME != nil { + s.TLSConfig = a.ACME.TLSConfig() + s.TLSConfig.NextProtos = append([]string{"h2", "http/1.1"}, s.TLSConfig.NextProtos...) + log.Log(ctx, "https server starting", + "addr", ln.Addr(), + "acmeDomains", strings.Join(a.ACME.Domains(), ","), + ) + return s.ServeTLS(ln, "", "") + } log.Log(ctx, "https server starting", "addr", ln.Addr(), "certPath", a.CLI.TLSCertPath, diff --git a/pkg/api/rtmp_server.go b/pkg/api/rtmp_server.go index a869134ab..30a4e67f5 100644 --- a/pkg/api/rtmp_server.go +++ b/pkg/api/rtmp_server.go @@ -298,14 +298,18 @@ func (a *StreamplaceAPI) ServeRTMPInternalPlayback(ctx context.Context) error { } func (a *StreamplaceAPI) ServeRTMPS(ctx context.Context, cli *config.CLI) error { - cert, err := tls.LoadX509KeyPair(cli.TLSCertPath, cli.TLSKeyPath) - if err != nil { - return fmt.Errorf("failed to load TLS certificate: %w", err) - } - - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + var tlsConfig *tls.Config + if a.ACME != nil { + tlsConfig = a.ACME.TLSConfig() + } else { + cert, err := tls.LoadX509KeyPair(cli.TLSCertPath, cli.TLSKeyPath) + if err != nil { + return fmt.Errorf("failed to load TLS certificate: %w", err) + } + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } } ln, err := tls.Listen("tcp", cli.RTMPSAddr, tlsConfig) diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index 53eec84d5..dd24c1e91 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/tls" "encoding/json" "errors" "flag" @@ -16,6 +17,7 @@ import ( "runtime/pprof" "slices" "strconv" + "stream.place/streamplace/pkg/acme" "strings" "syscall" "time" @@ -532,7 +534,22 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu }) } + if cli.ACME && !cli.Secure { + log.Warn(ctx, "--acme has no effect without --secure; TLS is terminated elsewhere") + } if cli.Secure { + var rtmpsTLS *tls.Config + if cli.ACME { + mgr, err := acme.New(ctx, cli, state) + if err != nil { + return err + } + a.ACME = mgr + rtmpsTLS = mgr.TLSConfig() + group.Go(func() error { + return mgr.Manage(ctx) + }) + } group.Go(func() error { return a.ServeHTTPS(ctx) }) @@ -541,7 +558,7 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu }) if cli.RTMPServerAddon != "" { group.Go(func() error { - return rtmps.ServeRTMPSAddon(ctx, cli) + return rtmps.ServeRTMPSAddon(ctx, cli, rtmpsTLS) }) } group.Go(func() error { diff --git a/pkg/config/config.go b/pkg/config/config.go index 1dde57e78..d819776e5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -57,34 +57,40 @@ func (b BuildFlags) BuildTimeStrExpo() string { } type CLI struct { - AdminAccount string - Build *BuildFlags - DataDir string - DBURL string - DBMaxOpenConns int - LocalDBURL string - EthAccountAddr string - EthKeystorePath string - EthPassword string - FirebaseServiceAccount string - FirebaseServiceAccountFile string - GitLabURL string - HTTPAddr string - HTTPInternalAddr string - HTTPSAddr string - RTMPAddr string - RTMPSAddr string - RTMPSAddonAddr string - Secure bool - NoMist bool - IsolatedIngest bool - MistAdminPort int - MistHTTPPort int - MistRTMPPort int - SigningKeyPath string - TAURL string - TLSCertPath string - TLSKeyPath string + AdminAccount string + Build *BuildFlags + DataDir string + DBURL string + DBMaxOpenConns int + LocalDBURL string + EthAccountAddr string + EthKeystorePath string + EthPassword string + FirebaseServiceAccount string + FirebaseServiceAccountFile string + GitLabURL string + HTTPAddr string + HTTPInternalAddr string + HTTPSAddr string + RTMPAddr string + RTMPSAddr string + RTMPSAddonAddr string + Secure bool + NoMist bool + IsolatedIngest bool + MistAdminPort int + MistHTTPPort int + MistRTMPPort int + SigningKeyPath string + TAURL string + TLSCertPath string + TLSKeyPath string + // ACME enables automatic TLS certificates from an ACME CA (Let\'s Encrypt) + // stored in statedb; see pkg/acme. + ACME bool + ACMEEmail string + ACMECA string + ACMEDomains []string PKCS11ModulePath string PKCS11Pin string PKCS11TokenSlot string @@ -337,6 +343,37 @@ func (cli *CLI) NewCommand(name string) *urfavecli.Command { Value: filepath.Join(SPDataDir, "tls", "tls.key"), Sources: urfavecli.EnvVars("SP_TLS_KEY"), }, + &urfavecli.BoolFlag{ + Name: "acme", + Usage: "With --secure, obtain and renew the node's TLS certificates automatically from an ACME CA (Let's Encrypt by default) instead of reading --tls-cert/--tls-key. Certificates are kept in the state database so every node in a station shares them. The CA must be able to reach this node on port 80 (HTTP-01) or 443 (TLS-ALPN-01) at --broadcaster-host and --server-host. Using this means you agree to the CA's terms of service.", + Value: false, + Destination: &cli.ACME, + Sources: urfavecli.EnvVars("SP_ACME"), + }, + &urfavecli.StringFlag{ + Name: "acme-email", + Usage: "Contact email registered with the ACME CA (expiry warnings, account recovery)", + Destination: &cli.ACMEEmail, + Sources: urfavecli.EnvVars("SP_ACME_EMAIL"), + }, + &urfavecli.StringFlag{ + Name: "acme-ca", + Usage: `ACME directory URL, or "letsencrypt" / "letsencrypt-staging" (default: letsencrypt)`, + Destination: &cli.ACMECA, + Sources: urfavecli.EnvVars("SP_ACME_CA"), + }, + &urfavecli.StringFlag{ + Name: "acme-domains", + Usage: `comma-separated extra hostnames to hold certificates for, on top of --broadcaster-host and --server-host (default: "")`, + Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { + if s == "" { + return nil + } + cli.ACMEDomains = strings.Split(s, ",") + return nil + }, + Sources: urfavecli.EnvVars("SP_ACME_DOMAINS"), + }, &urfavecli.StringFlag{ Name: "signing-key", Usage: "Path to signing key for pushing OTA updates to the app", diff --git a/pkg/rtmps/rtmps.go b/pkg/rtmps/rtmps.go index 2b3352531..c85939a8d 100644 --- a/pkg/rtmps/rtmps.go +++ b/pkg/rtmps/rtmps.go @@ -14,19 +14,22 @@ import ( ) // passthrough RTMPS TLS terminator to external RTMP server -func ServeRTMPSAddon(ctx context.Context, cli *config.CLI) error { +// tlsConfig may be nil, in which case the certificate files from the CLI +// are used; the ACME manager passes its own. +func ServeRTMPSAddon(ctx context.Context, cli *config.CLI, tlsConfig *tls.Config) error { if cli.RTMPServerAddon == "" { return fmt.Errorf("RTMP server address not configured") } - cert, err := tls.LoadX509KeyPair(cli.TLSCertPath, cli.TLSKeyPath) - if err != nil { - return fmt.Errorf("failed to load TLS certificate: %w", err) - } - - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + if tlsConfig == nil { + cert, err := tls.LoadX509KeyPair(cli.TLSCertPath, cli.TLSKeyPath) + if err != nil { + return fmt.Errorf("failed to load TLS certificate: %w", err) + } + tlsConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } } listener, err := tls.Listen("tcp", cli.RTMPSAddonAddr, tlsConfig) diff --git a/pkg/statedb/certmagic.go b/pkg/statedb/certmagic.go new file mode 100644 index 000000000..4d162ccf2 --- /dev/null +++ b/pkg/statedb/certmagic.go @@ -0,0 +1,99 @@ +package statedb + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CertmagicItem is one entry of the certmagic key/value store: ACME account +// keys, certificates, and in-flight challenge state. Living in statedb means +// every node in a station shares them, so one node obtains a certificate and +// the rest serve it, and any node can answer an HTTP-01 challenge another +// node started. Keys are slash-separated paths, as certmagic expects. +type CertmagicItem struct { + Key string `gorm:"column:key;primaryKey"` + Value []byte `gorm:"column:value;type:bytes"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (CertmagicItem) TableName() string { + return "certmagic_items" +} + +// CertmagicGet returns the item at key, or nil when absent. +func (state *StatefulDB) CertmagicGet(ctx context.Context, key string) (*CertmagicItem, error) { + var item CertmagicItem + err := state.DB.WithContext(ctx).Where("key = ?", key).First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("certmagic get %q: %w", key, err) + } + return &item, nil +} + +// CertmagicPut creates or overwrites key. +func (state *StatefulDB) CertmagicPut(ctx context.Context, key string, value []byte) error { + item := CertmagicItem{Key: key, Value: value, UpdatedAt: time.Now()} + err := state.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}), + }).Create(&item).Error + if err != nil { + return fmt.Errorf("certmagic put %q: %w", key, err) + } + return nil +} + +// escapeLike makes s safe to use inside a LIKE pattern with ESCAPE '\'. +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +// certmagicScope matches key itself and everything under key + "/". An empty +// prefix matches everything. +func certmagicScope(db *gorm.DB, prefix string) *gorm.DB { + if prefix == "" { + return db + } + return db.Where(`key = ? OR key LIKE ? ESCAPE '\'`, prefix, escapeLike(prefix)+"/%") +} + +// CertmagicDelete removes key and every key under it. Returns the number of +// rows removed. +func (state *StatefulDB) CertmagicDelete(ctx context.Context, key string) (int64, error) { + res := certmagicScope(state.DB.WithContext(ctx), key).Delete(&CertmagicItem{}) + if res.Error != nil { + return 0, fmt.Errorf("certmagic delete %q: %w", key, res.Error) + } + return res.RowsAffected, nil +} + +// CertmagicListInfo is the metadata of one item, without its value. +type CertmagicListInfo struct { + Key string + Size int64 + UpdatedAt time.Time +} + +// CertmagicList returns the metadata of prefix itself (when it is an item) +// and of every item under prefix + "/", ordered by key. +func (state *StatefulDB) CertmagicList(ctx context.Context, prefix string) ([]CertmagicListInfo, error) { + var rows []CertmagicListInfo + err := certmagicScope(state.DB.WithContext(ctx).Model(&CertmagicItem{}), prefix). + Select("key, length(value) AS size, updated_at"). + Order("key ASC"). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("certmagic list %q: %w", prefix, err) + } + return rows, nil +} diff --git a/pkg/statedb/statedb.go b/pkg/statedb/statedb.go index f7d339265..bcbd955e5 100644 --- a/pkg/statedb/statedb.go +++ b/pkg/statedb/statedb.go @@ -73,6 +73,7 @@ var StatefulDBModels = []any{ MultistreamTarget{}, MultistreamEvent{}, BrandingBlob{}, + CertmagicItem{}, ModerationAuditLog{}, Storage{}, BroadcastOrigin{},