From 72218376a6f0d45b38a906385375e7ec6057dd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Fri, 27 Mar 2026 19:26:57 +0100 Subject: [PATCH 1/4] feat: Add Docker container auto-discovery with provider registry Adds automatic discovery of Docker containers via labels (roxy.domain, roxy.port) and registers them as proxy targets. Includes debounced event watching, host port mapping resolution, and a provider registry that aggregates config + Docker sources with nudge-based reload. Also cleans up layer boundaries: moves validate_path() out of domain, extracts AppContext composition root, and fixes inconsistent source labels in CLI output. --- AGENTS.md | 149 ++++- Cargo.lock | 608 +++++++++++++++++- Cargo.toml | 4 + src/application/daemon_status.rs | 35 +- src/application/list_all_domains.rs | 161 +++++ src/application/list_domains.rs | 102 --- src/application/manage_routes.rs | 19 +- src/application/mod.rs | 3 +- src/application/ports/daemon_connection.rs | 10 +- src/application/ports/mod.rs | 3 +- src/application/provider_registry.rs | 155 +++++ src/application/register_domain.rs | 28 +- src/application/restart_daemon.rs | 25 +- src/application/testkit.rs | 95 ++- src/application/uninstall.rs | 30 +- src/application/unregister_domain.rs | 16 - src/cli/context.rs | 27 + src/cli/install.rs | 14 +- src/cli/list.rs | 51 +- src/cli/mod.rs | 1 + src/cli/register.rs | 15 +- src/cli/reload.rs | 12 +- src/cli/restart.rs | 12 +- src/cli/route.rs | 23 +- src/cli/status.rs | 45 +- src/cli/stop.rs | 8 +- src/cli/uninstall.rs | 17 +- src/cli/unregister.rs | 22 +- src/config.rs | 8 + src/daemon/lifecycle.rs | 92 ++- src/daemon/mgmt_socket.rs | 125 ++-- src/daemon/mod.rs | 1 - src/daemon/router.rs | 8 +- src/daemon/server.rs | 370 ++++++----- src/daemon/tls.rs | 2 +- src/domain/mod.rs | 2 +- src/domain/registration.rs | 141 ++-- src/domain/value_objects/domain_pattern.rs | 39 +- src/domain/value_objects/mod.rs | 2 +- src/domain/value_objects/port.rs | 30 +- src/domain/value_objects/route.rs | 43 +- src/infrastructure/certs/generator.rs | 6 +- src/infrastructure/certs/mod.rs | 42 ++ src/infrastructure/config/mod.rs | 33 +- .../config/watcher.rs} | 38 +- src/infrastructure/docker/discovery.rs | 473 ++++++++++++++ src/infrastructure/docker/mod.rs | 11 + src/infrastructure/docker/network.rs | 123 ++++ src/infrastructure/docker/provider.rs | 93 +++ src/infrastructure/docker/watcher.rs | 265 ++++++++ src/infrastructure/mgmt_client.rs | 156 +++++ src/infrastructure/mod.rs | 2 + src/main.rs | 39 +- 53 files changed, 3028 insertions(+), 806 deletions(-) create mode 100644 src/application/list_all_domains.rs delete mode 100644 src/application/list_domains.rs create mode 100644 src/application/provider_registry.rs create mode 100644 src/cli/context.rs rename src/{daemon/config_watcher.rs => infrastructure/config/watcher.rs} (86%) create mode 100644 src/infrastructure/docker/discovery.rs create mode 100644 src/infrastructure/docker/mod.rs create mode 100644 src/infrastructure/docker/network.rs create mode 100644 src/infrastructure/docker/provider.rs create mode 100644 src/infrastructure/docker/watcher.rs create mode 100644 src/infrastructure/mgmt_client.rs diff --git a/AGENTS.md b/AGENTS.md index 7a50da2..8aed1da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,26 +182,24 @@ impl DomainRegistration { Use enums to model domain concepts and behavior: ```rust -pub enum Target { +pub enum RouteTarget { StaticFiles(PathBuf), - ReverseProxy(Port), + Proxy(ProxyTarget), } -impl Target { +impl RouteTarget { + /// Domain validation — structural invariants only, no I/O. + /// Filesystem checks (path exists, is directory) belong in + /// infrastructure or application layer. pub fn validate(&self) -> Result<()> { match self { - Target::StaticFiles(path) => { - if !path.exists() { - return Err(anyhow!("Path does not exist: {}", path.display())); - } - Ok(()) - } - Target::ReverseProxy(port) => { - if port.0 < 1024 { - return Err(anyhow!("Port must be >= 1024")); + RouteTarget::StaticFiles(path) => { + if path.as_os_str().is_empty() { + return Err(anyhow!("Static files path cannot be empty")); } Ok(()) } + RouteTarget::Proxy(_) => Ok(()), } } } @@ -471,6 +469,133 @@ pub struct DomainRegistration { **Remember:** DDD should make code MORE clear, not more complex. If a DDD pattern adds complexity without clear benefit, skip it. +#### Layer Boundary Rules (Enforced) + +These are concrete, non-negotiable rules for keeping layers clean as the project grows. + +**Rule 1: Domain layer MUST NOT do I/O or import infrastructure.** + +The `src/domain/` layer must only depend on `std`, `thiserror`, and `anyhow`. +No filesystem calls (`path.exists()`, `path.is_dir()`), no network, no external +crates like `bollard` or `serde`. Domain types validate structural invariants +only (e.g., "routes not empty", "name matches pattern"). Filesystem or network +validation belongs in infrastructure or application layers. + +```rust +// BAD — domain does I/O +impl DomainRegistration { + pub fn validate(&self) -> Result<()> { + if !path.exists() { /* ... */ } // filesystem check in domain + } +} + +// GOOD — domain checks structural invariants only +impl DomainRegistration { + pub fn validate(&self) -> Result<()> { + if self.routes.is_empty() { + return Err(RegistrationError::NoRoutes); + } + Ok(()) + } +} +// Infrastructure or application layer checks filesystem +``` + +**Rule 2: Domain enums MUST NOT name infrastructure implementations.** + +Domain enums model what the business cares about, not where data comes from. +If no business rule behaves differently per source, use a generic variant. + +```rust +// BAD — domain knows about Docker +pub enum RegistrationSource { + Config, + Docker, // infrastructure name in domain +} + +// GOOD — domain only knows "config vs external" +pub enum RegistrationSource { + Config, + External, // could be Docker, Podman, K8s — domain doesn't care +} +``` + +When adding a new infrastructure provider (Podman, Kubernetes, etc.), you should +NOT need to modify `src/domain/`. The provider name is metadata carried by the +infrastructure layer. + +**Rule 3: Domain value objects MUST NOT have infrastructure-specific constructors.** + +Type-driven design should encode domain distinctions, not infrastructure ones. +If two things follow different validation rules, express that as a domain concept +— not as an infrastructure-named constructor. + +```rust +// BAD — domain knows about containers +impl Port { + pub fn new(port: u16) -> Result { /* rejects < 1024 */ } + pub fn container(port: u16) -> Result { /* allows any */ } +} + +// GOOD — domain concepts, no infrastructure names +impl Port { + pub fn new(port: u16) -> Result { /* 1..=65535 */ } +} +// If different validation is truly needed, model the domain distinction: +// Port::target(port) — connecting TO a service (any port valid) +// Port::listen(port) — binding a port (may restrict range) +``` + +Ask: "Does any business rule behave differently here?" If yes, model the domain +distinction with a domain name. If no, one constructor is enough. + +**Rule 4: Application ports MUST return domain types or application DTOs.** + +Port interfaces (traits in `src/application/ports/`) must never expose +infrastructure types like `DaemonConfig`, `RoxyPaths`, raw PIDs, or +OS-specific structures. If the application needs data from infrastructure, +define a DTO in the application layer. + +```rust +// BAD — infrastructure types in port interface +fn load(&self) -> Result<(DaemonConfig, RoxyPaths)>; + +// GOOD — application-level DTO +pub struct AppConfig { pub registrations: Vec } +fn load(&self) -> Result; +``` + +**Rule 5: Domain entities should be fully constructed, not mutated by infrastructure.** + +If a property needs to be set (like registration source), pass it via +the constructor. Don't add setters that let infrastructure poke values +into domain entities after creation. + +```rust +// BAD — infrastructure mutates domain entity +let mut reg = DomainRegistration::new(pattern, routes); +reg.set_source(RegistrationSource::Docker); // setter for infra + +// GOOD — fully constructed +let reg = DomainRegistration::with_source( + pattern, routes, RegistrationSource::External +); +``` + +**Rule 6: The `daemon/` module is infrastructure.** + +`src/daemon/` contains HTTP server, proxy, TLS, DNS server — all +infrastructure. It must not contain application-level orchestration. +If something implements an application port (trait from +`src/application/ports/`), it belongs in `src/application/` or +`src/infrastructure/`, not `src/daemon/`. + +**Rule 7: Presentation concerns stay in CLI layer.** + +Step tracking, progress display, formatting — these belong in `src/cli/`. +Application use cases return results; the CLI layer decides how to present +them. Don't pass `Vec<(String, StepOutcome)>` through use cases. + ### 6. What NOT to Do ❌ **Don't over-engineer:** diff --git a/Cargo.lock b/Cargo.lock index c226d04..8204746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -213,6 +222,56 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -237,6 +296,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.5.57" @@ -301,6 +372,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "data-encoding" version = "2.10.0" @@ -328,6 +405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -347,6 +425,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "equivalent" version = "1.0.2" @@ -483,13 +567,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.16.1" @@ -502,6 +592,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -582,6 +678,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -602,6 +713,158 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -609,7 +872,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -634,6 +899,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -652,6 +927,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "log" version = "0.4.29" @@ -814,6 +1095,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -858,6 +1148,26 @@ dependencies = [ "yasna", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -897,8 +1207,10 @@ dependencies = [ "arc-swap", "axum", "base64", + "bollard", "clap", "clap_complete", + "futures-util", "http-body-util", "humantime", "hyper", @@ -991,6 +1303,30 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "serde" version = "1.0.228" @@ -1045,6 +1381,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -1066,6 +1413,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1122,6 +1487,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1235,6 +1606,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.49.0" @@ -1292,7 +1673,7 @@ version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde_core", "serde_spanned", "toml_datetime", @@ -1465,6 +1846,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1501,12 +1900,132 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1675,6 +2194,12 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "x509-parser" version = "0.18.1" @@ -1702,12 +2227,89 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index 9cf1061..f556a96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } simple-dns = "0.11" base64 = "0.22.1" +# Docker +bollard = "0.18" +futures-util = "0.3" + [profile.release] strip = true lto = true diff --git a/src/application/daemon_status.rs b/src/application/daemon_status.rs index 8106ad3..2bac7e3 100644 --- a/src/application/daemon_status.rs +++ b/src/application/daemon_status.rs @@ -2,22 +2,20 @@ use std::net::Ipv4Addr; use anyhow::Result; -use crate::domain::DomainRegistration; +use super::ports::{CertificateManager, DaemonControl, NetworkInfo}; -use super::ports::{CertificateManager, DaemonControl, DomainRepository, NetworkInfo}; - -/// Snapshot of the daemon's current status. +/// Snapshot of the daemon's current status (pid, network, CA). +/// +/// Domain listing is handled separately by `ListAllDomains`. pub struct DaemonStatus { pub pid: Option, pub lan_ip: Ipv4Addr, pub ca_installed: bool, - pub domains: Vec, } /// Application service for querying daemon status. pub struct QueryDaemonStatus<'a> { daemon: &'a dyn DaemonControl, - domains: &'a dyn DomainRepository, certs: &'a dyn CertificateManager, network: &'a dyn NetworkInfo, } @@ -25,13 +23,11 @@ pub struct QueryDaemonStatus<'a> { impl<'a> QueryDaemonStatus<'a> { pub fn new( daemon: &'a dyn DaemonControl, - domains: &'a dyn DomainRepository, certs: &'a dyn CertificateManager, network: &'a dyn NetworkInfo, ) -> Self { Self { daemon, - domains, certs, network, } @@ -42,13 +38,11 @@ impl<'a> QueryDaemonStatus<'a> { let pid = self.daemon.get_running_pid()?; let lan_ip = self.network.lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); let ca_installed = self.certs.is_ca_installed().unwrap_or(false); - let domains = self.domains.list().unwrap_or_default(); Ok(DaemonStatus { pid, lan_ip, ca_installed, - domains, }) } } @@ -57,47 +51,32 @@ impl<'a> QueryDaemonStatus<'a> { mod tests { use super::*; use crate::application::testkit::*; - use crate::domain::{DomainPattern, PathPrefix, ProxyTarget, Route, RouteTarget}; - - fn registration(name: &str) -> DomainRegistration { - DomainRegistration::new( - DomainPattern::from_name(name, false).unwrap(), - vec![Route::new( - PathPrefix::new("/").unwrap(), - RouteTarget::Proxy(ProxyTarget::parse("3000").unwrap()), - )], - ) - } #[test] - fn reports_running_daemon_with_domains() { + fn reports_running_daemon() { let daemon = InMemoryDaemonControl::running(42); - let repo = InMemoryDomainRepository::with_domains(vec![registration("myapp.roxy")]); let certs = InMemoryCertificateManager::with_ca_installed(); let network = InMemoryNetworkInfo::with_ip(Ipv4Addr::new(10, 0, 0, 1)); - let svc = QueryDaemonStatus::new(&daemon, &repo, &certs, &network); + let svc = QueryDaemonStatus::new(&daemon, &certs, &network); let status = svc.execute().unwrap(); assert_eq!(status.pid, Some(42)); assert_eq!(status.lan_ip, Ipv4Addr::new(10, 0, 0, 1)); assert!(status.ca_installed); - assert_eq!(status.domains.len(), 1); } #[test] fn reports_stopped_daemon() { let daemon = InMemoryDaemonControl::stopped(); - let repo = InMemoryDomainRepository::new(); let certs = InMemoryCertificateManager::new(); let network = InMemoryNetworkInfo::unavailable(); - let svc = QueryDaemonStatus::new(&daemon, &repo, &certs, &network); + let svc = QueryDaemonStatus::new(&daemon, &certs, &network); let status = svc.execute().unwrap(); assert_eq!(status.pid, None); assert_eq!(status.lan_ip, Ipv4Addr::LOCALHOST); assert!(!status.ca_installed); - assert!(status.domains.is_empty()); } } diff --git a/src/application/list_all_domains.rs b/src/application/list_all_domains.rs new file mode 100644 index 0000000..2534f84 --- /dev/null +++ b/src/application/list_all_domains.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use tracing::warn; + +use crate::domain::DomainRegistration; +#[cfg(test)] +use crate::domain::RegistrationSource; + +use super::ports::{CertificateManager, DaemonConnection, DaemonConnectionError, DomainRepository}; + +/// Extended domain info for display purposes, including source awareness. +pub struct DomainInfo { + pub registration: DomainRegistration, + pub has_cert: bool, + pub cert_trusted: Option, +} + +/// Result of listing all domains. +pub struct ListResult { + pub domains: Vec, + /// Whether the daemon was reachable (useful for showing Docker hints). + pub daemon_reachable: bool, +} + +/// Use case: list all domains from daemon (config + Docker) with +/// fallback to config-only when daemon is not running. +pub struct ListAllDomains<'a> { + daemon: &'a dyn DaemonConnection, + domains: &'a dyn DomainRepository, + certs: &'a dyn CertificateManager, +} + +impl<'a> ListAllDomains<'a> { + pub fn new( + daemon: &'a dyn DaemonConnection, + domains: &'a dyn DomainRepository, + certs: &'a dyn CertificateManager, + ) -> Self { + Self { + daemon, + domains, + certs, + } + } + + /// List all domains, trying the daemon first for the full picture + /// (config + Docker), falling back to config-only if daemon is not running. + pub fn execute(&self) -> Result { + let cert_trusted = self.certs.is_trusted().ok(); + + // Try daemon first — it has config + Docker domains + match self.daemon.list_registrations() { + Ok(regs) => { + let domains = regs + .into_iter() + .map(|reg| { + let has_cert = self.certs.exists(reg.pattern()); + DomainInfo { + registration: reg, + has_cert, + cert_trusted, + } + }) + .collect(); + + return Ok(ListResult { + domains, + daemon_reachable: true, + }); + } + Err(DaemonConnectionError::NotRunning) => { + // Expected when daemon is stopped — fall through to config + } + Err(e) => { + warn!("Failed to query daemon: {e}"); + } + } + + // Fallback: config file only + let regs = self.domains.list()?; + let domains = regs + .into_iter() + .map(|reg| { + let has_cert = self.certs.exists(reg.pattern()); + DomainInfo { + registration: reg, + has_cert, + cert_trusted, + } + }) + .collect(); + + Ok(ListResult { + domains, + daemon_reachable: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::testkit::*; + + fn external_registration(name: &str) -> DomainRegistration { + DomainRegistration::with_source( + exact(name), + vec![proxy_route("/", 3000)], + RegistrationSource::External, + ) + } + + #[test] + fn uses_daemon_when_available() { + let daemon = InMemoryDaemonConnection::new(vec![ + registration("app.roxy"), + external_registration("docker-app.roxy"), + ]); + let repo = InMemoryDomainRepository::with_domains(vec![registration("app.roxy")]); + let certs = InMemoryCertificateManager::with_ca_installed(); + + let svc = ListAllDomains::new(&daemon, &repo, &certs); + let result = svc.execute().unwrap(); + + assert!(result.daemon_reachable); + assert_eq!(result.domains.len(), 2); + + let docker = result + .domains + .iter() + .find(|d| d.registration.source() == RegistrationSource::External); + assert!(docker.is_some()); + } + + #[test] + fn falls_back_to_config_when_daemon_not_running() { + let daemon = NotRunningDaemonConnection; + let repo = InMemoryDomainRepository::with_domains(vec![registration("app.roxy")]); + let certs = InMemoryCertificateManager::new(); + + let svc = ListAllDomains::new(&daemon, &repo, &certs); + let result = svc.execute().unwrap(); + + assert!(!result.daemon_reachable); + assert_eq!(result.domains.len(), 1); + } + + #[test] + fn enriches_with_cert_info() { + let daemon = InMemoryDaemonConnection::new(vec![registration("app.roxy")]); + let repo = InMemoryDomainRepository::new(); + let certs = InMemoryCertificateManager::with_ca_installed(); + certs.create_and_install(&exact("app.roxy")).unwrap(); + + let svc = ListAllDomains::new(&daemon, &repo, &certs); + let result = svc.execute().unwrap(); + + assert_eq!(result.domains.len(), 1); + assert!(result.domains[0].has_cert); + assert_eq!(result.domains[0].cert_trusted, Some(true)); + } +} diff --git a/src/application/list_domains.rs b/src/application/list_domains.rs deleted file mode 100644 index 45b42d8..0000000 --- a/src/application/list_domains.rs +++ /dev/null @@ -1,102 +0,0 @@ -use anyhow::Result; - -use crate::domain::DomainRegistration; - -use super::ports::{CertificateManager, DomainRepository}; - -/// Extended domain info for display purposes. -pub struct DomainInfo { - pub registration: DomainRegistration, - pub has_cert: bool, - pub cert_trusted: Option, -} - -/// Use case: list all registered domains with certificate status. -pub struct ListDomains<'a> { - domains: &'a dyn DomainRepository, - certs: &'a dyn CertificateManager, -} - -impl<'a> ListDomains<'a> { - pub fn new(domains: &'a dyn DomainRepository, certs: &'a dyn CertificateManager) -> Self { - Self { domains, certs } - } - - /// Return all registered domains with certificate information. - pub fn execute(&self) -> Result> { - let registrations = self.domains.list()?; - let cert_trusted = self.certs.is_trusted().ok(); - - let infos = registrations - .into_iter() - .map(|reg| { - let has_cert = self.certs.exists(reg.pattern()); - DomainInfo { - registration: reg, - has_cert, - cert_trusted, - } - }) - .collect(); - - Ok(infos) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::application::testkit::*; - use crate::domain::{DomainPattern, PathPrefix, ProxyTarget, Route, RouteTarget}; - - fn exact(name: &str) -> DomainPattern { - DomainPattern::from_name(name, false).unwrap() - } - - fn registration(name: &str) -> DomainRegistration { - DomainRegistration::new( - exact(name), - vec![Route::new( - PathPrefix::new("/").unwrap(), - RouteTarget::Proxy(ProxyTarget::parse("3000").unwrap()), - )], - ) - } - - #[test] - fn lists_all_domains_with_cert_info() { - let repo = InMemoryDomainRepository::with_domains(vec![ - registration("a.roxy"), - registration("b.roxy"), - ]); - let certs = InMemoryCertificateManager::with_ca_installed(); - certs.create_and_install(&exact("a.roxy")).unwrap(); - let svc = ListDomains::new(&repo, &certs); - - let infos = svc.execute().unwrap(); - - assert_eq!(infos.len(), 2); - let a_info = infos - .iter() - .find(|i| i.registration.domain().as_str() == "a.roxy") - .unwrap(); - assert!(a_info.has_cert); - assert_eq!(a_info.cert_trusted, Some(true)); - - let b_info = infos - .iter() - .find(|i| i.registration.domain().as_str() == "b.roxy") - .unwrap(); - assert!(!b_info.has_cert); - } - - #[test] - fn returns_empty_list_when_no_domains() { - let repo = InMemoryDomainRepository::new(); - let certs = InMemoryCertificateManager::new(); - let svc = ListDomains::new(&repo, &certs); - - let infos = svc.execute().unwrap(); - assert!(infos.is_empty()); - } -} diff --git a/src/application/manage_routes.rs b/src/application/manage_routes.rs index 76caba3..8e1108e 100644 --- a/src/application/manage_routes.rs +++ b/src/application/manage_routes.rs @@ -58,22 +58,7 @@ impl<'a> ManageRoutes<'a> { mod tests { use super::*; use crate::application::testkit::*; - use crate::domain::{DomainRegistration, ProxyTarget}; - - fn exact(name: &str) -> DomainPattern { - DomainPattern::from_name(name, false).unwrap() - } - - fn proxy_route(path: &str, port: u16) -> Route { - Route::new( - PathPrefix::new(path).unwrap(), - RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), - ) - } - - fn registration(name: &str) -> DomainRegistration { - DomainRegistration::new(exact(name), vec![proxy_route("/", 3000)]) - } + use crate::domain::ProxyTarget; #[test] fn adds_route_to_existing_domain() { @@ -88,7 +73,7 @@ mod tests { ) .unwrap(); - assert_eq!(route.path.as_str(), "/api"); + assert_eq!(route.path().as_str(), "/api"); // Verify persisted let reg = repo.get(&exact("myapp.roxy")).unwrap().unwrap(); assert_eq!(reg.routes().len(), 2); diff --git a/src/application/mod.rs b/src/application/mod.rs index 39942a6..5dcc602 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,8 +1,9 @@ pub mod daemon_status; pub mod install; -pub mod list_domains; +pub mod list_all_domains; pub mod manage_routes; pub mod ports; +pub mod provider_registry; pub mod register_domain; pub mod restart_daemon; pub mod start_daemon; diff --git a/src/application/ports/daemon_connection.rs b/src/application/ports/daemon_connection.rs index 1d08d1b..3ca7399 100644 --- a/src/application/ports/daemon_connection.rs +++ b/src/application/ports/daemon_connection.rs @@ -1,11 +1,8 @@ -// Port types for CLI-to-daemon communication. Used by testkit now; -// concrete Unix-socket adapter comes when CLI commands are wired up. -#![allow(dead_code)] - use crate::domain::DomainRegistration; #[derive(Debug, Clone)] -pub struct DaemonStatus { +#[allow(dead_code)] // Used by trait impls; no production caller for status() yet. +pub struct DaemonRuntimeInfo { pub pid: u32, pub registrations: Vec, pub http_port: u16, @@ -25,8 +22,9 @@ pub enum DaemonConnectionError { /// Port for communicating with a running daemon process. /// Complements DaemonControl (lifecycle) with runtime queries. +#[allow(dead_code)] // status()/reload() have impls but no production callers yet. pub trait DaemonConnection { - fn status(&self) -> Result; + fn status(&self) -> Result; fn reload(&self) -> Result<(), DaemonConnectionError>; fn list_registrations(&self) -> Result, DaemonConnectionError>; } diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index c1ee9fb..b1d7c94 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,8 +10,7 @@ pub mod system_setup; pub use certificate_manager::{CertificateError, CertificateManager}; pub use config_loader::{ConfigLoadError, ConfigLoader}; -#[allow(unused_imports)] // Used by testkit; CLI adapter comes later. -pub use daemon_connection::{DaemonConnection, DaemonConnectionError, DaemonStatus}; +pub use daemon_connection::{DaemonConnection, DaemonConnectionError, DaemonRuntimeInfo}; pub use daemon_control::DaemonControl; pub use dns_manager::{DnsConfigError, DnsManager}; pub use domain_repository::{DomainRepository, RepositoryError}; diff --git a/src/application/provider_registry.rs b/src/application/provider_registry.rs new file mode 100644 index 0000000..b8b2d0c --- /dev/null +++ b/src/application/provider_registry.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use tracing::warn; + +use super::ports::RegistrationProvider; +use crate::domain::DomainRegistration; + +/// Aggregates registrations from multiple providers. +/// +/// When `load()` is called, each provider is queried and results are +/// concatenated. Individual provider errors are logged but don't fail +/// the merge — other providers still contribute their registrations. +pub struct ProviderRegistry { + providers: Vec>, +} + +impl Default for ProviderRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self { + providers: Vec::new(), + } + } + + pub fn add(&mut self, provider: Arc) { + self.providers.push(provider); + } +} + +impl RegistrationProvider for ProviderRegistry { + fn name(&self) -> &str { + "aggregator" + } + + fn load(&self) -> anyhow::Result> { + let mut all = Vec::new(); + + for provider in &self.providers { + match provider.load() { + Ok(regs) => all.extend(regs), + Err(e) => { + warn!( + provider = provider.name(), + error = %e, + "Provider failed to load registrations, skipping" + ); + } + } + } + + Ok(all) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FixedProvider { + name: &'static str, + registrations: Vec, + } + + impl RegistrationProvider for FixedProvider { + fn name(&self) -> &str { + self.name + } + fn load(&self) -> anyhow::Result> { + Ok(self.registrations.clone()) + } + } + + struct FailingProvider; + + impl RegistrationProvider for FailingProvider { + fn name(&self) -> &str { + "failing" + } + fn load(&self) -> anyhow::Result> { + Err(anyhow::anyhow!("provider error")) + } + } + + fn test_reg(domain: &str) -> DomainRegistration { + use crate::domain::{DomainName, DomainPattern, Route}; + let name = DomainName::new(domain).unwrap(); + let pattern = DomainPattern::Exact(name); + let routes = vec![Route::parse("/=3000").unwrap()]; + DomainRegistration::new(pattern, routes) + } + + #[test] + fn empty_registry_returns_empty() { + let registry = ProviderRegistry::new(); + let result = registry.load().unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn single_provider() { + let mut registry = ProviderRegistry::new(); + registry.add(Arc::new(FixedProvider { + name: "test", + registrations: vec![test_reg("app.roxy")], + })); + + let result = registry.load().unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].domain().as_str(), "app.roxy"); + } + + #[test] + fn merges_multiple_providers() { + let mut registry = ProviderRegistry::new(); + registry.add(Arc::new(FixedProvider { + name: "config", + registrations: vec![test_reg("app.roxy")], + })); + registry.add(Arc::new(FixedProvider { + name: "docker", + registrations: vec![test_reg("web.myproject.roxy")], + })); + + let result = registry.load().unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn failing_provider_does_not_block_others() { + let mut registry = ProviderRegistry::new(); + registry.add(Arc::new(FixedProvider { + name: "config", + registrations: vec![test_reg("app.roxy")], + })); + registry.add(Arc::new(FailingProvider)); + + let result = registry.load().unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].domain().as_str(), "app.roxy"); + } + + #[test] + fn all_providers_failing_returns_empty() { + let mut registry = ProviderRegistry::new(); + registry.add(Arc::new(FailingProvider)); + + let result = registry.load().unwrap(); + assert!(result.is_empty()); + } +} diff --git a/src/application/register_domain.rs b/src/application/register_domain.rs index 685cc92..de91ea9 100644 --- a/src/application/register_domain.rs +++ b/src/application/register_domain.rs @@ -75,18 +75,6 @@ impl<'a> RegisterDomain<'a> { mod tests { use super::*; use crate::application::testkit::*; - use crate::domain::{PathPrefix, ProxyTarget, RouteTarget}; - - fn proxy_route(path: &str, port: u16) -> Route { - Route::new( - PathPrefix::new(path).unwrap(), - RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), - ) - } - - fn pattern(name: &str) -> DomainPattern { - DomainPattern::from_name(name, false).unwrap() - } #[test] fn registers_domain_with_https() { @@ -95,12 +83,12 @@ mod tests { let svc = RegisterDomain::new(&repo, &certs); let result = svc - .execute(pattern("myapp.roxy"), vec![proxy_route("/", 3000)]) + .execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) .unwrap(); assert!(result.registration.is_https_enabled()); assert!(matches!(result.cert_outcome, StepOutcome::Success(_))); - assert!(repo.get(&pattern("myapp.roxy")).unwrap().is_some()); + assert!(repo.get(&exact("myapp.roxy")).unwrap().is_some()); } #[test] @@ -110,13 +98,13 @@ mod tests { let svc = RegisterDomain::new(&repo, &certs); let result = svc - .execute(pattern("myapp.roxy"), vec![proxy_route("/", 3000)]) + .execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) .unwrap(); assert!(!result.registration.is_https_enabled()); assert!(matches!(result.cert_outcome, StepOutcome::Warning(_))); // Domain is still registered despite cert failure - assert!(repo.get(&pattern("myapp.roxy")).unwrap().is_some()); + assert!(repo.get(&exact("myapp.roxy")).unwrap().is_some()); } #[test] @@ -125,7 +113,7 @@ mod tests { let certs = InMemoryCertificateManager::new(); let svc = RegisterDomain::new(&repo, &certs); - let err = svc.execute(pattern("myapp.roxy"), vec![]).err().unwrap(); + let err = svc.execute(exact("myapp.roxy"), vec![]).err().unwrap(); assert!(err.to_string().contains("At least one route")); } @@ -135,11 +123,11 @@ mod tests { let certs = InMemoryCertificateManager::new(); let svc = RegisterDomain::new(&repo, &certs); - svc.execute(pattern("myapp.roxy"), vec![proxy_route("/", 3000)]) + svc.execute(exact("myapp.roxy"), vec![proxy_route("/", 3000)]) .unwrap(); let err = svc - .execute(pattern("myapp.roxy"), vec![proxy_route("/", 4000)]) + .execute(exact("myapp.roxy"), vec![proxy_route("/", 4000)]) .err() .unwrap(); assert!(err.to_string().contains("already registered")); @@ -152,7 +140,7 @@ mod tests { let svc = RegisterDomain::new(&repo, &certs); let routes = vec![proxy_route("/", 3000), proxy_route("/api", 3001)]; - let result = svc.execute(pattern("myapp.roxy"), routes).unwrap(); + let result = svc.execute(exact("myapp.roxy"), routes).unwrap(); assert_eq!(result.registration.routes().len(), 2); } diff --git a/src/application/restart_daemon.rs b/src/application/restart_daemon.rs index f540ffe..7162df5 100644 --- a/src/application/restart_daemon.rs +++ b/src/application/restart_daemon.rs @@ -31,10 +31,17 @@ impl<'a> RestartDaemon<'a> { } } - /// Stop the current daemon (if running) and return fresh config for restarting. - /// - /// When `require_running` is true, returns an error if the daemon is not running. - pub fn execute(&self, require_running: bool) -> Result { + /// Restart the daemon. Tolerates a stopped daemon (just re-reads config). + pub fn restart(&self) -> Result { + self.stop_and_reload(false) + } + + /// Reload the daemon. Fails if the daemon is not running. + pub fn reload(&self) -> Result { + self.stop_and_reload(true) + } + + fn stop_and_reload(&self, require_running: bool) -> Result { let is_running = self.daemon.is_running()?; if require_running && !is_running { @@ -43,8 +50,6 @@ impl<'a> RestartDaemon<'a> { if is_running { self.daemon.stop_gracefully(Duration::from_millis(500))?; - // Brief pause to ensure clean shutdown - std::thread::sleep(Duration::from_millis(500)); } // Re-load config from disk to pick up changes @@ -68,7 +73,7 @@ mod tests { let loader = InMemoryConfigLoader::existing(); let svc = RestartDaemon::new(&daemon, &loader); - let ready = svc.execute(false).unwrap(); + let ready = svc.restart().unwrap(); // Daemon was stopped assert!(!daemon.is_running().unwrap()); @@ -82,7 +87,7 @@ mod tests { let loader = InMemoryConfigLoader::existing(); let svc = RestartDaemon::new(&daemon, &loader); - let ready = svc.execute(false).unwrap(); + let ready = svc.restart().unwrap(); assert_eq!(ready.daemon_config.http_port, 80); } @@ -92,7 +97,7 @@ mod tests { let loader = InMemoryConfigLoader::existing(); let svc = RestartDaemon::new(&daemon, &loader); - let err = svc.execute(true).err().unwrap(); + let err = svc.reload().err().unwrap(); assert!(err.to_string().contains("not running")); } @@ -102,7 +107,7 @@ mod tests { let loader = InMemoryConfigLoader::existing(); let svc = RestartDaemon::new(&daemon, &loader); - let ready = svc.execute(true).unwrap(); + let ready = svc.reload().unwrap(); assert!(!daemon.is_running().unwrap()); assert_eq!(ready.daemon_config.dns_port, 1053); diff --git a/src/application/testkit.rs b/src/application/testkit.rs index fc23d53..ad5c566 100644 --- a/src/application/testkit.rs +++ b/src/application/testkit.rs @@ -8,11 +8,13 @@ use std::net::Ipv4Addr; use std::time::Duration; use crate::config::{DaemonConfig, RoxyPaths}; -use crate::domain::{DomainPattern, DomainRegistration}; +use crate::domain::{ + DomainPattern, DomainRegistration, PathPrefix, ProxyTarget, Route, RouteTarget, +}; use super::ports::{ CertificateError, CertificateManager, ConfigLoadError, ConfigLoader, DaemonConnection, - DaemonConnectionError, DaemonControl, DaemonStatus, DnsConfigError, DnsManager, + DaemonConnectionError, DaemonControl, DaemonRuntimeInfo, DnsConfigError, DnsManager, DomainRepository, NetworkInfo, RegistrationProvider, RepositoryError, SystemSetup, }; @@ -77,8 +79,13 @@ impl DomainRepository for InMemoryDomainRepository { } fn add(&self, registration: DomainRegistration) -> Result<(), RepositoryError> { - let key = registration.config_key(); - if self.domains.borrow().iter().any(|r| r.config_key() == key) { + let key = registration.display_pattern(); + if self + .domains + .borrow() + .iter() + .any(|r| r.display_pattern() == key) + { return Err(RepositoryError::DomainExists(key)); } self.domains.borrow_mut().push(registration); @@ -86,11 +93,11 @@ impl DomainRepository for InMemoryDomainRepository { } fn update(&self, registration: DomainRegistration) -> Result<(), RepositoryError> { - let key = registration.config_key(); + let key = registration.display_pattern(); let mut domains = self.domains.borrow_mut(); let pos = domains .iter() - .position(|r| r.config_key() == key) + .position(|r| r.display_pattern() == key) .ok_or_else(|| RepositoryError::DomainNotFound(key))?; domains[pos] = registration; Ok(()) @@ -101,7 +108,7 @@ impl DomainRepository for InMemoryDomainRepository { let mut domains = self.domains.borrow_mut(); let pos = domains .iter() - .position(|r| r.config_key() == key) + .position(|r| r.display_pattern() == key) .ok_or_else(|| RepositoryError::DomainNotFound(key))?; domains.remove(pos); Ok(()) @@ -166,7 +173,9 @@ impl CertificateManager for InMemoryCertificateManager { "simulated cert failure" ))); } - self.certs.borrow_mut().push(pattern.cert_name()); + self.certs + .borrow_mut() + .push(pattern.display_pattern()); Ok(()) } @@ -178,7 +187,7 @@ impl CertificateManager for InMemoryCertificateManager { } self.certs .borrow_mut() - .retain(|c| *c != pattern.cert_name()); + .retain(|c| *c != pattern.display_pattern()); Ok(()) } @@ -193,7 +202,9 @@ impl CertificateManager for InMemoryCertificateManager { } fn exists(&self, pattern: &DomainPattern) -> bool { - self.certs.borrow().contains(&pattern.cert_name()) + self.certs + .borrow() + .contains(&pattern.display_pattern()) } fn is_trusted(&self) -> Result { @@ -409,32 +420,24 @@ impl SystemSetup for InMemorySystemSetup { pub struct InMemoryDaemonConnection { registrations: RefCell>, - pid: u32, - http_port: u16, - https_port: u16, - dns_port: u16, } impl InMemoryDaemonConnection { pub fn new(registrations: Vec) -> Self { Self { registrations: RefCell::new(registrations), - pid: 1234, - http_port: 80, - https_port: 443, - dns_port: 1053, } } } impl DaemonConnection for InMemoryDaemonConnection { - fn status(&self) -> Result { - Ok(DaemonStatus { - pid: self.pid, + fn status(&self) -> Result { + Ok(DaemonRuntimeInfo { + pid: 1234, registrations: self.registrations.borrow().clone(), - http_port: self.http_port, - https_port: self.https_port, - dns_port: self.dns_port, + http_port: 80, + https_port: 443, + dns_port: 1053, }) } @@ -446,3 +449,47 @@ impl DaemonConnection for InMemoryDaemonConnection { Ok(self.registrations.borrow().clone()) } } + +// --------------------------------------------------------------------------- +// NotRunningDaemonConnection +// --------------------------------------------------------------------------- + +/// A `DaemonConnection` that always returns `NotRunning`. +/// Useful for testing fallback-to-config paths. +pub struct NotRunningDaemonConnection; + +impl DaemonConnection for NotRunningDaemonConnection { + fn status(&self) -> Result { + Err(DaemonConnectionError::NotRunning) + } + + fn reload(&self) -> Result<(), DaemonConnectionError> { + Err(DaemonConnectionError::NotRunning) + } + + fn list_registrations(&self) -> Result, DaemonConnectionError> { + Err(DaemonConnectionError::NotRunning) + } +} + +// --------------------------------------------------------------------------- +// Shared test helpers +// --------------------------------------------------------------------------- + +/// Build an exact `DomainPattern` from a domain name string. +pub fn exact(name: &str) -> DomainPattern { + DomainPattern::from_name(name, false).unwrap() +} + +/// Build a proxy `Route` from a path prefix and port number. +pub fn proxy_route(path: &str, port: u16) -> Route { + Route::new( + PathPrefix::new(path).unwrap(), + RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), + ) +} + +/// Build a simple `DomainRegistration` with one proxy route (/ -> 3000). +pub fn registration(name: &str) -> DomainRegistration { + DomainRegistration::new(exact(name), vec![proxy_route("/", 3000)]) +} diff --git a/src/application/uninstall.rs b/src/application/uninstall.rs index 0b7d451..f538f09 100644 --- a/src/application/uninstall.rs +++ b/src/application/uninstall.rs @@ -1,6 +1,7 @@ use std::time::Duration; use anyhow::Result; +use tracing::warn; use super::StepOutcome; use super::ports::{CertificateManager, DaemonControl, DnsManager, DomainRepository, SystemSetup}; @@ -47,7 +48,13 @@ impl<'a> Uninstall<'a> { /// Build a preview so the CLI can show a confirmation prompt. pub fn preview(&self) -> Result { - let domain_count = self.domains.list().unwrap_or_default().len(); + let domain_count = match self.domains.list() { + Ok(domains) => domains.len(), + Err(e) => { + warn!(error = %e, "Could not read domain list for preview"); + 0 + } + }; Ok(UninstallPreview { domain_count, data_dir: self.data_dir_display.clone(), @@ -85,7 +92,13 @@ impl<'a> Uninstall<'a> { } fn remove_certificates(&self, steps: &mut Vec<(String, StepOutcome)>) { - let domains = self.domains.list().unwrap_or_default(); + let domains = match self.domains.list() { + Ok(domains) => domains, + Err(e) => { + warn!(error = %e, "Could not read domain list for certificate cleanup"); + Vec::new() + } + }; for registration in &domains { let label = format!("Remove cert: {}", registration.display_pattern()); @@ -155,19 +168,6 @@ impl<'a> Uninstall<'a> { mod tests { use super::*; use crate::application::testkit::*; - use crate::domain::{ - DomainPattern, DomainRegistration, PathPrefix, ProxyTarget, Route, RouteTarget, - }; - - fn registration(name: &str) -> DomainRegistration { - DomainRegistration::new( - DomainPattern::from_name(name, false).unwrap(), - vec![Route::new( - PathPrefix::new("/").unwrap(), - RouteTarget::Proxy(ProxyTarget::parse("3000").unwrap()), - )], - ) - } #[test] fn uninstall_stops_daemon_and_cleans_up() { diff --git a/src/application/unregister_domain.rs b/src/application/unregister_domain.rs index eb269bd..5e507fe 100644 --- a/src/application/unregister_domain.rs +++ b/src/application/unregister_domain.rs @@ -56,22 +56,6 @@ impl<'a> UnregisterDomain<'a> { mod tests { use super::*; use crate::application::testkit::*; - use crate::domain::{PathPrefix, ProxyTarget, Route, RouteTarget}; - - fn proxy_route(path: &str, port: u16) -> Route { - Route::new( - PathPrefix::new(path).unwrap(), - RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), - ) - } - - fn exact(name: &str) -> DomainPattern { - DomainPattern::from_name(name, false).unwrap() - } - - fn registration(name: &str) -> DomainRegistration { - DomainRegistration::new(exact(name), vec![proxy_route("/", 3000)]) - } #[test] fn unregisters_domain_and_removes_cert() { diff --git a/src/cli/context.rs b/src/cli/context.rs new file mode 100644 index 0000000..f7a6209 --- /dev/null +++ b/src/cli/context.rs @@ -0,0 +1,27 @@ +use std::path::Path; + +use crate::infrastructure::certs::CertificateService; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::mgmt_client::MgmtSocketClient; +use crate::infrastructure::paths::RoxyPaths; +use crate::infrastructure::pid::PidFile; + +/// Composition root: wires infrastructure adapters once, +/// shared by all CLI commands that need them. +pub struct AppContext { + pub config_store: ConfigStore, + pub cert_service: CertificateService, + pub pid_file: PidFile, + pub mgmt_client: MgmtSocketClient, +} + +impl AppContext { + pub fn new(config_path: &Path, paths: &RoxyPaths) -> Self { + Self { + config_store: ConfigStore::new(config_path.to_path_buf()), + cert_service: CertificateService::new(paths), + pid_file: PidFile::new(paths.pid_file.clone()), + mgmt_client: MgmtSocketClient::new(&paths.socket_path), + } + } +} diff --git a/src/cli/install.rs b/src/cli/install.rs index 9c6c10a..795da3e 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,28 +1,24 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::StepOutcome; use crate::application::install::Install; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::{Config, ConfigStore}; +use crate::infrastructure::config::Config; use crate::infrastructure::dns::get_dns_service; use crate::infrastructure::filesystem::FileSystemSetup; use crate::infrastructure::network::get_network_info; use crate::infrastructure::paths::RoxyPaths; -pub fn execute(config_path: &Path, paths: &RoxyPaths, config: &Config) -> Result<()> { +pub fn execute(ctx: &AppContext, paths: &RoxyPaths, config: &Config) -> Result<()> { println!("Setting up Roxy...\n"); - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); let dns_service = get_dns_service()?; let network_info = get_network_info(); let system = FileSystemSetup::new(paths); let use_case = Install::new( - &cert_service, - &config_store, + &ctx.cert_service, + &ctx.config_store, &dns_service, &network_info, &system, diff --git a/src/cli/list.rs b/src/cli/list.rs index 5e48b86..fbc3092 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -1,31 +1,33 @@ -use std::path::Path; - use anyhow::Result; -use crate::application::list_domains::ListDomains; -use crate::domain::RouteTarget; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; -use crate::infrastructure::paths::RoxyPaths; +use super::context::AppContext; +use crate::application::list_all_domains::ListAllDomains; +use crate::domain::{RegistrationSource, RouteTarget}; -pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); +pub fn execute(ctx: &AppContext) -> Result<()> { + let docker_enabled = ctx + .config_store + .load() + .map(|c| c.docker.enabled) + .unwrap_or(false); - let use_case = ListDomains::new(&config_store, &cert_service); - let domains = use_case.execute()?; + let use_case = ListAllDomains::new(&ctx.mgmt_client, &ctx.config_store, &ctx.cert_service); + let result = use_case.execute()?; - if domains.is_empty() { + if result.domains.is_empty() { println!("No domains registered."); println!("\nRegister a domain with:"); println!(" roxy register myapp.roxy --route \"/=3000\""); println!(" roxy register myapp.roxy --route \"/=3000\" --route \"/api=3001\""); + if !result.daemon_reachable && docker_enabled { + println!("\n Note: Docker domains are only visible when the daemon is running."); + } return Ok(()); } println!("Registered domains:\n"); - for info in domains { + for info in result.domains { let https_status = if info.has_cert { match info.cert_trusted { Some(true) => "(HTTPS)", @@ -36,17 +38,32 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { "" }; - println!(" {} {}", info.registration.display_pattern(), https_status); + let source_label = if info.registration.source() == RegistrationSource::External { + " [external]" + } else { + "" + }; + + println!( + " {}{} {}", + info.registration.display_pattern(), + source_label, + https_status, + ); for route in info.registration.routes() { - let target_str = match &route.target { + let target_str = match route.target() { RouteTarget::Proxy(p) => p.to_string(), RouteTarget::StaticFiles(p) => p.display().to_string(), }; - println!(" {:<15} -> {}", route.path, target_str); + println!(" {:<15} -> {}", route.path(), target_str); } println!(); } + if !result.daemon_reachable && docker_enabled { + println!(" Note: Docker domains are only visible when the daemon is running."); + } + Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 40dcd0a..ccf77fe 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod context; pub mod install; pub mod list; pub mod logs; diff --git a/src/cli/register.rs b/src/cli/register.rs index e85c2f9..d2a283a 100644 --- a/src/cli/register.rs +++ b/src/cli/register.rs @@ -1,20 +1,15 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::StepOutcome; use crate::application::register_domain::RegisterDomain; use crate::domain::{DomainPattern, Route}; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; -use crate::infrastructure::paths::RoxyPaths; pub fn execute( domain: String, wildcard: bool, routes: Vec, - config_path: &Path, - paths: &RoxyPaths, + ctx: &AppContext, ) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; @@ -24,9 +19,7 @@ pub fn execute( .collect::, _>>() .map_err(|e| anyhow::anyhow!("Invalid route: {}", e))?; - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); - let use_case = RegisterDomain::new(&config_store, &cert_service); + let use_case = RegisterDomain::new(&ctx.config_store, &ctx.cert_service); println!( "Generating SSL certificate for {}...", @@ -54,7 +47,7 @@ pub fn execute( ); println!(" Routes:"); for route in result.registration.routes() { - println!(" {} -> {}", route.path, route.target); + println!(" {} -> {}", route.path(), route.target()); } println!( " HTTPS: {}", diff --git a/src/cli/reload.rs b/src/cli/reload.rs index 498ee96..9bf7868 100644 --- a/src/cli/reload.rs +++ b/src/cli/reload.rs @@ -2,16 +2,12 @@ use std::path::Path; use anyhow::Result; +use super::context::AppContext; use crate::application::restart_daemon::RestartDaemon; -use crate::infrastructure::config::ConfigStore; -use crate::infrastructure::paths::RoxyPaths; -use crate::infrastructure::pid::PidFile; -pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let pid_file = PidFile::new(paths.pid_file.clone()); - let config_store = ConfigStore::new(config_path.to_path_buf()); - let service = RestartDaemon::new(&pid_file, &config_store); - let ready = service.execute(true)?; +pub fn execute(verbose: bool, config_path: &Path, ctx: &AppContext) -> Result<()> { + let service = RestartDaemon::new(&ctx.pid_file, &ctx.config_store); + let ready = service.reload()?; println!("Starting Roxy daemon..."); super::start::execute( diff --git a/src/cli/restart.rs b/src/cli/restart.rs index 45301a0..32fa5bf 100644 --- a/src/cli/restart.rs +++ b/src/cli/restart.rs @@ -2,16 +2,12 @@ use std::path::Path; use anyhow::Result; +use super::context::AppContext; use crate::application::restart_daemon::RestartDaemon; -use crate::infrastructure::config::ConfigStore; -use crate::infrastructure::paths::RoxyPaths; -use crate::infrastructure::pid::PidFile; -pub fn execute(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let pid_file = PidFile::new(paths.pid_file.clone()); - let config_store = ConfigStore::new(config_path.to_path_buf()); - let service = RestartDaemon::new(&pid_file, &config_store); - let ready = service.execute(false)?; +pub fn execute(verbose: bool, config_path: &Path, ctx: &AppContext) -> Result<()> { + let service = RestartDaemon::new(&ctx.pid_file, &ctx.config_store); + let ready = service.restart()?; println!("Starting Roxy daemon..."); super::start::execute( diff --git a/src/cli/route.rs b/src/cli/route.rs index ece4301..f5cf13b 100644 --- a/src/cli/route.rs +++ b/src/cli/route.rs @@ -1,10 +1,8 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::manage_routes::ManageRoutes; use crate::domain::{DomainPattern, PathPrefix, RouteTarget}; -use crate::infrastructure::config::ConfigStore; /// Add a route to an existing domain pub fn add( @@ -12,31 +10,29 @@ pub fn add( wildcard: bool, path: String, target: String, - config_path: &Path, + ctx: &AppContext, ) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; let path_prefix = PathPrefix::new(&path)?; let route_target = RouteTarget::parse(&target) .map_err(|e| anyhow::anyhow!("Invalid target '{}': {}", target, e))?; - let config_store = ConfigStore::new(config_path.to_path_buf()); - let use_case = ManageRoutes::new(&config_store); + let use_case = ManageRoutes::new(&ctx.config_store); let route = use_case.add_route(&pattern, path_prefix, route_target)?; - println!("Added route: {} -> {}", route.path, route.target); + println!("Added route: {} -> {}", route.path(), route.target()); println!("\nReload the daemon to apply changes: roxy reload"); Ok(()) } /// Remove a route from a domain -pub fn remove(domain: String, wildcard: bool, path: String, config_path: &Path) -> Result<()> { +pub fn remove(domain: String, wildcard: bool, path: String, ctx: &AppContext) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; let path_prefix = PathPrefix::new(&path)?; - let config_store = ConfigStore::new(config_path.to_path_buf()); - let use_case = ManageRoutes::new(&config_store); + let use_case = ManageRoutes::new(&ctx.config_store); use_case.remove_route(&pattern, &path_prefix)?; @@ -47,11 +43,10 @@ pub fn remove(domain: String, wildcard: bool, path: String, config_path: &Path) } /// List all routes for a domain -pub fn list(domain: String, wildcard: bool, config_path: &Path) -> Result<()> { +pub fn list(domain: String, wildcard: bool, ctx: &AppContext) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; - let config_store = ConfigStore::new(config_path.to_path_buf()); - let use_case = ManageRoutes::new(&config_store); + let use_case = ManageRoutes::new(&ctx.config_store); let registration = use_case.list_routes(&pattern)?; @@ -68,7 +63,7 @@ pub fn list(domain: String, wildcard: bool, config_path: &Path) -> Result<()> { println!("{}", "-".repeat(52)); for route in registration.routes() { - println!("{:<20} {:<30}", route.path, route.target); + println!("{:<20} {:<30}", route.path(), route.target()); } Ok(()) diff --git a/src/cli/status.rs b/src/cli/status.rs index 925c162..3b8f5c6 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,21 +1,16 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::daemon_status::QueryDaemonStatus; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; +use crate::application::list_all_domains::ListAllDomains; +use crate::config::DaemonConfig; +use crate::domain::RegistrationSource; use crate::infrastructure::network::get_network_info; -use crate::infrastructure::paths::RoxyPaths; -use crate::infrastructure::pid::PidFile; -pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let pid_file = PidFile::new(paths.pid_file.clone()); - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); +pub fn execute(ctx: &AppContext, daemon_config: &DaemonConfig) -> Result<()> { let network_info = get_network_info(); - let service = QueryDaemonStatus::new(&pid_file, &config_store, &cert_service, &network_info); + let service = QueryDaemonStatus::new(&ctx.pid_file, &ctx.cert_service, &network_info); let status = service.execute()?; let offline_note = if status.lan_ip.is_loopback() { @@ -35,8 +30,8 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { println!("Roxy daemon: running (PID: {})", pid); println!(" LAN IP: {}{}", status.lan_ip, offline_note); println!(" Root CA: {}", ca_label); - println!(" HTTP: http://localhost:80"); - println!(" HTTPS: https://localhost:443"); + println!(" HTTP: http://localhost:{}", daemon_config.http_port); + println!(" HTTPS: https://localhost:{}", daemon_config.https_port); if !status.lan_ip.is_loopback() { println!( "\n Access from other devices: use http://{}", @@ -52,15 +47,29 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { } } - if !status.domains.is_empty() { - println!("\nRegistered domains: {}", status.domains.len()); - for reg in status.domains { - let scheme = if reg.is_https_enabled() { + // List domains via ListAllDomains (daemon + Docker, or config fallback) + let list_svc = ListAllDomains::new(&ctx.mgmt_client, &ctx.config_store, &ctx.cert_service); + let result = list_svc.execute()?; + + if !result.domains.is_empty() { + println!("\nRegistered domains: {}", result.domains.len()); + for info in result.domains { + let scheme = if info.registration.is_https_enabled() { "https" } else { "http" }; - println!(" {}://{}", scheme, reg.display_pattern()); + let source_label = if info.registration.source() == RegistrationSource::External { + " [external]" + } else { + "" + }; + println!( + " {}://{}{}", + scheme, + info.registration.display_pattern(), + source_label, + ); } } diff --git a/src/cli/stop.rs b/src/cli/stop.rs index 6a20b23..01da2f2 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -1,12 +1,10 @@ use anyhow::Result; +use super::context::AppContext; use crate::application::stop_daemon::StopDaemon; -use crate::infrastructure::paths::RoxyPaths; -use crate::infrastructure::pid::PidFile; -pub fn execute(paths: &RoxyPaths) -> Result<()> { - let pid_file = PidFile::new(paths.pid_file.clone()); - let service = StopDaemon::new(&pid_file); +pub fn execute(ctx: &AppContext) -> Result<()> { + let service = StopDaemon::new(&ctx.pid_file); service.execute()?; println!("Roxy daemon stopped."); Ok(()) diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index 4f5605f..a2f8865 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -1,27 +1,20 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::StepOutcome; use crate::application::uninstall::Uninstall; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; use crate::infrastructure::dns::get_dns_service; use crate::infrastructure::filesystem::FileSystemSetup; use crate::infrastructure::paths::RoxyPaths; -use crate::infrastructure::pid::PidFile; -pub fn execute(force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); - let pid_file = PidFile::new(paths.pid_file.clone()); +pub fn execute(force: bool, ctx: &AppContext, paths: &RoxyPaths) -> Result<()> { let dns_service = get_dns_service()?; let system = FileSystemSetup::new(paths); let use_case = Uninstall::new( - &config_store, - &cert_service, - &pid_file, + &ctx.config_store, + &ctx.cert_service, + &ctx.pid_file, &dns_service, &system, paths.data_dir.display().to_string(), diff --git a/src/cli/unregister.rs b/src/cli/unregister.rs index 843a2cb..280ad8e 100644 --- a/src/cli/unregister.rs +++ b/src/cli/unregister.rs @@ -1,26 +1,14 @@ -use std::path::Path; - use anyhow::Result; +use super::context::AppContext; use crate::application::StepOutcome; use crate::application::unregister_domain::UnregisterDomain; use crate::domain::DomainPattern; -use crate::infrastructure::certs::CertificateService; -use crate::infrastructure::config::ConfigStore; -use crate::infrastructure::paths::RoxyPaths; - -pub fn execute( - domain: String, - wildcard: bool, - force: bool, - config_path: &Path, - paths: &RoxyPaths, -) -> Result<()> { + +pub fn execute(domain: String, wildcard: bool, force: bool, ctx: &AppContext) -> Result<()> { let pattern = DomainPattern::from_name(&domain, wildcard)?; - let config_store = ConfigStore::new(config_path.to_path_buf()); - let cert_service = CertificateService::new(paths); - let use_case = UnregisterDomain::new(&config_store, &cert_service); + let use_case = UnregisterDomain::new(&ctx.config_store, &ctx.cert_service); if !force { let registration = use_case.preview(&pattern)?; @@ -28,7 +16,7 @@ pub fn execute( println!(" Domain: {}", registration.display_pattern()); println!(" Routes:"); for route in registration.routes() { - println!(" {} -> {}", route.path, route.target); + println!(" {} -> {}", route.path(), route.target()); } if registration.is_https_enabled() { println!(" HTTPS certificate files will be removed"); diff --git a/src/config.rs b/src/config.rs index 0c9725c..6230811 100644 --- a/src/config.rs +++ b/src/config.rs @@ -97,6 +97,14 @@ fn default_socket_path() -> PathBuf { PathBuf::from("/etc/roxy/roxy.sock") } +/// Docker integration configuration. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct DockerConfig { + /// Enable Docker auto-discovery of containers. + #[serde(default)] + pub enabled: bool, +} + /// All resolved paths needed by Roxy components. /// Loaded once from config, then passed to components via DI. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src/daemon/lifecycle.rs b/src/daemon/lifecycle.rs index 0f5521e..e08d801 100644 --- a/src/daemon/lifecycle.rs +++ b/src/daemon/lifecycle.rs @@ -5,12 +5,13 @@ use std::sync::Arc; use anyhow::Result; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::info; +use tracing::{info, warn}; use super::Server; -use super::config_watcher::ConfigFileProvider; -use crate::domain::DomainRegistration; +use crate::application::provider_registry::ProviderRegistry; use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::config::watcher::ConfigFileProvider; +use crate::infrastructure::docker::DockerProvider; use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; use crate::infrastructure::tracing::{TracingOutput, init_tracing}; @@ -42,30 +43,67 @@ pub async fn run(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result let config_store = ConfigStore::new(config_path.to_path_buf()); let config = config_store.load()?; - // Create reload channel for hot-reloading registrations - let (reload_tx, reload_rx) = mpsc::channel::>(4); + // Create reload channel for hot-reloading registrations (nudge-based) + let (reload_tx, reload_rx) = mpsc::channel::<()>(4); + + // Create the config file provider + let config_provider = Arc::new(ConfigFileProvider::new(config_path.to_path_buf())); + + // Build the provider registry (aggregates all registration sources) + let mut registry = ProviderRegistry::new(); + registry.add(config_provider.clone()); + + // Optionally enable Docker auto-discovery + let docker_provider = if config.docker.enabled { + match bollard::Docker::connect_with_local_defaults() { + Ok(docker) => { + info!("Docker integration enabled"); + let provider = Arc::new(DockerProvider::new(docker)); + registry.add(provider.clone()); + Some(provider) + } + Err(e) => { + warn!( + error = %e, + "Docker integration enabled but could not connect to Docker socket. \ + Continuing without Docker support." + ); + None + } + } + } else { + None + }; - // Create the registration provider (shared by config watcher and mgmt socket) - let provider = Arc::new(ConfigFileProvider::new(config_path.to_path_buf())); + let registry = Arc::new(registry); - let server = Server::new( - &config, - paths, - reload_rx, - reload_tx.clone(), - provider.clone(), - )?; + let server = Server::new(&config, paths, reload_rx, reload_tx.clone(), registry)?; let cancel = CancellationToken::new(); // Spawn config file watcher for hot-reload let cancel_watcher = cancel.clone(); - tokio::spawn({ - let reload_tx = reload_tx; - async move { - provider.watch(reload_tx, cancel_watcher).await; - } + let config_reload_tx = reload_tx.clone(); + tokio::spawn(async move { + config_provider + .watch(config_reload_tx, cancel_watcher) + .await; }); + // Spawn Docker watcher if enabled + if let Some(provider) = docker_provider { + let cancel_docker = cancel.clone(); + let docker_reload_tx = reload_tx; + tokio::spawn(async move { + crate::infrastructure::docker::watcher::watch( + provider.docker().clone(), + provider.state(), + docker_reload_tx, + cancel_docker, + ) + .await; + }); + } + // Spawn signal handler for graceful shutdown let cancel_signal = cancel.clone(); let pid_signal = PidFile::new(paths.pid_file.clone()); @@ -88,11 +126,17 @@ async fn wait_for_shutdown_signal() { #[cfg(unix)] { - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to register SIGTERM handler"); - tokio::select! { - _ = ctrl_c => {}, - _ = sigterm.recv() => {}, + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut sigterm) => { + tokio::select! { + _ = ctrl_c => {}, + _ = sigterm.recv() => {}, + } + } + Err(e) => { + warn!(error = %e, "Failed to register SIGTERM handler, using Ctrl+C only"); + ctrl_c.await.ok(); + } } } diff --git a/src/daemon/mgmt_socket.rs b/src/daemon/mgmt_socket.rs index 9cf10c3..ca7c6ea 100644 --- a/src/daemon/mgmt_socket.rs +++ b/src/daemon/mgmt_socket.rs @@ -1,5 +1,4 @@ use std::path::PathBuf; -use std::sync::Arc; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -9,8 +8,6 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use super::router::SharedState; -use crate::application::ports::RegistrationProvider; -use crate::domain::DomainRegistration; /// Management commands sent over the Unix socket. #[derive(Debug, Deserialize)] @@ -73,6 +70,15 @@ fn parse_command(line: &str) -> Option { } } +/// Daemon port configuration passed to the management socket +/// so status responses include actual configured ports. +#[derive(Debug, Clone, Copy)] +pub struct DaemonPorts { + pub http: u16, + pub https: u16, + pub dns: u16, +} + /// Serve management commands on a Unix domain socket. /// /// The socket accepts one command per connection (JSON-over-lines protocol). @@ -80,9 +86,9 @@ fn parse_command(line: &str) -> Option { pub async fn serve( socket_path: PathBuf, state: SharedState, - reload_provider: Arc, - reload_tx: mpsc::Sender>, + reload_tx: mpsc::Sender<()>, cancel: CancellationToken, + ports: DaemonPorts, ) -> anyhow::Result<()> { // Remove stale socket file if it exists let _ = std::fs::remove_file(&socket_path); @@ -105,7 +111,6 @@ pub async fn serve( }; let state = state.clone(); - let reload_provider = reload_provider.clone(); let reload_tx = reload_tx.clone(); tokio::spawn(async move { @@ -126,33 +131,48 @@ pub async fn serve( MgmtResponse::success(serde_json::json!({ "pid": pid, "registration_count": count, + "http_port": ports.http, + "https_port": ports.https, + "dns_port": ports.dns, })) } Some(MgmtCommand::List) => { let snapshot = state.load(); - let domains: Vec = snapshot + let domains: Vec = snapshot .registrations() .iter() - .map(|r| r.display_pattern()) + .map(|r| { + let routes: Vec = r + .routes() + .iter() + .map(|route| { + serde_json::json!({ + "path": route.path().as_str(), + "target": route.target().to_string(), + }) + }) + .collect(); + serde_json::json!({ + "pattern": r.display_pattern(), + "source": r.source().to_string(), + "https": r.is_https_enabled(), + "routes": routes, + }) + }) .collect(); MgmtResponse::success(serde_json::json!({ "domains": domains, })) } - Some(MgmtCommand::Reload) => match reload_provider.load() { - Ok(regs) => { - let count = regs.len(); - if reload_tx.send(regs).await.is_ok() { - MgmtResponse::success(serde_json::json!({ - "reloaded": true, - "registration_count": count, - })) - } else { - MgmtResponse::error("Reload channel closed") - } + Some(MgmtCommand::Reload) => { + if reload_tx.send(()).await.is_ok() { + MgmtResponse::success(serde_json::json!({ + "reloaded": true, + })) + } else { + MgmtResponse::error("Reload channel closed") } - Err(e) => MgmtResponse::error(format!("Failed to load config: {e}")), - }, + } None => MgmtResponse::error(format!("Unknown command: {}", line.trim())), }; @@ -174,8 +194,9 @@ pub async fn serve( mod tests { use super::*; use crate::daemon::router::RuntimeState; - use crate::domain::{DomainName, DomainPattern, Route}; + use crate::domain::{DomainName, DomainPattern, DomainRegistration, Route}; use arc_swap::ArcSwap; + use std::sync::Arc; use std::time::Duration; use tokio::net::UnixStream; @@ -186,44 +207,32 @@ mod tests { DomainRegistration::new(pattern, routes) } - struct FixedProvider { - registrations: Vec, - } - - impl RegistrationProvider for FixedProvider { - fn name(&self) -> &str { - "fixed" - } - fn load(&self) -> anyhow::Result> { - Ok(self.registrations.clone()) - } - } - /// Helper: start the management socket and return what tests need to interact with it. /// Returns the TempDir to keep it alive for the socket path. async fn start_server( registrations: Vec, - provider_regs: Vec, ) -> ( PathBuf, CancellationToken, - mpsc::Receiver>, + mpsc::Receiver<()>, tempfile::TempDir, ) { let dir = tempfile::tempdir().unwrap(); let sock = dir.path().join("test.sock"); let state: SharedState = Arc::new(ArcSwap::from_pointee(RuntimeState::new(registrations))); - let provider: Arc = Arc::new(FixedProvider { - registrations: provider_regs, - }); - let (reload_tx, reload_rx) = mpsc::channel(4); + let (reload_tx, reload_rx) = mpsc::channel::<()>(4); let cancel = CancellationToken::new(); let cancel_serve = cancel.clone(); let sock_clone = sock.clone(); + let test_ports = DaemonPorts { + http: 80, + https: 443, + dns: 1053, + }; tokio::spawn(async move { - let _ = serve(sock_clone, state, provider, reload_tx, cancel_serve).await; + let _ = serve(sock_clone, state, reload_tx, cancel_serve, test_ports).await; }); // Give the listener time to bind @@ -288,7 +297,7 @@ mod tests { #[tokio::test] async fn serve_status_returns_pid_and_count() { let (sock, cancel, _rx, _dir) = - start_server(vec![test_reg("app.roxy"), test_reg("api.roxy")], vec![]).await; + start_server(vec![test_reg("app.roxy"), test_reg("api.roxy")]).await; let resp = send_command(&sock, r#"{"cmd":"status"}"#).await; assert_eq!(resp["ok"], true); @@ -299,45 +308,43 @@ mod tests { } #[tokio::test] - async fn serve_list_returns_domain_names() { - let (sock, cancel, _rx, _dir) = start_server(vec![test_reg("app.roxy")], vec![]).await; + async fn serve_list_returns_domain_details() { + let (sock, cancel, _rx, _dir) = start_server(vec![test_reg("app.roxy")]).await; let resp = send_command(&sock, r#"{"cmd":"list"}"#).await; assert_eq!(resp["ok"], true); let domains = resp["data"]["domains"].as_array().unwrap(); assert_eq!(domains.len(), 1); - assert_eq!(domains[0], "app.roxy"); + assert_eq!(domains[0]["pattern"], "app.roxy"); + assert_eq!(domains[0]["source"], "config"); + assert_eq!(domains[0]["https"], false); + let routes = domains[0]["routes"].as_array().unwrap(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0]["path"], "/"); cancel.cancel(); } #[tokio::test] async fn serve_reload_pushes_to_channel() { - let (sock, cancel, mut rx, _dir) = start_server( - vec![test_reg("old.roxy")], - vec![test_reg("new.roxy")], // provider returns this on reload - ) - .await; + let (sock, cancel, mut rx, _dir) = start_server(vec![test_reg("old.roxy")]).await; let resp = send_command(&sock, r#"{"cmd":"reload"}"#).await; assert_eq!(resp["ok"], true); assert_eq!(resp["data"]["reloaded"], true); - assert_eq!(resp["data"]["registration_count"], 1); - // Verify the reload channel received the new registrations - let regs = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + // Verify the reload channel received a nudge + tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await .expect("timed out") .expect("channel closed"); - assert_eq!(regs.len(), 1); - assert_eq!(regs[0].domain().as_str(), "new.roxy"); cancel.cancel(); } #[tokio::test] async fn serve_unknown_command_returns_error() { - let (sock, cancel, _rx, _dir) = start_server(vec![], vec![]).await; + let (sock, cancel, _rx, _dir) = start_server(vec![]).await; let resp = send_command(&sock, "foobar").await; assert_eq!(resp["ok"], false); @@ -348,7 +355,7 @@ mod tests { #[tokio::test] async fn serve_plain_text_status() { - let (sock, cancel, _rx, _dir) = start_server(vec![test_reg("app.roxy")], vec![]).await; + let (sock, cancel, _rx, _dir) = start_server(vec![test_reg("app.roxy")]).await; let resp = send_command(&sock, "status").await; assert_eq!(resp["ok"], true); @@ -359,7 +366,7 @@ mod tests { #[tokio::test] async fn serve_cleans_up_socket_on_cancel() { - let (sock, cancel, _rx, _dir) = start_server(vec![], vec![]).await; + let (sock, cancel, _rx, _dir) = start_server(vec![]).await; assert!(sock.exists()); cancel.cancel(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 78ddf90..05bf9cc 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,4 +1,3 @@ -pub mod config_watcher; pub mod dns_server; pub mod embedded_assets; pub mod lifecycle; diff --git a/src/daemon/router.rs b/src/daemon/router.rs index cde479c..1fc092b 100644 --- a/src/daemon/router.rs +++ b/src/daemon/router.rs @@ -156,16 +156,16 @@ async fn handle_request( host = %host, domain = %registration.domain(), path = %path, - route = %route.path, + route = %route.path(), "Routing request" ); let proto = scheme.map(|Extension(s)| s.as_str()).unwrap_or("http"); let client_ip = client_addr.map(|Extension(a)| a.0); - let response = match &route.target { + let response = match route.target() { RouteTarget::StaticFiles(dir) => { - serve_static(route.path.as_str(), dir.clone(), request).await + serve_static(route.path().as_str(), dir.clone(), request).await } RouteTarget::Proxy(target) => { proxy_request(target, request, host, proto, client_ip).await @@ -445,7 +445,7 @@ mod tests { route, } => { assert_eq!(registration.domain().as_str(), "app.roxy"); - assert_eq!(route.path.as_str(), "/"); + assert_eq!(route.path().as_str(), "/"); } other => panic!("expected Matched, got {:?}", other), } diff --git a/src/daemon/server.rs b/src/daemon/server.rs index b18af46..2e6841a 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -19,7 +19,6 @@ use super::proxy::{ClientAddr, Scheme}; use super::router::{RuntimeState, SharedState, create_router}; use super::tls::create_tls_acceptor; use crate::application::ports::RegistrationProvider; -use crate::domain::DomainRegistration; use crate::infrastructure::config::Config; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; @@ -36,8 +35,8 @@ async fn inject_client_addr( pub struct Server { state: SharedState, - reload_rx: mpsc::Receiver>, - reload_tx: mpsc::Sender>, + reload_rx: mpsc::Receiver<()>, + reload_tx: mpsc::Sender<()>, reload_provider: Arc, socket_path: PathBuf, tls_acceptor: Option, @@ -51,8 +50,8 @@ impl Server { pub fn new( config: &Config, paths: &RoxyPaths, - reload_rx: mpsc::Receiver>, - reload_tx: mpsc::Sender>, + reload_rx: mpsc::Receiver<()>, + reload_tx: mpsc::Sender<()>, reload_provider: Arc, ) -> Result { // Validate config before starting @@ -97,174 +96,227 @@ impl Server { "Roxy daemon starting" ); - // Start DNS server with LAN IP (handles source-based IP resolution internally) - let dns_server = DnsServer::new(self.dns_port, self.lan_ip); - let dns_handle = tokio::spawn(async move { - if let Err(e) = dns_server.run().await { - error!(error = %e, "DNS server error"); - } - }); - - // Spawn reload listener — swaps RuntimeState when new registrations arrive - let state_for_reload = self.state.clone(); - let cancel_reload = cancel.clone(); - let mut reload_rx = self.reload_rx; - tokio::spawn(async move { - loop { - tokio::select! { - _ = cancel_reload.cancelled() => break, - msg = reload_rx.recv() => match msg { - Some(regs) => { - let count = regs.len(); - let new = Arc::new(RuntimeState::new(regs)); - state_for_reload.store(new); - info!(count, "Runtime state reloaded"); + let dns_handle = spawn_dns_server(self.dns_port, self.lan_ip); + spawn_reload_listener( + self.state.clone(), + self.reload_rx, + self.reload_provider, + cancel.clone(), + ); + spawn_mgmt_socket( + self.state.clone(), + self.reload_tx, + self.socket_path, + self.http_port, + self.https_port, + self.dns_port, + cancel.clone(), + ); + + let http_server = + start_http_server(self.state.clone(), self.http_port, cancel.clone()).await?; + + await_servers( + http_server, + dns_handle, + self.state, + self.tls_acceptor, + self.https_port, + cancel, + ) + .await + } +} + +fn spawn_dns_server(port: u16, lan_ip: Ipv4Addr) -> tokio::task::JoinHandle<()> { + let dns_server = DnsServer::new(port, lan_ip); + tokio::spawn(async move { + if let Err(e) = dns_server.run().await { + error!(error = %e, "DNS server error"); + } + }) +} + +fn spawn_reload_listener( + state: SharedState, + mut reload_rx: mpsc::Receiver<()>, + reload_provider: Arc, + cancel: CancellationToken, +) { + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + msg = reload_rx.recv() => match msg { + Some(()) => { + match reload_provider.load() { + Ok(regs) => { + let count = regs.len(); + let new = Arc::new(RuntimeState::new(regs)); + state.store(new); + info!(count, "Runtime state reloaded"); + } + Err(e) => { + warn!(error = %e, "Failed to load registrations during reload"); + } } - None => break, } + None => break, } } - }); - - // Spawn management socket for status/reload/list commands - let mgmt_cancel = cancel.clone(); - tokio::spawn({ - let state = self.state.clone(); - let reload_tx = self.reload_tx.clone(); - let reload_provider = self.reload_provider.clone(); - let socket_path = self.socket_path.clone(); - async move { - if let Err(e) = super::mgmt_socket::serve( - socket_path, - state, - reload_provider, - reload_tx, - mgmt_cancel, - ) - .await - { - error!(error = %e, "Management socket error"); - } - } - }); + } + }); +} - let http_addr = SocketAddr::from(([0, 0, 0, 0], self.http_port)); - let https_addr = SocketAddr::from(([0, 0, 0, 0], self.https_port)); +fn spawn_mgmt_socket( + state: SharedState, + reload_tx: mpsc::Sender<()>, + socket_path: PathBuf, + http_port: u16, + https_port: u16, + dns_port: u16, + cancel: CancellationToken, +) { + let mgmt_ports = super::mgmt_socket::DaemonPorts { + http: http_port, + https: https_port, + dns: dns_port, + }; + tokio::spawn(async move { + if let Err(e) = + super::mgmt_socket::serve(socket_path, state, reload_tx, cancel, mgmt_ports).await + { + error!(error = %e, "Management socket error"); + } + }); +} - // Start HTTP server - always serve content (no redirect to HTTPS) - let http_router = create_router(self.state.clone()) - .layer(Extension(Scheme::Http)) - .layer(axum::middleware::from_fn(inject_client_addr)); +async fn start_http_server( + state: SharedState, + http_port: u16, + cancel: CancellationToken, +) -> Result>> { + let http_addr = SocketAddr::from(([0, 0, 0, 0], http_port)); + let http_router = create_router(state) + .layer(Extension(Scheme::Http)) + .layer(axum::middleware::from_fn(inject_client_addr)); - let http_listener = TcpListener::bind(http_addr).await.context(format!( - "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", - self.http_port, self.http_port - ))?; + let http_listener = TcpListener::bind(http_addr).await.context(format!( + "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", + http_port, http_port + ))?; - info!(addr = %http_addr, "HTTP server listening"); + info!(addr = %http_addr, "HTTP server listening"); - let http_server = tokio::spawn({ - let cancel = cancel.clone(); - async move { - axum::serve( - http_listener, - http_router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(cancel.cancelled_owned()) - .await - .map_err(|e| anyhow::anyhow!("HTTP server error: {}", e)) - } - }); - - // Start HTTPS server if TLS is available - if let Some(tls_acceptor) = self.tls_acceptor { - let https_router = create_router(self.state).layer(Extension(Scheme::Https)); - let https_listener = TcpListener::bind(https_addr).await.context(format!( - "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", - self.https_port, self.https_port - ))?; - - info!(addr = %https_addr, "HTTPS server listening"); - - let https_server = tokio::spawn({ - let cancel = cancel.clone(); - async move { - loop { - let (stream, addr) = tokio::select! { - _ = cancel.cancelled() => break, - result = https_listener.accept() => match result { - Ok(conn) => conn, - Err(e) => { - error!(error = %e, "Failed to accept connection"); - continue; - } - }, - }; - - let acceptor = tls_acceptor.clone(); - // The HTTPS path uses manual TLS accept, so ConnectInfo is not - // available. Instead, inject the client IP directly as an Extension - // on each accepted connection. - let router = https_router.clone().layer(Extension(ClientAddr(addr.ip()))); - - tokio::spawn(async move { - let stream = match acceptor.accept(stream).await { - Ok(s) => s, - Err(e) => { - warn!(error = %e, "TLS handshake failed"); - return; - } - }; - - let io = hyper_util::rt::TokioIo::new(stream); - let service = hyper_util::service::TowerToHyperService::new( - router.into_service(), - ); - - if let Err(e) = hyper_util::server::conn::auto::Builder::new( - hyper_util::rt::TokioExecutor::new(), - ) - .serve_connection_with_upgrades(io, service) - .await - { - error!(error = %e, "Error serving connection"); - } - }); - } - } - }); + Ok(tokio::spawn(async move { + axum::serve( + http_listener, + http_router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(cancel.cancelled_owned()) + .await + .map_err(|e| anyhow::anyhow!("HTTP server error: {}", e)) + })) +} - tokio::select! { - r = http_server => r??, - r = https_server => { - if let Err(e) = r { - error!(error = %e, "HTTPS server task failed"); - anyhow::bail!("HTTPS server task failed: {e}"); - } - }, - r = dns_handle => { - if let Err(e) = r { - error!(error = %e, "DNS server task failed"); - anyhow::bail!("DNS server task failed: {e}"); +async fn start_https_server( + state: SharedState, + tls_acceptor: TlsAcceptor, + https_port: u16, + cancel: CancellationToken, +) -> Result> { + let https_addr = SocketAddr::from(([0, 0, 0, 0], https_port)); + let https_router = create_router(state).layer(Extension(Scheme::Https)); + let https_listener = TcpListener::bind(https_addr).await.context(format!( + "Failed to bind to port {}. Is another service using it? Try: sudo lsof -i :{}", + https_port, https_port + ))?; + + info!(addr = %https_addr, "HTTPS server listening"); + + Ok(tokio::spawn(async move { + loop { + let (stream, addr) = tokio::select! { + _ = cancel.cancelled() => break, + result = https_listener.accept() => match result { + Ok(conn) => conn, + Err(e) => { + error!(error = %e, "Failed to accept connection"); + continue; } }, - } - } else { - warn!( - "No HTTPS certificates found, running HTTP only. \ - Register a domain with sudo to enable HTTPS." - ); - tokio::select! { - r = http_server => r??, - r = dns_handle => { - if let Err(e) = r { - error!(error = %e, "DNS server task failed"); - anyhow::bail!("DNS server task failed: {e}"); + }; + + let acceptor = tls_acceptor.clone(); + let router = https_router.clone().layer(Extension(ClientAddr(addr.ip()))); + + tokio::spawn(async move { + let stream = match acceptor.accept(stream).await { + Ok(s) => s, + Err(e) => { + warn!(error = %e, "TLS handshake failed"); + return; } - }, - } + }; + + let io = hyper_util::rt::TokioIo::new(stream); + let service = hyper_util::service::TowerToHyperService::new(router.into_service()); + + if let Err(e) = hyper_util::server::conn::auto::Builder::new( + hyper_util::rt::TokioExecutor::new(), + ) + .serve_connection_with_upgrades(io, service) + .await + { + error!(error = %e, "Error serving connection"); + } + }); } + })) +} + +async fn await_servers( + http_server: tokio::task::JoinHandle>, + dns_handle: tokio::task::JoinHandle<()>, + state: SharedState, + tls_acceptor: Option, + https_port: u16, + cancel: CancellationToken, +) -> Result<()> { + if let Some(tls_acceptor) = tls_acceptor { + let https_server = + start_https_server(state, tls_acceptor, https_port, cancel.clone()).await?; - Ok(()) + tokio::select! { + r = http_server => r??, + r = https_server => { + if let Err(e) = r { + error!(error = %e, "HTTPS server task failed"); + anyhow::bail!("HTTPS server task failed: {e}"); + } + }, + r = dns_handle => { + if let Err(e) = r { + error!(error = %e, "DNS server task failed"); + anyhow::bail!("DNS server task failed: {e}"); + } + }, + } + } else { + warn!( + "No HTTPS certificates found, running HTTP only. \ + Register a domain with sudo to enable HTTPS." + ); + tokio::select! { + r = http_server => r??, + r = dns_handle => { + if let Err(e) = r { + error!(error = %e, "DNS server task failed"); + anyhow::bail!("DNS server task failed: {e}"); + } + }, + } } + + Ok(()) } diff --git a/src/daemon/tls.rs b/src/daemon/tls.rs index f7a36ae..b627836 100644 --- a/src/daemon/tls.rs +++ b/src/daemon/tls.rs @@ -102,7 +102,7 @@ pub fn create_tls_acceptor( let generator = CertificateGenerator::new(data_dir.to_path_buf(), certs_dir.to_path_buf()); for pattern in patterns { - let stem = pattern.cert_name(); + let stem = crate::infrastructure::certs::cert_name(pattern); let cert_path = certs_dir.join(format!("{}.crt", stem)); let key_path = certs_dir.join(format!("{}.key", stem)); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 61ccc15..437d220 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,5 +1,5 @@ mod registration; pub mod value_objects; -pub use registration::DomainRegistration; +pub use registration::{DomainRegistration, RegistrationSource}; pub use value_objects::{DomainName, DomainPattern, PathPrefix, ProxyTarget, Route, RouteTarget}; diff --git a/src/domain/registration.rs b/src/domain/registration.rs index 4badd54..61df5fe 100644 --- a/src/domain/registration.rs +++ b/src/domain/registration.rs @@ -1,15 +1,10 @@ -use super::{DomainName, DomainPattern, PathPrefix, Route, RouteTarget}; -use std::path::PathBuf; +use std::fmt; + +use super::{DomainName, DomainPattern, PathPrefix, Route}; use thiserror::Error; #[derive(Debug, Error)] pub enum RegistrationError { - #[error("Target path does not exist: {0}")] - PathNotFound(PathBuf), - - #[error("Target path is not a directory: {0}")] - NotADirectory(PathBuf), - #[error("Route for path '{0}' already exists")] RouteExists(String), @@ -20,11 +15,30 @@ pub enum RegistrationError { CannotRemoveLastRoute, } +/// Where a domain registration originated from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationSource { + /// Loaded from the config file. + Config, + /// Discovered dynamically from an external provider (Docker, etc.). + External, +} + +impl fmt::Display for RegistrationSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Config => write!(f, "config"), + Self::External => write!(f, "external"), + } + } +} + #[derive(Debug, Clone)] pub struct DomainRegistration { pattern: DomainPattern, routes: Vec, https_enabled: bool, + source: RegistrationSource, } impl DomainRegistration { @@ -33,6 +47,21 @@ impl DomainRegistration { pattern, routes, https_enabled: false, + source: RegistrationSource::Config, + } + } + + /// Create a registration with an explicit source. + pub fn with_source( + pattern: DomainPattern, + routes: Vec, + source: RegistrationSource, + ) -> Self { + Self { + pattern, + routes, + https_enabled: false, + source, } } @@ -54,6 +83,10 @@ impl DomainRegistration { self.https_enabled } + pub fn source(&self) -> RegistrationSource { + self.source + } + pub fn is_wildcard(&self) -> bool { self.pattern.is_wildcard() } @@ -64,10 +97,6 @@ impl DomainRegistration { self.pattern.display_pattern() } - pub fn config_key(&self) -> String { - self.pattern.display_pattern() - } - // --- Mutators --- pub fn enable_https(&mut self) { @@ -80,15 +109,15 @@ impl DomainRegistration { pub fn match_route(&self, request_path: &str) -> Option<&Route> { self.routes .iter() - .filter(|r| r.path.matches(request_path)) - .max_by_key(|r| r.path.len()) + .filter(|r| r.path().matches(request_path)) + .max_by_key(|r| r.path().len()) } /// Add a route to this registration. /// Returns error if a route with the same path already exists. pub fn add_route(&mut self, route: Route) -> Result<(), RegistrationError> { - if self.routes.iter().any(|r| r.path == route.path) { - return Err(RegistrationError::RouteExists(route.path.to_string())); + if self.routes.iter().any(|r| r.path() == route.path()) { + return Err(RegistrationError::RouteExists(route.path().to_string())); } self.routes.push(route); Ok(()) @@ -102,7 +131,7 @@ impl DomainRegistration { } let len_before = self.routes.len(); - self.routes.retain(|r| &r.path != path); + self.routes.retain(|r| r.path() != path); if self.routes.len() == len_before { return Err(RegistrationError::RouteNotFound(path.to_string())); @@ -110,19 +139,12 @@ impl DomainRegistration { Ok(()) } - /// Validate that the registration is still valid (e.g., paths exist) + /// Validate structural invariants of this registration. + /// + /// Filesystem checks (path exists, is directory) are NOT done here — + /// those belong in the infrastructure or application layer. pub fn validate(&self) -> Result<(), RegistrationError> { - for route in &self.routes { - if let RouteTarget::StaticFiles(path) = &route.target { - if !path.exists() { - return Err(RegistrationError::PathNotFound(path.clone())); - } - if !path.is_dir() { - return Err(RegistrationError::NotADirectory(path.clone())); - } - } - // Proxy targets don't need validation - the service may not be running yet - } + // Structural invariants only — no I/O Ok(()) } } @@ -139,14 +161,7 @@ mod tests { fn proxy_route(path: &str, port: u16) -> Route { Route::new( PathPrefix::new(path).unwrap(), - RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), - ) - } - - fn static_route(path_prefix: &str, dir: PathBuf) -> Route { - Route::new( - PathPrefix::new(path_prefix).unwrap(), - RouteTarget::StaticFiles(dir), + crate::domain::RouteTarget::Proxy(ProxyTarget::parse(&port.to_string()).unwrap()), ) } @@ -160,6 +175,16 @@ mod tests { assert_eq!(reg.domain().as_str(), "myapp.roxy"); } + #[test] + fn with_source_sets_source() { + let reg = DomainRegistration::with_source( + make_pattern("myapp.roxy"), + vec![proxy_route("/", 3000)], + RegistrationSource::External, + ); + assert_eq!(reg.source(), RegistrationSource::External); + } + // --- enable_https --- #[test] @@ -177,7 +202,7 @@ mod tests { fn match_route_returns_exact_match() { let reg = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); let matched = reg.match_route("/").unwrap(); - assert_eq!(matched.path.as_str(), "/"); + assert_eq!(matched.path().as_str(), "/"); } #[test] @@ -189,11 +214,11 @@ mod tests { // /api/users should match /api (more specific) not / let matched = reg.match_route("/api/users").unwrap(); - assert_eq!(matched.path.as_str(), "/api"); + assert_eq!(matched.path().as_str(), "/api"); // / should match root let matched = reg.match_route("/").unwrap(); - assert_eq!(matched.path.as_str(), "/"); + assert_eq!(matched.path().as_str(), "/"); } #[test] @@ -266,47 +291,13 @@ mod tests { assert!(reg.validate().is_ok()); } - #[test] - fn validate_passes_for_existing_directory() { - let tmp = tempfile::tempdir().unwrap(); - let reg = DomainRegistration::new( - make_pattern("myapp.roxy"), - vec![static_route("/", tmp.path().to_path_buf())], - ); - assert!(reg.validate().is_ok()); - } - - #[test] - fn validate_fails_for_nonexistent_path() { - let reg = DomainRegistration::new( - make_pattern("myapp.roxy"), - vec![static_route("/", PathBuf::from("/no/such/path"))], - ); - let result = reg.validate(); - assert!(matches!(result, Err(RegistrationError::PathNotFound(_)))); - } - - #[test] - fn validate_fails_for_file_not_directory() { - let tmp = tempfile::tempdir().unwrap(); - let file_path = tmp.path().join("file.txt"); - std::fs::write(&file_path, "content").unwrap(); - let reg = DomainRegistration::new( - make_pattern("myapp.roxy"), - vec![static_route("/", file_path)], - ); - let result = reg.validate(); - assert!(matches!(result, Err(RegistrationError::NotADirectory(_)))); - } - - // --- display_pattern / config_key --- + // --- display_pattern --- #[test] fn display_pattern_delegates_to_domain_pattern() { let exact = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); assert_eq!(exact.display_pattern(), "myapp.roxy"); - assert_eq!(exact.config_key(), "myapp.roxy"); let wildcard = DomainRegistration::new( DomainPattern::Wildcard(DomainName::new("myapp.roxy").unwrap()), diff --git a/src/domain/value_objects/domain_pattern.rs b/src/domain/value_objects/domain_pattern.rs index cf1b75a..641122c 100644 --- a/src/domain/value_objects/domain_pattern.rs +++ b/src/domain/value_objects/domain_pattern.rs @@ -1,12 +1,6 @@ use std::fmt; -use super::domain_name::DomainName; - -/// Filename prefix for wildcard certificates stored on disk. -/// -/// Uses underscores so it can't collide with a valid `.roxy` domain -/// (underscores are rejected by `DomainName` validation). -pub const WILDCARD_CERT_PREFIX: &str = "__wildcard__."; +use super::domain_name::{DomainName, DomainNameError}; /// Value object representing how a domain is matched — either /// exactly or as a wildcard pattern covering one-level subdomains. @@ -23,7 +17,7 @@ impl DomainPattern { /// Build a `DomainPattern` from a raw domain name string and a /// wildcard flag. Validates the domain name and returns the /// appropriate variant. - pub fn from_name(name: &str, wildcard: bool) -> anyhow::Result { + pub fn from_name(name: &str, wildcard: bool) -> Result { let domain = DomainName::new(name)?; Ok(if wildcard { Self::Wildcard(domain) @@ -78,20 +72,6 @@ impl DomainPattern { } } - /// Certificate file stem used for on-disk certificate naming. - /// - /// Exact domains use the domain directly (`myapp.roxy`). - /// Wildcard domains use the `__wildcard__.` prefix - /// (`__wildcard__.myapp.roxy`). - pub fn cert_name(&self) -> String { - match self { - Self::Exact(d) => d.as_str().to_string(), - Self::Wildcard(d) => { - format!("{}{}", WILDCARD_CERT_PREFIX, d.as_str()) - } - } - } - /// Specificity score for "most specific wins" ordering. /// Longer base domains are more specific. pub fn specificity(&self) -> usize { @@ -223,21 +203,6 @@ mod tests { assert_eq!(wildcard("myapp.roxy").display_pattern(), "*.myapp.roxy"); } - // --- cert_name --- - - #[test] - fn exact_cert_name() { - assert_eq!(exact("myapp.roxy").cert_name(), "myapp.roxy"); - } - - #[test] - fn wildcard_cert_name() { - assert_eq!( - wildcard("myapp.roxy").cert_name(), - "__wildcard__.myapp.roxy" - ); - } - // --- specificity --- #[test] diff --git a/src/domain/value_objects/mod.rs b/src/domain/value_objects/mod.rs index b8958cc..f4cadd3 100644 --- a/src/domain/value_objects/mod.rs +++ b/src/domain/value_objects/mod.rs @@ -9,4 +9,4 @@ pub use domain_name::DomainName; pub use domain_pattern::DomainPattern; pub use path_prefix::PathPrefix; pub use proxy_target::ProxyTarget; -pub use route::{Route, RouteTarget}; +pub use route::{Route, RouteTarget, RouteTargetError}; diff --git a/src/domain/value_objects/port.rs b/src/domain/value_objects/port.rs index 327c80b..574b02c 100644 --- a/src/domain/value_objects/port.rs +++ b/src/domain/value_objects/port.rs @@ -23,7 +23,17 @@ impl Port { Ok(Self(port)) } - #[cfg(test)] + /// Create a port allowing the full 1..=65535 range. + /// + /// Use for targets where Roxy connects as a client (e.g., container + /// ports mapped to the host) rather than user-configured services. + pub fn any(port: u16) -> Result { + if port == 0 { + return Err(PortError::OutOfRange(port)); + } + Ok(Self(port)) + } + pub fn value(&self) -> u16 { self.0 } @@ -71,4 +81,22 @@ mod tests { assert!(Port::new(80).is_err()); // Privileged assert!(Port::new(443).is_err()); // Privileged } + + #[test] + fn test_any_port_allows_privileged() { + assert!(Port::any(80).is_ok()); + assert!(Port::any(443).is_ok()); + assert_eq!(Port::any(80).unwrap().value(), 80); + } + + #[test] + fn test_any_port_rejects_zero() { + assert!(Port::any(0).is_err()); + } + + #[test] + fn test_any_port_allows_high_ports() { + assert!(Port::any(3000).is_ok()); + assert!(Port::any(65535).is_ok()); + } } diff --git a/src/domain/value_objects/route.rs b/src/domain/value_objects/route.rs index 15c05b5..35cd141 100644 --- a/src/domain/value_objects/route.rs +++ b/src/domain/value_objects/route.rs @@ -7,8 +7,8 @@ use thiserror::Error; #[derive(Debug, Clone)] pub struct Route { - pub path: PathPrefix, - pub target: RouteTarget, + path: PathPrefix, + target: RouteTarget, } #[derive(Debug, Clone)] @@ -42,25 +42,18 @@ pub enum RouteError { } impl RouteTarget { - /// Parse target string: absolute path (starting with /) = static files, otherwise proxy - /// Note: To distinguish from PathPrefix, static file paths must exist on disk + /// Parse target string: absolute path (starting with /) = static files, otherwise proxy. + /// + /// This is pure parsing — no filesystem I/O. Callers that need to verify + /// the path exists should do so at the application or CLI layer. pub fn parse(s: &str) -> Result { - // If it starts with / and looks like a filesystem path, try static files if s.starts_with('/') { - let path = PathBuf::from(s); - if path.exists() { - if !path.is_dir() { - return Err(RouteTargetError::NotADirectory(path)); - } - return Ok(Self::StaticFiles(path.canonicalize().unwrap_or(path))); - } - // Path doesn't exist - could be a typo, report it - return Err(RouteTargetError::PathNotFound(path)); + Ok(Self::StaticFiles(PathBuf::from(s))) + } else { + Ok(Self::Proxy(ProxyTarget::parse(s)?)) } - - // Otherwise it's a proxy target - Ok(Self::Proxy(ProxyTarget::parse(s)?)) } + } impl fmt::Display for RouteTarget { @@ -106,6 +99,14 @@ impl Route { Self { path, target } } + pub fn path(&self) -> &PathPrefix { + &self.path + } + + pub fn target(&self) -> &RouteTarget { + &self.target + } + /// Parse from CLI format: "PATH=TARGET" e.g., "/api=3001" or "/=3000" pub fn parse(s: &str) -> Result { let (path_str, target_str) = s @@ -158,20 +159,20 @@ mod tests { #[test] fn test_parse_proxy_route() { let route = Route::parse("/api=3001").unwrap(); - assert_eq!(route.path.to_string(), "/api"); - assert!(matches!(route.target, RouteTarget::Proxy(_))); + assert_eq!(route.path().to_string(), "/api"); + assert!(matches!(route.target(), RouteTarget::Proxy(_))); } #[test] fn test_parse_root_route() { let route = Route::parse("/=3000").unwrap(); - assert_eq!(route.path.to_string(), "/"); + assert_eq!(route.path().to_string(), "/"); } #[test] fn test_parse_with_host() { let route = Route::parse("/api=192.168.1.50:3001").unwrap(); - let RouteTarget::Proxy(proxy) = &route.target else { + let RouteTarget::Proxy(proxy) = route.target() else { panic!("expected proxy target"); }; assert_eq!(proxy.host(), "192.168.1.50"); diff --git a/src/infrastructure/certs/generator.rs b/src/infrastructure/certs/generator.rs index db42cb9..be03a64 100644 --- a/src/infrastructure/certs/generator.rs +++ b/src/infrastructure/certs/generator.rs @@ -100,7 +100,7 @@ impl CertificateGenerator { let cert_pem = ca.sign_certificate(params, &key_pair)?; Ok(Certificate { - file_stem: pattern.cert_name(), + file_stem: super::cert_name(pattern), cert_pem, key_pem: key_pair.serialize_pem(), }) @@ -142,7 +142,7 @@ impl CertificateGenerator { /// Delete certificate files for a domain pattern pub fn delete(&self, pattern: &DomainPattern) -> Result<(), CertError> { - let stem = pattern.cert_name(); + let stem = super::cert_name(pattern); let cert_path = self.certs_dir.join(format!("{}.crt", stem)); let key_path = self.certs_dir.join(format!("{}.key", stem)); @@ -165,7 +165,7 @@ impl CertificateGenerator { /// Check if certificate exists for a domain pattern pub fn exists(&self, pattern: &DomainPattern) -> bool { - let stem = pattern.cert_name(); + let stem = super::cert_name(pattern); let cert_path = self.certs_dir.join(format!("{}.crt", stem)); let key_path = self.certs_dir.join(format!("{}.key", stem)); cert_path.exists() && key_path.exists() diff --git a/src/infrastructure/certs/mod.rs b/src/infrastructure/certs/mod.rs index 780aab9..b366723 100644 --- a/src/infrastructure/certs/mod.rs +++ b/src/infrastructure/certs/mod.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use thiserror::Error; +use crate::domain::DomainPattern; + pub mod ca; pub mod generator; pub mod service; @@ -9,6 +11,26 @@ pub mod trust_store; pub use generator::CertificateGenerator; pub use service::CertificateService; +/// Filename prefix for wildcard certificates stored on disk. +/// +/// Uses underscores so it can't collide with a valid `.roxy` domain +/// (underscores are rejected by `DomainName` validation). +pub const WILDCARD_CERT_PREFIX: &str = "__wildcard__."; + +/// Certificate file stem used for on-disk certificate naming. +/// +/// Exact domains use the domain directly (`myapp.roxy`). +/// Wildcard domains use the `__wildcard__.` prefix +/// (`__wildcard__.myapp.roxy`). +pub fn cert_name(pattern: &DomainPattern) -> String { + match pattern { + DomainPattern::Exact(d) => d.as_str().to_string(), + DomainPattern::Wildcard(d) => { + format!("{}{}", WILDCARD_CERT_PREFIX, d.as_str()) + } + } +} + #[derive(Error, Debug)] pub enum CertError { #[error("Failed to generate certificate: {0}")] @@ -40,3 +62,23 @@ pub enum CertError { )] PermissionDenied, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::DomainName; + + #[test] + fn exact_cert_name() { + let name = DomainName::new("myapp.roxy").unwrap(); + let pattern = DomainPattern::Exact(name); + assert_eq!(cert_name(&pattern), "myapp.roxy"); + } + + #[test] + fn wildcard_cert_name() { + let name = DomainName::new("myapp.roxy").unwrap(); + let pattern = DomainPattern::Wildcard(name); + assert_eq!(cert_name(&pattern), "__wildcard__.myapp.roxy"); + } +} diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index da5d739..3437251 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1,9 +1,11 @@ mod dto; +pub mod watcher; // Re-export shared config types so existing CLI/daemon code keeps compiling. -pub use crate::config::{DaemonConfig, RoxyPaths}; +pub use crate::config::{DaemonConfig, DockerConfig, RoxyPaths}; use crate::application::ports::{ConfigLoadError, ConfigLoader, DomainRepository, RepositoryError}; +use crate::domain::value_objects::{RouteTarget, RouteTargetError}; use crate::domain::{DomainPattern, DomainRegistration}; use dto::RegistrationDto; use std::collections::HashMap; @@ -43,6 +45,9 @@ pub struct Config { #[serde(default)] pub paths: RoxyPaths, + #[serde(default)] + pub docker: DockerConfig, + #[serde(default)] domains: HashMap, } @@ -62,15 +67,37 @@ impl Config { for (name, dto) in &self.domains { let registration = DomainRegistration::from(dto.clone()); + // Structural validation (invariants enforced at construction) registration .validate() .map_err(|e| ConfigError::InvalidDomain(name.clone(), e.to_string()))?; + // Infrastructure-level validation: check filesystem paths exist + for route in registration.routes() { + validate_route_path(route.target()).map_err(|e: RouteTargetError| { + ConfigError::InvalidDomain(name.clone(), e.to_string()) + })?; + } } Ok(()) } } +/// Validate that a static files target points to an existing directory. +/// +/// This is an infrastructure concern (filesystem I/O), kept out of the domain layer. +fn validate_route_path(target: &RouteTarget) -> Result<(), RouteTargetError> { + if let RouteTarget::StaticFiles(path) = target { + if !path.exists() { + return Err(RouteTargetError::PathNotFound(path.clone())); + } + if !path.is_dir() { + return Err(RouteTargetError::NotADirectory(path.clone())); + } + } + Ok(()) +} + pub struct ConfigStore { path: PathBuf, } @@ -111,7 +138,7 @@ impl ConfigStore { pub fn add_domain(&self, registration: DomainRegistration) -> Result<(), ConfigError> { let mut config = self.load()?; - let key = registration.config_key(); + let key = registration.display_pattern(); if config.domains.contains_key(&key) { return Err(ConfigError::DomainExists(key)); } @@ -152,7 +179,7 @@ impl ConfigStore { pub fn update_domain(&self, registration: DomainRegistration) -> Result<(), ConfigError> { let mut config = self.load()?; - let key = registration.config_key(); + let key = registration.display_pattern(); if !config.domains.contains_key(&key) { return Err(ConfigError::DomainNotFound(key)); } diff --git a/src/daemon/config_watcher.rs b/src/infrastructure/config/watcher.rs similarity index 86% rename from src/daemon/config_watcher.rs rename to src/infrastructure/config/watcher.rs index 28c61c7..f1e5e78 100644 --- a/src/daemon/config_watcher.rs +++ b/src/infrastructure/config/watcher.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use tokio::sync::mpsc; @@ -35,13 +35,9 @@ impl ConfigFileProvider { self } - /// Poll the config file for mtime changes and send new registrations + /// Poll the config file for mtime changes and send a nudge /// through the channel when the file is modified. - pub async fn watch( - &self, - tx: mpsc::Sender>, - cancel: CancellationToken, - ) { + pub async fn watch(&self, tx: mpsc::Sender<()>, cancel: CancellationToken) { let mut last_mtime = file_mtime(&self.config_path); loop { @@ -56,13 +52,14 @@ impl ConfigFileProvider { } last_mtime = current_mtime; + // Verify the file is parseable before nudging match self.load() { Ok(registrations) => { let count = registrations.len(); - if tx.send(registrations).await.is_err() { + if tx.send(()).await.is_err() { break; // receiver dropped } - info!(count, "Config file changed, reloaded registrations"); + info!(count, "Config file changed, nudging reload"); } Err(e) => { warn!(error = %e, "Config file changed but failed to load, keeping old state"); @@ -83,7 +80,7 @@ impl RegistrationProvider for ConfigFileProvider { } } -fn file_mtime(path: &PathBuf) -> Option { +fn file_mtime(path: &Path) -> Option { std::fs::metadata(path).ok().and_then(|m| m.modified().ok()) } @@ -121,7 +118,7 @@ mod tests { let provider = ConfigFileProvider::new(config_path.clone()) .with_poll_interval(Duration::from_millis(10)); - let (tx, mut rx) = mpsc::channel(4); + let (tx, mut rx) = mpsc::channel::<()>(4); let cancel = CancellationToken::new(); let cancel_watch = cancel.clone(); @@ -143,15 +140,12 @@ routes = [{ path = "/", target = "3000" }] ) .unwrap(); - // Should receive registrations within a few poll cycles - let regs = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + // Should receive a nudge within a few poll cycles + tokio::time::timeout(Duration::from_secs(2), rx.recv()) .await .expect("timed out waiting for reload") .expect("channel closed"); - assert_eq!(regs.len(), 1); - assert_eq!(regs[0].domain().as_str(), "app.roxy"); - cancel.cancel(); handle.await.unwrap(); } @@ -164,7 +158,7 @@ routes = [{ path = "/", target = "3000" }] let provider = ConfigFileProvider::new(config_path).with_poll_interval(Duration::from_millis(10)); - let (tx, mut rx) = mpsc::channel(4); + let (tx, mut rx) = mpsc::channel::<()>(4); let cancel = CancellationToken::new(); let cancel_watch = cancel.clone(); @@ -189,7 +183,7 @@ routes = [{ path = "/", target = "3000" }] let provider = ConfigFileProvider::new(config_path).with_poll_interval(Duration::from_millis(10)); - let (tx, _rx) = mpsc::channel(4); + let (tx, _rx) = mpsc::channel::<()>(4); let cancel = CancellationToken::new(); let cancel_watch = cancel.clone(); @@ -214,7 +208,7 @@ routes = [{ path = "/", target = "3000" }] let provider = ConfigFileProvider::new(config_path.clone()) .with_poll_interval(Duration::from_millis(10)); - let (tx, mut rx) = mpsc::channel(4); + let (tx, mut rx) = mpsc::channel::<()>(4); let cancel = CancellationToken::new(); let cancel_watch = cancel.clone(); @@ -232,7 +226,7 @@ routes = [{ path = "/", target = "3000" }] // No message should be sent (parse failure keeps old state) assert!(rx.try_recv().is_err()); - // Now write valid config — watcher should recover + // Now write valid config — watcher should recover and send a nudge std::fs::write( &config_path, r#" @@ -243,12 +237,10 @@ routes = [{ path = "/", target = "4000" }] ) .unwrap(); - let regs = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + tokio::time::timeout(Duration::from_secs(2), rx.recv()) .await .expect("timed out waiting for recovery reload") .expect("channel closed"); - assert_eq!(regs.len(), 1); - assert_eq!(regs[0].domain().as_str(), "fixed.roxy"); cancel.cancel(); } diff --git a/src/infrastructure/docker/discovery.rs b/src/infrastructure/docker/discovery.rs new file mode 100644 index 0000000..697065d --- /dev/null +++ b/src/infrastructure/docker/discovery.rs @@ -0,0 +1,473 @@ +use std::collections::HashMap; + +use tracing::warn; + +use crate::domain::value_objects::port::Port; +use crate::domain::{ + DomainName, DomainPattern, DomainRegistration, ProxyTarget, RegistrationSource, Route, +}; + +/// Information extracted from a Docker container for registration decisions. +pub struct ContainerInfo { + pub id: String, + pub name: String, + pub labels: HashMap, + /// Ports exposed by the container (internal ports, not host mappings). + pub exposed_ports: Vec, + /// Host port mappings (container_port -> host_port) from `-p` / `ports:`. + pub host_port_mappings: HashMap, +} + +/// Result of evaluating a container for registration. +#[derive(Debug)] +pub enum DiscoveryResult { + /// Container qualifies; here's its registration. + Register(DomainRegistration), + /// Container was explicitly opted out or doesn't qualify. + Skip(String), +} + +/// Evaluate a container and decide whether to register it. +/// +/// Container qualification rules: +/// 1. Has `roxy.enable=false` label --> skip (explicit opt-out) +/// 2. Has `roxy.enable=true` label --> register (explicit opt-in) +/// 3. Has compose labels AND at least one exposed port --> register +/// 4. Otherwise --> skip +/// +/// The proxy target is always `127.0.0.1:{host_port}` using the +/// container's published host port mapping. Containers without +/// published ports cannot be proxied. +pub fn evaluate_container(info: &ContainerInfo) -> DiscoveryResult { + // Check explicit opt-out + if info.labels.get("roxy.enable").is_some_and(|v| v == "false") { + return DiscoveryResult::Skip("explicitly disabled via roxy.enable=false".into()); + } + + let explicit_opt_in = info.labels.get("roxy.enable").is_some_and(|v| v == "true"); + + // Determine domain name + let domain = match resolve_domain(info) { + Some(d) => d, + None => { + if explicit_opt_in { + return DiscoveryResult::Skip(format!( + "container {} has roxy.enable=true but no domain could be determined \ + (set roxy.domain or use docker compose)", + info.name + )); + } + return DiscoveryResult::Skip("no compose labels and no roxy.domain".into()); + } + }; + + // Check if container qualifies (explicit opt-in or compose with ports) + if !explicit_opt_in { + let has_compose_labels = info.labels.contains_key("com.docker.compose.project") + && info.labels.contains_key("com.docker.compose.service"); + + if !has_compose_labels { + return DiscoveryResult::Skip("no compose labels and roxy.enable not set".into()); + } + + if info.exposed_ports.is_empty() { + return DiscoveryResult::Skip(format!( + "compose service {} has no exposed ports", + info.name + )); + } + } + + // Resolve container port + let port = match resolve_port(info) { + Some(p) => p, + None => { + return DiscoveryResult::Skip(format!( + "container {} has multiple exposed ports ({:?}), set roxy.port to choose one", + info.name, info.exposed_ports + )); + } + }; + + // Look up the host port mapping for this container port. + let host_port = match info.host_port_mappings.get(&port.value()) { + Some(&hp) => hp, + None => { + return DiscoveryResult::Skip(format!( + "container {} has no published host port for container port {}. \ + Add a `ports:` mapping in docker-compose.yml (e.g., \"8080:{}\")", + info.name, + port.value(), + port.value(), + )); + } + }; + + let target_port = match Port::any(host_port) { + Ok(p) => p, + Err(e) => { + return DiscoveryResult::Skip(format!( + "container {} has invalid host port {host_port}: {e}", + info.name + )); + } + }; + + let target = ProxyTarget::localhost(target_port); + let root_path = match crate::domain::PathPrefix::new("/") { + Ok(p) => p, + Err(e) => { + return DiscoveryResult::Skip(format!("bug: root path is invalid: {e}")); + } + }; + let route = Route::new(root_path, crate::domain::RouteTarget::Proxy(target)); + + // Determine if wildcard + let is_wildcard = info + .labels + .get("roxy.wildcard") + .is_some_and(|v| v == "true"); + + let pattern = if is_wildcard { + DomainPattern::Wildcard(domain) + } else { + DomainPattern::Exact(domain) + }; + + let mut reg = + DomainRegistration::with_source(pattern, vec![route], RegistrationSource::External); + reg.enable_https(); + + DiscoveryResult::Register(reg) +} + +/// Resolve the domain name for a container. +/// +/// Priority: +/// 1. `roxy.domain` label (explicit override) +/// 2. `{service}.{project}.roxy` from compose labels +fn resolve_domain(info: &ContainerInfo) -> Option { + // Check for explicit domain override + if let Some(domain_str) = info.labels.get("roxy.domain") { + return match DomainName::new(domain_str) { + Ok(d) => Some(d), + Err(e) => { + warn!( + container = %info.name, + domain = %domain_str, + error = %e, + "Invalid roxy.domain label" + ); + None + } + }; + } + + // Build from compose labels + let project = info.labels.get("com.docker.compose.project")?; + let service = info.labels.get("com.docker.compose.service")?; + + let domain_str = format!("{service}.{project}.roxy"); + match DomainName::new(&domain_str) { + Ok(d) => Some(d), + Err(e) => { + warn!( + container = %info.name, + domain = %domain_str, + error = %e, + "Could not create valid domain from compose labels" + ); + None + } + } +} + +/// Resolve the target port for a container. +/// +/// Priority: +/// 1. `roxy.port` label (explicit) +/// 2. Single exposed port (auto-detect) +/// 3. None (ambiguous — multiple ports without label) +fn resolve_port(info: &ContainerInfo) -> Option { + // Check for explicit port label + if let Some(port_str) = info.labels.get("roxy.port") { + return match port_str.parse::() { + Ok(p) => match Port::any(p) { + Ok(port) => Some(port), + Err(e) => { + warn!( + container = %info.name, + port = %port_str, + error = %e, + "Invalid roxy.port label" + ); + None + } + }, + Err(e) => { + warn!( + container = %info.name, + port = %port_str, + error = %e, + "Cannot parse roxy.port label as number" + ); + None + } + }; + } + + // Auto-detect from exposed ports + match info.exposed_ports.len() { + 0 => None, + 1 => Port::any(info.exposed_ports[0]).ok(), + _ => None, // Multiple ports — ambiguous + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_info( + labels: Vec<(&str, &str)>, + exposed_ports: Vec, + host_port_mappings: HashMap, + ) -> ContainerInfo { + ContainerInfo { + id: "abc123".into(), + name: "test-container".into(), + labels: labels + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + exposed_ports, + host_port_mappings, + } + } + + // --- evaluate_container --- + + #[test] + fn explicit_opt_out_skips() { + let info = make_info( + vec![ + ("roxy.enable", "false"), + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ], + vec![3000], + HashMap::from([(3000, 8080)]), + ); + assert!(matches!( + evaluate_container(&info), + DiscoveryResult::Skip(_) + )); + } + + #[test] + fn explicit_opt_in_with_domain_and_port() { + let info = make_info( + vec![ + ("roxy.enable", "true"), + ("roxy.domain", "custom.roxy"), + ("roxy.port", "8080"), + ], + vec![], + HashMap::from([(8080, 9090)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + assert_eq!(reg.domain().as_str(), "custom.roxy"); + let route = ®.routes()[0]; + assert_eq!(route.target().to_string(), "127.0.0.1:9090"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + #[test] + fn compose_service_auto_discovery() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ], + vec![3000], + HashMap::from([(3000, 8080)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + assert_eq!(reg.domain().as_str(), "web.myproject.roxy"); + assert!(reg.is_https_enabled()); + let route = ®.routes()[0]; + assert_eq!(route.target().to_string(), "127.0.0.1:8080"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + #[test] + fn compose_service_without_ports_skips() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "db"), + ], + vec![], + HashMap::new(), + ); + assert!(matches!( + evaluate_container(&info), + DiscoveryResult::Skip(_) + )); + } + + #[test] + fn no_labels_skips() { + let info = make_info(vec![], vec![3000], HashMap::from([(3000, 8080)])); + assert!(matches!( + evaluate_container(&info), + DiscoveryResult::Skip(_) + )); + } + + // --- Domain resolution --- + + #[test] + fn roxy_domain_label_overrides_compose() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ("roxy.domain", "custom.roxy"), + ], + vec![3000], + HashMap::from([(3000, 8080)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + assert_eq!(reg.domain().as_str(), "custom.roxy"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + // --- Port resolution --- + + #[test] + fn roxy_port_label_overrides_exposed() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ("roxy.port", "8080"), + ], + vec![3000, 4000], + HashMap::from([(8080, 9090)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + let route = ®.routes()[0]; + assert_eq!(route.target().to_string(), "127.0.0.1:9090"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + #[test] + fn single_exposed_port_auto_detected() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ], + vec![3000], + HashMap::from([(3000, 8080)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + let route = ®.routes()[0]; + assert_eq!(route.target().to_string(), "127.0.0.1:8080"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + #[test] + fn multiple_ports_without_label_skips() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ], + vec![3000, 4000], + HashMap::from([(3000, 8080), (4000, 9090)]), + ); + assert!(matches!( + evaluate_container(&info), + DiscoveryResult::Skip(_) + )); + } + + #[test] + fn privileged_container_port_with_host_mapping() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "nginx"), + ], + vec![80], + HashMap::from([(80, 8080)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + let route = ®.routes()[0]; + assert_eq!(route.target().to_string(), "127.0.0.1:8080"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + // --- Wildcard support --- + + #[test] + fn wildcard_label_creates_wildcard_pattern() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ("roxy.wildcard", "true"), + ], + vec![3000], + HashMap::from([(3000, 8080)]), + ); + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + assert!(reg.is_wildcard()); + assert_eq!(reg.display_pattern(), "*.web.myproject.roxy"); + } + DiscoveryResult::Skip(reason) => panic!("Expected Register, got Skip: {reason}"), + } + } + + // --- Missing host port mapping --- + + #[test] + fn no_host_port_mapping_skips_with_helpful_message() { + let info = make_info( + vec![ + ("com.docker.compose.project", "myproject"), + ("com.docker.compose.service", "web"), + ], + vec![3000], + HashMap::new(), // No published ports + ); + match evaluate_container(&info) { + DiscoveryResult::Skip(reason) => { + assert!(reason.contains("no published host port")); + assert!(reason.contains("ports:")); + } + DiscoveryResult::Register(_) => panic!("Expected Skip"), + } + } +} diff --git a/src/infrastructure/docker/mod.rs b/src/infrastructure/docker/mod.rs new file mode 100644 index 0000000..7a736f6 --- /dev/null +++ b/src/infrastructure/docker/mod.rs @@ -0,0 +1,11 @@ +// Docker items are used from daemon/lifecycle.rs (bin crate) but the +// lib crate doesn't see the usage, producing dead_code warnings. +#[allow(dead_code)] +pub(crate) mod discovery; +#[allow(dead_code)] +pub(crate) mod network; +pub mod provider; +#[allow(dead_code)] +pub(crate) mod watcher; + +pub use provider::DockerProvider; diff --git a/src/infrastructure/docker/network.rs b/src/infrastructure/docker/network.rs new file mode 100644 index 0000000..f398745 --- /dev/null +++ b/src/infrastructure/docker/network.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; + +/// Extract host port mappings from a container's network settings. +/// +/// Returns a map of container_port -> host_port for ports published +/// to the host (via `-p` or `ports:` in compose). +/// +/// Roxy runs on the host and proxies to containers via their published +/// ports. Container-to-container resolution uses `extra_hosts` pointing +/// at `host-gateway` so traffic flows through Roxy on the host. +pub fn get_host_port_mappings( + ports: &Option>>>, +) -> HashMap { + let mut mappings = HashMap::new(); + let Some(ports) = ports else { + return mappings; + }; + + for (container_port_key, bindings) in ports { + // Key format: "80/tcp" + let container_port = match container_port_key.split('/').next() { + Some(p) => match p.parse::() { + Ok(port) => port, + Err(_) => continue, + }, + None => continue, + }; + + let Some(bindings) = bindings else { continue }; + + for binding in bindings { + if let Some(host_port_str) = &binding.host_port + && let Ok(host_port) = host_port_str.parse::() + { + mappings.insert(container_port, host_port); + break; // Use first binding + } + } + } + + mappings +} + +/// Get exposed ports from a container's inspect response. +/// +/// Extracts port numbers from the image's ExposedPorts config +/// (e.g., {"3000/tcp": {}} -> [3000]). +pub fn get_exposed_ports(exposed_ports: &HashMap>) -> Vec { + exposed_ports + .keys() + .filter_map(|key| { + // Format is "port/proto" e.g., "3000/tcp" + let port_str = key.split('/').next()?; + port_str.parse::().ok() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_exposed_ports() { + let mut ports = HashMap::new(); + ports.insert("3000/tcp".to_string(), HashMap::new()); + ports.insert("8080/tcp".to_string(), HashMap::new()); + + let mut result = get_exposed_ports(&ports); + result.sort(); + assert_eq!(result, vec![3000, 8080]); + } + + #[test] + fn parse_exposed_ports_empty() { + let ports = HashMap::new(); + let result = get_exposed_ports(&ports); + assert!(result.is_empty()); + } + + #[test] + fn parse_exposed_ports_udp() { + let mut ports = HashMap::new(); + ports.insert("53/udp".to_string(), HashMap::new()); + + let result = get_exposed_ports(&ports); + assert_eq!(result, vec![53]); + } + + // --- Host port mappings --- + + #[test] + fn host_port_mappings_extracted() { + use bollard::models::PortBinding; + + let mut ports = HashMap::new(); + ports.insert( + "80/tcp".to_string(), + Some(vec![PortBinding { + host_ip: Some("0.0.0.0".to_string()), + host_port: Some("8081".to_string()), + }]), + ); + + let mappings = get_host_port_mappings(&Some(ports)); + assert_eq!(mappings.get(&80), Some(&8081)); + } + + #[test] + fn host_port_mappings_none_bindings() { + let mut ports = HashMap::new(); + ports.insert("80/tcp".to_string(), None); + + let mappings = get_host_port_mappings(&Some(ports)); + assert!(mappings.is_empty()); + } + + #[test] + fn host_port_mappings_none_ports() { + let mappings = get_host_port_mappings(&None); + assert!(mappings.is_empty()); + } +} diff --git a/src/infrastructure/docker/provider.rs b/src/infrastructure/docker/provider.rs new file mode 100644 index 0000000..18fcded --- /dev/null +++ b/src/infrastructure/docker/provider.rs @@ -0,0 +1,93 @@ +use std::sync::{Arc, RwLock}; + +use bollard::Docker; + +use crate::application::ports::RegistrationProvider; +use crate::domain::DomainRegistration; + +/// Supplies domain registrations discovered from running Docker containers. +/// +/// The watcher task updates the internal state via `RwLock`; the `load()` +/// method reads it. Uses `std::sync::RwLock` (not tokio) since contention +/// is near-zero and the trait is sync. +pub struct DockerProvider { + state: Arc>>, + docker: Docker, +} + +impl DockerProvider { + pub fn new(docker: Docker) -> Self { + Self { + state: Arc::new(RwLock::new(Vec::new())), + docker, + } + } + + /// Shared reference to the internal state, for use by the watcher task. + pub fn state(&self) -> Arc>> { + self.state.clone() + } + + /// Reference to the Docker client, for use by the watcher task. + pub fn docker(&self) -> &Docker { + &self.docker + } +} + +impl RegistrationProvider for DockerProvider { + fn name(&self) -> &str { + "docker" + } + + fn load(&self) -> anyhow::Result> { + Ok(self + .state + .read() + .map_err(|e| anyhow::anyhow!("Docker provider lock poisoned: {e}"))? + .clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_name() { + let docker = Docker::connect_with_local_defaults().unwrap(); + let provider = DockerProvider::new(docker); + assert_eq!(provider.name(), "docker"); + } + + #[test] + fn load_returns_empty_initially() { + let docker = Docker::connect_with_local_defaults().unwrap(); + let provider = DockerProvider::new(docker); + let result = provider.load().unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn load_returns_state_after_update() { + use crate::domain::{DomainName, DomainPattern, Route}; + + let docker = Docker::connect_with_local_defaults().unwrap(); + let provider = DockerProvider::new(docker); + + // Simulate watcher updating state + { + let name = DomainName::new("web.myproject.roxy").unwrap(); + let pattern = DomainPattern::Exact(name); + let routes = vec![Route::parse("/=3000").unwrap()]; + let reg = DomainRegistration::new(pattern, routes); + + let state_arc = provider.state(); + let mut state = state_arc.write().unwrap(); + state.push(reg); + } + + let result = provider.load().unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].domain().as_str(), "web.myproject.roxy"); + } +} diff --git a/src/infrastructure/docker/watcher.rs b/src/infrastructure/docker/watcher.rs new file mode 100644 index 0000000..4675e59 --- /dev/null +++ b/src/infrastructure/docker/watcher.rs @@ -0,0 +1,265 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use bollard::Docker; +use bollard::container::ListContainersOptions; +use bollard::models::EventMessageTypeEnum; +use bollard::system::EventsOptions; +use futures_util::StreamExt; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use super::discovery::{ContainerInfo, DiscoveryResult, evaluate_container}; +use super::network; +use crate::domain::DomainRegistration; + +/// Watch Docker events and maintain an up-to-date list of registrations. +/// +/// On each container start/stop event, performs a full reconciliation: +/// lists all running containers, evaluates each one, and updates the +/// shared state. Then sends a `()` nudge to trigger server reload. +pub async fn watch( + docker: Docker, + state: Arc>>, + nudge_tx: mpsc::Sender<()>, + cancel: CancellationToken, +) { + // Initial reconciliation on startup + if let Err(e) = reconcile(&docker, &state, &nudge_tx).await { + warn!(error = %e, "Initial Docker reconciliation failed"); + } + + loop { + tokio::select! { + _ = cancel.cancelled() => { + info!("Docker watcher shutting down"); + break; + } + result = watch_events(&docker, &state, &nudge_tx, &cancel) => { + match result { + Ok(()) => break, // cancelled + Err(e) => { + warn!(error = %e, "Docker event stream error, reconnecting in 5s"); + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(5)) => {} + } + } + } + } + } + } +} + +/// How long to wait after the last Docker event before reconciling. +/// This prevents a burst of events (e.g., `docker compose up` starting +/// 10 services) from triggering 10 separate full reconciliations. +const DEBOUNCE_DURATION: Duration = Duration::from_millis(500); + +/// Subscribe to Docker events and reconcile on container lifecycle events. +/// +/// Uses a debounce timer: each qualifying event resets the timer, and +/// reconciliation only runs once the timer expires without new events. +async fn watch_events( + docker: &Docker, + state: &Arc>>, + nudge_tx: &mpsc::Sender<()>, + cancel: &CancellationToken, +) -> anyhow::Result<()> { + let mut filters = HashMap::new(); + filters.insert("type", vec!["container"]); + filters.insert("event", vec!["start", "stop", "die", "destroy"]); + + let options = EventsOptions { + filters, + ..Default::default() + }; + + let mut stream = docker.events(Some(options)); + let mut debounce = std::pin::pin!(tokio::time::sleep(DEBOUNCE_DURATION)); + let mut pending = false; + + info!("Docker event stream connected"); + + loop { + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = &mut debounce, if pending => { + pending = false; + if let Err(e) = reconcile(docker, state, nudge_tx).await { + warn!(error = %e, "Docker reconciliation failed after event"); + } + } + event = stream.next() => { + match event { + Some(Ok(ev)) => { + let action = ev.action.as_deref().unwrap_or("unknown"); + let actor_id = ev + .actor + .as_ref() + .and_then(|a| a.id.as_deref()) + .unwrap_or("unknown"); + + // Only debounce on container events + if ev.typ == Some(EventMessageTypeEnum::CONTAINER) { + debug!( + action, + container = actor_id, + "Docker container event, scheduling reconciliation" + ); + // Reset the debounce timer + debounce.as_mut().reset(tokio::time::Instant::now() + DEBOUNCE_DURATION); + pending = true; + } + } + Some(Err(e)) => { + return Err(anyhow::anyhow!(e)); + } + None => { + return Err(anyhow::anyhow!("Docker event stream ended")); + } + } + } + } + } +} + +/// Full reconciliation: list all running containers, evaluate each, +/// and replace the shared state. +async fn reconcile( + docker: &Docker, + state: &Arc>>, + nudge_tx: &mpsc::Sender<()>, +) -> anyhow::Result<()> { + let containers = docker + .list_containers(Some(ListContainersOptions::<&str> { + all: false, // only running + ..Default::default() + })) + .await?; + + let mut registrations = Vec::new(); + + for container in &containers { + let id = match &container.id { + Some(id) => id, + None => continue, + }; + + // Inspect the container for full details + let inspect = match docker.inspect_container(id, None).await { + Ok(info) => info, + Err(e) => { + warn!(container = %id, error = %e, "Failed to inspect container"); + continue; + } + }; + + let name = inspect + .name + .as_deref() + .unwrap_or("") + .trim_start_matches('/') + .to_string(); + + let labels = inspect + .config + .as_ref() + .and_then(|c| c.labels.clone()) + .unwrap_or_default(); + + let exposed_ports: Vec = inspect + .config + .as_ref() + .and_then(|c| c.exposed_ports.as_ref()) + .map(network::get_exposed_ports) + .unwrap_or_default(); + + let host_port_mappings = network::get_host_port_mappings( + &inspect + .network_settings + .as_ref() + .and_then(|ns| ns.ports.clone()), + ); + + let info = ContainerInfo { + id: id.clone(), + name, + labels, + exposed_ports, + host_port_mappings, + }; + + match evaluate_container(&info) { + DiscoveryResult::Register(reg) => { + debug!( + domain = %reg.display_pattern(), + container = %info.name, + "Docker container registered" + ); + registrations.push(reg); + } + DiscoveryResult::Skip(reason) => { + debug!(container = %info.name, reason, "Docker container skipped"); + } + } + } + + let count = registrations.len(); + + // Compare old vs new to find adds/removes before swapping state + let (added_count, removed_count) = { + let guard = state + .read() + .map_err(|e| anyhow::anyhow!("Docker state lock poisoned: {e}"))?; + + let old_domains: HashSet = guard.iter().map(|r| r.display_pattern()).collect(); + let new_domains: HashSet = + registrations.iter().map(|r| r.display_pattern()).collect(); + + for pattern in new_domains.difference(&old_domains) { + if let Some(reg) = registrations + .iter() + .find(|r| &r.display_pattern() == pattern) + { + let target = reg + .routes() + .first() + .map(|r| r.target().to_string()) + .unwrap_or_default(); + info!(domain = %pattern, target = %target, "Docker domain added"); + } + } + for pattern in old_domains.difference(&new_domains) { + info!(domain = %pattern, "Docker domain removed"); + } + + ( + new_domains.difference(&old_domains).count(), + old_domains.difference(&new_domains).count(), + ) + }; + + // Update shared state + { + let mut guard = state + .write() + .map_err(|e| anyhow::anyhow!("Docker state lock poisoned: {e}"))?; + *guard = registrations; + } + + if added_count > 0 || removed_count > 0 { + info!( + added = added_count, + removed = removed_count, + total = count, + "Docker reconciliation complete" + ); + // Nudge the server to reload only when registrations changed + let _ = nudge_tx.send(()).await; + } + + Ok(()) +} diff --git a/src/infrastructure/mgmt_client.rs b/src/infrastructure/mgmt_client.rs new file mode 100644 index 0000000..220a6ad --- /dev/null +++ b/src/infrastructure/mgmt_client.rs @@ -0,0 +1,156 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::application::ports::{DaemonConnection, DaemonConnectionError, DaemonRuntimeInfo}; +use crate::domain::{ + DomainPattern, DomainRegistration, PathPrefix, ProxyTarget, Route, RouteTarget, +}; + +/// Concrete infrastructure adapter implementing `DaemonConnection` +/// via a synchronous Unix socket client to the daemon's management socket. +pub struct MgmtSocketClient { + socket_path: PathBuf, +} + +impl MgmtSocketClient { + pub fn new(socket_path: &Path) -> Self { + Self { + socket_path: socket_path.to_path_buf(), + } + } + + /// Send a command to the management socket and return the parsed JSON response. + fn send_command(&self, cmd: &str) -> Result { + let mut stream = UnixStream::connect(&self.socket_path) + .map_err(|_| DaemonConnectionError::NotRunning)?; + + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|e| DaemonConnectionError::ConnectionFailed(e.into()))?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|e| DaemonConnectionError::ConnectionFailed(e.into()))?; + + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| DaemonConnectionError::ConnectionFailed(e.into()))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| DaemonConnectionError::ConnectionFailed(e.into()))?; + + let resp: serde_json::Value = serde_json::from_str(&line) + .map_err(|e| DaemonConnectionError::ProtocolError(e.to_string()))?; + + if !resp["ok"].as_bool().unwrap_or(false) { + let msg = resp["error"] + .as_str() + .unwrap_or("unknown error") + .to_string(); + return Err(DaemonConnectionError::ProtocolError(msg)); + } + + Ok(resp) + } +} + +impl DaemonConnection for MgmtSocketClient { + fn status(&self) -> Result { + let resp = self.send_command("status")?; + let data = &resp["data"]; + + let pid = data["pid"] + .as_u64() + .ok_or_else(|| DaemonConnectionError::ProtocolError("missing pid".into()))? + as u32; + let registration_count = data["registration_count"].as_u64().unwrap_or(0) as usize; + let http_port = data["http_port"].as_u64().unwrap_or(80) as u16; + let https_port = data["https_port"].as_u64().unwrap_or(443) as u16; + let dns_port = data["dns_port"].as_u64().unwrap_or(1053) as u16; + + // Status doesn't return full registrations, just a count. + // Return empty vec — callers should use list_registrations() for full data. + Ok(DaemonRuntimeInfo { + pid, + registrations: Vec::with_capacity(registration_count), + http_port, + https_port, + dns_port, + }) + } + + fn reload(&self) -> Result<(), DaemonConnectionError> { + self.send_command("reload")?; + Ok(()) + } + + fn list_registrations(&self) -> Result, DaemonConnectionError> { + let resp = self.send_command("list")?; + let domains = resp["data"]["domains"] + .as_array() + .ok_or_else(|| DaemonConnectionError::ProtocolError("missing domains array".into()))?; + + let mut registrations = Vec::with_capacity(domains.len()); + + for d in domains { + let pattern_str = d["pattern"] + .as_str() + .ok_or_else(|| DaemonConnectionError::ProtocolError("missing pattern".into()))?; + let source_str = d["source"].as_str().unwrap_or("config"); + let https = d["https"].as_bool().unwrap_or(false); + + // Parse the pattern string (handles "*.foo.roxy" and "foo.roxy") + let is_wildcard = pattern_str.starts_with("*."); + let base_name = if is_wildcard { + &pattern_str[2..] + } else { + pattern_str + }; + let pattern = DomainPattern::from_name(base_name, is_wildcard).map_err(|e| { + DaemonConnectionError::ProtocolError(format!( + "invalid pattern '{pattern_str}': {e}" + )) + })?; + + // Parse routes (supports both Proxy and StaticFiles targets) + let routes = d["routes"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|r| { + let path_str = r["path"].as_str()?; + let target_str = r["target"].as_str()?; + let path = PathPrefix::new(path_str).ok()?; + let target = if target_str.starts_with('/') { + RouteTarget::StaticFiles(std::path::PathBuf::from(target_str)) + } else { + ProxyTarget::parse(target_str) + .map(RouteTarget::Proxy) + .ok()? + }; + Some(Route::new(path, target)) + }) + .collect() + }) + .unwrap_or_default(); + + let source = if source_str != "config" { + crate::domain::RegistrationSource::External + } else { + crate::domain::RegistrationSource::Config + }; + let mut reg = DomainRegistration::with_source(pattern, routes, source); + if https { + reg.enable_https(); + } + + registrations.push(reg); + } + + Ok(registrations) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 8acdabe..8050b82 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,8 +1,10 @@ pub mod certs; pub mod config; pub mod dns; +pub mod docker; pub mod file_security; pub mod filesystem; +pub mod mgmt_client; pub mod network; pub mod paths; pub mod pid; diff --git a/src/main.rs b/src/main.rs index 9387dfb..cdeff51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod daemon; mod domain; mod infrastructure; +use cli::context::AppContext; use infrastructure::config::{Config, ConfigStore}; use infrastructure::paths::RoxyPaths; @@ -180,53 +181,55 @@ fn main() -> Result<()> { let cli = Cli::parse(); let config_path = &cli.config; + // Handle completions before loading config (works even with malformed config) + if let Commands::Completions { shell } = &cli.command { + clap_complete::generate(*shell, &mut Cli::command(), "roxy", &mut std::io::stdout()); + return Ok(()); + } + let (config, paths) = load_config_and_paths(config_path)?; + let ctx = AppContext::new(config_path, &paths); match cli.command { - Commands::Install => cli::install::execute(config_path, &paths, &config), - Commands::Uninstall { force } => cli::uninstall::execute(force, config_path, &paths), + Commands::Install => cli::install::execute(&ctx, &paths, &config), + Commands::Uninstall { force } => cli::uninstall::execute(force, &ctx, &paths), Commands::Register { domain, wildcard, route, - } => cli::register::execute(domain, wildcard, route, config_path, &paths), + } => cli::register::execute(domain, wildcard, route, &ctx), Commands::Unregister { domain, wildcard, force, - } => cli::unregister::execute(domain, wildcard, force, config_path, &paths), + } => cli::unregister::execute(domain, wildcard, force, &ctx), Commands::Route { command } => match command { RouteCommands::Add { wildcard, domain, path, target, - } => cli::route::add(domain, wildcard, path, target, config_path), + } => cli::route::add(domain, wildcard, path, target, &ctx), RouteCommands::Remove { wildcard, domain, path, - } => cli::route::remove(domain, wildcard, path, config_path), - RouteCommands::List { wildcard, domain } => { - cli::route::list(domain, wildcard, config_path) - } + } => cli::route::remove(domain, wildcard, path, &ctx), + RouteCommands::List { wildcard, domain } => cli::route::list(domain, wildcard, &ctx), }, - Commands::List => cli::list::execute(config_path, &paths), + Commands::List => cli::list::execute(&ctx), Commands::Start { foreground } => { cli::start::execute(foreground, cli.verbose, config_path, &paths, &config.daemon) } - Commands::Stop => cli::stop::execute(&paths), - Commands::Restart => cli::restart::execute(cli.verbose, config_path, &paths), - Commands::Status => cli::status::execute(config_path, &paths), + Commands::Stop => cli::stop::execute(&ctx), + Commands::Restart => cli::restart::execute(cli.verbose, config_path, &ctx), + Commands::Status => cli::status::execute(&ctx, &config.daemon), Commands::Logs { lines, clear, follow, } => cli::logs::execute(lines, clear, follow, &paths), - Commands::Reload => cli::reload::execute(cli.verbose, config_path, &paths), - Commands::Completions { shell } => { - clap_complete::generate(shell, &mut Cli::command(), "roxy", &mut std::io::stdout()); - Ok(()) - } + Commands::Reload => cli::reload::execute(cli.verbose, config_path, &ctx), + Commands::Completions { .. } => unreachable!(), } } From 13ca7abd426cb098d801945618f34d6e531341de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Fri, 27 Mar 2026 23:03:20 +0100 Subject: [PATCH 2/4] style: Fix style formatting --- src/application/testkit.rs | 8 ++------ src/domain/value_objects/route.rs | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/application/testkit.rs b/src/application/testkit.rs index ad5c566..6038c9e 100644 --- a/src/application/testkit.rs +++ b/src/application/testkit.rs @@ -173,9 +173,7 @@ impl CertificateManager for InMemoryCertificateManager { "simulated cert failure" ))); } - self.certs - .borrow_mut() - .push(pattern.display_pattern()); + self.certs.borrow_mut().push(pattern.display_pattern()); Ok(()) } @@ -202,9 +200,7 @@ impl CertificateManager for InMemoryCertificateManager { } fn exists(&self, pattern: &DomainPattern) -> bool { - self.certs - .borrow() - .contains(&pattern.display_pattern()) + self.certs.borrow().contains(&pattern.display_pattern()) } fn is_trusted(&self) -> Result { diff --git a/src/domain/value_objects/route.rs b/src/domain/value_objects/route.rs index 35cd141..01504e1 100644 --- a/src/domain/value_objects/route.rs +++ b/src/domain/value_objects/route.rs @@ -53,7 +53,6 @@ impl RouteTarget { Ok(Self::Proxy(ProxyTarget::parse(s)?)) } } - } impl fmt::Display for RouteTarget { From 9613309e490dbf7f3108b66a920d42e76540ec09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Fri, 27 Mar 2026 23:07:45 +0100 Subject: [PATCH 3/4] docs: Add documentation --- README.md | 46 +++++++- docs/README.md | 29 +++-- docs/docker.md | 302 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 docs/docker.md diff --git a/README.md b/README.md index fbb2cbd..57b0eb3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ HTTPS for every local project. - ✓ Test OAuth/webhooks locally with real HTTPS - ✓ See all traffic in real-time - ✓ Share work across devices -- ✓ No nginx. No dnsmasq. No Docker. No YAML. +- ✓ No nginx. No dnsmasq. No YAML. > ⚠️ **Early Development**: Roxy is ready for daily use on macOS and Linux, > but things may shift around. @@ -123,6 +123,15 @@ No config files to manage. Go, Rust, PHP... anything on any port - ✓ **Shell completions** — Tab completion for bash, zsh, and fish +**Docker Integration:** + +- ✓ **Auto-discovery** — Docker Compose services are automatically + registered as `.roxy` domains. Start a stack, get HTTPS instantly +- ✓ **Zero config** — Enable once, then `docker compose up` is all + you need. No `roxy register` required +- ✓ **Label-driven** — Fine-tune with `roxy.domain`, `roxy.port`, + and `roxy.wildcard` labels + ## Real-World Examples ### Testing Stripe Webhooks Locally @@ -171,6 +180,37 @@ roxy register myapp.roxy --wildcard \ # No extra registration needed. Just add a subdomain and go. ``` +### Docker Compose — Zero-Config HTTPS + +With Docker integration enabled, `docker compose up` is all +you need: + +```yaml +# docker-compose.yml +services: + web: + build: . + ports: + - "3000:3000" + + api: + build: ./api + ports: + - "8080:8080" +``` + +```bash +docker compose up -d + +# Roxy auto-discovers both services: +# https://web.myproject.roxy → port 3000 +# https://api.myproject.roxy → port 8080 +# No roxy register needed. Just works. +``` + +See the [Docker guide](docs/docker.md) for labels, custom +domains, and container-to-Roxy communication. + ### Mobile App Development ```bash @@ -331,6 +371,7 @@ For configuration details, logging options, and file locations see the [full doc | Any tech stack | ✓ | ✓ | ✓ | PHP-focused | ✓ | ✓ | | WebSocket support | ✓ | ✓ | ✓ | ~ | ✓ | ~ | | Wildcard subdomains | ✓ | Config-based | Manual setup | ✗ | ✗ | ✗ | +| Docker auto-discovery | ✓ | ✗ | Labels only | ✗ | ✗ | ✗ | **Caddy** is the closest alternative — it has excellent HTTPS ergonomics with a built-in CA, `caddy trust`, and a powerful CLI (`caddy reverse-proxy --from domain --to target`). The main @@ -393,6 +434,8 @@ Roxy is ready for daily development use on macOS and Linux. Recent additions and with automatic certificate generation - [x] **Linux support** — Ubuntu/Debian with systemd-resolved DNS integration and system CA trust +- [x] **Docker auto-discovery** — Compose services get `.roxy` + domains automatically with label-driven customization - [ ] **Docker network DNS** — resolve `.roxy` domains inside containers without `extra_hosts` @@ -404,6 +447,7 @@ Have a feature idea? ## Documentation & Support - 📖 **Full documentation**: [docs/README.md](docs/README.md) +- 🐳 **Docker guide**: [docs/docker.md](docs/docker.md) - 🐧 **Linux guide**: [docs/linux.md](docs/linux.md) - 🐛 **Having issues?**: Check the [troubleshooting guide](docs/README.md#troubleshooting) - 💬 **Questions or feedback?**: [Open an issue](https://github.com/rbas/roxy/issues) diff --git a/docs/README.md b/docs/README.md index 5a456d1..ce177fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -476,27 +476,24 @@ certs_dir = "/etc/roxy/certs" The values above are the defaults. You only need this section if you want different locations. -## Using Roxy with Docker +## Docker Integration -Roxy runs on the host, so containers need to know how -to reach `.roxy` domains. Add the domain as an extra host -pointing to the host gateway in your `docker-compose.yml`: +Roxy can automatically discover Docker Compose services and +register them as `.roxy` domains. Enable it in `config.toml`: -```yaml -services: - myservice: - image: myimage - extra_hosts: - - "myservice.roxy:host-gateway" +```toml +[docker] +enabled = true ``` -`host-gateway` resolves to the host machine's IP -(typically `host.docker.internal` on Docker Desktop). -The container can now reach `http://myservice.roxy` or -`https://myservice.roxy` through Roxy on the host. +Once enabled and the daemon is restarted, starting a compose +stack automatically registers domains like +`..roxy` with HTTPS. No manual +`roxy register` needed. -Add one entry per `.roxy` domain the container needs -to access. +See [docker.md](docker.md) for the full guide: labels +reference, container-to-Roxy communication, troubleshooting, +and more. ## Troubleshooting diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..7f05aec --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,302 @@ +# Docker Integration + +Roxy can automatically discover Docker Compose services and +register them as `.roxy` domains. When enabled, starting a +compose stack instantly makes services available at +`https://..roxy`. + +## Enabling Docker Integration + +Add the `[docker]` section to `/etc/roxy/config.toml`: + +```toml +[docker] +enabled = true +``` + +Then restart the daemon: + +```bash +sudo roxy restart +``` + +Roxy connects to the Docker socket and watches for container +lifecycle events. When a container starts or stops, Roxy +automatically updates its routing table. + +## How Auto-Discovery Works + +When a container starts, Roxy evaluates it using these rules +(in order): + +1. **`roxy.enable=false`** label -- skip (explicit opt-out) +2. **`roxy.enable=true`** label -- register (explicit opt-in) +3. **Compose labels + exposed port** -- register automatically +4. **Otherwise** -- skip + +For compose services, the domain is derived from the project +and service names: `..roxy`. For example, +a service named `web` in project `myapp` becomes +`web.myapp.roxy`. + +### Requirements for Auto-Discovery + +- The container must have at least one **published port** + (`ports:` mapping in `docker-compose.yml`) +- Roxy proxies to the **host port**, not the container port +- If a container exposes multiple ports, set `roxy.port` to + pick one (see [Labels Reference](#labels-reference)) + +## Quick Start + +Given this `docker-compose.yml`: + +```yaml +services: + web: + build: . + ports: + - "3000:3000" +``` + +```bash +# Enable Docker integration in Roxy config +# (add [docker] enabled = true, then restart) + +# Start your compose stack +docker compose up -d + +# Roxy auto-discovers and registers web.myproject.roxy +# (project name comes from directory name by default) +open https://web.myproject.roxy +``` + +No `roxy register` needed -- it happens automatically. + +## Labels Reference + +Control Roxy behavior with container labels, either in +`docker-compose.yml` or via `docker run --label`. + +| Label | Values | Description | +| ----- | ------ | ----------- | +| `roxy.enable` | `true` / `false` | Force opt-in or opt-out | +| `roxy.domain` | e.g. `app.roxy` | Override the auto-generated domain | +| `roxy.port` | e.g. `8080` | Pick which container port to proxy | +| `roxy.wildcard` | `true` | Register as wildcard (`*.domain`) | + +### Examples + +**Custom domain:** + +```yaml +services: + web: + build: . + ports: + - "3000:3000" + labels: + roxy.domain: "myapp.roxy" +``` + +**Explicit opt-in (non-compose container):** + +```yaml +services: + standalone: + image: nginx + ports: + - "8080:80" + labels: + roxy.enable: "true" + roxy.domain: "nginx.roxy" +``` + +**Multiple ports -- pick one:** + +```yaml +services: + api: + build: . + ports: + - "3000:3000" + - "9090:9090" + labels: + roxy.port: "3000" +``` + +**Wildcard subdomains:** + +```yaml +services: + web: + build: . + ports: + - "3000:3000" + labels: + roxy.wildcard: "true" +``` + +This registers `*.web.myproject.roxy`, so +`anything.web.myproject.roxy` routes to the container. + +**Opt out a service:** + +```yaml +services: + db: + image: postgres + ports: + - "5432:5432" + labels: + roxy.enable: "false" +``` + +## Domain Name Resolution + +Roxy determines the domain in this order: + +1. **`roxy.domain` label** -- used as-is (must end with `.roxy`) +2. **Compose labels** -- `..roxy` + +The compose project name defaults to the directory name. You +can set it explicitly with `COMPOSE_PROJECT_NAME` or the +`name:` key in `docker-compose.yml`: + +```yaml +name: myapp + +services: + web: + build: . + ports: + - "3000:3000" +# Domain: web.myapp.roxy +``` + +## Container-to-Roxy Communication + +When one container needs to reach another container's +`.roxy` domain (or any `.roxy` domain served by the host), +Docker's default DNS won't resolve `.roxy` names. Use +`extra_hosts` to point the domain at the host: + +```yaml +services: + web: + build: . + ports: + - "3000:3000" + + worker: + build: . + extra_hosts: + - "web.myproject.roxy:host-gateway" +``` + +`host-gateway` resolves to the host machine's IP (typically +`host.docker.internal` on Docker Desktop). The `worker` +container can now reach `http://web.myproject.roxy` through +Roxy on the host. + +Add one entry per `.roxy` domain the container needs to +access. + +> **Note:** Wildcard `extra_hosts` (e.g., `*.roxy`) are not +> supported -- `/etc/hosts` does not allow wildcards. You must +> list each domain explicitly. + +### Linux vs macOS + +On **Linux**, Docker containers share the host network +namespace (or use a bridge with direct host access). DNS +resolution and HTTP connectivity work without `extra_hosts` +in most setups. + +On **macOS**, Docker Desktop runs containers inside a Linux +VM. Containers cannot reach the host via its LAN IP, but can +reach it via `host-gateway` / `host.docker.internal`. The +`extra_hosts` approach above is required for container-to-Roxy +communication. + +## Monitoring + +Check which Docker containers Roxy has discovered: + +```bash +roxy list +``` + +Docker-discovered domains show their source as "external" in +the output. They appear alongside manually registered domains +but cannot be edited with `roxy register` or `roxy route` -- +they are managed entirely by the Docker watcher. + +View discovery logs: + +```bash +# See container add/remove events +ROXY_LOG=debug sudo roxy start --foreground + +# Or check the log file +roxy logs -f +``` + +Example log output: + +```text +INFO Docker integration enabled +INFO Docker domain added domain=web.myapp.roxy target=127.0.0.1:3000 +INFO Docker reconciliation complete added=1 removed=0 total=1 +``` + +## Troubleshooting + +### Container Not Discovered + +Check that: + +1. Docker integration is enabled (`[docker] enabled = true`) +2. The container has published ports (`ports:` in compose) +3. The container is not opted out (`roxy.enable=false`) +4. The daemon is running (`roxy status`) + +Run with debug logging to see why a container was skipped: + +```bash +ROXY_LOG=debug sudo roxy start --foreground +``` + +Look for `Docker container skipped` messages with a reason. + +### "No published host port" Error + +Roxy needs a host port mapping to proxy traffic. Make sure +your service has a `ports:` entry: + +```yaml +services: + web: + build: . + # This is required: + ports: + - "3000:3000" + # EXPOSE alone is not enough +``` + +### Multiple Ports Without Label + +If a container exposes more than one port and no `roxy.port` +label is set, Roxy skips it (ambiguous). Add the label: + +```yaml +labels: + roxy.port: "3000" +``` + +### Docker Socket Permission + +Roxy connects to the Docker socket +(`/var/run/docker.sock` by default). If running as root +(via `sudo`), this works automatically. If you see connection +errors, verify the socket exists and is accessible. From 305126e2c5388847530cb2f56efb81e92d41d520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vold=C5=99ich?= Date: Mon, 30 Mar 2026 16:33:13 +0200 Subject: [PATCH 4/4] test: Fix the docker connection --- src/infrastructure/docker/provider.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/docker/provider.rs b/src/infrastructure/docker/provider.rs index 18fcded..960ae61 100644 --- a/src/infrastructure/docker/provider.rs +++ b/src/infrastructure/docker/provider.rs @@ -52,17 +52,21 @@ impl RegistrationProvider for DockerProvider { mod tests { use super::*; + /// Create a Docker client that doesn't require a running Docker daemon. + /// These tests only exercise in-memory state; no Docker API calls are made. + fn test_docker() -> Docker { + Docker::connect_with_http("http://localhost:1", 1, bollard::API_DEFAULT_VERSION).unwrap() + } + #[test] fn provider_name() { - let docker = Docker::connect_with_local_defaults().unwrap(); - let provider = DockerProvider::new(docker); + let provider = DockerProvider::new(test_docker()); assert_eq!(provider.name(), "docker"); } #[test] fn load_returns_empty_initially() { - let docker = Docker::connect_with_local_defaults().unwrap(); - let provider = DockerProvider::new(docker); + let provider = DockerProvider::new(test_docker()); let result = provider.load().unwrap(); assert!(result.is_empty()); } @@ -71,8 +75,7 @@ mod tests { fn load_returns_state_after_update() { use crate::domain::{DomainName, DomainPattern, Route}; - let docker = Docker::connect_with_local_defaults().unwrap(); - let provider = DockerProvider::new(docker); + let provider = DockerProvider::new(test_docker()); // Simulate watcher updating state {