diff --git a/CMakeLists.txt b/CMakeLists.txt index 67b71755..f0422c42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,12 @@ add_library(moqx_core STATIC src/stats/PicoQuicStatsCollector.cpp src/stats/QuicStatsCollector.cpp src/UpstreamProvider.cpp + src/tls/TlsCertLoader.cpp + src/tls/InsecureCertProvider.cpp + src/tls/FileCertLoader.cpp + src/tls/DirectoryCertLoader.cpp + src/tls/FizzContextFactory.cpp + src/tls/BuiltinTlsProviders.cpp ) target_include_directories(moqx_core @@ -93,17 +99,21 @@ target_include_directories(moqx_core ${PROJECT_SOURCE_DIR}/src ) -target_link_libraries(moqx_core PUBLIC - moxygen::moxygen_relay_moq_forwarder - moxygen::moxygen_relay_moq_cache - moxygen::moxygen_moq_relay_session - moxygen::moxygen_moq_server - moxygen::openmoq_pico_evb_server_transport - proxygen::proxygenhttpserver - # Workaround: wangle::wangle_acceptor_acceptor_core uses AsyncFdSocket from - # FizzAcceptorHandshakeHelper but omits this dep from its cmake config. - Folly::folly_io_async_fdsock_async_fd_socket - moxygen::moxygen_moqclient +target_link_libraries(moqx_core + PUBLIC + moxygen::moxygen_relay_moq_forwarder + moxygen::moxygen_relay_moq_cache + moxygen::moxygen_moq_relay_session + moxygen::moxygen_moq_server + moxygen::openmoq_pico_evb_server_transport + proxygen::proxygenhttpserver + # Workaround: wangle::wangle_acceptor_acceptor_core uses AsyncFdSocket from + # FizzAcceptorHandshakeHelper but omits this dep from its cmake config. + Folly::folly_io_async_fdsock_async_fd_socket + moxygen::moxygen_moqclient + PRIVATE + # builtin_tls_providers.cpp includes parsed_config.h (rfl types) + reflectcpp ) target_compile_options(moqx_core PRIVATE -Wall -Wextra -Wpedantic) @@ -133,6 +143,7 @@ add_library(moqx_config_loader STATIC src/config/Loader.cpp src/config/ConfigResolver.cpp src/config/ConfigInit.cpp + src/tls/TlsProviderRegistry.cpp ) target_include_directories(moqx_config_loader diff --git a/config.example.yaml b/config.example.yaml index 46479f3f..d0777386 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -8,9 +8,15 @@ 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 - insecure: true # Skip TLS for local development + type: insecure # Skip TLS for local development + # --- OR single cert+key pair --- + # type: file + # cert_file: /path/to/cert.pem + # key_file: /path/to/key.pem + # --- OR directory with multiple certs (SNI selection) --- + # type: directory + # cert_dir: /etc/ssl/certs.d/ # contains .crt + .key pairs + # default_cert: example.com # optional: SNI identity for default fallback endpoint: "/moq-relay" # WebTransport endpoint path # moqt_versions: [14, 16] # MOQT draft versions (empty = all supported) # quic: # Per-listener QUIC overrides (optional; inherits listener_defaults.quic) diff --git a/src/MoqxPicoRelayServer.cpp b/src/MoqxPicoRelayServer.cpp index 64993cba..71d4b46d 100644 --- a/src/MoqxPicoRelayServer.cpp +++ b/src/MoqxPicoRelayServer.cpp @@ -7,6 +7,7 @@ #include "MoqxPicoRelayServer.h" #include "stats/PicoQuicStatsCollector.h" +#include "tls/TlsCertLoader.h" #include #include #include @@ -35,31 +36,21 @@ moxygen::PicoTransportConfig picoTransportConfigFromQuicConfig(const config::Qui } std::string resolveCert(const config::ListenerConfig& cfg) { - return std::visit( - [](const auto& tls) -> std::string { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return ""; - } else { - return tls.certFile; - } - }, - cfg.tlsMode - ); + auto result = cfg.tlsProvider->getCertPath(); + if (result.hasError()) { + XLOG(FATAL) << "Unable to get certificate path for picoquic: " << result.error(); + } + + return result.value(); } std::string resolveKey(const config::ListenerConfig& cfg) { - return std::visit( - [](const auto& tls) -> std::string { - using T = std::decay_t; - if constexpr (std::is_same_v) { - return ""; - } else { - return tls.keyFile; - } - }, - cfg.tlsMode - ); + auto result = cfg.tlsProvider->getKeyPath(); + if (result.hasError()) { + XLOG(FATAL) << "Unable to get key path for picoquic: " << result.error(); + } + + return result.value(); } } // namespace diff --git a/src/MoqxRelayServer.cpp b/src/MoqxRelayServer.cpp index 35d520fa..41eb94cb 100644 --- a/src/MoqxRelayServer.cpp +++ b/src/MoqxRelayServer.cpp @@ -20,39 +20,6 @@ namespace openmoq::moqx { namespace { -std::vector buildAlpns(const std::string& versions) { - std::vector alpns = {"h3"}; - auto moqt = getMoqtProtocols(versions, true); - alpns.insert(alpns.end(), moqt.begin(), moqt.end()); - return alpns; -} - -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 { - return quic::samples::createFizzServerContext( - alpns, - fizz::server::ClientAuthMode::Optional, - tls.certFile, - tls.keyFile - ); - } - }, - cfg.tlsMode - ); -} - quic::TransportSettings buildTransportSettings(const config::QuicConfig& quic) { // Start with MoQServer's optimized defaults, then apply config overrides. quic::TransportSettings ts; @@ -91,10 +58,11 @@ quic::TransportSettings buildTransportSettings(const config::QuicConfig& quic) { MoqxRelayServer::MoqxRelayServer( const config::ListenerConfig& listenerCfg, std::shared_ptr context, + std::shared_ptr fizzContext, std::shared_ptr ioExecutor ) : MoQServer( - buildFizzContext(listenerCfg), + std::move(fizzContext), listenerCfg.endpoint, buildTransportSettings(listenerCfg.quic) ), diff --git a/src/MoqxRelayServer.h b/src/MoqxRelayServer.h index 09f5bf9d..1b054963 100644 --- a/src/MoqxRelayServer.h +++ b/src/MoqxRelayServer.h @@ -21,6 +21,7 @@ class MoqxRelayServer : public moxygen::MoQServer { MoqxRelayServer( const config::ListenerConfig& listenerCfg, std::shared_ptr context, + std::shared_ptr fizzContext, std::shared_ptr ioExecutor ); diff --git a/src/MoqxServerFactory.h b/src/MoqxServerFactory.h index 4b01e181..e46a1160 100644 --- a/src/MoqxServerFactory.h +++ b/src/MoqxServerFactory.h @@ -13,6 +13,8 @@ #include "MoqxRelayServer.h" #include "config/Config.h" #include "stats/StatsRegistry.h" +#include "tls/FizzContextFactory.h" +#include "tls/TlsCertLoader.h" #include #include namespace openmoq::moqx { @@ -34,8 +36,19 @@ inline std::shared_ptr makeRelayServer( server->setStatsRegistry(std::move(statsRegistry)); return server; } - auto server = - std::make_shared(listenerCfg, std::move(context), std::move(ioExecutor)); + + auto alpns = openmoq::moqx::tls::buildAlpns(listenerCfg.moqtVersions); + auto fizzCtx = listenerCfg.tlsProvider->createContext(alpns); + if (fizzCtx.hasError()) { + XLOG(FATAL) << "Failed to create TLS context: " << fizzCtx.error(); + } + + auto server = std::make_shared( + listenerCfg, + std::move(context), + std::move(fizzCtx).value(), + std::move(ioExecutor) + ); server->setStatsRegistry(std::move(statsRegistry)); return server; } diff --git a/src/config/Config.h b/src/config/Config.h index a05e674b..45b73eed 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -17,6 +17,10 @@ #include #include +namespace openmoq::moqx::tls { +class TlsCertProvider; +} // namespace openmoq::moqx::tls + namespace openmoq::moqx::config { struct TlsConfig { @@ -25,10 +29,6 @@ struct TlsConfig { std::vector alpn; // must be empty for QUIC listener: ALPN derived from moqt_versions }; -struct Insecure {}; - -using TlsMode = std::variant; - struct CacheConfig { size_t maxCachedTracks; // 0 when cache disabled size_t maxCachedGroupsPerTrack; @@ -60,7 +60,7 @@ struct QuicConfig { struct ListenerConfig { std::string name; folly::SocketAddress address; - TlsMode tlsMode; + std::shared_ptr tlsProvider; std::string endpoint; std::string moqtVersions; // comma-separated string QuicStack quicStack{QuicStack::Mvfst}; diff --git a/src/config/ConfigInit.cpp b/src/config/ConfigInit.cpp index 3a14515a..66ec6d05 100644 --- a/src/config/ConfigInit.cpp +++ b/src/config/ConfigInit.cpp @@ -25,7 +25,8 @@ folly::Expected handleConfigSubcommand( std::string_view subcommand, std::string_view configPath, bool strictConfig, - const char* programName + const char* programName, + const tls::TlsProviderRegistry& registry ) { if (subcommand == kDumpConfigSchemaCommand) { std::cout << generateSchema() << '\n'; @@ -46,7 +47,7 @@ folly::Expected handleConfigSubcommand( return folly::makeUnexpected(1); } - auto result = resolveConfig(parsed); + auto result = resolveConfig(parsed, registry); if (result.hasError()) { std::cerr << "Error: " << result.error() << std::endl; return folly::makeUnexpected(1); diff --git a/src/config/ConfigResolver.cpp b/src/config/ConfigResolver.cpp index 20c29da6..d472b53a 100644 --- a/src/config/ConfigResolver.cpp +++ b/src/config/ConfigResolver.cpp @@ -14,6 +14,8 @@ #include +#include "tls/TlsProviderRegistry.h" + namespace openmoq::moqx::config { namespace { @@ -58,27 +60,6 @@ mergeCacheConfigs(const ParsedCacheConfig& base, const ParsedCacheConfig& overla return merged; } -void validateListenerTlsConfig( - const ParsedListenerTlsConfig& tls, - std::string_view context, - std::vector& errors, - std::vector& warnings -) { - 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(); - if (!tls.insecure.value()) { - if (!hasCert || !hasKey) { - errors.push_back( - std::string(context) + ": cert_file and key_file are required when insecure=false" - ); - } - } else if (hasCert || hasKey) { - warnings.push_back( - std::string(context) + ": cert_file/key_file are ignored when insecure=true" - ); - } -} - void validateAdminTlsConfig(const ParsedAdminTlsConfig& tls, std::vector& errors) { 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(); @@ -87,14 +68,6 @@ void validateAdminTlsConfig(const ParsedAdminTlsConfig& tls, std::vector& defaultAlpn @@ -154,21 +127,29 @@ std::string makeCompositeKey( void validateListener( const ParsedListenerConfig& listener, - std::vector& errors, - std::vector& warnings + const tls::TlsProviderRegistry& registry, + std::vector& errors ) { const auto& sock = listener.udp.value().socket.value(); if (sock.port.value() == 0) { errors.push_back("Listener '" + listener.name.value() + "' port must be 1-65535, got 0"); } - // TLS validation - validateListenerTlsConfig( - listener.tls.value(), - "Listener '" + listener.name.value() + "'", - errors, - warnings - ); + listener.tls.visit([&](const auto& variant) { + using T = std::decay_t; + auto type = typename T::Tag{}.name(); + + auto* factory = registry.getFactory(type); + if (!factory) { + errors.push_back("Listener '" + listener.name.value() + "': unknown TLS type '" + type + "'"); + return; + } + auto result = (*factory)(listener.tls); + if (result.hasError()) { + errors.push_back("Listener '" + listener.name.value() + "': " + result.error()); + return; + } + }); // quic_stack validation const auto& stackOpt = listener.quic_stack.value(); @@ -178,7 +159,8 @@ void validateListener( "' (expected \"mvfst\" or \"picoquic\")" ); } - if (stackOpt.value_or(kStackMvfst) == kStackPicoquic && listener.tls.value().insecure.value()) { + if (stackOpt.value_or(kStackMvfst) == kStackPicoquic && + rfl::holds_alternative(listener.tls.variant())) { errors.push_back( "Listener '" + listener.name.value() + "': quic_stack \"picoquic\" requires real TLS credentials (insecure: true is not supported)" @@ -538,16 +520,29 @@ void validatePicoServicePaths( } } -ListenerConfig resolveListener(const ParsedListenerConfig& listener, const QuicConfig& quic) { +ListenerConfig resolveListener( + const ParsedListenerConfig& listener, + const QuicConfig& quic, + const tls::TlsProviderRegistry& registry +) { const auto& sock = listener.udp.value().socket.value(); - const auto& tls = listener.tls.value(); - TlsMode tlsMode; - if (tls.insecure.value()) { - tlsMode = Insecure{}; - } else { - tlsMode = resolveTlsConfig(tls); - } + // TLS: extract type string from variant, look up factory, invoke it + std::shared_ptr tlsProvider; + listener.tls.visit([&](const auto& variant) { + using T = std::decay_t; + auto type = typename T::Tag{}.name(); + + auto* factory = registry.getFactory(type); + if (!factory) { + return; + } + auto result = (*factory)(listener.tls); + if (result.hasError()) { + return; + } + tlsProvider = std::move(result.value()); + }); const auto& stackStr = listener.quic_stack.value().value_or(kStackMvfst); auto quicStack = (stackStr == kStackPicoquic) ? QuicStack::Picoquic : QuicStack::Mvfst; @@ -555,7 +550,7 @@ ListenerConfig resolveListener(const ParsedListenerConfig& listener, const QuicC return ListenerConfig{ .name = listener.name.value(), .address = folly::SocketAddress(sock.address.value(), sock.port.value()), - .tlsMode = std::move(tlsMode), + .tlsProvider = std::move(tlsProvider), .endpoint = listener.endpoint.value(), .moqtVersions = moqtVersionsToString(listener), .quicStack = quicStack, @@ -611,7 +606,8 @@ ServiceConfig resolveService(const ParsedServiceConfig& svc, const ParsedCacheCo } // namespace -folly::Expected resolveConfig(const ParsedConfig& config) { +folly::Expected +resolveConfig(const ParsedConfig& config, const tls::TlsProviderRegistry& registry) { std::vector errors; std::vector warnings; @@ -631,7 +627,7 @@ folly::Expected resolveConfig(const ParsedConfig& c { std::unordered_set listenerAddrs; for (const auto& listener : config.listeners.value()) { - validateListener(listener, errors, warnings); + validateListener(listener, registry, errors); auto addr = listener.udp.value().socket.value().address.value() + ":" + std::to_string(listener.udp.value().socket.value().port.value()); if (!listenerAddrs.insert(addr).second) { @@ -724,7 +720,7 @@ folly::Expected resolveConfig(const ParsedConfig& c v.reserve(config.listeners.value().size()); const auto& listeners = config.listeners.value(); for (size_t i = 0; i < listeners.size(); ++i) { - v.push_back(resolveListener(listeners[i], mergedQuicConfigs[i])); + v.push_back(resolveListener(listeners[i], mergedQuicConfigs[i], registry)); } return v; }(), diff --git a/src/config/loader/ConfigInit.h b/src/config/loader/ConfigInit.h index a91ed599..79f8c785 100644 --- a/src/config/loader/ConfigInit.h +++ b/src/config/loader/ConfigInit.h @@ -13,6 +13,10 @@ #include "config/ResolvedConfig.h" +namespace openmoq::moqx::tls { +class TlsProviderRegistry; +} // namespace openmoq::moqx::tls + namespace openmoq::moqx::config { constexpr std::string_view kDumpConfigSchemaCommand = "dump-config-schema"; @@ -27,7 +31,8 @@ folly::Expected handleConfigSubcommand( std::string_view subcommand, std::string_view configPath, bool strictConfig, - const char* programName + const char* programName, + const tls::TlsProviderRegistry& registry ); } // namespace openmoq::moqx::config diff --git a/src/config/loader/ConfigResolver.h b/src/config/loader/ConfigResolver.h index 28ff8254..fe378746 100644 --- a/src/config/loader/ConfigResolver.h +++ b/src/config/loader/ConfigResolver.h @@ -13,11 +13,16 @@ #include "config/ResolvedConfig.h" #include "config/loader/ParsedConfig.h" +namespace openmoq::moqx::tls { +class TlsProviderRegistry; +} // namespace openmoq::moqx::tls + namespace openmoq::moqx::config { /// Validate and resolve a ParsedConfig into concrete Config types. /// Returns warnings alongside the config on success. /// On validation failure, returns a combined error string. -folly::Expected resolveConfig(const ParsedConfig& config); +folly::Expected +resolveConfig(const ParsedConfig& config, const tls::TlsProviderRegistry& registry); } // namespace openmoq::moqx::config diff --git a/src/config/loader/ParsedConfig.h b/src/config/loader/ParsedConfig.h index c4a68c57..b14c5477 100644 --- a/src/config/loader/ParsedConfig.h +++ b/src/config/loader/ParsedConfig.h @@ -32,18 +32,6 @@ struct ParsedUdpConfig { rfl::Description<"Socket configuration", ParsedSocketConfig> socket; }; -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; - rfl::Description<"Insecure mode, use default compiled-in cert", bool> insecure; -}; - -struct ParsedAdminTlsConfig { - rfl::Description<"Path to TLS certificate file", std::optional> cert_file; - rfl::Description<"Path to TLS private key file", std::optional> key_file; - rfl::Description<"ALPN protocol list", std::optional>> alpn; -}; - struct ParsedQuicConfig { // Flow control rfl::Description< @@ -81,10 +69,39 @@ struct ParsedQuicConfig { cc_algo; }; +// rfl::ExtraFields absorbs the "type" discriminator key that the TaggedUnion +// parser has already consumed but that would otherwise be rejected by the +// NoExtraFields processor in strict mode. +struct ParsedTlsInsecure { + using Tag = rfl::Literal<"insecure">; + rfl::ExtraFields extra_; +}; + +struct ParsedTlsFile { + using Tag = rfl::Literal<"file">; + rfl::Description<"Path to TLS certificate file (PEM)", std::string> cert_file; + rfl::Description<"Path to TLS private key file (PEM)", std::string> key_file; + rfl::ExtraFields extra_; +}; + +struct ParsedTlsDirectory { + using Tag = rfl::Literal<"directory">; + rfl::Description<"Directory containing cert/key pairs (.crt + .key)", std::string> + cert_dir; + rfl::Description< + "SNI identity of the default certificate (optional, first cert if omitted)", + std::optional> + default_cert; + rfl::ExtraFields extra_; +}; + +using ParsedTlsMode = + rfl::TaggedUnion<"type", ParsedTlsInsecure, ParsedTlsFile, ParsedTlsDirectory>; + struct ParsedListenerConfig { rfl::Description<"Listener name", std::string> name; rfl::Description<"UDP/QUIC transport config", ParsedUdpConfig> udp; - rfl::Description<"TLS configuration", ParsedListenerTlsConfig> tls; + ParsedTlsMode tls; rfl::Description<"WebTransport endpoint path", std::string> endpoint; rfl::Description< "MOQT draft versions (empty = all supported)", @@ -133,6 +150,12 @@ struct ParsedCacheConfig { default_max_cache_duration_s; }; +struct ParsedAdminTlsConfig { + rfl::Description<"Path to TLS certificate file", std::optional> cert_file; + rfl::Description<"Path to TLS private key file", std::optional> key_file; + rfl::Description<"ALPN protocol list", std::optional>> alpn; +}; + struct ParsedAdminConfig { rfl::Description<"HTTP admin server port, 1-65535", uint16_t> port; rfl::Description<"Bind address", std::string> address; diff --git a/src/main.cpp b/src/main.cpp index 0b5ec591..f82a1bec 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,8 @@ #include "admin/MetricsHandler.h" #include "config/loader/ConfigInit.h" #include "stats/StatsRegistry.h" +#include "tls/BuiltinTlsProviders.h" +#include "tls/TlsProviderRegistry.h" #include @@ -69,7 +71,17 @@ int main(int argc, char* argv[]) { subcommand = argv[1]; } - auto result = cfg::handleConfigSubcommand(subcommand, FLAGS_config, FLAGS_strict_config, argv[0]); + // Create TLS provider registry and register built-in providers + openmoq::moqx::tls::TlsProviderRegistry tlsRegistry; + openmoq::moqx::tls::registerBuiltinTlsProviders(tlsRegistry); + + auto result = cfg::handleConfigSubcommand( + subcommand, + FLAGS_config, + FLAGS_strict_config, + argv[0], + tlsRegistry + ); if (result.hasError()) { return result.error(); } diff --git a/src/tls/BuiltinTlsProviders.cpp b/src/tls/BuiltinTlsProviders.cpp new file mode 100644 index 00000000..2b6892d7 --- /dev/null +++ b/src/tls/BuiltinTlsProviders.cpp @@ -0,0 +1,100 @@ +/* + * 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 "BuiltinTlsProviders.h" + +#include "DirectoryCertLoader.h" +#include "FileCertLoader.h" +#include "InsecureCertProvider.h" +#include "TlsProviderRegistry.h" +#include "config/loader/ParsedConfig.h" + +namespace openmoq::moqx::tls { + +void registerBuiltinTlsProviders(TlsProviderRegistry& registry) { + registry.registerProvider(config::ParsedTlsInsecure::Tag{}.name(), makeInsecureFactory()); + registry.registerProvider(config::ParsedTlsFile::Tag{}.name(), makeFileFactory()); + registry.registerProvider(config::ParsedTlsDirectory::Tag{}.name(), makeDirectoryFactory()); +} + +TlsProviderFactory makeInsecureFactory(std::function()> creator) { + if (!creator) { + creator = [] { return std::make_shared(); }; + } + + return + [creator = std::move(creator)](const config::ParsedTlsMode&) + -> folly::Expected, std::string> { return creator(); }; +} + +TlsProviderFactory +makeFileFactory(std::function(std::string, std::string)> creator) { + using namespace std::literals; + + if (!creator) { + creator = [](std::string cert, std::string key) { + return std::make_shared(std::move(cert), std::move(key)); + }; + } + + return [creator = std::move(creator)](const config::ParsedTlsMode& tls + ) -> folly::Expected, std::string> { + return tls.visit( + [&creator](const auto& variant + ) -> folly::Expected, std::string> { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + if (variant.cert_file.value().empty()) { + return folly::makeUnexpected("cert_file is required"s); + } + + if (variant.key_file.value().empty()) { + return folly::makeUnexpected("key_file is required"s); + } + + return creator(variant.cert_file.value(), variant.key_file.value()); + } else { + return folly::makeUnexpected("'file' factory called with wrong TLS variant"s); + } + } + ); + }; +} + +TlsProviderFactory makeDirectoryFactory( + std::function(std::string, std::string)> creator +) { + using namespace std::literals; + + if (!creator) { + creator = [](std::string certDir, std::string defaultCert) { + return std::make_shared(std::move(certDir), std::move(defaultCert)); + }; + } + + return [creator = std::move(creator)](const config::ParsedTlsMode& tls + ) -> folly::Expected, std::string> { + return tls.visit( + [&creator](const auto& variant + ) -> folly::Expected, std::string> { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + if (variant.cert_dir.value().empty()) { + return folly::makeUnexpected("cert_dir is required"s); + } + + return creator(variant.cert_dir.value(), variant.default_cert.value().value_or(""s)); + } else { + return folly::makeUnexpected("'directory' factory called with wrong TLS variant"s); + } + } + ); + }; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/BuiltinTlsProviders.h b/src/tls/BuiltinTlsProviders.h new file mode 100644 index 00000000..e7170261 --- /dev/null +++ b/src/tls/BuiltinTlsProviders.h @@ -0,0 +1,38 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include "TlsProviderRegistry.h" + +namespace openmoq::moqx::tls { + +class TlsProviderRegistry; + +void registerBuiltinTlsProviders(TlsProviderRegistry& registry); + +/// Build a TlsProviderFactory for the "insecure" type. +/// Optional creator overrides the default InsecureCertProvider. +TlsProviderFactory +makeInsecureFactory(std::function()> creator = nullptr); + +/// Build a TlsProviderFactory for the "file" type. +/// Validates cert_file/key_file non-empty, then delegates to creator. +TlsProviderFactory makeFileFactory( + std::function(std::string, std::string)> creator = nullptr +); + +/// Build a TlsProviderFactory for the "directory" type. +/// Validates cert_dir non-empty, then delegates to creator. +TlsProviderFactory makeDirectoryFactory( + std::function(std::string, std::string)> creator = nullptr +); + +} // namespace openmoq::moqx::tls diff --git a/src/tls/CertUtils.h b/src/tls/CertUtils.h new file mode 100644 index 00000000..a52bd02e --- /dev/null +++ b/src/tls/CertUtils.h @@ -0,0 +1,41 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace openmoq::moqx::tls { + +/// Read a PEM cert+key pair from disk and return a fizz SelfCert. +inline folly::Expected, std::string> +loadCertKeyPair(const std::string& certPath, const std::string& keyPath) { + std::string certData; + if (!folly::readFile(certPath.c_str(), certData)) { + return folly::makeUnexpected("Failed to read certificate file: " + certPath); + } + + std::string keyData; + if (!folly::readFile(keyPath.c_str(), keyData)) { + return folly::makeUnexpected("Failed to read key file: " + keyPath); + } + + try { + return fizz::openssl::CertUtils::makeSelfCert(certData, keyData); + } catch (const std::exception& e) { + return folly::makeUnexpected( + "Failed to parse certificate " + certPath + " / " + keyPath + ": " + e.what() + ); + } +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/DirectoryCertLoader.cpp b/src/tls/DirectoryCertLoader.cpp new file mode 100644 index 00000000..3a61fba5 --- /dev/null +++ b/src/tls/DirectoryCertLoader.cpp @@ -0,0 +1,79 @@ +/* + * 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 "DirectoryCertLoader.h" + +#include +#include + +#include + +#include "CertUtils.h" + +namespace openmoq::moqx::tls { + +DirectoryCertLoader::DirectoryCertLoader(std::string certDir, std::string defaultCertIdentity) + : certDir_(std::move(certDir)), defaultCertIdentity_(std::move(defaultCertIdentity)) {} + +folly::Expected DirectoryCertLoader::load() const { + namespace fs = std::filesystem; + + // Collect .crt files sorted by name for deterministic ordering + std::error_code ec; + std::vector crtFiles; + for (const auto& entry : fs::directory_iterator(certDir_, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".crt") { + crtFiles.push_back(entry.path()); + } + } + if (ec) { + return folly::makeUnexpected("Failed to read directory " + certDir_ + ": " + ec.message()); + } + + std::sort(crtFiles.begin(), crtFiles.end()); + + if (crtFiles.empty()) { + return folly::makeUnexpected("No certificate pairs found in " + certDir_); + } + + LoadedCerts result; + for (const auto& crtPath : crtFiles) { + auto keyPath = crtPath; + keyPath.replace_extension(".key"); + + auto cert = loadCertKeyPair(crtPath.string(), keyPath.string()); + if (cert.hasError()) { + return folly::makeUnexpected(std::move(cert.error())); + } + + auto identity = cert.value()->getIdentity(); + XLOG(INFO) << "Loaded certificate: " << identity << " from " << crtPath.filename().string(); + result.certs.push_back(LoadedCerts::Entry{ + .identity = std::move(identity), + .cert = std::move(cert.value()), + }); + } + + // Determine default identity + if (!defaultCertIdentity_.empty()) { + bool found = std::any_of(result.certs.begin(), result.certs.end(), [&](const auto& e) { + return e.identity == defaultCertIdentity_; + }); + if (!found) { + return folly::makeUnexpected( + "Specified default_cert identity '" + defaultCertIdentity_ + + "' not found among loaded certificates" + ); + } + result.defaultIdentity = defaultCertIdentity_; + } else { + result.defaultIdentity = result.certs.front().identity; + } + + return result; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/DirectoryCertLoader.h b/src/tls/DirectoryCertLoader.h new file mode 100644 index 00000000..a363863e --- /dev/null +++ b/src/tls/DirectoryCertLoader.h @@ -0,0 +1,36 @@ +/* + * 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. + */ + +#pragma once + +#include + +#include "TlsCertLoader.h" + +namespace openmoq::moqx::tls { + +/// Loads certificates from a specified directory. This directory is searched for pairs of files +/// with `.crt` and `.key` extensions; the `.crt` file is expected to be the certificate, and the +/// `.key` file the corresponding key file. +class DirectoryCertLoader : public TlsCertLoader { +public: + DirectoryCertLoader(std::string certDir, std::string defaultCertIdentity); + folly::Expected load() const override; + + folly::Expected getKeyPath() const override { + return folly::makeUnexpected("Directory cert loader cannot provide single key file."); + } + + folly::Expected getCertPath() const override { + return folly::makeUnexpected("Directory cert loader cannot provide single cert file."); + } + +private: + std::string certDir_; + std::string defaultCertIdentity_; +}; + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FileCertLoader.cpp b/src/tls/FileCertLoader.cpp new file mode 100644 index 00000000..3a199dbb --- /dev/null +++ b/src/tls/FileCertLoader.cpp @@ -0,0 +1,31 @@ +/* + * 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 "FileCertLoader.h" +#include "CertUtils.h" + +namespace openmoq::moqx::tls { + +FileCertLoader::FileCertLoader(std::string certFile, std::string keyFile) + : certFile_(std::move(certFile)), keyFile_(std::move(keyFile)) {} + +folly::Expected FileCertLoader::load() const { + auto cert = loadCertKeyPair(certFile_, keyFile_); + if (cert.hasError()) { + return folly::makeUnexpected(std::move(cert.error())); + } + + auto identity = cert.value()->getIdentity(); + LoadedCerts result; + result.defaultIdentity = identity; + result.certs.push_back(LoadedCerts::Entry{ + .identity = std::move(identity), + .cert = std::move(cert.value()), + }); + return result; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FileCertLoader.h b/src/tls/FileCertLoader.h new file mode 100644 index 00000000..d30c59d3 --- /dev/null +++ b/src/tls/FileCertLoader.h @@ -0,0 +1,33 @@ +/* + * 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. + */ + +#pragma once + +#include + +#include "TlsCertLoader.h" + +namespace openmoq::moqx::tls { + +class FileCertLoader : public TlsCertLoader { +public: + FileCertLoader(std::string certFile, std::string keyFile); + folly::Expected load() const override; + + folly::Expected getKeyPath() const override { + return folly::makeExpected(keyFile_); + } + + folly::Expected getCertPath() const override { + return folly::makeExpected(certFile_); + }; + +private: + std::string certFile_; + std::string keyFile_; +}; + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FizzContextFactory.cpp b/src/tls/FizzContextFactory.cpp new file mode 100644 index 00000000..a6104eb0 --- /dev/null +++ b/src/tls/FizzContextFactory.cpp @@ -0,0 +1,73 @@ +/* + * 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 "FizzContextFactory.h" + +#include + +#include +#include +#include +#include + +namespace openmoq::moqx::tls { + +std::vector buildAlpns(const std::string& moqtVersions) { + std::vector alpns = {"h3"}; + auto moqt = moxygen::getMoqtProtocols(moqtVersions, true); + alpns.insert(alpns.end(), moqt.begin(), moqt.end()); + return alpns; +} + +folly::Expected, std::string> +buildStandardFizzContext( + std::shared_ptr certManager, + const std::vector& alpns, + const std::vector& ticketSeeds +) { + auto serverCtx = std::make_shared(); + serverCtx->setCertManager(certManager); + + auto ticketCipher = std::make_shared>>( + serverCtx->getFactoryPtr(), + std::move(certManager) + ); + // First seed encrypts new tickets; all seeds can decrypt (key rotation). + if (!ticketSeeds.empty()) { + std::vector secrets; + secrets.reserve(ticketSeeds.size()); + for (const auto& seed : ticketSeeds) { + secrets.emplace_back(folly::range(seed)); + } + ticketCipher->setTicketSecrets(std::move(secrets)); + } else { + std::array randomSeed; + folly::Random::secureRandom(randomSeed.data(), randomSeed.size()); + ticketCipher->setTicketSecrets({{folly::range(randomSeed)}}); + } + serverCtx->setTicketCipher(ticketCipher); + + serverCtx->setClientAuthMode(fizz::server::ClientAuthMode::Optional); + serverCtx->setSupportedAlpns(alpns); + serverCtx->setAlpnMode(fizz::server::AlpnMode::Required); + serverCtx->setSendNewSessionTicket(false); + serverCtx->setEarlyDataFbOnly(false); + serverCtx->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(); + + serverCtx->setEarlyDataSettings(true, tolerance, std::move(replayCache)); + + return serverCtx; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/FizzContextFactory.h b/src/tls/FizzContextFactory.h new file mode 100644 index 00000000..770051c3 --- /dev/null +++ b/src/tls/FizzContextFactory.h @@ -0,0 +1,39 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace openmoq::moqx::tls { + +/// A single TLS ticket encryption seed (typically 32 bytes). +using TicketSeed = std::vector; + +/// Build a standard FizzServerContext from a CertManager and ALPN list. +/// Configures ticket cipher, client auth, early data, etc. +/// +/// @param ticketSeeds Optional ticket encryption seeds. The first seed +/// encrypts new tickets; all seeds can decrypt (supports key rotation). +/// When empty, a random seed is generated (tickets won't survive restart). +folly::Expected, std::string> +buildStandardFizzContext( + std::shared_ptr certManager, + const std::vector& alpns, + const std::vector& ticketSeeds = {} +); + +/// Build ALPN list from a comma-separated MOQT versions string. +std::vector buildAlpns(const std::string& moqtVersions); + +} // namespace openmoq::moqx::tls diff --git a/src/tls/InsecureCertProvider.cpp b/src/tls/InsecureCertProvider.cpp new file mode 100644 index 00000000..58cac709 --- /dev/null +++ b/src/tls/InsecureCertProvider.cpp @@ -0,0 +1,28 @@ +/* + * 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 + +#include "InsecureCertProvider.h" + +namespace openmoq::moqx::tls { + +folly::Expected, std::string> +InsecureCertProvider::createContext( + const std::vector& alpns, + const std::vector& /* ticketSeeds */ +) const { + // Seeds intentionally unused — insecure mode uses proxygen's built-in + // context and doesn't need session resumption. + return quic::samples::createFizzServerContextWithInsecureDefault( + alpns, + fizz::server::ClientAuthMode::None, + "", + "" + ); +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/InsecureCertProvider.h b/src/tls/InsecureCertProvider.h new file mode 100644 index 00000000..60ea042e --- /dev/null +++ b/src/tls/InsecureCertProvider.h @@ -0,0 +1,29 @@ +/* + * 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. + */ + +#pragma once + +#include "TlsCertLoader.h" + +namespace openmoq::moqx::tls { + +/// TLS provider using compiled-in self-signed certs for development. +class InsecureCertProvider : public TlsCertProvider { +public: + folly::Expected, std::string> + createContext(const std::vector& alpns, const std::vector& ticketSeeds) + const override; + + folly::Expected getKeyPath() const override { + return folly::makeExpected(""); + } + + folly::Expected getCertPath() const override { + return folly::makeExpected(""); + } +}; + +} // namespace openmoq::moqx::tls diff --git a/src/tls/TlsCertLoader.cpp b/src/tls/TlsCertLoader.cpp new file mode 100644 index 00000000..a33275f8 --- /dev/null +++ b/src/tls/TlsCertLoader.cpp @@ -0,0 +1,36 @@ +/* + * 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 + +#include "FizzContextFactory.h" +#include "TlsCertLoader.h" + +namespace openmoq::moqx::tls { + +folly::Expected, std::string> +TlsCertLoader::createContext( + const std::vector& alpns, + const std::vector& ticketSeeds +) const { + auto loaded = load(); + if (loaded.hasError()) { + return folly::makeUnexpected(loaded.error()); + } + + auto certManager = std::make_shared(); + for (auto& entry : loaded.value().certs) { + if (entry.identity == loaded.value().defaultIdentity) { + certManager->addCertAndSetDefault(std::move(entry.cert)); + } else { + certManager->addCert(std::move(entry.cert)); + } + } + + return buildStandardFizzContext(std::move(certManager), alpns, ticketSeeds); +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/TlsCertLoader.h b/src/tls/TlsCertLoader.h new file mode 100644 index 00000000..65b2c23e --- /dev/null +++ b/src/tls/TlsCertLoader.h @@ -0,0 +1,62 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "FizzContextFactory.h" + +namespace openmoq::moqx::tls { + +struct LoadedCerts { + struct Entry { + std::string identity; + std::shared_ptr cert; + }; + std::vector certs; + std::string defaultIdentity; +}; + +/// Base interface for TLS credential plugins. +/// +/// Providers implement createContext() to produce a fully configured +/// FizzServerContext. On-demand providers (remote HSM/KMS) can supply a custom +/// CertManager that handles SNI lookup and cert retrieval at handshake time. +class TlsCertProvider { +public: + virtual ~TlsCertProvider() = default; + virtual folly::Expected, std::string> + createContext( + const std::vector& alpns, + const std::vector& ticketSeeds = {} + ) const = 0; + + virtual folly::Expected getKeyPath() const = 0; + virtual folly::Expected getCertPath() const = 0; +}; + +/// Convenience base for providers that load all certs upfront. +/// Subclasses implement load(); createContext() builds a +/// DefaultCertManager from the loaded certs and wraps it in a +/// standard FizzServerContext automatically. +class TlsCertLoader : public TlsCertProvider { +public: + virtual folly::Expected load() const = 0; + folly::Expected, std::string> + createContext( + const std::vector& alpns, + const std::vector& ticketSeeds = {} + ) const override; +}; + +} // namespace openmoq::moqx::tls diff --git a/src/tls/TlsProviderRegistry.cpp b/src/tls/TlsProviderRegistry.cpp new file mode 100644 index 00000000..5c69c438 --- /dev/null +++ b/src/tls/TlsProviderRegistry.cpp @@ -0,0 +1,32 @@ +/* + * 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 "TlsProviderRegistry.h" + +namespace openmoq::moqx::tls { + +void TlsProviderRegistry::registerProvider(std::string type, TlsProviderFactory factory) { + factories_.emplace(std::move(type), std::move(factory)); +} + +const TlsProviderFactory* TlsProviderRegistry::getFactory(const std::string& type) const { + auto it = factories_.find(type); + if (it == factories_.end()) { + return nullptr; + } + return &it->second; +} + +std::vector TlsProviderRegistry::registeredTypes() const { + std::vector types; + types.reserve(factories_.size()); + for (const auto& [type, _] : factories_) { + types.push_back(type); + } + return types; +} + +} // namespace openmoq::moqx::tls diff --git a/src/tls/TlsProviderRegistry.h b/src/tls/TlsProviderRegistry.h new file mode 100644 index 00000000..be4486a9 --- /dev/null +++ b/src/tls/TlsProviderRegistry.h @@ -0,0 +1,38 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "config/loader/ParsedConfig.h" + +namespace openmoq::moqx::tls { + +class TlsCertProvider; + +using TlsProviderFactory = + std::function, std::string>( + const config::ParsedTlsMode& tls + )>; + +class TlsProviderRegistry { +public: + void registerProvider(std::string type, TlsProviderFactory factory); + const TlsProviderFactory* getFactory(const std::string& type) const; + std::vector registeredTypes() const; + +private: + folly::F14FastMap factories_; +}; + +} // namespace openmoq::moqx::tls diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bf75e374..555f7042 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -40,6 +40,8 @@ target_link_libraries(moqx_config_test PRIVATE GTest::gtest_main GTest::gmock ) +target_include_directories(moqx_config_test PRIVATE + ${PROJECT_SOURCE_DIR}/test) target_compile_definitions(moqx_config_test PRIVATE CONFIG_EXAMPLE_PATH="${PROJECT_SOURCE_DIR}/config.example.yaml" ) @@ -58,6 +60,7 @@ add_executable(moqx_config_resolver_test ) target_link_libraries(moqx_config_resolver_test PRIVATE moqx_config_loader + moqx_core GTest::gtest_main GTest::gmock ) @@ -119,6 +122,28 @@ target_link_libraries(moqx_relay_context_test PRIVATE ) gtest_discover_tests(moqx_relay_context_test) +add_executable(moqx_tls_test + tls/FileCertLoaderTest.cpp + tls/DirectoryCertLoaderTest.cpp + tls/FizzContextFactoryTest.cpp + tls/InsecureCertProviderTest.cpp + tls/TlsProviderRegistryTest.cpp +) +target_link_libraries(moqx_tls_test PRIVATE + moqx_core + moqx_config + moqx_config_loader + GTest::gtest_main + GTest::gmock + "$" +) +target_include_directories(moqx_tls_test PRIVATE + ${PROJECT_SOURCE_DIR}/test) +set_property(TARGET moqx_tls_test PROPERTY + LINK_LIBRARY_OVERRIDE "WHOLE_ARCHIVE,gflags_nothreads_static" +) +gtest_discover_tests(moqx_tls_test) + add_test( NAME relay_chain COMMAND bash ${PROJECT_SOURCE_DIR}/test/test_relay_chain.sh $ diff --git a/test/config/config_resolver_test.cpp b/test/config/config_resolver_test.cpp index 1dd3966f..44fefa7d 100644 --- a/test/config/config_resolver_test.cpp +++ b/test/config/config_resolver_test.cpp @@ -9,6 +9,13 @@ #include #include +#include "tls/BuiltinTlsProviders.h" +#include "tls/DirectoryCertLoader.h" +#include "tls/FileCertLoader.h" +#include "tls/InsecureCertProvider.h" +#include "tls/TlsCertLoader.h" +#include "tls/TlsProviderRegistry.h" + namespace openmoq::moqx::config { namespace { @@ -18,6 +25,46 @@ using ::testing::IsEmpty; using AuthMatch = ParsedServiceConfig::MatchRule::AuthorityMatch; using PMatch = ParsedServiceConfig::MatchRule::PathMatch; +// Lightweight dummy provider — no fizz dependency needed. +class DummyCertProvider : public tls::TlsCertProvider { +public: + folly::Expected, std::string> + createContext(const std::vector&, const std::vector&) + const override { + return folly::makeUnexpected(std::string("dummy — not a real provider")); + } + + folly::Expected getKeyPath() const override { + return folly::makeUnexpected(std::string("dummy — not a real provider")); + } + + folly::Expected getCertPath() const override { + return folly::makeUnexpected(std::string("dummy — not a real provider")); + } +}; + +// Build a registry with real validation but dummy providers. +tls::TlsProviderRegistry makeTestRegistry() { + tls::TlsProviderRegistry registry; + auto dummy = [] { return std::make_shared(); }; + + registry.registerProvider("insecure", tls::makeInsecureFactory([dummy] { return dummy(); })); + registry.registerProvider( + "file", + tls::makeFileFactory([dummy](auto, auto) -> std::shared_ptr { + return dummy(); + }) + ); + registry.registerProvider( + "directory", + tls::makeDirectoryFactory([dummy](auto, auto) -> std::shared_ptr { + return dummy(); + }) + ); + + return registry; +} + // Shorthand for the common "match any path" path matcher. PMatch anyPath() { return PMatch{ParsedServiceConfig::MatchRule::PrefixPath{"/"}}; @@ -31,12 +78,12 @@ ParsedCacheConfig makeDefaultCache() { return cache; } -ParsedAdminConfig makeDefaultAdmin() { +std::optional makeDefaultAdmin() { ParsedAdminConfig admin; admin.port = uint16_t{9669}; admin.address = std::string{"::"}; admin.plaintext = true; - return admin; + return std::optional{admin}; } ParsedAdminConfig makeAdminWithTls(std::string cert = "/cert.pem", std::string key = "/key.pem") { @@ -61,10 +108,24 @@ ParsedServiceConfig makeDefaultService() { return svc; } +// Helper: build a ParsedListenerConfig with the given TLS mode. +// ParsedListenerConfig can't be default-constructed (TaggedUnion has no +// default ctor), so we aggregate-initialize with a placeholder and then +// overwrite fields. +ParsedListenerConfig makeListener(ParsedTlsMode tlsMode) { + ParsedListenerConfig lc{ + .name = std::string(""), + .udp = ParsedUdpConfig{}, + .tls = std::move(tlsMode), + .endpoint = std::string(""), + }; + return lc; +} + // Build a minimal valid insecure config with one any-authority service and admin. ParsedConfig makeMinimalInsecureConfig(std::string name = "test") { ParsedConfig cfg; - ParsedListenerConfig lc; + auto lc = makeListener(ParsedTlsMode{ParsedTlsInsecure{}}); lc.name = std::move(name); ParsedSocketConfig sock; sock.address = std::string("::"); @@ -72,13 +133,57 @@ ParsedConfig makeMinimalInsecureConfig(std::string name = "test") { ParsedUdpConfig udp; udp.socket = std::move(sock); lc.udp = std::move(udp); - ParsedListenerTlsConfig tls; - tls.insecure = true; - lc.tls = std::move(tls); + lc.endpoint = std::string("/moq-relay"); + cfg.listeners.value().push_back(std::move(lc)); + cfg.admin = makeDefaultAdmin(); + cfg.services.value().emplace("default", makeDefaultService()); + return cfg; +} + +ParsedConfig makeFileConfig( + std::string certFile = "/etc/ssl/cert.pem", + std::string keyFile = "/etc/ssl/key.pem" +) { + ParsedConfig cfg; + ParsedTlsFile tls; + tls.cert_file = std::move(certFile); + tls.key_file = std::move(keyFile); + auto lc = makeListener(ParsedTlsMode{std::move(tls)}); + lc.name = std::string("production"); + ParsedSocketConfig sock; + sock.address = std::string("0.0.0.0"); + sock.port = uint16_t{4443}; + ParsedUdpConfig udp; + udp.socket = std::move(sock); + lc.udp = std::move(udp); + lc.endpoint = std::string("/relay"); + lc.moqt_versions = std::vector{14, 16}; + cfg.listeners.value().push_back(std::move(lc)); + cfg.admin = makeDefaultAdmin(); + cfg.services.value().emplace("default", makeDefaultService()); + return cfg; +} + +ParsedConfig +makeDirectoryConfig(std::string certDir = "/etc/ssl/certs.d/", std::string defaultCert = "") { + ParsedConfig cfg; + ParsedTlsDirectory tls; + tls.cert_dir = std::move(certDir); + if (!defaultCert.empty()) { + tls.default_cert = std::move(defaultCert); + } + auto lc = makeListener(ParsedTlsMode{std::move(tls)}); + lc.name = std::string("multi"); + ParsedSocketConfig sock; + sock.address = std::string("::"); + sock.port = uint16_t{4443}; + ParsedUdpConfig udp; + udp.socket = std::move(sock); + lc.udp = std::move(udp); lc.endpoint = std::string("/moq-relay"); cfg.listeners.value().push_back(std::move(lc)); cfg.services.value().emplace("default", makeDefaultService()); - cfg.admin = std::optional{makeDefaultAdmin()}; + cfg.admin = makeDefaultAdmin(); return cfg; } @@ -119,26 +224,38 @@ ParsedServiceConfig::MatchRule makeAnyAuthorityMatch(PMatch path = anyPath()) { TEST(ResolveConfig, NoListeners) { ParsedConfig cfg; cfg.services.value().emplace("default", makeDefaultService()); - cfg.admin = std::optional{makeDefaultAdmin()}; - auto result = resolveConfig(cfg); + cfg.admin = makeDefaultAdmin(); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("At least one listener")); } -TEST(ResolveConfig, InsecureFalseNoCerts) { - auto cfg = makeMinimalInsecureConfig(); - cfg.listeners.value()[0].tls.value().insecure = false; - - auto result = resolveConfig(cfg); +TEST(ResolveConfig, FileTypeEmptyCertFile) { + auto cfg = makeFileConfig("", "/etc/ssl/key.pem"); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cert_file")); } +TEST(ResolveConfig, FileTypeEmptyKeyFile) { + auto cfg = makeFileConfig("/etc/ssl/cert.pem", ""); + auto result = resolveConfig(cfg, makeTestRegistry()); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("key_file")); +} + +TEST(ResolveConfig, DirectoryTypeEmptyCertDir) { + auto cfg = makeDirectoryConfig(""); + auto result = resolveConfig(cfg, makeTestRegistry()); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("cert_dir")); +} + TEST(ResolveConfig, PicoquicInsecureRejected) { auto cfg = makeMinimalInsecureConfig(); cfg.listeners.value()[0].quic_stack = std::string("picoquic"); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("insecure: true is not supported")); } @@ -147,29 +264,18 @@ TEST(ResolveConfig, PortZero) { auto cfg = makeMinimalInsecureConfig(); cfg.listeners.value()[0].udp.value().socket.value().port = uint16_t{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("port")); } -TEST(ResolveConfig, InsecureWithCertsWarning) { - auto cfg = makeMinimalInsecureConfig(); - cfg.listeners.value()[0].tls.value().cert_file = std::string("/some/cert.pem"); - cfg.listeners.value()[0].tls.value().key_file = std::string("/some/key.pem"); - - auto result = resolveConfig(cfg); - ASSERT_TRUE(result.hasValue()); - ASSERT_FALSE(result.value().warnings.empty()); - EXPECT_THAT(result.value().warnings[0], HasSubstr("ignored")); -} - // --- Service validation error tests --- TEST(ResolveConfig, NoServices) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().clear(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("At least one service")); } @@ -187,7 +293,7 @@ TEST(ResolveConfig, DuplicateExactAuthorityAcrossServices) { makeAuthorityService({makeExactAuthorityMatch("live.example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("Duplicate")); } @@ -203,7 +309,7 @@ TEST(ResolveConfig, NoCacheAndNoServiceDefaultsCache) { // No cache set, no service_defaults cfg.services.value().emplace("no-cache", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cache.enabled is required")); } @@ -216,7 +322,7 @@ TEST(ResolveConfig, InvalidWildcardMissingStar) { makeAuthorityService({makeWildcardAuthorityMatch("example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("wildcard must start with '*.'")); } @@ -229,7 +335,7 @@ TEST(ResolveConfig, InvalidWildcardMultipleStars) { makeAuthorityService({makeWildcardAuthorityMatch("*.*.example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("exactly one '*'")); } @@ -247,7 +353,7 @@ TEST(ResolveConfig, DuplicateWildcardAcrossServices) { makeAuthorityService({makeWildcardAuthorityMatch("*.example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("Duplicate")); } @@ -257,7 +363,7 @@ TEST(ResolveConfig, ExactAuthorityEmpty) { cfg.services.value().clear(); cfg.services.value().emplace("svc", makeAuthorityService({makeExactAuthorityMatch("")})); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("exact value must be non-empty")); } @@ -270,7 +376,7 @@ TEST(ResolveConfig, AnyAuthorityFalseRejected) { entry.path = anyPath(); cfg.services.value().emplace("svc", makeAuthorityService({std::move(entry)})); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("any must be true")); } @@ -283,7 +389,7 @@ TEST(ResolveConfig, DuplicateAnySamePath) { cfg.services.value().emplace("svc1", makeAuthorityService({makeAnyAuthorityMatch()})); cfg.services.value().emplace("svc2", makeAuthorityService({makeAnyAuthorityMatch()})); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("Duplicate")); } @@ -305,7 +411,7 @@ TEST(ResolveConfig, MultipleAnyDifferentPaths) { ) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); } @@ -321,7 +427,7 @@ TEST(ResolveConfig, PathExactEmpty) { ) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("path: value must be non-empty")); } @@ -337,7 +443,7 @@ TEST(ResolveConfig, PathNoSlash) { )}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("must start with '/'")); } @@ -356,7 +462,7 @@ TEST(ResolveConfig, DuplicateAuthorityPathTuple) { makeAuthorityService({makeExactAuthorityMatch("a.com", path)}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("Duplicate")); } @@ -380,7 +486,7 @@ TEST(ResolveConfig, SameAuthorityDifferentPaths) { )}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); } @@ -388,13 +494,13 @@ TEST(ResolveConfig, SameAuthorityDifferentPaths) { TEST(ResolveConfig, MinimalInsecure) { auto cfg = makeMinimalInsecureConfig("main"); - auto result = resolveConfig(cfg); - ASSERT_TRUE(result.hasValue()); + auto result = resolveConfig(cfg, makeTestRegistry()); + ASSERT_TRUE(result.hasValue()) << result.error(); const auto& resolved = result.value().config; EXPECT_EQ(resolved.listeners[0].name, "main"); EXPECT_EQ(resolved.listeners[0].address.getPort(), 9668); - EXPECT_TRUE(std::holds_alternative(resolved.listeners[0].tlsMode)); + EXPECT_NE(resolved.listeners[0].tlsProvider, nullptr); EXPECT_EQ(resolved.listeners[0].endpoint, "/moq-relay"); EXPECT_EQ(resolved.listeners[0].moqtVersions, ""); EXPECT_THAT(result.value().warnings, IsEmpty()); @@ -404,28 +510,9 @@ TEST(ResolveConfig, MinimalInsecure) { EXPECT_EQ(resolved.services.at("default").cache.maxCachedGroupsPerTrack, 3u); } -TEST(ResolveConfig, FullTls) { - ParsedConfig cfg; - ParsedListenerConfig lc; - lc.name = std::string("production"); - ParsedSocketConfig sock; - sock.address = std::string("0.0.0.0"); - sock.port = uint16_t{4443}; - ParsedUdpConfig udp; - udp.socket = std::move(sock); - lc.udp = std::move(udp); - ParsedListenerTlsConfig tls; - tls.cert_file = std::string("/etc/ssl/cert.pem"); - tls.key_file = std::string("/etc/ssl/key.pem"); - tls.insecure = false; - lc.tls = std::move(tls); - lc.endpoint = std::string("/relay"); - lc.moqt_versions = std::vector{14, 16}; - cfg.listeners.value().push_back(std::move(lc)); - cfg.services.value().emplace("default", makeDefaultService()); - cfg.admin = std::optional{makeDefaultAdmin()}; - - auto result = resolveConfig(cfg); +TEST(ResolveConfig, FullTlsFile) { + auto cfg = makeFileConfig(); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; @@ -433,18 +520,30 @@ TEST(ResolveConfig, FullTls) { EXPECT_EQ(resolved.listeners[0].address.getPort(), 4443); EXPECT_EQ(resolved.listeners[0].endpoint, "/relay"); EXPECT_EQ(resolved.listeners[0].moqtVersions, "14,16"); + EXPECT_NE(resolved.listeners[0].tlsProvider, nullptr); +} - ASSERT_TRUE(std::holds_alternative(resolved.listeners[0].tlsMode)); - const auto& creds = std::get(resolved.listeners[0].tlsMode); - EXPECT_EQ(creds.certFile, "/etc/ssl/cert.pem"); - EXPECT_EQ(creds.keyFile, "/etc/ssl/key.pem"); +TEST(ResolveConfig, TlsDirectory) { + auto cfg = makeDirectoryConfig("/etc/ssl/certs.d/", "example.com"); + auto result = resolveConfig(cfg, makeTestRegistry()); + ASSERT_TRUE(result.hasValue()); + const auto& resolved = result.value().config; + EXPECT_NE(resolved.listeners[0].tlsProvider, nullptr); +} + +TEST(ResolveConfig, TlsDirectoryNoDefault) { + auto cfg = makeDirectoryConfig("/etc/ssl/certs.d/"); + auto result = resolveConfig(cfg, makeTestRegistry()); + ASSERT_TRUE(result.hasValue()); + const auto& resolved = result.value().config; + EXPECT_NE(resolved.listeners[0].tlsProvider, nullptr); } TEST(ResolveConfig, CacheDisabled) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().at("default").cache.value()->enabled = std::optional{false}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.services.at("default").cache.maxCachedTracks, 0u); @@ -458,7 +557,7 @@ TEST(ResolveConfig, CacheCustomValues) { cfg.services.value().at("default").cache.value()->max_groups_per_track = std::optional{5}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.services.at("default").cache.maxCachedTracks, 200u); @@ -481,7 +580,7 @@ TEST(ResolveConfig, CacheInheritanceFromServiceDefaults) { svc.match.value().push_back(makeAnyAuthorityMatch()); cfg.services.value().emplace("inheritor", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; ASSERT_EQ(resolved.services.size(), 1); @@ -510,7 +609,7 @@ TEST(ResolveConfig, CachePerServiceOverridesDefaults) { svc.cache = std::move(svcCache); cfg.services.value().emplace("custom", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.services.at("custom").cache.maxCachedTracks, 300u); @@ -537,7 +636,7 @@ TEST(ResolveConfig, CachePartialOverrideMergesWithDefaults) { svc.cache = std::move(svcCache); cfg.services.value().emplace("partial", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; ASSERT_EQ(resolved.services.size(), 1); @@ -557,7 +656,7 @@ TEST(ResolveConfig, CachePartialOverrideWithoutDefaultsFails) { svc.cache = std::move(svcCache); cfg.services.value().emplace("incomplete", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cache.enabled is required")); EXPECT_THAT(result.error(), HasSubstr("cache.max_groups_per_track is required")); @@ -572,7 +671,7 @@ TEST(ResolveConfig, ResolveExactAuthority) { makeAuthorityService({makeExactAuthorityMatch("live.example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& entries = result.value().config.services.at("exact-svc").match; ASSERT_EQ(entries.size(), 1); @@ -595,7 +694,7 @@ TEST(ResolveConfig, ResolveWildcardAuthority) { makeAuthorityService({makeWildcardAuthorityMatch("*.example.com")}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& entries = result.value().config.services.at("wild-svc").match; ASSERT_EQ(entries.size(), 1); @@ -611,7 +710,7 @@ TEST(ResolveConfig, ResolveWildcardAuthority) { TEST(ResolveConfig, ResolveAnyAuthority) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& match = result.value().config.services.at("default").match; ASSERT_EQ(match.size(), 1); @@ -632,7 +731,7 @@ TEST(ResolveConfig, ResolveExactPath) { )}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& entries = result.value().config.services.at("svc").match; ASSERT_EQ(entries.size(), 1); @@ -652,7 +751,7 @@ TEST(ResolveConfig, ResolvePrefixPath) { )}) ); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& entries = result.value().config.services.at("svc").match; ASSERT_EQ(entries.size(), 1); @@ -662,7 +761,7 @@ TEST(ResolveConfig, ResolvePrefixPath) { TEST(ResolveConfig, VersionsEmpty) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.listeners[0].moqtVersions, ""); } @@ -670,7 +769,7 @@ TEST(ResolveConfig, VersionsEmpty) { TEST(ResolveConfig, VersionsPopulated) { auto cfg = makeMinimalInsecureConfig(); cfg.listeners.value()[0].moqt_versions = std::vector{14}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.listeners[0].moqtVersions, "14"); } @@ -680,7 +779,7 @@ TEST(ResolveConfig, AddressResolution) { cfg.listeners.value()[0].udp.value().socket.value().address = std::string("127.0.0.1"); cfg.listeners.value()[0].udp.value().socket.value().port = uint16_t{8080}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.listeners[0].address.getPort(), 8080); @@ -693,7 +792,7 @@ TEST(ResolveConfig, AdminPortZero) { auto cfg = makeMinimalInsecureConfig(); cfg.admin.value()->port = uint16_t{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("admin.port")); } @@ -705,7 +804,7 @@ TEST(ResolveConfig, AdminTlsPartialCreds) { }) { auto cfg = makeMinimalInsecureConfig(); cfg.admin = std::optional{makeAdminWithTls(cert, key)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cert_file and key_file are required")); } @@ -717,7 +816,7 @@ TEST(ResolveConfig, AdminPlaintextAndTlsMutuallyExclusive) { admin.plaintext = true; cfg.admin = std::optional{std::move(admin)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("mutually exclusive")); } @@ -726,7 +825,7 @@ TEST(ResolveConfig, AdminNeitherPlaintextNorTls) { auto cfg = makeMinimalInsecureConfig(); cfg.admin.value()->plaintext = false; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("plaintext or tls")); } @@ -737,14 +836,14 @@ TEST(ResolveConfig, AdminAbsent) { auto cfg = makeMinimalInsecureConfig(); cfg.admin.value() = std::nullopt; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_FALSE(result.value().config.admin.has_value()); } TEST(ResolveConfig, AdminNoTls) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); EXPECT_FALSE(result.value().config.admin->tls.has_value()); @@ -752,7 +851,7 @@ TEST(ResolveConfig, AdminNoTls) { TEST(ResolveConfig, AdminAddress) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); EXPECT_EQ(result.value().config.admin->address.getPort(), 9669); @@ -762,7 +861,7 @@ TEST(ResolveConfig, AdminAddress) { TEST(ResolveConfig, AdminCustomAddress) { auto cfg = makeMinimalInsecureConfig(); cfg.admin.value()->address = std::string("127.0.0.1"); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); EXPECT_EQ(result.value().config.admin->address.getAddressStr(), "127.0.0.1"); @@ -772,7 +871,7 @@ TEST(ResolveConfig, AdminTlsResolution) { auto cfg = makeMinimalInsecureConfig(); cfg.admin = std::optional{makeAdminWithTls("/etc/ssl/cert.pem", "/etc/ssl/key.pem")}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); ASSERT_TRUE(result.value().config.admin->tls.has_value()); @@ -784,7 +883,7 @@ TEST(ResolveConfig, AdminTlsResolution) { TEST(ResolveConfig, AdminTlsDefaultAlpn) { auto cfg = makeMinimalInsecureConfig(); cfg.admin = std::optional{makeAdminWithTls()}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); ASSERT_TRUE(result.value().config.admin->tls.has_value()); @@ -796,7 +895,7 @@ TEST(ResolveConfig, AdminTlsCustomAlpn) { auto admin = makeAdminWithTls(); admin.tls.value()->alpn = std::vector{"h2"}; cfg.admin = std::optional{std::move(admin)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.admin.has_value()); ASSERT_TRUE(result.value().config.admin->tls.has_value()); @@ -827,7 +926,7 @@ static void setServiceUpstream(ParsedConfig& cfg, ParsedUpstreamConfig upstream) TEST(ResolveConfig, UpstreamAbsent) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_FALSE(result.value().config.services.at("default").upstream.has_value()); } @@ -835,7 +934,7 @@ TEST(ResolveConfig, UpstreamAbsent) { TEST(ResolveConfig, UpstreamInsecureFalseNoCA) { auto cfg = makeMinimalInsecureConfig(); setServiceUpstream(cfg, makeUpstreamConfig()); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.services.at("default").upstream.has_value()); const auto& up = *result.value().config.services.at("default").upstream; @@ -847,7 +946,7 @@ TEST(ResolveConfig, UpstreamInsecureFalseNoCA) { TEST(ResolveConfig, UpstreamInsecureTrue) { auto cfg = makeMinimalInsecureConfig(); setServiceUpstream(cfg, makeUpstreamConfig("moqt://dev:4433/", true)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_TRUE(result.value().config.services.at("default").upstream.has_value()); EXPECT_TRUE(result.value().config.services.at("default").upstream->tls.insecure); @@ -856,7 +955,7 @@ TEST(ResolveConfig, UpstreamInsecureTrue) { TEST(ResolveConfig, UpstreamInsecureTrueWithCACertRejected) { auto cfg = makeMinimalInsecureConfig(); setServiceUpstream(cfg, makeUpstreamConfig("moqt://dev:4433/", true, "/path/to/ca.pem")); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("mutually exclusive")); } @@ -864,7 +963,7 @@ TEST(ResolveConfig, UpstreamInsecureTrueWithCACertRejected) { TEST(ResolveConfig, UpstreamEmptyUrlRejected) { auto cfg = makeMinimalInsecureConfig(); setServiceUpstream(cfg, makeUpstreamConfig("")); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("upstream.url must be non-empty")); } @@ -873,15 +972,15 @@ TEST(ResolveConfig, UpstreamEmptyUrlRejected) { TEST(ResolveConfig, RelayIDAbsentGeneratesNonEmpty) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_FALSE(result.value().config.relayID.empty()); } TEST(ResolveConfig, RelayIDGeneratedUniquePerCall) { auto cfg = makeMinimalInsecureConfig(); - auto r1 = resolveConfig(cfg); - auto r2 = resolveConfig(cfg); + auto r1 = resolveConfig(cfg, makeTestRegistry()); + auto r2 = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(r1.hasValue()); ASSERT_TRUE(r2.hasValue()); EXPECT_NE(r1.value().config.relayID, r2.value().config.relayID); @@ -890,7 +989,7 @@ TEST(ResolveConfig, RelayIDGeneratedUniquePerCall) { TEST(ResolveConfig, RelayIDExplicitPreserved) { auto cfg = makeMinimalInsecureConfig(); cfg.relay_id = std::optional{"my-relay-1"}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.relayID, "my-relay-1"); } @@ -899,7 +998,7 @@ TEST(ResolveConfig, RelayIDExplicitPreserved) { TEST(ResolveConfig, ThreadsAbsentDefaultsToOne) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.threads, 1u); } @@ -907,7 +1006,7 @@ TEST(ResolveConfig, ThreadsAbsentDefaultsToOne) { TEST(ResolveConfig, ThreadsExplicitOneAccepted) { auto cfg = makeMinimalInsecureConfig(); cfg.threads = std::optional{1}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.threads, 1u); } @@ -915,7 +1014,7 @@ TEST(ResolveConfig, ThreadsExplicitOneAccepted) { TEST(ResolveConfig, ThreadsZeroRejected) { auto cfg = makeMinimalInsecureConfig(); cfg.threads = std::optional{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("threads must be >= 1")); } @@ -923,7 +1022,7 @@ TEST(ResolveConfig, ThreadsZeroRejected) { TEST(ResolveConfig, ThreadsGreaterThanOneRejected) { auto cfg = makeMinimalInsecureConfig(); cfg.threads = std::optional{2}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("threads > 1 is not yet supported")); } @@ -933,21 +1032,18 @@ TEST(ResolveConfig, ThreadsGreaterThanOneRejected) { TEST(ResolveConfig, MultipleListeners) { auto cfg = makeMinimalInsecureConfig("first"); - ParsedListenerConfig lc2; - lc2.name = std::string("second"); - ParsedSocketConfig sock2; - sock2.address = std::string("::"); - sock2.port = uint16_t{9669}; - ParsedUdpConfig udp2; - udp2.socket = std::move(sock2); - lc2.udp = std::move(udp2); - ParsedListenerTlsConfig tls2; - tls2.insecure = true; - lc2.tls = std::move(tls2); - lc2.endpoint = std::string("/moq-relay"); + ParsedListenerConfig lc2{ + .name = std::string("second"), + .udp = + ParsedUdpConfig{ + .socket = ParsedSocketConfig{.address = std::string("::"), .port = uint16_t{9669}} + }, + .tls = ParsedTlsInsecure{}, + .endpoint = std::string("/moq-relay") + }; cfg.listeners.value().push_back(std::move(lc2)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_EQ(result.value().config.listeners.size(), 2u); EXPECT_EQ(result.value().config.listeners[0].name, "first"); @@ -959,16 +1055,15 @@ TEST(ResolveConfig, MultipleListeners) { TEST(ResolveConfig, MultipleListenersDuplicateAddress) { auto cfg = makeMinimalInsecureConfig("first"); - ParsedListenerConfig lc2; - lc2.name = std::string("second"); - lc2.udp = cfg.listeners.value()[0].udp; // same address as first - ParsedListenerTlsConfig tls2; - tls2.insecure = true; - lc2.tls = std::move(tls2); - lc2.endpoint = std::string("/moq-relay"); + ParsedListenerConfig lc2{ + .name = std::string("second"), + .udp = cfg.listeners.value()[0].udp, // same address as first + .tls = ParsedTlsInsecure{}, + .endpoint = std::string("/moq-relay"), + }; cfg.listeners.value().push_back(std::move(lc2)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("Duplicate listener address")); } @@ -976,21 +1071,18 @@ TEST(ResolveConfig, MultipleListenersDuplicateAddress) { TEST(ResolveConfig, MultipleListenersInvalidPort) { auto cfg = makeMinimalInsecureConfig("first"); - ParsedListenerConfig lc2; - lc2.name = std::string("bad"); - ParsedSocketConfig sock2; - sock2.address = std::string("::"); - sock2.port = uint16_t{0}; - ParsedUdpConfig udp2; - udp2.socket = std::move(sock2); - lc2.udp = std::move(udp2); - ParsedListenerTlsConfig tls2; - tls2.insecure = true; - lc2.tls = std::move(tls2); - lc2.endpoint = std::string("/moq-relay"); + ParsedListenerConfig lc2{ + .name = std::string("bad"), + .udp = + ParsedUdpConfig{ + .socket = ParsedSocketConfig{.address = std::string("::"), .port = uint16_t{0}} + }, + .tls = ParsedTlsInsecure{}, + .endpoint = std::string("/moq-relay") + }; cfg.listeners.value().push_back(std::move(lc2)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("bad")); EXPECT_THAT(result.error(), HasSubstr("port")); @@ -1000,7 +1092,7 @@ TEST(ResolveConfig, MultipleListenersInvalidPort) { TEST(ResolveConfig, QuicDefaultsUsedWhenNoneSpecified) { auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& quic = result.value().config.listeners[0].quic; EXPECT_EQ(quic.maxData, 67108864u); @@ -1024,7 +1116,7 @@ TEST(ResolveConfig, ListenerDefaultsQuicApplied) { ld.quic = std::optional{std::move(quicCfg)}; cfg.listener_defaults = std::optional{std::move(ld)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& quic = result.value().config.listeners[0].quic; EXPECT_EQ(quic.maxData, 33554432u); @@ -1049,7 +1141,7 @@ TEST(ResolveConfig, PerListenerQuicOverridesDefaults) { perListenerQuic.max_data = std::optional{67108864}; cfg.listeners.value()[0].quic = std::optional{std::move(perListenerQuic)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& quic = result.value().config.listeners[0].quic; EXPECT_EQ(quic.maxData, 67108864u); // per-listener wins @@ -1063,7 +1155,7 @@ TEST(ResolveConfig, PerListenerQuicWithNoDefaults) { perListenerQuic.max_bidi_streams = std::optional{512}; cfg.listeners.value()[0].quic = std::optional{std::move(perListenerQuic)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& quic = result.value().config.listeners[0].quic; EXPECT_EQ(quic.maxBidiStreams, 512u); @@ -1077,7 +1169,7 @@ TEST(ResolveConfig, QuicConnFcLessThanStreamFcIsError) { quicCfg.max_stream_data = std::optional{2000}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("max_data")); EXPECT_THAT(result.error(), HasSubstr("max_stream_data")); @@ -1089,7 +1181,7 @@ TEST(ResolveConfig, QuicLowUniStreamsWarning) { quicCfg.max_uni_streams = std::optional{10}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_FALSE(result.value().warnings.empty()); EXPECT_THAT(result.value().warnings[0], HasSubstr("max_uni_streams")); @@ -1101,7 +1193,7 @@ TEST(ResolveConfig, QuicLowBidiStreamsWarning) { quicCfg.max_bidi_streams = std::optional{5}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_FALSE(result.value().warnings.empty()); EXPECT_THAT(result.value().warnings[0], HasSubstr("max_bidi_streams")); @@ -1120,7 +1212,7 @@ TEST(ResolveConfig, QuicValidationUseMergedValues) { perListener.max_stream_data = std::optional{8192}; // exceeds max_data from defaults cfg.listeners.value()[0].quic = std::optional{std::move(perListener)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("max_data")); } @@ -1131,7 +1223,7 @@ TEST(ResolveConfig, QuicIdleTimeoutLowWarning) { quicCfg.idle_timeout_ms = std::optional{1000}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); ASSERT_FALSE(result.value().warnings.empty()); EXPECT_THAT(result.value().warnings[0], HasSubstr("idle_timeout_ms")); @@ -1144,7 +1236,7 @@ TEST(ResolveConfig, QuicMaxAckDelayLessThanMinIsError) { quicCfg.min_ack_delay_us = std::optional{1000}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("max_ack_delay_us")); EXPECT_THAT(result.error(), HasSubstr("min_ack_delay_us")); @@ -1156,7 +1248,7 @@ TEST(ResolveConfig, QuicUnknownCcAlgoIsError) { quicCfg.cc_algo = std::optional{"notanalgo"}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cc_algo")); EXPECT_THAT(result.error(), HasSubstr("notanalgo")); @@ -1169,16 +1261,14 @@ TEST(ResolveConfig, QuicCcAlgoPicoOnlyRejectedByMvfst) { quicCfg.cc_algo = std::optional{"dcubic"}; cfg.listeners.value()[0].quic = std::optional{quicCfg}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("cc_algo")); EXPECT_THAT(result.error(), HasSubstr("dcubic")); cfg.listeners.value()[0].quic_stack = std::optional{"picoquic"}; - cfg.listeners.value()[0].tls.value().insecure = false; - cfg.listeners.value()[0].tls.value().cert_file = std::optional{"cert.pem"}; - cfg.listeners.value()[0].tls.value().key_file = std::optional{"key.pem"}; - auto result2 = resolveConfig(cfg); + cfg.listeners.value()[0].tls = ParsedTlsFile{.cert_file = "cert.pem", .key_file = "key.pem"}; + auto result2 = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result2.hasValue()); EXPECT_EQ(result2.value().config.listeners[0].quic.ccAlgo, "dcubic"); } @@ -1190,16 +1280,14 @@ TEST(ResolveConfig, QuicCcAlgoMvfstOnlyRejectedByPico) { quicCfg.cc_algo = std::optional{"bbr2"}; cfg.listeners.value()[0].quic = std::optional{quicCfg}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.listeners[0].quic.ccAlgo, "bbr2"); cfg.listeners.value()[0].quic_stack = std::optional{"picoquic"}; - cfg.listeners.value()[0].tls.value().insecure = false; - cfg.listeners.value()[0].tls.value().cert_file = std::optional{"cert.pem"}; - cfg.listeners.value()[0].tls.value().key_file = std::optional{"key.pem"}; + cfg.listeners.value()[0].tls = ParsedTlsFile{.cert_file = "cert.pem", .key_file = "key.pem"}; cfg.listeners.value()[0].quic = std::optional{quicCfg}; - auto result2 = resolveConfig(cfg); + auto result2 = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result2.hasError()); EXPECT_THAT(result2.error(), HasSubstr("cc_algo")); EXPECT_THAT(result2.error(), HasSubstr("bbr2")); @@ -1211,7 +1299,7 @@ TEST(ResolveConfig, QuicCcAlgoRoundTrips) { quicCfg.cc_algo = std::optional{"cubic"}; cfg.listeners.value()[0].quic = std::optional{std::move(quicCfg)}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ(result.value().config.listeners[0].quic.ccAlgo, "cubic"); } @@ -1223,9 +1311,7 @@ ParsedConfig makeMinimalPicoConfig() { auto cfg = makeMinimalInsecureConfig(); auto& lc = cfg.listeners.value()[0]; lc.quic_stack = std::optional{"picoquic"}; - lc.tls.value().insecure = false; - lc.tls.value().cert_file = std::optional{"cert.pem"}; - lc.tls.value().key_file = std::optional{"key.pem"}; + lc.tls = ParsedTlsFile{.cert_file = "cert.pem", .key_file = "key.pem"}; return cfg; } @@ -1233,7 +1319,7 @@ TEST(ResolveConfig, PicoPrefixPathServiceWarning) { // The default service uses only a prefix path — pico warns about the prefix // rule AND about having no exact-path endpoints registered. auto cfg = makeMinimalPicoConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& warnings = result.value().warnings; EXPECT_THAT(warnings, ::testing::Contains(HasSubstr("prefix path"))); @@ -1254,7 +1340,7 @@ TEST(ResolveConfig, PicoNoExactPathsWarning) { svc.cache = makeDefaultCache(); cfg.services.value().emplace("prefix-only-svc", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_THAT(result.value().warnings, ::testing::Contains(HasSubstr("no WebTransport endpoints"))); } @@ -1271,7 +1357,7 @@ TEST(ResolveConfig, PicoExactPathServiceNoWarning) { svc.cache = makeDefaultCache(); cfg.services.value().emplace("exact-svc", std::move(svc)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); // No pico path warnings for (const auto& w : result.value().warnings) { @@ -1282,7 +1368,7 @@ TEST(ResolveConfig, PicoExactPathServiceNoWarning) { TEST(ResolveConfig, MvfstPrefixPathServiceNoWarning) { // Prefix paths on mvfst listeners are fine — no pico warning. auto cfg = makeMinimalInsecureConfig(); // default stack = mvfst - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); for (const auto& w : result.value().warnings) { EXPECT_THAT(w, ::testing::Not(HasSubstr("Picoquic listener"))); @@ -1294,7 +1380,7 @@ TEST(ResolveConfig, MvfstPrefixPathServiceNoWarning) { TEST(ResolveConfig, MaxCacheDurationDefaultIs1Day) { // When max_cache_duration_s is absent, code default of 1 day is used. auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ( result.value().config.services.at("default").cache.maxCacheDuration, @@ -1309,7 +1395,7 @@ TEST(ResolveConfig, CacheDurationExplicitValues) { std::optional{3600}; cfg.services.value().at("default").cache.value()->default_max_cache_duration_s = std::optional{60}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& cache = result.value().config.services.at("default").cache; EXPECT_EQ(cache.maxCacheDuration, std::chrono::seconds(3600)); @@ -1320,7 +1406,7 @@ TEST(ResolveConfig, MaxCacheDurationZeroIsInvalid) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().at("default").cache.value()->max_cache_duration_s = std::optional{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("max_cache_duration_s must not be 0")); } @@ -1331,7 +1417,7 @@ TEST(ResolveConfig, DefaultCacheDurationAbsentUsesMaxCacheDuration) { // When default_max_cache_duration_s is absent, defaultMaxCacheDuration falls back to // maxCacheDuration (1 day by default). auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ( *result.value().config.services.at("default").cache.defaultMaxCacheDuration, @@ -1344,7 +1430,7 @@ TEST(ResolveConfig, DefaultCacheDurationZeroMeansOptInOnly) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().at("default").cache.value()->default_max_cache_duration_s = std::optional{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); EXPECT_EQ( *result.value().config.services.at("default").cache.defaultMaxCacheDuration, @@ -1380,7 +1466,7 @@ TEST(ResolveConfig, CacheDurationMergesWithDefaults) { overrider.cache = std::move(svcCache); cfg.services.value().emplace("overrider", std::move(overrider)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.services.at("inheritor").cache.maxCacheDuration, std::chrono::seconds(7200)); @@ -1400,7 +1486,7 @@ TEST(ResolveConfig, CacheDurationMergesWithDefaults) { TEST(ResolveConfig, CacheByteLimitsDefaults) { // max_cached_mb absent → 16 MB; min_eviction_kb absent → 64 KB. auto cfg = makeMinimalInsecureConfig(); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& cache = result.value().config.services.at("default").cache; EXPECT_EQ(cache.maxCachedMb, 16u); @@ -1410,7 +1496,7 @@ TEST(ResolveConfig, CacheByteLimitsDefaults) { TEST(ResolveConfig, CacheMbZeroIsInvalid) { auto cfg = makeMinimalInsecureConfig(); cfg.services.value().at("default").cache.value()->max_cached_mb = std::optional{0}; - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasError()); EXPECT_THAT(result.error(), HasSubstr("max_cached_mb must not be 0")); } @@ -1442,7 +1528,7 @@ TEST(ResolveConfig, CacheByteLimitsMergeWithDefaults) { overrider.cache = std::move(svcCache); cfg.services.value().emplace("overrider", std::move(overrider)); - auto result = resolveConfig(cfg); + auto result = resolveConfig(cfg, makeTestRegistry()); ASSERT_TRUE(result.hasValue()); const auto& resolved = result.value().config; EXPECT_EQ(resolved.services.at("inheritor").cache.maxCachedMb, 256u); @@ -1451,5 +1537,37 @@ TEST(ResolveConfig, CacheByteLimitsMergeWithDefaults) { EXPECT_EQ(resolved.services.at("overrider").cache.minEvictionKb, 512u); } +// --- Partial registration tests --- + +TEST(ResolveConfig, UnregisteredProviderRejected) { + // Registry with only "file" — insecure config should fail + tls::TlsProviderRegistry registry; + registry.registerProvider( + "file", + tls::makeFileFactory([](auto, auto) -> std::shared_ptr { + return std::make_shared(); + }) + ); + + auto cfg = makeMinimalInsecureConfig(); + auto result = resolveConfig(cfg, registry); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("unknown TLS type")); + EXPECT_THAT(result.error(), HasSubstr("insecure")); +} + +TEST(ResolveConfig, RegisteredProviderAccepted) { + // Registry with only "insecure" — insecure config should succeed + tls::TlsProviderRegistry registry; + registry.registerProvider("insecure", tls::makeInsecureFactory([] { + return std::make_shared(); + })); + + auto cfg = makeMinimalInsecureConfig(); + auto result = resolveConfig(cfg, registry); + ASSERT_TRUE(result.hasValue()); + EXPECT_NE(result.value().config.listeners[0].tlsProvider, nullptr); +} + } // namespace } // namespace openmoq::moqx::config diff --git a/test/config/loader_test.cpp b/test/config/loader_test.cpp index 2c5ad093..c9b9e15b 100644 --- a/test/config/loader_test.cpp +++ b/test/config/loader_test.cpp @@ -10,18 +10,19 @@ #include #include -#include "test_utils.h" +#include "util/TempDir.h" namespace openmoq::moqx::config { namespace { -using test::TempYamlFile; +using test::util::TempDir; using ::testing::HasSubstr; // --- Parse tests --- TEST(ConfigLoader, MinimalConfig) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -29,7 +30,7 @@ TEST(ConfigLoader, MinimalConfig) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: default: @@ -44,12 +45,13 @@ TEST(ConfigLoader, MinimalConfig) { port: 9669 address: "::1" plaintext: true +admin: + port: 9669 )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); EXPECT_EQ(cfg.listeners.value().size(), 1); EXPECT_EQ(cfg.listeners.value()[0].name.value(), "main"); - EXPECT_TRUE(cfg.listeners.value()[0].tls.value().insecure.value()); const auto& sock = cfg.listeners.value()[0].udp.value().socket.value(); EXPECT_EQ(sock.port.value(), 9668); @@ -64,8 +66,9 @@ TEST(ConfigLoader, MinimalConfig) { EXPECT_EQ(svc.cache.value()->max_groups_per_track.value(), 3); } -TEST(ConfigLoader, FullConfig) { - TempYamlFile yaml(R"( +TEST(ConfigLoader, TlsFileConfig) { + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: production udp: @@ -73,9 +76,9 @@ TEST(ConfigLoader, FullConfig) { address: "0.0.0.0" port: 4443 tls: + type: file cert_file: /etc/ssl/cert.pem key_file: /etc/ssl/key.pem - insecure: false endpoint: "/relay" moqt_versions: [14, 16] services: @@ -91,18 +94,17 @@ TEST(ConfigLoader, FullConfig) { port: 9669 address: "::1" plaintext: true +admin: + port: 9669 )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); const auto& l = cfg.listeners.value()[0]; EXPECT_EQ(l.name.value(), "production"); const auto& sock = l.udp.value().socket.value(); EXPECT_EQ(sock.address.value(), "0.0.0.0"); EXPECT_EQ(sock.port.value(), 4443); - EXPECT_EQ(l.tls.value().cert_file.value().value(), "/etc/ssl/cert.pem"); - EXPECT_EQ(l.tls.value().key_file.value().value(), "/etc/ssl/key.pem"); - EXPECT_FALSE(l.tls.value().insecure.value()); EXPECT_EQ(l.endpoint.value(), "/relay"); ASSERT_TRUE(l.moqt_versions.value().has_value()); EXPECT_EQ(l.moqt_versions.value()->size(), 2); @@ -118,7 +120,8 @@ TEST(ConfigLoader, FullConfig) { } TEST(ConfigLoader, ServicesWithAuthorityAndPath) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -126,7 +129,7 @@ TEST(ConfigLoader, ServicesWithAuthorityAndPath) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: live: @@ -149,7 +152,7 @@ TEST(ConfigLoader, ServicesWithAuthorityAndPath) { max_groups_per_track: 3 )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); ASSERT_EQ(cfg.services.value().size(), 2); const auto& svc0 = cfg.services.value().at("live"); @@ -208,7 +211,8 @@ TEST(ConfigLoader, ServicesWithAuthorityAndPath) { } TEST(ConfigLoader, ServicesWithAnyAuthority) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -216,7 +220,7 @@ TEST(ConfigLoader, ServicesWithAnyAuthority) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: default: @@ -229,7 +233,7 @@ TEST(ConfigLoader, ServicesWithAnyAuthority) { max_groups_per_track: 3 )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); ASSERT_EQ(cfg.services.value().size(), 1); const auto& svc = cfg.services.value().at("default"); ASSERT_EQ(svc.match.value().size(), 1); @@ -256,7 +260,8 @@ TEST(ConfigLoader, ServicesWithAnyAuthority) { } TEST(ConfigLoader, ServiceDefaults) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -264,7 +269,7 @@ TEST(ConfigLoader, ServiceDefaults) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" service_defaults: cache: @@ -278,7 +283,7 @@ TEST(ConfigLoader, ServiceDefaults) { path: {prefix: "/"} )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); ASSERT_TRUE(cfg.service_defaults.value().has_value()); ASSERT_TRUE(cfg.service_defaults.value()->cache.value().has_value()); EXPECT_EQ(cfg.service_defaults.value()->cache.value()->max_tracks.value(), 50); @@ -288,6 +293,63 @@ TEST(ConfigLoader, ServiceDefaults) { EXPECT_FALSE(cfg.services.value().at("default").cache.value().has_value()); } +TEST(ConfigLoader, TlsDirectoryConfig) { + TempDir dir; + auto yaml = dir.writeYaml(R"( +listeners: + - name: multi + udp: + socket: + address: "::" + port: 4443 + tls: + type: directory + cert_dir: /etc/ssl/certs.d/ + default_cert: example.com + endpoint: "/moq-relay" +cache: + enabled: true + max_tracks: 100 + max_groups_per_track: 3 +services: + default: + match: + - authority: {any: true} + path: {prefix: "/"} +)"); + + auto cfg = loadConfig(yaml); + EXPECT_EQ(cfg.listeners.value().size(), 1); +} + +TEST(ConfigLoader, TlsDirectoryConfigNoDefault) { + TempDir dir; + auto yaml = dir.writeYaml(R"( +listeners: + - name: multi + udp: + socket: + address: "::" + port: 4443 + tls: + type: directory + cert_dir: /etc/ssl/certs.d/ + endpoint: "/moq-relay" +cache: + enabled: true + max_tracks: 100 + max_groups_per_track: 3 +services: + default: + match: + - authority: {any: true} + path: {prefix: "/"} +)"); + + auto cfg = loadConfig(yaml); + EXPECT_EQ(cfg.listeners.value().size(), 1); +} + // --- Schema generation test --- TEST(ConfigSchema, GeneratesValidJson) { @@ -314,7 +376,8 @@ TEST(ConfigSchema, GeneratesValidJson) { // --- Load from file test --- TEST(ConfigLoader, LoadFromFile) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: test udp: @@ -322,7 +385,7 @@ TEST(ConfigLoader, LoadFromFile) { address: "::" port: 8080 tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: default: @@ -337,9 +400,11 @@ TEST(ConfigLoader, LoadFromFile) { port: 9669 address: "::1" plaintext: true +admin: + port: 9669 )"); - auto cfg = loadConfig(yaml.path()); + auto cfg = loadConfig(yaml); EXPECT_EQ(cfg.listeners.value()[0].name.value(), "test"); ASSERT_TRUE(cfg.services.value().at("default").cache.value().has_value()); EXPECT_EQ(cfg.services.value().at("default").cache.value()->enabled.value(), false); @@ -350,14 +415,16 @@ TEST(ConfigLoader, LoadFromFileNotFound) { } TEST(ConfigLoader, LoadFromFileInvalidYaml) { - TempYamlFile yaml("not: [valid: yaml: config"); - EXPECT_THROW(loadConfig(yaml.path()), std::runtime_error); + TempDir dir; + auto yaml = dir.writeYaml("not: [valid: yaml: config"); + EXPECT_THROW(loadConfig(yaml), std::runtime_error); } // --- Unknown field tests --- TEST(ConfigLoader, UnknownFieldIgnoredNonStrict) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -365,7 +432,7 @@ TEST(ConfigLoader, UnknownFieldIgnoredNonStrict) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" bogus: 42 services: @@ -381,13 +448,16 @@ TEST(ConfigLoader, UnknownFieldIgnoredNonStrict) { port: 9669 address: "::1" plaintext: true +admin: + port: 9669 )"); - EXPECT_NO_THROW(loadConfig(yaml.path())); + EXPECT_NO_THROW(loadConfig(yaml)); } TEST(ConfigLoader, UnknownFieldRejectedStrict) { - TempYamlFile yaml(R"( + TempDir dir; + auto yaml = dir.writeYaml(R"( listeners: - name: main udp: @@ -395,7 +465,7 @@ TEST(ConfigLoader, UnknownFieldRejectedStrict) { address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" bogus: 42 services: @@ -411,9 +481,11 @@ TEST(ConfigLoader, UnknownFieldRejectedStrict) { port: 9669 address: "::1" plaintext: true +admin: + port: 9669 )"); - EXPECT_THROW(loadConfig(yaml.path(), /*strict=*/true), std::runtime_error); + EXPECT_THROW(loadConfig(yaml, /*strict=*/true), std::runtime_error); } #ifdef CONFIG_EXAMPLE_PATH diff --git a/test/config/test_utils.h b/test/config/test_utils.h deleted file mode 100644 index 64200d62..00000000 --- a/test/config/test_utils.h +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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. - */ - -#pragma once - -#include -#include -#include -#include -#include - -namespace openmoq::moqx::config::test { - -// RAII helper: writes YAML content to a unique temp file, removes it on destruction. -class TempYamlFile { -public: - explicit TempYamlFile(std::string_view content) { - static std::atomic counter{0}; - path_ = std::filesystem::temp_directory_path() / - ("moqx_test_" + std::to_string(::getpid()) + "_" + std::to_string(counter++) + ".yaml"); - std::ofstream ofs(path_); - ofs << content; - } - ~TempYamlFile() { std::filesystem::remove(path_); } - - TempYamlFile(const TempYamlFile&) = delete; - TempYamlFile& operator=(const TempYamlFile&) = delete; - - std::string path() const { return path_.string(); } - -private: - std::filesystem::path path_; -}; - -} // namespace openmoq::moqx::config::test diff --git a/test/test.config.yaml b/test/test.config.yaml index 727cf379..e4913e77 100644 --- a/test/test.config.yaml +++ b/test/test.config.yaml @@ -6,7 +6,7 @@ listeners: address: "::" port: 9668 tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: default: diff --git a/test/test_admin_tls.sh b/test/test_admin_tls.sh index d3152202..7f9ff679 100755 --- a/test/test_admin_tls.sh +++ b/test/test_admin_tls.sh @@ -30,7 +30,7 @@ listeners: address: "::1" port: ${LISTEN_PORT} tls: - insecure: true + type: insecure endpoint: "/moq-relay" services: default: diff --git a/test/tls/DirectoryCertLoaderTest.cpp b/test/tls/DirectoryCertLoaderTest.cpp new file mode 100644 index 00000000..a87bd7a7 --- /dev/null +++ b/test/tls/DirectoryCertLoaderTest.cpp @@ -0,0 +1,98 @@ +/* + * 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/DirectoryCertLoader.h" + +#include +#include + +#include "TestCertUtils.h" +#include "util/TempDir.h" + +namespace openmoq::moqx::tls { +namespace { + +using openmoq::moqx::test::util::TempDir; +using test::kTestCert2Pem; +using test::kTestCertPem; +using test::kTestKey2Pem; +using test::kTestKeyPem; +using ::testing::HasSubstr; + +TEST(DirectoryCertLoader, LoadsSingleCert) { + TempDir dir; + dir.writeCert("test", kTestCertPem, kTestKeyPem); + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasValue()) << result.error(); + + EXPECT_EQ(result.value().certs.size(), 1); + EXPECT_FALSE(result.value().defaultIdentity.empty()); +} + +TEST(DirectoryCertLoader, LoadsMultipleCerts) { + TempDir dir; + dir.writeCert("alpha", kTestCertPem, kTestKeyPem); + dir.writeCert("beta", kTestCert2Pem, kTestKey2Pem); + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasValue()) << result.error(); + + EXPECT_EQ(result.value().certs.size(), 2); + // Default should be the first cert (alphabetically sorted: alpha before beta) + EXPECT_FALSE(result.value().defaultIdentity.empty()); +} + +TEST(DirectoryCertLoader, EmptyDirectory) { + TempDir dir; + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("No certificate pairs found")); +} + +TEST(DirectoryCertLoader, NonexistentDirectory) { + DirectoryCertLoader loader("/nonexistent/dir", ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("Failed to read directory")); +} + +TEST(DirectoryCertLoader, CrtWithoutMatchingKey) { + TempDir dir; + dir.writeCertOnly("orphan", kTestCertPem); + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("Failed to read key file")); +} + +TEST(DirectoryCertLoader, InvalidDefaultCertIdentity) { + TempDir dir; + dir.writeCert("test", kTestCertPem, kTestKeyPem); + + DirectoryCertLoader loader(dir.path(), "nonexistent.example.com"); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("not found")); +} + +TEST(DirectoryCertLoader, InvalidPem) { + TempDir dir; + dir.writeCert("bad", "not a cert", "not a key"); + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("Failed to parse certificate")); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/FileCertLoaderTest.cpp b/test/tls/FileCertLoaderTest.cpp new file mode 100644 index 00000000..93f09ad1 --- /dev/null +++ b/test/tls/FileCertLoaderTest.cpp @@ -0,0 +1,65 @@ +/* + * 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/FileCertLoader.h" + +#include +#include + +#include "TestCertUtils.h" +#include "util/TempDir.h" + +namespace openmoq::moqx::tls { +namespace { + +using openmoq::moqx::test::util::TempDir; +using test::kTestCertPem; +using test::kTestKeyPem; +using ::testing::HasSubstr; + +TEST(FileCertLoader, LoadsValidCertKeyPair) { + TempDir dir; + dir.writeCert("test", kTestCertPem, kTestKeyPem); + + FileCertLoader loader(dir.filePath("test.crt"), dir.filePath("test.key")); + auto result = loader.load(); + ASSERT_TRUE(result.hasValue()) << result.error(); + + EXPECT_EQ(result.value().certs.size(), 1); + EXPECT_FALSE(result.value().defaultIdentity.empty()); + EXPECT_EQ(result.value().certs[0].identity, result.value().defaultIdentity); + EXPECT_NE(result.value().certs[0].cert, nullptr); +} + +TEST(FileCertLoader, MissingCertFile) { + FileCertLoader loader("/nonexistent/cert.pem", "/nonexistent/key.pem"); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("cert")); +} + +TEST(FileCertLoader, MissingKeyFile) { + TempDir dir; + dir.writeCertOnly("test", kTestCertPem); + + FileCertLoader loader(dir.filePath("test.crt"), "/nonexistent/key.pem"); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("key")); +} + +TEST(FileCertLoader, InvalidPem) { + TempDir dir; + dir.writeCert("test", "not a cert", "not a key"); + + FileCertLoader loader(dir.filePath("test.crt"), dir.filePath("test.key")); + auto result = loader.load(); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("Failed to parse certificate")); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/FizzContextFactoryTest.cpp b/test/tls/FizzContextFactoryTest.cpp new file mode 100644 index 00000000..e191a65b --- /dev/null +++ b/test/tls/FizzContextFactoryTest.cpp @@ -0,0 +1,93 @@ +/* + * 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 +#include +#include + +#include + +#include +#include + +#include "TestCertUtils.h" +#include "util/TempDir.h" + +namespace openmoq::moqx::tls { +namespace { + +using openmoq::moqx::test::util::TempDir; +using test::kTestCert2Pem; +using test::kTestCertPem; +using test::kTestKey2Pem; +using test::kTestKeyPem; +using ::testing::HasSubstr; + +TEST(BuildAlpns, IncludesH3) { + auto alpns = buildAlpns(""); + ASSERT_FALSE(alpns.empty()); + EXPECT_EQ(alpns[0], "h3"); +} + +TEST(BuildAlpns, IncludesMoqtVersions) { + auto alpns = buildAlpns("14,16"); + EXPECT_GE(alpns.size(), 2u); + EXPECT_EQ(alpns[0], "h3"); +} + +TEST(BuildStandardFizzContext, ValidCertManager) { + TempDir dir; + dir.writeCert("test", kTestCertPem, kTestKeyPem); + + FileCertLoader loader(dir.filePath("test.crt"), dir.filePath("test.key")); + auto loaded = loader.load(); + ASSERT_TRUE(loaded.hasValue()) << loaded.error(); + + auto certManager = std::make_shared(); + for (auto& entry : loaded.value().certs) { + certManager->addCertAndSetDefault(std::move(entry.cert)); + } + + auto result = buildStandardFizzContext(std::move(certManager), {"h3"}); + ASSERT_TRUE(result.hasValue()) << result.error(); + EXPECT_NE(result.value(), nullptr); + + const auto& alpns = result.value()->getSupportedAlpns(); + ASSERT_FALSE(alpns.empty()); + EXPECT_EQ(alpns[0], "h3"); +} + +TEST(FileCertLoader, CreateContextEndToEnd) { + TempDir dir; + dir.writeCert("test", kTestCertPem, kTestKeyPem); + + FileCertLoader loader(dir.filePath("test.crt"), dir.filePath("test.key")); + auto result = loader.createContext({"h3"}); + ASSERT_TRUE(result.hasValue()) << result.error(); + EXPECT_NE(result.value(), nullptr); + EXPECT_FALSE(result.value()->getSupportedAlpns().empty()); +} + +TEST(DirectoryCertLoader, CreateContextEndToEnd) { + TempDir dir; + dir.writeCert("alpha", kTestCertPem, kTestKeyPem); + dir.writeCert("beta", kTestCert2Pem, kTestKey2Pem); + + DirectoryCertLoader loader(dir.path(), ""); + auto result = loader.createContext({"h3"}); + ASSERT_TRUE(result.hasValue()) << result.error(); + EXPECT_NE(result.value(), nullptr); +} + +TEST(FileCertLoader, CreateContextInvalidCert) { + FileCertLoader loader("/nonexistent/cert.pem", "/nonexistent/key.pem"); + auto result = loader.createContext({"h3"}); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("cert")); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/InsecureCertProviderTest.cpp b/test/tls/InsecureCertProviderTest.cpp new file mode 100644 index 00000000..b62f5beb --- /dev/null +++ b/test/tls/InsecureCertProviderTest.cpp @@ -0,0 +1,37 @@ +/* + * 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 + +#include +#include + +namespace openmoq::moqx::tls { +namespace { + +TEST(InsecureCertProvider, CreateContextReturnsValidContext) { + InsecureCertProvider provider; + auto result = provider.createContext({"h3"}, {}); + ASSERT_TRUE(result.hasValue()) << result.error(); + EXPECT_NE(result.value(), nullptr); + + const auto& alpns = result.value()->getSupportedAlpns(); + ASSERT_FALSE(alpns.empty()); + EXPECT_EQ(alpns[0], "h3"); +} + +TEST(InsecureCertProvider, CreateContextWithMoqtAlpns) { + InsecureCertProvider provider; + auto result = provider.createContext({"h3", "moq-00"}, {}); + ASSERT_TRUE(result.hasValue()) << result.error(); + EXPECT_NE(result.value(), nullptr); + + const auto& alpns = result.value()->getSupportedAlpns(); + EXPECT_GE(alpns.size(), 2u); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/tls/TestCertUtils.h b/test/tls/TestCertUtils.h new file mode 100644 index 00000000..832bb424 --- /dev/null +++ b/test/tls/TestCertUtils.h @@ -0,0 +1,119 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace openmoq::moqx::tls::test { + +// Self-signed RSA cert+key pairs for testing, generated via: +// openssl req -x509 -newkey rsa:2048 -nodes -days 3650 +// -subj '/CN=' -keyout key.pem -out cert.pem +// NOLINTBEGIN(cert-*) + +// CN=test.example.com +inline constexpr std::string_view kTestCertPem = R"(-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUOnKhCz63sMKM5bJgAec46+CNL9EwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAeFw0yNjAzMTAxMTMxMzFa +Fw0zNjAzMDcxMTMxMzFaMBsxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtY/D+jir6s7Dhm/j1c0BrBEKc +qAtgynmuL/ixx6PUNWXWvGmINWLFhBjiH/fMoN02Wp7DKzkq5sSwMzPypltG6gwc +G4MYpvarfV2fuB7qAXTk/HkhQ7XLZgLyNIoaPQSVFE45PlJJXpJgQnL7Z0bujkAB +GyXztaouks3toikmS24I17Ecb9iuNsHMjwrjxiKC07UTn3fISoZtTXDjSvir8JRP +4rM/+Ozhc1LUvtqgEYjpYsAAM2AX8hYdTCYNyPAdNzdYS0YTSDy/V4HbjOJq5tZd +TX06zet93/sylg6PR6i0soCMnTkRm+qQpQICfRGztGCMpWJ7h98gcXNKpvlHAgMB +AAGjUzBRMB0GA1UdDgQWBBSNjpgRXPw2ScI81af4PJCIZwQ0jDAfBgNVHSMEGDAW +gBSNjpgRXPw2ScI81af4PJCIZwQ0jDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQCIQKqF0YP+qlK/7CchMNDmW5GI0k2tJeict83QeItzXNrCn+fG +sYGqyHETYRODQjpArg/tDtt9EWwsy9JRX8ZGNcZlcL5PLaRPyBsn5qz5bjD/IiWo +hg0pv754jxZvuNd5SG4WmWaONoE8XsR3bW7NDlRtBGVviNMm8TFhC+3ryPT3f3fB +giG+p0UQzzHmpdygjtqTgxCzcNvYsbiVx/RPgdIWdbXJe1m2n0GCWyMVeCOquycm +detra9fkFX2K26KMTT7Qon8OYSnouwOsG/f7sQ40J0vo35WE1/C29DdKrJTvFszt +pkII1lrqJk10+t2RghWF2i57I7dzb+bJwHCl +-----END CERTIFICATE-----)"; + +inline constexpr std::string_view kTestKeyPem = R"(-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtY/D+jir6s7Dh +m/j1c0BrBEKcqAtgynmuL/ixx6PUNWXWvGmINWLFhBjiH/fMoN02Wp7DKzkq5sSw +MzPypltG6gwcG4MYpvarfV2fuB7qAXTk/HkhQ7XLZgLyNIoaPQSVFE45PlJJXpJg +QnL7Z0bujkABGyXztaouks3toikmS24I17Ecb9iuNsHMjwrjxiKC07UTn3fISoZt +TXDjSvir8JRP4rM/+Ozhc1LUvtqgEYjpYsAAM2AX8hYdTCYNyPAdNzdYS0YTSDy/ +V4HbjOJq5tZdTX06zet93/sylg6PR6i0soCMnTkRm+qQpQICfRGztGCMpWJ7h98g +cXNKpvlHAgMBAAECggEAH7p4mJQ6YCrulLI+cefXo12htNn5TwpuDsZfe2S9YXEu +BAfxRcgDHYKpLQPNjAfpwu79O1iW+vdEibus51uyuzzL337XU/UFkWb88WO3YHnI +wrhCkCg8RY6SvnCHzvpYctFG6Smy1BM2tN+j+8Yv0Cp+otUtcjXNgP1DKpdwcT2y +CfJ/xT+kINy+zzt3dZR8GZf2NMhJRJU8utf8Jar9RVWqVmehBl6+DhrUxbb1yom8 +hn5nbkFbaJpTzhz5IRJvhTyQxVcLPLnnbmvPUv7LS/6qZpEftoHii2lKXejfHSD6 ++JGWPbHqjuUm8gerjDF9cVPjw0JxrNM8PLDht9xFrQKBgQDhbehGQ35ugEM5sYqb +2KI+sqG13twhGF1t8IhqyWyye8oo4uyB6+llCbGr69D8Swq1TlBwV88GPfNkyGPd +jXRGH4IZAA4MG/M38r8TVOlg82WhiTiogL8nd1uyIT4dwbmrAPIzq+vCl257Oiq0 +3Tv7koUSLKwv7z14cnKQGtkhAwKBgQDE524jI3+Iar/Ti4/QEuahu6MbRZLes0nD +/B6Kk0OOBf3jqMaaDh+Ni63osE1aHx1z5JzBsvEYxu1h7F3jNdnNH74w0n7a6g/n +0GwJPXhuvVdWS4luY1WwXpi9gnRXheyDEIBRVUkPvV9QmD6Pnzp0lPZJiWRpmyOf +n5264Qj5bQKBgCPOu3h9vBV9VjBR3TyIGq1u3nTvI3Q2VJDkBidAO33WX/RCp2Kz +wG0GLyyp1pZcrSTDfc96gy3wpTq7AfHtSCzjUFz8Pz75KZcXffZqJG/7+YbBLzjE +yphQQ0Z2NVGwtfdNvSssAdT1DN2SDbqQ8bgyO+T5J5itncwGEeCGAztVAoGAS1M5 +d+nJjPdBYPz/zBqe7fopAHLSJ62wp2/Ygyyo6Dj0klXre92xRmXL5rsjLDnA+6fW +K+d3ggH/p7lThWsBYg4lpOmxq69k3EqIOdSxMLPwKEwHTBpmGm1lwwGX3i+WdeEn +JXYZ2BKa1usW67x/EUA3I5SSvC+kJhlarrYNx9UCgYEA1xabQ0ZBszTsxQz6oB+7 +PrZYmVqlo3ylmLn+7ghGcig4l9ivRfjOFTSy/OTaukNiedh6aMwfz1GkhbikKai0 +WXYWV/Iq7RsupsP+eGJx3tP6UkIXrkAFaXHrqHiwMfYR0naIAiBipObAOnpqFu7y ++P2OQBSxVVDDX+JiwJJcdp4= +-----END PRIVATE KEY-----)"; + +// CN=other.example.com +inline constexpr std::string_view kTestCert2Pem = R"(-----BEGIN CERTIFICATE----- +MIIDGTCCAgGgAwIBAgIUNXS0qZWo25EtkbwFYfswQnruGAswDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRb3RoZXIuZXhhbXBsZS5jb20wHhcNMjYwMzEwMTEzMTM1 +WhcNMzYwMzA3MTEzMTM1WjAcMRowGAYDVQQDDBFvdGhlci5leGFtcGxlLmNvbTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOWcXNymcgccdEBC4e6fHued +HODQaV1Qi/vzOOAom4XfU5MpqETZgdtiz/3PkuaHLVzgswne0K+gUdIxnhKj8jhZ +x1FiOObu/z9a51NB2dFEvpX5WOpmJp06DkJBgQD2bIpCsg6qjDuooFN+NqW2fhQL +wBwe9zW8eLK+M/hwms4iWDbbrSSeAHmy3xpCmAlKAeguO20LAK2r6Wi0EKraI9vT +qSFMTKT+AMybrIWMKD1K3S+mZdgvaErxju9ssfJjQFmwsL4lgBf9yzz0+uHxjre5 +aeOxFRgB+2Gyui7yFvxKgYVqxABeXNj1Afhul2RsSYE8u2+Qap6ZildgkADHDj0C +AwEAAaNTMFEwHQYDVR0OBBYEFE0IzNcgY6p5jMzqGSb/FbJkY8H1MB8GA1UdIwQY +MBaAFE0IzNcgY6p5jMzqGSb/FbJkY8H1MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBAOPEgvxu9I/8h2Lpv1RNNogNGbZSd7Cl9jizkbcanJ+meNFG +bHyvU+4zqPMjraXhO2JssK7T4IZzluIISG7BtyR8+kLm427fpbeUmt+fjCqbru1J +fyTAd855FGtgGn0NPLR5fCBmfiaV1ElNIdI87KK5MKKNuAqKbAByx1KaRAWn5rQd +y+D1/MNx1nCz35uMDWUP0MKx8H1XN1YpJOLj8tvPOcHBqrZ2F+U2Vg/5axKJnjbH +zbl3U0Ix5bA3qAe6s2Ifd6ZNq7I54ACM4tz+ZnWY6Rb7oiHErggnUXtQukyetevX +EWL806wbIE7SYnYdZPlBvF0n+etkB52mwPAvVPQ= +-----END CERTIFICATE-----)"; + +inline constexpr std::string_view kTestKey2Pem = R"(-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDlnFzcpnIHHHRA +QuHunx7nnRzg0GldUIv78zjgKJuF31OTKahE2YHbYs/9z5Lmhy1c4LMJ3tCvoFHS +MZ4So/I4WcdRYjjm7v8/WudTQdnRRL6V+VjqZiadOg5CQYEA9myKQrIOqow7qKBT +fjaltn4UC8AcHvc1vHiyvjP4cJrOIlg2260kngB5st8aQpgJSgHoLjttCwCtq+lo +tBCq2iPb06khTEyk/gDMm6yFjCg9St0vpmXYL2hK8Y7vbLHyY0BZsLC+JYAX/cs8 +9Prh8Y63uWnjsRUYAfthsrou8hb8SoGFasQAXlzY9QH4bpdkbEmBPLtvkGqemYpX +YJAAxw49AgMBAAECggEAAufRJq1y/fqtjGLTv3NjQc/kpBu3C3tdxIPYjsDxD8+j +Ctr6LXMg+A/aQnCD6paVWqdO7kpT6CXWbVtIadjOSwVKFdrGdHXKveuU2NWMtR+9 +Bma38oIxT5lC7H82mq4Y8sDh8Ph4Wvvo95pPWfRP0RZ9LYmnYEmFHp4ODHiX6fer +uEFK46w69DOtWHtTE/2LH8k4ApwEiNO5ijRXaCUO4d25pFb7gECwFXe7bY7AtXnV +sI9vDfi4iSDgYLeOdS9fPy5hb3P6NI0g4i8KX1WENfKAhGix9jpftNWefB8SoZiY +B+kv5yFWjZv6I2sehe0Y67lclhsAsBDAobgL414+SQKBgQD6mPAys083frczM+HP +x+23m6ebV3lN3THSmvGObdtoVD/3rn8ohM3vEIyuBIcJPjnm4cyfHqLTT1YczwCk +da26FtUmxZQY03aB0nSxNfzHO/08fy51ToJyWdxw9KiNASizHSamQ7o6ngxFgTXZ +H5QXUaUh79w0LyteFZ4A5BuzNQKBgQDqj5kbTjA+EjuvD6ydELW1HHxmDXEo1m/X +Lzfk5N7OTPW+0K5kvRuJD7PxX1puolVPo9FyiJ6SMyGa0vI/QWFfF8LtIGZ1CrDG +IzkKbJyZWH/i11KobvmIlem/fSwkC+LUrWV0khDKtSaFUzyRhAggO/c3tNUgQW8J +v88kGdCH6QKBgESzjw5nSC1vqOv5qkubhRlULBQTXCczoAgcAGNKzN8CUfMmPKgw +GIEU6Wx/w0GOdLNObhmlfYAu/O2y9nsf4/vjbJZPjnVr685VkzZOFbnNQXTHbUYt +uud8qUmyWU8m5TCNql3krXaKg9S+QrP+y0vFT19JcfZAhEQr6wBViR6NAoGALzRI +5rLciJFYy4lG/rDvMIyUCGGqJULKbS7Ge90HbdMVHZqXjhR0pyeu2eOLqnom2wkn +zHnsF5YMrEDJmatJsj5w7xG3LNTC8I0EHLHw7fdefUNCEj2LIE6zJONG79Yohw6C +PWxrzq+YGfq/VLWSgRIwVViiD4S7mOWuBSDg04kCgYEAsR2QoLNJEp91o0bfm3wc +kyPBExIDppaIr5QNr+4xIFKv8jG5TtfK96fh6CE2PmK4tekcDXaisbsTkfZrYDIc +Y7d17T/IYJesXUQBKvz6POjn24BHOYRy9rAzqR7AixrS0stD6s8Gd3PaHtWnV6+i +LLG9cblFRFcOQE1olYuaAkY= +-----END PRIVATE KEY-----)"; +// NOLINTEND(cert-*) + +} // namespace openmoq::moqx::tls::test diff --git a/test/tls/TlsProviderRegistryTest.cpp b/test/tls/TlsProviderRegistryTest.cpp new file mode 100644 index 00000000..bc6bdb25 --- /dev/null +++ b/test/tls/TlsProviderRegistryTest.cpp @@ -0,0 +1,103 @@ +/* + * 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 + +#include +#include + +#include +#include + +namespace openmoq::moqx::tls { +namespace { + +using ::testing::HasSubstr; +using ::testing::UnorderedElementsAre; + +// Dummy provider for testing +class DummyProvider : public TlsCertProvider { +public: + folly::Expected, std::string> + createContext(const std::vector&, const std::vector&) const override { + return folly::makeUnexpected(std::string("dummy")); + } + + folly::Expected getKeyPath() const override { + return folly::makeUnexpected(std::string("dummy")); + } + + folly::Expected getCertPath() const override { + return folly::makeUnexpected(std::string("dummy")); + } +}; + +TEST(TlsProviderRegistry, RegisterAndLookup) { + TlsProviderRegistry registry; + registry.registerProvider( + "test", + [](const config::ParsedTlsMode&) + -> folly::Expected, std::string> { + return std::make_shared(); + } + ); + + auto* factory = registry.getFactory("test"); + ASSERT_NE(factory, nullptr); + + config::ParsedTlsMode tls{config::ParsedTlsInsecure{}}; + auto result = (*factory)(tls); + ASSERT_TRUE(result.hasValue()); + EXPECT_NE(result.value(), nullptr); +} + +TEST(TlsProviderRegistry, MissingTypeReturnsNull) { + TlsProviderRegistry registry; + EXPECT_EQ(registry.getFactory("nonexistent"), nullptr); +} + +TEST(TlsProviderRegistry, RegisteredTypes) { + TlsProviderRegistry registry; + registry.registerProvider( + "alpha", + [](const config::ParsedTlsMode&) + -> folly::Expected, std::string> { + return std::make_shared(); + } + ); + registry.registerProvider( + "beta", + [](const config::ParsedTlsMode&) + -> folly::Expected, std::string> { + return std::make_shared(); + } + ); + + auto types = registry.registeredTypes(); + EXPECT_THAT(types, UnorderedElementsAre("alpha", "beta")); +} + +TEST(TlsProviderRegistry, FactoryErrorPropagation) { + TlsProviderRegistry registry; + registry.registerProvider( + "failing", + [](const config::ParsedTlsMode&) + -> folly::Expected, std::string> { + return folly::makeUnexpected(std::string("something went wrong")); + } + ); + + auto* factory = registry.getFactory("failing"); + ASSERT_NE(factory, nullptr); + + config::ParsedTlsMode tls{config::ParsedTlsInsecure{}}; + auto result = (*factory)(tls); + ASSERT_TRUE(result.hasError()); + EXPECT_THAT(result.error(), HasSubstr("something went wrong")); +} + +} // namespace +} // namespace openmoq::moqx::tls diff --git a/test/util/TempDir.h b/test/util/TempDir.h new file mode 100644 index 00000000..b485e961 --- /dev/null +++ b/test/util/TempDir.h @@ -0,0 +1,62 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace openmoq::moqx::test::util { + +// RAII helper: Creates a temp directory to store temporary YAML config files or cert/key files. +// The directory is removed on destruction. +class TempDir { +public: + TempDir() + : dir_{std::filesystem::temp_directory_path() / ("moqx_test_" + std::to_string(::getpid()))} { + std::filesystem::create_directories(dir_); + } + + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + + ~TempDir() { std::filesystem::remove_all(dir_); } + + std::filesystem::path writeYaml(std::string_view content) { + auto path = dir_ / ("config_" + std::to_string(yaml_counter_++) + ".yaml"); + std::ofstream ofs(path); + ofs << content; + return path; + } + + void writeCert(const std::string& name, std::string_view certData, std::string_view keyData) { + { + std::ofstream ofs(dir_ / (name + ".crt")); + ofs << certData; + } + { + std::ofstream ofs(dir_ / (name + ".key")); + ofs << keyData; + } + } + + void writeCertOnly(const std::string& name, std::string_view certData) { + std::ofstream ofs(dir_ / (name + ".crt")); + ofs << certData; + } + + std::filesystem::path path() const { return dir_; } + std::filesystem::path filePath(const std::string& filename) const { return dir_ / filename; } + +private: + std::filesystem::path dir_; + size_t yaml_counter_ = 0; +}; + +} // namespace openmoq::moqx::test::util