diff --git a/CMakeLists.txt b/CMakeLists.txt
index b744a241a..717cf3cf3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -397,6 +397,9 @@ add_library(moqx_core STATIC
src/relay/PublisherCrossExecFilter.cpp
src/relay/SubscriberCrossExecFilter.cpp
src/logging/LogSetup.cpp
+ src/tls/CertDirScanner.cpp
+ src/tls/SniCertManager.cpp
+ src/tls/FizzContextBuilder.cpp
)
target_include_directories(moqx_core
diff --git a/RUNNING.md b/RUNNING.md
index 688d397d4..42599356b 100644
--- a/RUNNING.md
+++ b/RUNNING.md
@@ -154,7 +154,7 @@ docker compose up -d
| `MOQX_VERBOSE` | `0` | VLOG level (0-4) |
| `MOQX_LOG_PORT` | `9999` | Dozzle log viewer port (localhost only) |
| `GLOG_vmodule` | -- | Per-module verbose level (passed through) |
-| `MOQX_INSECURE` | `false` | Use built-in dev cert |
+| `MOQX_INSECURE` | `false` | Use built-in dev cert; the entrypoint exits when set with `MOQX_CERT`/`MOQX_KEY` |
The entrypoint maps `MOQX_LOG_LEVEL` and `MOQX_VERBOSE` to their `GLOG_*`
equivalents and forces `GLOG_logtostderr=1`.
diff --git a/config.example.yaml b/config.example.yaml
index 5329ccd6f..7b00a295f 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -8,8 +8,8 @@ listeners:
address: "::" # Bind address (default: "::" = all interfaces)
port: 9668 # Listen port (1-65535)
tls:
- # cert_file: /path/to/cert.pem # Required when insecure: false
- # key_file: /path/to/key.pem # Required when insecure: false
+ # cert_file: /path/to/cert.pem # Required when insecure: false (with key_file)
+ # key_file: /path/to/key.pem # Required when insecure: false (with cert_file)
# --- PKCS#12 bundle (alternative to cert_file/key_file) ---
# A single .p12/.pfx carrying the cert chain + private key. Decrypted in
# memory; the key is never written to disk. Mutually exclusive with
@@ -18,7 +18,18 @@ listeners:
# pkcs12_password_file: /path/to/password.txt # Preferred: keep the secret off the YAML
# pkcs12_password_env: MOQX_PKCS12_PASSWORD # Cloud-vault friendly: read the password from this env var
# pkcs12_password: "inline-secret" # Discouraged: persists in the config at rest
- insecure: true # Skip TLS for local development
+ # --- Multi-cert / SNI (fizz TLS stack: mvfst + proxygen_qmux) ---
+ # Serve per-hostname certs from a directory of .pem + .key pairs,
+ # selected by SNI (identities: DNS SANs, or CN when there are no DNS SANs).
+ # cert_file/key_file or pkcs12_file, if also set, serve connections whose SNI
+ # is absent or matches nothing. See docs/config.md.
+ # fizz:
+ # cert_dir: /path/to/certs
+ # cert_reload_interval_s: 60 # background rescan; 0 = scan once at startup
+ # ticket_seeds_file: /path/to/ticket-seeds # hex seeds, one per line; share across relays so resumption survives restarts
+ # --- Development-only mode ---
+ # insecure is rejected alongside cert_file/key_file/pkcs12_file/fizz.
+ insecure: true # Serve the compiled-in dev cert; no real credentials (development only)
endpoint: "/moq-relay" # WebTransport endpoint path
# moqt_versions: [14, 16] # MOQT draft versions (empty = default 14,16)
# quic: # Per-listener QUIC overrides (optional; inherits listener_defaults.quic)
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 154fd2391..25f373010 100755
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -101,7 +101,21 @@ export MOQX_PORT="${MOQX_PORT:-4433}"
export MOQX_ADMIN_PORT="${MOQX_ADMIN_PORT:-8000}"
export MOQX_CERT="${MOQX_CERT:-}"
export MOQX_KEY="${MOQX_KEY:-}"
-export MOQX_INSECURE="${MOQX_INSECURE:-false}"
+# Canonicalized before use: YAML accepts yes/on/1 as true, but the shell tests
+# below and in the picoquic block compare against the literal "true".
+case "$(printf %s "${MOQX_INSECURE:-false}" | tr '[:upper:]' '[:lower:]')" in
+ true|yes|on|1) MOQX_INSECURE=true ;;
+ false|no|off|0) MOQX_INSECURE=false ;;
+ *) echo "invalid boolean for MOQX_INSECURE (want true/false)" >&2; exit 2 ;;
+esac
+export MOQX_INSECURE
+# Both defaulted to empty above, so a value here is one the operator set.
+# Clearing it would serve the built-in dev cert instead, which is the silent
+# swap the config schema exists to refuse.
+if [ "$MOQX_INSECURE" = "true" ] && { [ -n "$MOQX_CERT" ] || [ -n "$MOQX_KEY" ]; }; then
+ echo "MOQX_INSECURE=true is mutually exclusive with MOQX_CERT/MOQX_KEY" >&2
+ exit 2
+fi
export MOQX_ENDPOINT="${MOQX_ENDPOINT:-/moq-relay}"
export MOQX_MOQT_VERSIONS="${MOQX_MOQT_VERSIONS:-[16, 14, 18]}"
export MOQX_MAX_TRACKS="${MOQX_MAX_TRACKS:-1000}"
diff --git a/docs/config.md b/docs/config.md
index a486c0d38..7d7cf83af 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -61,14 +61,80 @@ listeners:
tls:
cert_file: /etc/moqx/cert.pem
key_file: /etc/moqx/key.pem
+ # fizz: # optional; mvfst/proxygen_qmux only
+ # cert_dir: /etc/moqx/certs
+ # cert_reload_interval_s: 60
endpoint: /moq-relay
quic_stack: mvfst # optional; default mvfst
moqt_versions: [] # optional; empty = default [14, 16]
quic: { ... } # optional; overrides listener_defaults.quic
```
-**TLS:** For development only, `tls: {insecure: true}` skips certificate
-verification. This is incompatible with `quic_stack: picoquic`.
+**TLS:** `tls: {insecure: true}` is for development only.
+
+- `insecure: true` serves a compiled-in certificate and does not request a
+ client certificate.
+- `insecure: true` alongside `cert_file`, `key_file`, `pkcs12_file`, or
+ `fizz.cert_dir` is rejected at config load.
+- `insecure: true` alongside `fizz.ticket_seeds_file` or
+ `fizz.cert_reload_interval_s` warns: neither reaches the compiled-in
+ certificate path.
+- `quic_stack: picoquic` rejects `insecure: true`.
+
+**Multi-certificate / SNI (`tls.fizz`):** options for the fizz TLS stack
+(`quic_stack: mvfst` and `proxygen_qmux`). `picoquic` rejects any option set in
+the block; an empty `fizz: {}` is accepted.
+
+| Field | Default | Notes |
+|---|---|---|
+| `cert_dir` | unset | Directory of certificate pairs, `.pem` + `.key` (arbitrary basenames, non-recursive). The certificate served is selected by the client's SNI. |
+| `cert_reload_interval_s` | 60 | Seconds between background rescans of `cert_dir`. `0` = scan once at startup, never rescan. Warns when set without `cert_dir`. |
+| `ticket_seeds_file` | unset | Session-ticket seeds: one hex-encoded seed (≥64 hex chars) per line, `#` starts a comment. Read once at startup. |
+
+- Identities come from each certificate's DNS SANs, or from its CN when the
+ certificate carries no DNS SANs. Other SAN types (IP, email) are ignored.
+- Wildcards match one label: `*.example.com` covers `a.example.com`, not
+ `b.a.example.com`.
+- Nested wildcards are rejected.
+- Certificates and keys are read up front: at startup, and on the rescan
+ thread afterwards. No handshake ever waits on disk.
+- Deferring the key read to first use is not an option: fizz's cert-selection
+ hook is synchronous, so the read would land on the connection's IO thread
+ and a slow `cert_dir` mount would stall every handshake sharing that thread.
+- `cert_file`/`key_file` (or `pkcs12_file`), if also set, become the fallback
+ certificate, serving connections with no SNI or no matching identity.
+- With `cert_dir` and no fallback certificate, a connection with no SNI or no
+ matching identity fails the handshake.
+- A PKCS#12 fallback is decrypted once at config load and is never refreshed.
+- Rescans pick up added, removed, and changed pairs, and refresh a changed
+ `cert_file`/`key_file` fallback.
+- Startup is strict: an orphan `.pem`/`.key`, an unparsable certificate, a
+ duplicate identity, or a key that fails to load aborts the relay.
+- A rescan is not strict: its errors are logged, and every certificate already
+ loaded keeps serving.
+- Removing either file of a pair retires it on the next rescan; a lone `.pem`
+ or `.key` left behind is not served.
+- A changed pair that fails to load keeps serving its previous version until a
+ later rescan succeeds.
+- A newly added pair that fails to load is dropped, and its identities fall to
+ the fallback certificate or to a handshake miss.
+- A new pair claiming an identity another pair already serves is dropped with
+ a warning, and the incumbent keeps that identity until its own files are
+ removed. At startup there is no incumbent: sorted filename order decides,
+ and the duplicate is fatal.
+
+**`ticket_seeds_file`:** a moqx format, not one shared with nginx or HAProxy.
+
+- The first seed encrypts new session tickets.
+- Every listed seed still decrypts, so rotate by prepending a new seed.
+- Generate a seed with `openssl rand -hex 32`.
+- Point every relay instance at the same file so resumption survives restarts
+ and works across relays.
+- The file is read once at startup, so a rotation takes effect on restart.
+- Without the option each process uses a random seed, and resumption dies with
+ the process.
+- An empty file, a comments-only file, a non-hex line, or a seed under 32
+ bytes is a config-load error.
**moqt_versions:**: Currently supports 14 and 16.
@@ -530,7 +596,8 @@ or a restart.
| Lifecycle | When changes apply | Examples |
|---|---|---|
-| **Static** | Process restart required | listeners, admin, relay_id, TLS certificates, QUIC stack |
+| **Static** | Process restart required | listeners, admin, relay_id, `cert_file`/`key_file`/PKCS#12 certificates, QUIC stack |
+| **Automatic** | Background rescan, no reload needed | certificates under `tls.fizz.cert_dir` (see `cert_reload_interval_s`) |
| **Reload:NewConn** | New connections/sessions only | service match rules, upstream URL |
| **Reload:NewSub** | New subscriptions/tracks only | cache settings |
| **Reload:Immediate** | All connections immediately | (reserved for future fields) |
diff --git a/scripts/moqx-run.sh b/scripts/moqx-run.sh
index d7920b8bc..eb4de3df1 100755
--- a/scripts/moqx-run.sh
+++ b/scripts/moqx-run.sh
@@ -279,6 +279,12 @@ else
fi
export MOQX_INSECURE
+# The config schema rejects insecure alongside a cert source, so drop the paths
+# here rather than rendering a config the relay refuses to load.
+if [[ "$MOQX_INSECURE" == true ]]; then
+ export MOQX_CERT="" MOQX_KEY=""
+fi
+
# ── Resolve placeholders into a temp config ───────────────────────────────
# Fixed path by default; override (e.g. per perf run, to avoid concurrent
# clobber) with MOQX_RESOLVED_CONFIG.
diff --git a/src/MoqxPicoRelayServer.cpp b/src/MoqxPicoRelayServer.cpp
index 41812a3c5..d33d0c95c 100644
--- a/src/MoqxPicoRelayServer.cpp
+++ b/src/MoqxPicoRelayServer.cpp
@@ -42,7 +42,7 @@ std::string resolveCert(const config::ListenerConfig& cfg) {
if constexpr (std::is_same_v) {
return "";
} else {
- return tls.certFile;
+ return tls.tls.certFile;
}
},
cfg.tlsMode
@@ -56,7 +56,7 @@ std::string resolveKey(const config::ListenerConfig& cfg) {
if constexpr (std::is_same_v) {
return "";
} else {
- return tls.keyFile;
+ return tls.tls.keyFile;
}
},
cfg.tlsMode
diff --git a/src/MoqxQmuxRelayServer.cpp b/src/MoqxQmuxRelayServer.cpp
index 46e52a854..1f2aa9854 100644
--- a/src/MoqxQmuxRelayServer.cpp
+++ b/src/MoqxQmuxRelayServer.cpp
@@ -7,9 +7,9 @@
#include "MoqxQmuxRelayServer.h"
#include "stats/EventBaseStatsCollector.h"
+#include "tls/FizzContextBuilder.h"
#include
#include
-#include
#include
#include
@@ -26,32 +26,6 @@ std::vector buildQmuxAlpns(const std::string& versions) {
return getMoqtProtocols(versions, /*useStandard=*/true);
}
-std::shared_ptr
-buildFizzContext(const config::ListenerConfig& cfg) {
- auto alpns = buildQmuxAlpns(cfg.moqtVersions);
- return std::visit(
- [&alpns](const auto& tls) -> std::shared_ptr {
- using T = std::decay_t;
- if constexpr (std::is_same_v) {
- return quic::samples::createFizzServerContextWithInsecureDefault(
- alpns,
- fizz::server::ClientAuthMode::None,
- "",
- ""
- );
- } else {
- return quic::samples::createFizzServerContext(
- alpns,
- fizz::server::ClientAuthMode::Optional,
- tls.certFile,
- tls.keyFile
- );
- }
- },
- cfg.tlsMode
- );
-}
-
// Translate the QUIC flow-control / idle-timeout knobs into the QMUX transport
// params advertised to the peer. mvfst-only tunables don't apply over TCP.
MoQQmuxServer::Config buildQmuxConfig(const config::QuicConfig& quic) {
@@ -78,7 +52,10 @@ MoqxQmuxRelayServer::MoqxQmuxRelayServer(
)
: MoQQmuxServer(
listenerCfg.endpoint,
- buildFizzContext(listenerCfg),
+ tls::buildFizzServerContext(
+ listenerCfg.tlsMode,
+ {.alpns = buildQmuxAlpns(listenerCfg.moqtVersions)}
+ ),
buildQmuxConfig(listenerCfg.quic)
),
listenerCfg_(listenerCfg), context_(std::move(context)), ioExecutor_(ioExecutor) {
diff --git a/src/MoqxRelayServer.cpp b/src/MoqxRelayServer.cpp
index b798ba7b3..47912b015 100644
--- a/src/MoqxRelayServer.cpp
+++ b/src/MoqxRelayServer.cpp
@@ -7,24 +7,15 @@
#include "MoqxRelayServer.h"
#include "stats/EventBaseStatsCollector.h"
#include "stats/QuicStatsCollector.h"
+#include "tls/FizzContextBuilder.h"
#include
#include
#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
#include
#include
#include
-#include
-
using namespace moxygen;
namespace openmoq::moqx {
@@ -38,97 +29,6 @@ std::vector buildAlpns(const std::string& versions) {
return alpns;
}
-// Build a FizzServerContext from in-memory PEM buffers so a PKCS#12-derived key
-// never touches disk. Mirrors quic::samples::createFizzServerContextImpl (the
-// path-based sample helper) to match the PEM-file path's TLS behavior.
-// TODO(#482): replace with a buffer-based helper in the moxygen fork.
-std::shared_ptr buildFizzContextFromMaterial(
- const std::vector& alpns,
- fizz::server::ClientAuthMode clientAuth,
- const std::string& certChainPem,
- const std::string& keyPem
-) {
- std::unique_ptr cert;
- fizz::Error err;
- FIZZ_THROW_ON_ERROR(fizz::openssl::CertUtils::makeSelfCert(cert, err, certChainPem, keyPem), err);
- auto certManager = std::make_shared();
- certManager->addCertAndSetDefault(std::move(cert));
-
- auto ctx = std::make_shared();
- ctx->setCertManager(certManager);
- auto ticketCipher = std::make_shared>>(
- ctx->getFactoryPtr(),
- std::move(certManager)
- );
- std::array ticketSeed;
- folly::Random::secureRandom(ticketSeed.data(), ticketSeed.size());
- ticketCipher->setTicketSecrets({{folly::range(ticketSeed)}});
- ctx->setTicketCipher(ticketCipher);
- ctx->setClientAuthMode(clientAuth);
- ctx->setSupportedAlpns(alpns);
- ctx->setAlpnMode(fizz::server::AlpnMode::Required);
- ctx->setSendNewSessionTicket(true);
- ctx->setEarlyDataFbOnly(false);
- ctx->setVersionFallbackEnabled(false);
-
- fizz::server::ClockSkewTolerance tolerance;
- tolerance.before = std::chrono::minutes(-5);
- tolerance.after = std::chrono::minutes(5);
- std::shared_ptr replayCache =
- std::make_shared();
- ctx->setEarlyDataSettings(true, tolerance, std::move(replayCache));
- return ctx;
-}
-
-std::shared_ptr
-buildFizzContext(const config::ListenerConfig& cfg) {
- auto alpns = buildAlpns(cfg.moqtVersions);
- return std::visit(
- [&alpns](const auto& tls) -> std::shared_ptr {
- using T = std::decay_t;
- if constexpr (std::is_same_v) {
- return quic::samples::createFizzServerContextWithInsecureDefault(
- alpns,
- fizz::server::ClientAuthMode::None,
- "",
- ""
- );
- } else {
- // PKCS#12 source: build the context from the in-memory PEM material
- // (cert/key never written to disk).
- if (tls.material.has_value()) {
- return buildFizzContextFromMaterial(
- alpns,
- fizz::server::ClientAuthMode::Optional,
- tls.material->certChainPem,
- tls.material->keyPem
- );
- }
- // createFizzServerContext throws (deep in fizz) when the cert/key
- // can't be read or contain no certificate. Enrich the message with
- // the offending paths so the caller can report it cleanly instead of
- // letting an opaque "no certificates read" escape to std::terminate.
- try {
- return quic::samples::createFizzServerContext(
- alpns,
- fizz::server::ClientAuthMode::Optional,
- tls.certFile,
- tls.keyFile
- );
- } catch (const std::exception& e) {
- throw std::runtime_error(
- "failed to load TLS certificate/key (cert='" + tls.certFile + "', key='" +
- tls.keyFile + "'): " + e.what() +
- " - check the paths exist and are readable by this process"
- );
- }
- }
- },
- cfg.tlsMode
- );
-}
-
quic::TransportSettings
buildTransportSettings(const config::QuicConfig& quic, const config::MvfstConfig& mvfst) {
// Start with MoQServer's optimized defaults, then apply config overrides.
@@ -211,7 +111,10 @@ MoqxRelayServer::MoqxRelayServer(
folly::IOThreadPoolExecutor* ioExecutor
)
: MoQServer(
- buildFizzContext(listenerCfg),
+ tls::buildFizzServerContext(
+ listenerCfg.tlsMode,
+ {.alpns = buildAlpns(listenerCfg.moqtVersions)}
+ ),
listenerCfg.endpoint,
MoQServer::Options{
.transportSettings = buildTransportSettings(listenerCfg.quic, listenerCfg.mvfst),
diff --git a/src/config/Config.h b/src/config/Config.h
index 1da4bfd07..6ce4ece29 100644
--- a/src/config/Config.h
+++ b/src/config/Config.h
@@ -33,6 +33,14 @@ struct TlsMaterial {
std::string keyPem; // unencrypted private key PEM
};
+// SNI multi-cert source: a directory of .pem/.key pairs, scanned
+// for identities (DNS SANs; the CN only when a cert has none) and loaded up
+// front on the scanning thread. Fizz stack only.
+struct CertDirConfig {
+ std::string dir;
+ std::chrono::seconds reloadInterval{60}; // 0 = scan once at startup, never rescan
+};
+
struct TlsConfig {
std::string certFile;
std::string keyFile;
@@ -43,9 +51,21 @@ struct TlsConfig {
std::optional material;
};
+// Listener TLS: the shared TLS source plus fizz-stack-only options.
+struct ListenerTlsConfig {
+ TlsConfig tls;
+ // When set, certs are served by SNI from this directory; tls.certFile/keyFile
+ // or tls.material, if also present, act as the fallback cert.
+ std::optional certDir;
+ // Decoded session-ticket seeds, raw bytes, each at least 32 bytes; the first
+ // encrypts new tickets, all decrypt. Secret material: never log or serialize.
+ // Empty = a random per-process seed (resumption dies with the process).
+ std::vector ticketSeeds;
+};
+
struct Insecure {};
-using TlsMode = std::variant;
+using TlsMode = std::variant;
struct CacheConfig {
size_t maxCachedTracks; // 0 when cache disabled
diff --git a/src/config/ConfigResolver.cpp b/src/config/ConfigResolver.cpp
index 9a2309efe..620873fe3 100644
--- a/src/config/ConfigResolver.cpp
+++ b/src/config/ConfigResolver.cpp
@@ -109,6 +109,23 @@ void validatePkcs12PasswordExclusivity(
}
}
+bool hasCertDir(const ParsedListenerTlsConfig& tls) {
+ const auto& fizz = tls.fizz.value();
+ return fizz.has_value() && fizz->cert_dir.value().has_value() && !fizz->cert_dir.value()->empty();
+}
+
+// At least one fizz option actually set; a bare `fizz: {}` block is inert and
+// must not trip the rejections/warnings that gate fizz-only behavior.
+bool hasFizzOptions(const ParsedListenerTlsConfig& tls) {
+ const auto& fizz = tls.fizz.value();
+ if (!fizz.has_value()) {
+ return false;
+ }
+ const auto& seeds = fizz->ticket_seeds_file.value();
+ return hasCertDir(tls) || fizz->cert_reload_interval_s.value().has_value() ||
+ (seeds.has_value() && !seeds->empty());
+}
+
void validateListenerTlsConfig(
const ParsedListenerTlsConfig& tls,
std::string_view context,
@@ -118,16 +135,70 @@ void validateListenerTlsConfig(
bool hasCert = tls.cert_file.value().has_value() && !tls.cert_file.value()->empty();
bool hasKey = tls.key_file.value().has_value() && !tls.key_file.value()->empty();
bool hasPkcs12 = tls.pkcs12_file.value().has_value() && !tls.pkcs12_file.value()->empty();
+ bool hasDir = hasCertDir(tls);
if (tls.insecure.value()) {
- if (hasCert || hasKey || hasPkcs12) {
- warnings.push_back(
- std::string(context) + ": cert_file/key_file/pkcs12_file are ignored when insecure=true"
+ // Rejected rather than ignored: silently dropping real credentials in
+ // favor of the compiled-in dev cert is the kind of mistake that only
+ // shows up in production traffic.
+ std::vector certSources;
+ if (hasCert) {
+ certSources.emplace_back("cert_file");
+ }
+ if (hasKey) {
+ certSources.emplace_back("key_file");
+ }
+ if (hasPkcs12) {
+ certSources.emplace_back("pkcs12_file");
+ }
+ if (hasDir) {
+ certSources.emplace_back("fizz.cert_dir");
+ }
+ if (!certSources.empty()) {
+ errors.push_back(
+ std::string(context) + ": insecure=true is mutually exclusive with " +
+ folly::join("/", certSources)
);
}
+ // The rest of the fizz block costs only resumption sharing and reloads,
+ // so it warns instead: insecure serves the compiled-in cert from the
+ // sample context, which no fizz option reaches (see FizzContextBuilder.h).
+ if (tls.fizz.value().has_value()) {
+ const auto& fizz = *tls.fizz.value();
+ const auto& seeds = fizz.ticket_seeds_file.value();
+ if (seeds.has_value() && !seeds->empty()) {
+ warnings.push_back(
+ std::string(context) + ": fizz.ticket_seeds_file has no effect with insecure=true"
+ );
+ }
+ if (fizz.cert_reload_interval_s.value().has_value()) {
+ warnings.push_back(
+ std::string(context) + ": fizz.cert_reload_interval_s has no effect with insecure=true"
+ );
+ }
+ }
return;
}
+ if (!hasDir && tls.fizz.value().has_value() &&
+ tls.fizz.value()->cert_reload_interval_s.value().has_value()) {
+ warnings.push_back(
+ std::string(context) + ": fizz.cert_reload_interval_s has no effect without fizz.cert_dir"
+ );
+ }
+
+ // Existence check only; the full directory scan runs at server construction.
+ if (hasDir) {
+ const auto& dir = *tls.fizz.value()->cert_dir.value();
+ std::error_code ec;
+ if (!std::filesystem::is_directory(dir, ec)) {
+ errors.push_back(
+ std::string(context) + ": fizz.cert_dir '" + dir +
+ "' does not exist or is not a directory"
+ );
+ }
+ }
+
if (hasPkcs12) {
if (hasCert || hasKey) {
errors.push_back(
@@ -135,10 +206,15 @@ void validateListenerTlsConfig(
);
}
validatePkcs12PasswordExclusivity(tls, context, errors);
- } else if (!hasCert || !hasKey) {
+ } else if (hasCert != hasKey) {
+ // A half-configured fallback pair must error even when cert_dir already
+ // satisfies the required-source check.
+ errors.push_back(std::string(context) + ": cert_file and key_file must be set together");
+ } else if (!hasDir && !hasCert) {
+ // hasCert == hasKey here (the != case errored above).
errors.push_back(
- std::string(context) +
- ": cert_file and key_file (or pkcs12_file) are required when insecure=false"
+ std::string(context) + ": cert_file and key_file (or pkcs12_file, or fizz.cert_dir) are "
+ "required when insecure=false"
);
}
}
@@ -157,14 +233,29 @@ void validateAdminTlsConfig(const ParsedAdminTlsConfig& tls, std::vector material) {
+ListenerTlsConfig resolveTlsConfig(
+ const ParsedListenerTlsConfig& tls,
+ std::optional material,
+ std::vector ticketSeeds
+) {
const bool hasMaterial = material.has_value();
- return TlsConfig{
- .certFile = hasMaterial ? std::string{} : tls.cert_file.value().value_or(""),
- .keyFile = hasMaterial ? std::string{} : tls.key_file.value().value_or(""),
- .alpn = {},
- .material = std::move(material),
+ std::optional certDir;
+ if (hasCertDir(tls)) {
+ certDir = CertDirConfig{.dir = *tls.fizz.value()->cert_dir.value()};
+ if (const auto& interval = tls.fizz.value()->cert_reload_interval_s.value()) {
+ certDir->reloadInterval = std::chrono::seconds(*interval);
+ }
+ }
+ return ListenerTlsConfig{
+ .tls =
+ TlsConfig{
+ .certFile = hasMaterial ? std::string{} : tls.cert_file.value().value_or(""),
+ .keyFile = hasMaterial ? std::string{} : tls.key_file.value().value_or(""),
+ .alpn = {},
+ .material = std::move(material),
+ },
+ .certDir = std::move(certDir),
+ .ticketSeeds = std::move(ticketSeeds),
};
}
@@ -222,6 +313,74 @@ folly::Expected resolvePkcs12Password(
return folly::makeExpected(std::string{});
}
+// Read and decode a ticket_seeds_file: one hex seed per line, '#' comments.
+// Fizz rejects the whole set if any seed is under 32 bytes, which is why the
+// length is validated per line here.
+folly::Expected, std::string> resolveTicketSeeds(const std::string& path) {
+ std::string contents;
+ if (!folly::readFile(path.c_str(), contents)) {
+ return folly::makeUnexpected("failed to read ticket_seeds_file '" + path + "'");
+ }
+
+ auto parse = [&]() -> folly::Expected, std::string> {
+ std::vector seeds;
+ std::vector lines;
+ folly::split('\n', contents, lines);
+ for (size_t i = 0; i < lines.size(); ++i) {
+ auto line = lines[i];
+ if (auto hash = line.find('#'); hash != folly::StringPiece::npos) {
+ line = line.subpiece(0, hash);
+ }
+ auto trimmed = folly::trimWhitespace(line);
+ if (trimmed.empty()) {
+ continue;
+ }
+ std::string decoded;
+ if (!folly::unhexlify(trimmed, decoded)) {
+ return folly::makeUnexpected(
+ "ticket_seeds_file '" + path + "' line " + std::to_string(i + 1) +
+ ": not a hex-encoded seed"
+ );
+ }
+ if (decoded.size() < 32) {
+ return folly::makeUnexpected(
+ "ticket_seeds_file '" + path + "' line " + std::to_string(i + 1) +
+ ": seed must be at least 32 bytes (64 hex characters)"
+ );
+ }
+ seeds.push_back(std::move(decoded));
+ }
+ if (seeds.empty()) {
+ return folly::makeUnexpected("ticket_seeds_file '" + path + "' contains no seeds");
+ }
+ return folly::makeExpected(std::move(seeds));
+ };
+
+ auto result = parse();
+ OPENSSL_cleanse(contents.data(), contents.size());
+ return result;
+}
+
+// Ticket seeds for a listener, from fizz.ticket_seeds_file. Empty when the
+// option is absent; on failure, pushes a context-prefixed error.
+std::vector resolveListenerTicketSeeds(
+ const ParsedListenerTlsConfig& tls,
+ const std::string& context,
+ std::vector& errors
+) {
+ const auto& fizz = tls.fizz.value();
+ if (!fizz.has_value() || !fizz->ticket_seeds_file.value().has_value() ||
+ fizz->ticket_seeds_file.value()->empty()) {
+ return {};
+ }
+ auto seeds = resolveTicketSeeds(*fizz->ticket_seeds_file.value());
+ if (seeds.hasError()) {
+ errors.push_back(context + ": " + seeds.error());
+ return {};
+ }
+ return std::move(*seeds);
+}
+
// Transcode the configured PKCS#12 bundle into in-memory PEM material. Returns
// nullopt when no pkcs12_file is set. On any failure, pushes a descriptive error
// (prefixed with context) and returns nullopt. The resolved password copy is
@@ -387,6 +546,12 @@ void validateListener(
"': quic_stack \"picoquic\" does not support pkcs12_file yet; use cert_file/key_file"
);
}
+ if (stackOpt.value_or(kStackMvfst) == kStackPicoquic && hasFizzOptions(listener.tls.value())) {
+ errors.push_back(
+ "Listener '" + listener.name.value() +
+ "': quic_stack \"picoquic\" does not support tls.fizz options; use cert_file/key_file"
+ );
+ }
}
// --- Admin validation ---
@@ -1075,7 +1240,8 @@ ListenerConfig resolveListener(
const ParsedListenerConfig& listener,
const QuicConfig& quic,
const MvfstConfig& mvfst,
- std::optional material
+ std::optional material,
+ std::vector ticketSeeds
) {
const auto& sock = listener.udp.value().socket.value();
const auto& tls = listener.tls.value();
@@ -1084,7 +1250,7 @@ ListenerConfig resolveListener(
if (tls.insecure.value()) {
tlsMode = Insecure{};
} else {
- tlsMode = resolveTlsConfig(tls, std::move(material));
+ tlsMode = resolveTlsConfig(tls, std::move(material), std::move(ticketSeeds));
}
const auto& stackStr = listener.quic_stack.value().value_or(kStackMvfst);
@@ -1176,13 +1342,16 @@ folly::Expected resolveConfig(const ParsedConfig& c
std::vector mergedQuicConfigs;
std::vector mergedMvfstConfigs;
std::vector> listenerTlsMaterials;
+ std::vector> listenerTicketSeeds;
{
std::unordered_set listenerAddrs;
for (const auto& listener : config.listeners.value()) {
validateListener(listener, errors, warnings);
- // Transcode any PKCS#12 bundle now (skipped for insecure listeners) so a
- // bad password/file surfaces as a load-time config error.
+ // Transcode any PKCS#12 bundle and read any ticket-seeds file now
+ // (skipped for insecure listeners) so a bad password/file surfaces as a
+ // load-time config error.
std::optional material;
+ std::vector ticketSeeds;
if (!listener.tls.value().insecure.value()) {
material = resolvePkcs12Material(
listener.tls.value(),
@@ -1190,8 +1359,14 @@ folly::Expected resolveConfig(const ParsedConfig& c
errors,
warnings
);
+ ticketSeeds = resolveListenerTicketSeeds(
+ listener.tls.value(),
+ "Listener '" + listener.name.value() + "'",
+ errors
+ );
}
listenerTlsMaterials.push_back(std::move(material));
+ listenerTicketSeeds.push_back(std::move(ticketSeeds));
auto addr = listener.udp.value().socket.value().address.value() + ":" +
std::to_string(listener.udp.value().socket.value().port.value());
if (!listenerAddrs.insert(addr).second) {
@@ -1427,7 +1602,8 @@ folly::Expected resolveConfig(const ParsedConfig& c
listeners[i],
mergedQuicConfigs[i],
mergedMvfstConfigs[i],
- std::move(listenerTlsMaterials[i])
+ std::move(listenerTlsMaterials[i]),
+ std::move(listenerTicketSeeds[i])
));
}
return v;
diff --git a/src/config/ConfigSerializer.h b/src/config/ConfigSerializer.h
index 7c04312e4..b0df9621c 100644
--- a/src/config/ConfigSerializer.h
+++ b/src/config/ConfigSerializer.h
@@ -86,8 +86,23 @@ inline void serializeTls(ConfigSink& s, const TlsConfig& tls) {
inline void serializeListenerTls(ConfigSink& s, const TlsMode& mode) {
s.beginObject("tls");
- if (const auto* tls = std::get_if(&mode)) {
- serializeTls(s, *tls);
+ if (const auto* tls = std::get_if(&mode)) {
+ serializeTls(s, tls->tls);
+ if (tls->certDir.has_value() || !tls->ticketSeeds.empty()) {
+ s.beginObject("fizz");
+ if (tls->certDir.has_value()) {
+ s.stringField("cert_dir", tls->certDir->dir);
+ s.uintField(
+ "cert_reload_interval_s",
+ static_cast(tls->certDir->reloadInterval.count())
+ );
+ }
+ // Seeds are secret material; expose only how many are loaded.
+ s.uintField("ticket_seed_count", tls->ticketSeeds.size());
+ s.endObject();
+ } else {
+ s.nullField("fizz");
+ }
} else {
s.boolField("insecure", true);
}
diff --git a/src/config/loader/ParsedConfig.h b/src/config/loader/ParsedConfig.h
index 5966d092e..49191df2c 100644
--- a/src/config/loader/ParsedConfig.h
+++ b/src/config/loader/ParsedConfig.h
@@ -32,6 +32,32 @@ struct ParsedUdpConfig {
rfl::Description<"Socket configuration", ParsedSocketConfig> socket;
};
+// Options specific to the fizz TLS stack (quic_stack mvfst/proxygen_qmux).
+struct ParsedFizzTlsConfig {
+ rfl::Description<
+ "Directory of certificate pairs (.pem + .key). Certificates are "
+ "selected by SNI against their DNS SANs (the CN only when a certificate has "
+ "none; wildcards match one label) and loaded up front, at startup and on "
+ "each rescan. cert_file/key_file or pkcs12_file, if also set, become the "
+ "fallback certificate for absent/unmatched SNI.",
+ std::optional>
+ cert_dir;
+ rfl::Description<
+ "Seconds between background rescans of cert_dir (picks up new, removed, and "
+ "changed pairs). 0 = scan once at startup, never rescan. Default 60.",
+ std::optional>
+ cert_reload_interval_s;
+ rfl::Description<
+ "Path to a session-ticket seeds file: one hex-encoded seed (at least 64 hex "
+ "chars) per line, '#' starts a comment; a file yielding no seeds is an error. "
+ "The first seed encrypts new tickets; every listed seed still decrypts, so "
+ "rotate by prepending a line and restarting. Share one file across relay "
+ "instances so TLS resumption survives restarts and works across relays. "
+ "Absent: a random per-process seed.",
+ std::optional>
+ ticket_seeds_file;
+};
+
struct ParsedListenerTlsConfig {
rfl::Description<"Path to TLS certificate file", std::optional> cert_file;
rfl::Description<"Path to TLS private key file", std::optional> key_file;
@@ -54,7 +80,17 @@ struct ParsedListenerTlsConfig {
"friendly: the secret stays out of the config file). Errors if the variable is unset.",
std::optional>
pkcs12_password_env;
- rfl::Description<"Insecure mode, use default compiled-in cert", bool> insecure;
+ rfl::Description<
+ "Fizz-stack TLS options (quic_stack mvfst/proxygen_qmux only; picoquic "
+ "rejects them)",
+ std::optional>
+ fizz;
+ rfl::Description<
+ "Development only: serve the compiled-in certificate and drop client "
+ "verification. Rejected alongside cert_file/key_file/pkcs12_file/fizz, and "
+ "on quic_stack picoquic.",
+ bool>
+ insecure;
};
struct ParsedAdminTlsConfig {
diff --git a/src/tls/CertDirScanner.cpp b/src/tls/CertDirScanner.cpp
new file mode 100644
index 000000000..95d6de927
--- /dev/null
+++ b/src/tls/CertDirScanner.cpp
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) OpenMOQ contributors.
+ * This source code is licensed under the Apache 2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+#include "tls/CertDirScanner.h"
+
+#include
+#include