diff --git a/.gitignore b/.gitignore index 0c5d6ed..c362aec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ assets/roxy_404.png assets/roxy_error.png assets/roxy_file_browser.png assets/roxy_file_browser_small.png + +.claude +.rust-skills diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7a50da2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,738 @@ +# Roxy - Claude Development Guide + +## Project Overview + +Roxy is a local development proxy tool written in Rust that enables developers to run multiple projects with custom `.roxy` domains and automatic HTTPS support. Think Laravel Valet, but written in Rust. + +**Philosophy**: Pragmatic, idiomatic Rust. Build the simplest thing that works. YAGNI principles apply throughout. + +## Rust Skills (AI) + +This repo vendors `rust-skills` (skills-only, no MCP) at `.rust-skills/skills/`. + +Local setup links those skills into: +- `~/.codex/skills/` (Codex) +- `~/.claude/skills/` (Claude Code) + +When working on Rust tasks, prefer: +- Invoke `rust-router` first for Rust questions/errors/design. +- Then follow the routed skill (`m01-*`..`m07-*`, `m09-*`..`m15-*`, `domain-*`). +- Use `unsafe-checker` for any unsafe/FFI review. + + +## Core Principles + +### 1. Idiomatic Rust + +- Use standard library types and patterns +- Embrace `Result` for error handling - no panics in library code +- Use `Option` appropriately - avoid unnecessary unwrapping +- Prefer iterators over loops where it improves clarity +- Use enums for state and behavior variants +- Implement standard traits (`Display`, `Debug`, `From`, etc.) where appropriate +- Follow Rust naming conventions (snake_case for functions/variables, PascalCase for types) + +### 2. YAGNI (You Aren't Gonna Need It) + +- **Don't build features that aren't in REQUIREMENTS.md** +- Don't create abstractions until you need them in multiple places +- Don't add configuration options until someone asks for them +- Don't optimize until there's a proven performance issue +- Don't add plugins, hooks, or extensibility mechanisms in v1 +- Start with simple implementations - refactor when complexity demands it + +### 3. Error Handling + +- Use `anyhow::Result` for application-level errors (CLI commands) +- Use custom error types (with `thiserror`) only when you need to match on error variants +- Provide helpful error messages - include context about what failed and why +- Never use `.unwrap()` or `.expect()` in production code paths +- Use `?` operator liberally for clean error propagation + +### 4. Architecture Guidelines + +#### Keep It Simple + +- Start with a single binary with multiple modules +- Don't split into multiple crates unless there's a clear benefit +- Avoid over-abstraction - traits should solve real problems, not theoretical ones +- Direct implementation beats clever generics + +#### Module Structure + +``` +roxy/ +├── src/ +│ ├── main.rs # CLI entry point, argument parsing +│ ├── cli/ # CLI commands implementation +│ │ ├── mod.rs +│ │ ├── install.rs +│ │ ├── register.rs +│ │ ├── unregister.rs +│ │ └── list.rs +│ ├── daemon/ # HTTP server and proxy logic +│ │ ├── mod.rs +│ │ ├── server.rs +│ │ └── router.rs +│ ├── dns/ # DNS configuration management +│ │ └── mod.rs +│ ├── certs/ # Certificate generation and management +│ │ └── mod.rs +│ ├── config/ # Configuration storage and loading +│ │ └── mod.rs +│ └── lib.rs # Library root (shared types and utilities) +``` + +#### Dependencies Philosophy + +- **Minimize dependencies** - each new crate is a maintenance burden +- Prefer well-maintained, popular crates over niche ones +- Read the code of small crates before adding them +- Don't add a dependency to save 20 lines of code + +### 5. Domain-Driven Design (Lightweight) + +Use DDD concepts to make the code expressive and clear, but avoid DDD ceremony and over-engineering. + +#### Value Objects + +Wrap primitives in meaningful types to prevent mistakes and make the domain explicit: + +```rust +// Value objects - immutable, validated on construction +pub struct DomainName(String); +pub struct Port(u16); + +impl DomainName { + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if !name.ends_with(".local") { + return Err(anyhow!("Domain must end with .local")); + } + if name.len() < 7 { // Minimum: "a.local" + return Err(anyhow!("Domain name too short")); + } + Ok(Self(name)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for DomainName { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +``` + +**When to use Value Objects:** + +- Primitives with validation rules (DomainName, Port) +- Concepts that are compared by value (Certificate, FilePath) +- Types that should be immutable + +**When NOT to use Value Objects:** + +- Don't wrap primitives that have no validation or domain meaning +- Don't create value objects just to have them + +#### Entities + +Types with identity that can change over time: + +```rust +// Entity - has identity (domain name), mutable state +pub struct DomainRegistration { + domain: DomainName, + target: Target, + https_enabled: bool, + created_at: SystemTime, +} + +impl DomainRegistration { + pub fn new(domain: DomainName, target: Target) -> Self { + Self { + domain, + target, + https_enabled: false, + created_at: SystemTime::now(), + } + } + + pub fn enable_https(&mut self) { + self.https_enabled = true; + } + + pub fn domain(&self) -> &DomainName { + &self.domain + } +} +``` + +**Key points:** + +- Entities have identity - two domains with same name are the same domain +- Can have mutable state with methods that enforce invariants +- Keep entity methods focused on domain logic, not persistence + +#### Domain Enums + +Use enums to model domain concepts and behavior: + +```rust +pub enum Target { + StaticFiles(PathBuf), + ReverseProxy(Port), +} + +impl Target { + 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")); + } + Ok(()) + } + } + } +} +``` + +#### Domain Services + +Operations that don't naturally belong to a single entity: + +```rust +// Domain service - coordinates domain operations +pub struct CertificateService { + cert_dir: PathBuf, +} + +impl CertificateService { + /// Generates a self-signed certificate for the given domain + pub fn generate_certificate(&self, domain: &DomainName) -> Result { + // Certificate generation logic + // This doesn't belong on DomainName or Certificate entities + } + + pub fn install_to_system_trust(&self, cert: &Certificate) -> Result<()> { + // System integration logic + } +} +``` + +**When to use Domain Services:** + +- Operations involving multiple entities +- Operations requiring external dependencies (filesystem, system calls) +- Complex domain logic that doesn't fit naturally on an entity + +**Keep them focused:** + +- One service per domain concern (CertificateService, DnsService) +- Methods should be about domain operations, not infrastructure + +#### Repositories + +Abstract persistence without coupling domain logic to storage: + +```rust +// Repository trait - only if you need multiple implementations +pub trait DomainRepository { + fn save(&mut self, registration: DomainRegistration) -> Result<()>; + fn find(&self, domain: &DomainName) -> Result>; + fn find_all(&self) -> Result>; + fn delete(&mut self, domain: &DomainName) -> Result<()>; +} + +// Simple implementation using JSON file +pub struct JsonDomainRepository { + config_path: PathBuf, +} + +impl DomainRepository for JsonDomainRepository { + fn save(&mut self, registration: DomainRegistration) -> Result<()> { + // Load, update, save config.json + } + // ... +} +``` + +**Important: Keep it simple** + +- Only create a Repository trait if you actually need multiple implementations +- If you only have one storage mechanism (JSON file), just use a concrete struct +- Don't create a repository for every entity - only for aggregates + +```rust +// Simpler version without trait (prefer this unless you need abstraction) +pub struct ConfigStore { + config_path: PathBuf, +} + +impl ConfigStore { + pub fn save_domain(&mut self, registration: DomainRegistration) -> Result<()> { + // Direct implementation + } +} +``` + +#### Application Services / Use Cases + +Orchestrate domain operations for specific use cases: + +```rust +// Application service - orchestrates use case +pub struct RegisterDomainUseCase { + config_store: ConfigStore, + cert_service: CertificateService, + dns_service: DnsService, +} + +impl RegisterDomainUseCase { + pub fn execute(&mut self, domain: DomainName, target: Target) -> Result<()> { + // 1. Validate domain isn't already registered + if self.config_store.find_domain(&domain)?.is_some() { + return Err(anyhow!("Domain already registered: {}", domain)); + } + + // 2. Validate target + target.validate()?; + + // 3. Create registration + let mut registration = DomainRegistration::new(domain.clone(), target); + + // 4. Generate certificate + let cert = self.cert_service.generate_certificate(&domain)?; + self.cert_service.install_to_system_trust(&cert)?; + registration.enable_https(); + + // 5. Save configuration + self.config_store.save_domain(registration)?; + + Ok(()) + } +} +``` + +**Use application services to:** + +- Coordinate multiple domain services +- Enforce business rules across entities +- Handle the full workflow of a use case +- Keep CLI commands thin - they just call use cases + +#### Proposed Module Structure with DDD + +``` +roxy/ +├── src/ +│ ├── main.rs # CLI entry point +│ ├── cli/ # CLI layer - thin, delegates to use cases +│ │ ├── mod.rs +│ │ ├── commands/ +│ │ │ ├── install.rs +│ │ │ ├── register.rs +│ │ │ ├── unregister.rs +│ │ │ └── list.rs +│ │ └── args.rs # Clap argument definitions +│ │ +│ ├── domain/ # Domain layer - core business logic +│ │ ├── mod.rs +│ │ ├── entities/ +│ │ │ └── domain_registration.rs +│ │ ├── value_objects/ +│ │ │ ├── domain_name.rs +│ │ │ ├── port.rs +│ │ │ ├── target.rs +│ │ │ └── certificate.rs +│ │ └── services/ +│ │ ├── certificate_service.rs +│ │ └── dns_service.rs +│ │ +│ ├── application/ # Application layer - use cases +│ │ ├── mod.rs +│ │ ├── register_domain.rs +│ │ ├── unregister_domain.rs +│ │ └── list_domains.rs +│ │ +│ ├── infrastructure/ # Infrastructure layer - I/O, persistence +│ │ ├── mod.rs +│ │ ├── config_store.rs # JSON persistence +│ │ ├── system/ +│ │ │ ├── macos.rs # macOS-specific DNS/cert code +│ │ │ └── linux.rs # Linux-specific DNS/cert code +│ │ └── daemon/ +│ │ ├── server.rs # HTTP server +│ │ └── router.rs # Request routing +│ │ +│ └── lib.rs +``` + +#### DDD Anti-Patterns to Avoid + +❌ **Don't create layers just to have layers:** + +```rust +// Bad - unnecessary abstraction +trait DomainNameValidator { + fn validate(&self, name: &str) -> Result<()>; +} + +// Good - validation in constructor +impl DomainName { + pub fn new(name: String) -> Result { + // validation here + } +} +``` + +❌ **Don't create repositories for everything:** + +```rust +// Bad - repository for value object +trait CertificateRepository { + fn find_certificate(&self, domain: &DomainName) -> Result; +} + +// Good - certificates are managed by CertificateService +impl CertificateService { + pub fn get_certificate(&self, domain: &DomainName) -> Result { + // Load from filesystem directly + } +} +``` + +❌ **Don't use events if you don't need them:** + +```rust +// Bad - overkill for simple app +pub struct DomainRegisteredEvent { + domain: DomainName, + timestamp: SystemTime, +} +pub trait EventBus { + fn publish(&self, event: DomainRegisteredEvent); +} + +// Good - direct function calls +pub fn register_domain(...) -> Result<()> { + // Just do the work +} +``` + +❌ **Don't create complex aggregate rules:** + +```rust +// Bad - unnecessary complexity +pub struct DomainAggregate { + root: DomainRegistration, + certificates: Vec, + dns_records: Vec, +} + +// Good - simple relationships +pub struct DomainRegistration { + domain: DomainName, + target: Target, + https_enabled: bool, +} +// Certificates and DNS are managed by services +``` + +#### DDD Guidelines Summary + +**DO:** + +- ✅ Use value objects for validated primitives (DomainName, Port) +- ✅ Use entities for things with identity and lifecycle +- ✅ Use enums to model domain variants (Target, Status) +- ✅ Create domain services for operations not belonging to entities +- ✅ Use application services to orchestrate use cases +- ✅ Make domain types expressive with good method names +- ✅ Keep domain layer free of infrastructure concerns + +**DON'T:** + +- ❌ Create abstractions without concrete need +- ❌ Use repository pattern unless you need multiple implementations +- ❌ Add events, sagas, or complex aggregate rules +- ❌ Create DTOs unless crossing clear boundaries (we rarely need them in Rust) +- ❌ Separate domain and persistence models (Rust's type system makes this less necessary) + +**Remember:** DDD should make code MORE clear, not more complex. If a DDD pattern adds complexity without clear benefit, skip it. + +### 6. What NOT to Do + +❌ **Don't over-engineer:** + +- No custom allocators or unsafe code (unless absolutely necessary) +- No macros for things functions can do +- No trait hierarchies more than 2 levels deep +- No generic programming for a single use case +- No async where blocking I/O works fine (file operations) + +❌ **Don't add unnecessary features:** + +- No plugin system in v1 +- No web UI in v1 +- No remote configuration +- No cloud sync +- No analytics or telemetry + +❌ **Don't optimize prematurely:** + +- Simple `String` is fine over `Cow` until proven otherwise +- `.clone()` is acceptable for small structs and infrequent operations +- HashMap is fine before trying FxHashMap or BTreeMap +- Standard threading is fine before considering rayon or custom thread pools + +### 7. Dependencies + +#### Required Core Dependencies + +```toml +# Async runtime (daemon needs async for HTTP server) +tokio = { version = "1", features = ["full"] } + +# HTTP server and client +axum = "0.7" # Simple, ergonomic web framework +hyper = "1.0" # HTTP primitives (used by axum) +tower = "0.4" # Middleware (used by axum) + +# TLS/SSL +rustls = "0.23" # Pure Rust TLS +rcgen = "0.12" # Certificate generation + +# CLI +clap = { version = "4", features = ["derive"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +anyhow = "1.0" # Application errors +thiserror = "1.0" # Library errors (if needed) +``` + +#### Consider Only When Needed + +- `tracing` / `tracing-subscriber` - if logging needs become complex +- `notify` - if we need file watching +- `daemonize` - if simple fork isn't enough + +### 8. Code Style + +#### Formatting + +- Use `rustfmt` with default settings +- Run `cargo fmt` before committing +- Max line length: 100 characters (rustfmt default) + +#### Documentation + +- Public functions must have doc comments +- Include examples in doc comments for non-obvious functions +- Explain *why*, not *what* - the code shows what + +```rust +// Good +/// Registers a new domain with either a file path or port. +/// +/// Returns an error if the domain is already registered or if +/// certificate generation fails. +pub fn register_domain(domain: &str, target: Target) -> Result<()> { + // ... +} + +// Bad - just repeating what the signature says +/// Registers a domain +pub fn register_domain(domain: &str, target: Target) -> Result<()> { + // ... +} +``` + +#### Function Size + +- Keep functions under 50 lines when possible +- Extract helper functions when logic becomes nested 3+ levels +- A function should do one thing + +#### Types Over Primitives + +```rust +// Good - clear intent +pub struct Domain(String); +pub enum Target { + Path(PathBuf), + Port(u16), +} + +// Bad - unclear what the string and u16 mean +pub fn register(domain: String, target: Either) -> Result<()> +``` + +### 9. Testing Strategy + +#### Unit Tests + +- Test business logic, not trivial getters/setters +- Use `#[cfg(test)]` modules in the same file +- Mock external dependencies (filesystem, network) in tests +- Test error cases, not just happy paths + +#### Integration Tests + +- Put in `tests/` directory +- Test actual CLI commands end-to-end +- Use temporary directories for filesystem tests + +#### What NOT to Test + +- Don't test external libraries (axum, rustls, etc.) +- Don't test trivial constructors or accessors +- Don't test private implementation details + +```rust +// Good - tests behavior +#[test] +fn test_duplicate_domain_returns_error() { + let mut config = Config::new(); + config.register("app.local", Target::Port(3000)).unwrap(); + let result = config.register("app.local", Target::Port(4000)); + assert!(result.is_err()); +} + +// Bad - tests implementation detail +#[test] +fn test_domains_stored_in_hashmap() { + let config = Config::new(); + assert_eq!(config.domains.len(), 0); +} +``` + +### 10. Error Messages + +Make errors actionable and friendly: + +```rust +// Good +return Err(anyhow!( + "Failed to register domain '{}': domain already exists.\n\ + Use 'roxy unregister {}' first, or choose a different domain name.", + domain, domain +)); + +// Bad +return Err(anyhow!("Domain exists")); +``` + +### 11. Git Commits + +- Use conventional commits format: `feat:`, `fix:`, `docs:`, `refactor:`, etc. +- Keep commits focused and atomic +- Write commit messages that explain *why*, not *what* + +### 12. Performance Targets + +Don't optimize until these are violated: + +- Daemon startup: < 100ms +- Domain registration: < 500ms (including cert generation) +- Request latency overhead: < 10ms +- Memory usage (idle): < 50MB +- Memory usage (under load): < 200MB + +### 13. Security Considerations + +- Never log or display certificate private keys +- Validate all user inputs (domain names, paths, ports) +- Use appropriate file permissions (0600 for keys, 0644 for certs) +- Require explicit confirmation for destructive operations +- Run daemon with minimum required privileges + +### 14. Platform-Specific Code + +Use conditional compilation for platform differences: + +```rust +#[cfg(target_os = "macos")] +fn setup_dns() -> Result<()> { + // macOS implementation using /etc/resolver/ +} + +#[cfg(target_os = "linux")] +fn setup_dns() -> Result<()> { + // Linux implementation using dnsmasq +} +``` + +Start with macOS support only. Add Linux when macOS is working. + +## Development Workflow + +1. **Before starting a feature:** + - Read the relevant section in REQUIREMENTS.md + - Understand what success looks like + - Choose the simplest approach + +2. **While implementing:** + - Write the minimal code that satisfies the requirement + - Add error handling as you go + - Add tests for non-trivial logic + - Run `cargo check` frequently + +3. **Before committing:** + - Run `cargo fmt` + - Run `cargo clippy -- -D warnings` + - Run `cargo test` + - Test manually if it's user-facing functionality + +4. **Code review checklist:** + - Is this the simplest solution? + - Are error messages helpful? + - Are edge cases handled? + - Is it idiomatic Rust? + - Does it follow YAGNI? + +## When to Refactor + +Refactor when you see: + +- Same code pattern repeated 3+ times +- Functions longer than 50 lines doing multiple things +- Deep nesting (3+ levels) +- Hard-to-test code due to tight coupling +- Actual performance problems (not theoretical) + +Don't refactor: + +- Code that's "not pretty" but works fine +- To make it "more generic" without a concrete second use case +- To match patterns from other projects +- Because you thought of a "better" way (unless current way has issues) + +## Questions to Ask + +Before adding complexity, ask: + +1. Does this solve a real problem today? +2. Is there a simpler way? +3. What's the cost of doing nothing? +4. Can we do this later if needed? + +## Remember + +> "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away." - Antoine de Saint-Exupéry + +Build Roxy to work reliably for its core use case. Resist the temptation to make it do everything. Shipping a simple, working tool beats architecting a flexible, unfinished framework. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index aa53a8c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,724 +0,0 @@ -# Roxy - Claude Development Guide - -## Project Overview - -Roxy is a local development proxy tool written in Rust that enables developers to run multiple projects with custom `.roxy` domains and automatic HTTPS support. Think Laravel Valet, but written in Rust. - -**Philosophy**: Pragmatic, idiomatic Rust. Build the simplest thing that works. YAGNI principles apply throughout. - -## Core Principles - -### 1. Idiomatic Rust - -- Use standard library types and patterns -- Embrace `Result` for error handling - no panics in library code -- Use `Option` appropriately - avoid unnecessary unwrapping -- Prefer iterators over loops where it improves clarity -- Use enums for state and behavior variants -- Implement standard traits (`Display`, `Debug`, `From`, etc.) where appropriate -- Follow Rust naming conventions (snake_case for functions/variables, PascalCase for types) - -### 2. YAGNI (You Aren't Gonna Need It) - -- **Don't build features that aren't in REQUIREMENTS.md** -- Don't create abstractions until you need them in multiple places -- Don't add configuration options until someone asks for them -- Don't optimize until there's a proven performance issue -- Don't add plugins, hooks, or extensibility mechanisms in v1 -- Start with simple implementations - refactor when complexity demands it - -### 3. Error Handling - -- Use `anyhow::Result` for application-level errors (CLI commands) -- Use custom error types (with `thiserror`) only when you need to match on error variants -- Provide helpful error messages - include context about what failed and why -- Never use `.unwrap()` or `.expect()` in production code paths -- Use `?` operator liberally for clean error propagation - -### 4. Architecture Guidelines - -#### Keep It Simple - -- Start with a single binary with multiple modules -- Don't split into multiple crates unless there's a clear benefit -- Avoid over-abstraction - traits should solve real problems, not theoretical ones -- Direct implementation beats clever generics - -#### Module Structure - -``` -roxy/ -├── src/ -│ ├── main.rs # CLI entry point, argument parsing -│ ├── cli/ # CLI commands implementation -│ │ ├── mod.rs -│ │ ├── install.rs -│ │ ├── register.rs -│ │ ├── unregister.rs -│ │ └── list.rs -│ ├── daemon/ # HTTP server and proxy logic -│ │ ├── mod.rs -│ │ ├── server.rs -│ │ └── router.rs -│ ├── dns/ # DNS configuration management -│ │ └── mod.rs -│ ├── certs/ # Certificate generation and management -│ │ └── mod.rs -│ ├── config/ # Configuration storage and loading -│ │ └── mod.rs -│ └── lib.rs # Library root (shared types and utilities) -``` - -#### Dependencies Philosophy - -- **Minimize dependencies** - each new crate is a maintenance burden -- Prefer well-maintained, popular crates over niche ones -- Read the code of small crates before adding them -- Don't add a dependency to save 20 lines of code - -### 5. Domain-Driven Design (Lightweight) - -Use DDD concepts to make the code expressive and clear, but avoid DDD ceremony and over-engineering. - -#### Value Objects - -Wrap primitives in meaningful types to prevent mistakes and make the domain explicit: - -```rust -// Value objects - immutable, validated on construction -pub struct DomainName(String); -pub struct Port(u16); - -impl DomainName { - pub fn new(name: impl Into) -> Result { - let name = name.into(); - if !name.ends_with(".local") { - return Err(anyhow!("Domain must end with .local")); - } - if name.len() < 7 { // Minimum: "a.local" - return Err(anyhow!("Domain name too short")); - } - Ok(Self(name)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl Display for DomainName { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} -``` - -**When to use Value Objects:** - -- Primitives with validation rules (DomainName, Port) -- Concepts that are compared by value (Certificate, FilePath) -- Types that should be immutable - -**When NOT to use Value Objects:** - -- Don't wrap primitives that have no validation or domain meaning -- Don't create value objects just to have them - -#### Entities - -Types with identity that can change over time: - -```rust -// Entity - has identity (domain name), mutable state -pub struct DomainRegistration { - domain: DomainName, - target: Target, - https_enabled: bool, - created_at: SystemTime, -} - -impl DomainRegistration { - pub fn new(domain: DomainName, target: Target) -> Self { - Self { - domain, - target, - https_enabled: false, - created_at: SystemTime::now(), - } - } - - pub fn enable_https(&mut self) { - self.https_enabled = true; - } - - pub fn domain(&self) -> &DomainName { - &self.domain - } -} -``` - -**Key points:** - -- Entities have identity - two domains with same name are the same domain -- Can have mutable state with methods that enforce invariants -- Keep entity methods focused on domain logic, not persistence - -#### Domain Enums - -Use enums to model domain concepts and behavior: - -```rust -pub enum Target { - StaticFiles(PathBuf), - ReverseProxy(Port), -} - -impl Target { - 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")); - } - Ok(()) - } - } - } -} -``` - -#### Domain Services - -Operations that don't naturally belong to a single entity: - -```rust -// Domain service - coordinates domain operations -pub struct CertificateService { - cert_dir: PathBuf, -} - -impl CertificateService { - /// Generates a self-signed certificate for the given domain - pub fn generate_certificate(&self, domain: &DomainName) -> Result { - // Certificate generation logic - // This doesn't belong on DomainName or Certificate entities - } - - pub fn install_to_system_trust(&self, cert: &Certificate) -> Result<()> { - // System integration logic - } -} -``` - -**When to use Domain Services:** - -- Operations involving multiple entities -- Operations requiring external dependencies (filesystem, system calls) -- Complex domain logic that doesn't fit naturally on an entity - -**Keep them focused:** - -- One service per domain concern (CertificateService, DnsService) -- Methods should be about domain operations, not infrastructure - -#### Repositories - -Abstract persistence without coupling domain logic to storage: - -```rust -// Repository trait - only if you need multiple implementations -pub trait DomainRepository { - fn save(&mut self, registration: DomainRegistration) -> Result<()>; - fn find(&self, domain: &DomainName) -> Result>; - fn find_all(&self) -> Result>; - fn delete(&mut self, domain: &DomainName) -> Result<()>; -} - -// Simple implementation using JSON file -pub struct JsonDomainRepository { - config_path: PathBuf, -} - -impl DomainRepository for JsonDomainRepository { - fn save(&mut self, registration: DomainRegistration) -> Result<()> { - // Load, update, save config.json - } - // ... -} -``` - -**Important: Keep it simple** - -- Only create a Repository trait if you actually need multiple implementations -- If you only have one storage mechanism (JSON file), just use a concrete struct -- Don't create a repository for every entity - only for aggregates - -```rust -// Simpler version without trait (prefer this unless you need abstraction) -pub struct ConfigStore { - config_path: PathBuf, -} - -impl ConfigStore { - pub fn save_domain(&mut self, registration: DomainRegistration) -> Result<()> { - // Direct implementation - } -} -``` - -#### Application Services / Use Cases - -Orchestrate domain operations for specific use cases: - -```rust -// Application service - orchestrates use case -pub struct RegisterDomainUseCase { - config_store: ConfigStore, - cert_service: CertificateService, - dns_service: DnsService, -} - -impl RegisterDomainUseCase { - pub fn execute(&mut self, domain: DomainName, target: Target) -> Result<()> { - // 1. Validate domain isn't already registered - if self.config_store.find_domain(&domain)?.is_some() { - return Err(anyhow!("Domain already registered: {}", domain)); - } - - // 2. Validate target - target.validate()?; - - // 3. Create registration - let mut registration = DomainRegistration::new(domain.clone(), target); - - // 4. Generate certificate - let cert = self.cert_service.generate_certificate(&domain)?; - self.cert_service.install_to_system_trust(&cert)?; - registration.enable_https(); - - // 5. Save configuration - self.config_store.save_domain(registration)?; - - Ok(()) - } -} -``` - -**Use application services to:** - -- Coordinate multiple domain services -- Enforce business rules across entities -- Handle the full workflow of a use case -- Keep CLI commands thin - they just call use cases - -#### Proposed Module Structure with DDD - -``` -roxy/ -├── src/ -│ ├── main.rs # CLI entry point -│ ├── cli/ # CLI layer - thin, delegates to use cases -│ │ ├── mod.rs -│ │ ├── commands/ -│ │ │ ├── install.rs -│ │ │ ├── register.rs -│ │ │ ├── unregister.rs -│ │ │ └── list.rs -│ │ └── args.rs # Clap argument definitions -│ │ -│ ├── domain/ # Domain layer - core business logic -│ │ ├── mod.rs -│ │ ├── entities/ -│ │ │ └── domain_registration.rs -│ │ ├── value_objects/ -│ │ │ ├── domain_name.rs -│ │ │ ├── port.rs -│ │ │ ├── target.rs -│ │ │ └── certificate.rs -│ │ └── services/ -│ │ ├── certificate_service.rs -│ │ └── dns_service.rs -│ │ -│ ├── application/ # Application layer - use cases -│ │ ├── mod.rs -│ │ ├── register_domain.rs -│ │ ├── unregister_domain.rs -│ │ └── list_domains.rs -│ │ -│ ├── infrastructure/ # Infrastructure layer - I/O, persistence -│ │ ├── mod.rs -│ │ ├── config_store.rs # JSON persistence -│ │ ├── system/ -│ │ │ ├── macos.rs # macOS-specific DNS/cert code -│ │ │ └── linux.rs # Linux-specific DNS/cert code -│ │ └── daemon/ -│ │ ├── server.rs # HTTP server -│ │ └── router.rs # Request routing -│ │ -│ └── lib.rs -``` - -#### DDD Anti-Patterns to Avoid - -❌ **Don't create layers just to have layers:** - -```rust -// Bad - unnecessary abstraction -trait DomainNameValidator { - fn validate(&self, name: &str) -> Result<()>; -} - -// Good - validation in constructor -impl DomainName { - pub fn new(name: String) -> Result { - // validation here - } -} -``` - -❌ **Don't create repositories for everything:** - -```rust -// Bad - repository for value object -trait CertificateRepository { - fn find_certificate(&self, domain: &DomainName) -> Result; -} - -// Good - certificates are managed by CertificateService -impl CertificateService { - pub fn get_certificate(&self, domain: &DomainName) -> Result { - // Load from filesystem directly - } -} -``` - -❌ **Don't use events if you don't need them:** - -```rust -// Bad - overkill for simple app -pub struct DomainRegisteredEvent { - domain: DomainName, - timestamp: SystemTime, -} -pub trait EventBus { - fn publish(&self, event: DomainRegisteredEvent); -} - -// Good - direct function calls -pub fn register_domain(...) -> Result<()> { - // Just do the work -} -``` - -❌ **Don't create complex aggregate rules:** - -```rust -// Bad - unnecessary complexity -pub struct DomainAggregate { - root: DomainRegistration, - certificates: Vec, - dns_records: Vec, -} - -// Good - simple relationships -pub struct DomainRegistration { - domain: DomainName, - target: Target, - https_enabled: bool, -} -// Certificates and DNS are managed by services -``` - -#### DDD Guidelines Summary - -**DO:** - -- ✅ Use value objects for validated primitives (DomainName, Port) -- ✅ Use entities for things with identity and lifecycle -- ✅ Use enums to model domain variants (Target, Status) -- ✅ Create domain services for operations not belonging to entities -- ✅ Use application services to orchestrate use cases -- ✅ Make domain types expressive with good method names -- ✅ Keep domain layer free of infrastructure concerns - -**DON'T:** - -- ❌ Create abstractions without concrete need -- ❌ Use repository pattern unless you need multiple implementations -- ❌ Add events, sagas, or complex aggregate rules -- ❌ Create DTOs unless crossing clear boundaries (we rarely need them in Rust) -- ❌ Separate domain and persistence models (Rust's type system makes this less necessary) - -**Remember:** DDD should make code MORE clear, not more complex. If a DDD pattern adds complexity without clear benefit, skip it. - -### 6. What NOT to Do - -❌ **Don't over-engineer:** - -- No custom allocators or unsafe code (unless absolutely necessary) -- No macros for things functions can do -- No trait hierarchies more than 2 levels deep -- No generic programming for a single use case -- No async where blocking I/O works fine (file operations) - -❌ **Don't add unnecessary features:** - -- No plugin system in v1 -- No web UI in v1 -- No remote configuration -- No cloud sync -- No analytics or telemetry - -❌ **Don't optimize prematurely:** - -- Simple `String` is fine over `Cow` until proven otherwise -- `.clone()` is acceptable for small structs and infrequent operations -- HashMap is fine before trying FxHashMap or BTreeMap -- Standard threading is fine before considering rayon or custom thread pools - -### 7. Dependencies - -#### Required Core Dependencies - -```toml -# Async runtime (daemon needs async for HTTP server) -tokio = { version = "1", features = ["full"] } - -# HTTP server and client -axum = "0.7" # Simple, ergonomic web framework -hyper = "1.0" # HTTP primitives (used by axum) -tower = "0.4" # Middleware (used by axum) - -# TLS/SSL -rustls = "0.23" # Pure Rust TLS -rcgen = "0.12" # Certificate generation - -# CLI -clap = { version = "4", features = ["derive"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling -anyhow = "1.0" # Application errors -thiserror = "1.0" # Library errors (if needed) -``` - -#### Consider Only When Needed - -- `tracing` / `tracing-subscriber` - if logging needs become complex -- `notify` - if we need file watching -- `daemonize` - if simple fork isn't enough - -### 8. Code Style - -#### Formatting - -- Use `rustfmt` with default settings -- Run `cargo fmt` before committing -- Max line length: 100 characters (rustfmt default) - -#### Documentation - -- Public functions must have doc comments -- Include examples in doc comments for non-obvious functions -- Explain *why*, not *what* - the code shows what - -```rust -// Good -/// Registers a new domain with either a file path or port. -/// -/// Returns an error if the domain is already registered or if -/// certificate generation fails. -pub fn register_domain(domain: &str, target: Target) -> Result<()> { - // ... -} - -// Bad - just repeating what the signature says -/// Registers a domain -pub fn register_domain(domain: &str, target: Target) -> Result<()> { - // ... -} -``` - -#### Function Size - -- Keep functions under 50 lines when possible -- Extract helper functions when logic becomes nested 3+ levels -- A function should do one thing - -#### Types Over Primitives - -```rust -// Good - clear intent -pub struct Domain(String); -pub enum Target { - Path(PathBuf), - Port(u16), -} - -// Bad - unclear what the string and u16 mean -pub fn register(domain: String, target: Either) -> Result<()> -``` - -### 9. Testing Strategy - -#### Unit Tests - -- Test business logic, not trivial getters/setters -- Use `#[cfg(test)]` modules in the same file -- Mock external dependencies (filesystem, network) in tests -- Test error cases, not just happy paths - -#### Integration Tests - -- Put in `tests/` directory -- Test actual CLI commands end-to-end -- Use temporary directories for filesystem tests - -#### What NOT to Test - -- Don't test external libraries (axum, rustls, etc.) -- Don't test trivial constructors or accessors -- Don't test private implementation details - -```rust -// Good - tests behavior -#[test] -fn test_duplicate_domain_returns_error() { - let mut config = Config::new(); - config.register("app.local", Target::Port(3000)).unwrap(); - let result = config.register("app.local", Target::Port(4000)); - assert!(result.is_err()); -} - -// Bad - tests implementation detail -#[test] -fn test_domains_stored_in_hashmap() { - let config = Config::new(); - assert_eq!(config.domains.len(), 0); -} -``` - -### 10. Error Messages - -Make errors actionable and friendly: - -```rust -// Good -return Err(anyhow!( - "Failed to register domain '{}': domain already exists.\n\ - Use 'roxy unregister {}' first, or choose a different domain name.", - domain, domain -)); - -// Bad -return Err(anyhow!("Domain exists")); -``` - -### 11. Git Commits - -- Use conventional commits format: `feat:`, `fix:`, `docs:`, `refactor:`, etc. -- Keep commits focused and atomic -- Write commit messages that explain *why*, not *what* - -### 12. Performance Targets - -Don't optimize until these are violated: - -- Daemon startup: < 100ms -- Domain registration: < 500ms (including cert generation) -- Request latency overhead: < 10ms -- Memory usage (idle): < 50MB -- Memory usage (under load): < 200MB - -### 13. Security Considerations - -- Never log or display certificate private keys -- Validate all user inputs (domain names, paths, ports) -- Use appropriate file permissions (0600 for keys, 0644 for certs) -- Require explicit confirmation for destructive operations -- Run daemon with minimum required privileges - -### 14. Platform-Specific Code - -Use conditional compilation for platform differences: - -```rust -#[cfg(target_os = "macos")] -fn setup_dns() -> Result<()> { - // macOS implementation using /etc/resolver/ -} - -#[cfg(target_os = "linux")] -fn setup_dns() -> Result<()> { - // Linux implementation using dnsmasq -} -``` - -Start with macOS support only. Add Linux when macOS is working. - -## Development Workflow - -1. **Before starting a feature:** - - Read the relevant section in REQUIREMENTS.md - - Understand what success looks like - - Choose the simplest approach - -2. **While implementing:** - - Write the minimal code that satisfies the requirement - - Add error handling as you go - - Add tests for non-trivial logic - - Run `cargo check` frequently - -3. **Before committing:** - - Run `cargo fmt` - - Run `cargo clippy -- -D warnings` - - Run `cargo test` - - Test manually if it's user-facing functionality - -4. **Code review checklist:** - - Is this the simplest solution? - - Are error messages helpful? - - Are edge cases handled? - - Is it idiomatic Rust? - - Does it follow YAGNI? - -## When to Refactor - -Refactor when you see: - -- Same code pattern repeated 3+ times -- Functions longer than 50 lines doing multiple things -- Deep nesting (3+ levels) -- Hard-to-test code due to tight coupling -- Actual performance problems (not theoretical) - -Don't refactor: - -- Code that's "not pretty" but works fine -- To make it "more generic" without a concrete second use case -- To match patterns from other projects -- Because you thought of a "better" way (unless current way has issues) - -## Questions to Ask - -Before adding complexity, ask: - -1. Does this solve a real problem today? -2. Is there a simpler way? -3. What's the cost of doing nothing? -4. Can we do this later if needed? - -## Remember - -> "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away." - Antoine de Saint-Exupéry - -Build Roxy to work reliably for its core use case. Resist the temptation to make it do everything. Shipping a simple, working tool beats architecting a flexible, unfinished framework. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 5ee8777..1c45157 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ HTTPS for every local project. | Remember 5 different ports for 5 services | One domain, path-based routing | | Can't test webhooks/OAuth locally | Works with Stripe, OAuth, callbacks | | Edit nginx configs, restart services | `roxy register myapp.roxy` | +| Manual setup for each subdomain | `--wildcard` and every subdomain just works | | No visibility into traffic | Full request/WebSocket logging | ## See It In Action @@ -71,7 +72,10 @@ to help others discover it! (Stripe, GitHub, etc.) - 👥 **Teams** — Share work across devices on the same network - 🔧 **Microservices developers** — Stop memorizing which service is on which port -- 🎨 **Anyone tired of `localhost:3000`** — Get real domains like `myapp.roxy` instead +- 🏢 **Multi-tenant SaaS developers** — Wildcard subdomains + (`*.myapp.roxy`) for testing tenant routing locally +- 🎨 **Anyone tired of `localhost:3000`** — Get real domains + like `myapp.roxy` instead ### Multiple Projects, Zero Conflicts @@ -79,9 +83,14 @@ to help others discover it! roxy register frontend.roxy --route "/=3000" roxy register backend.roxy --route "/=8080" --route "/api=8081" roxy register docs.roxy --route "/=/var/www/docs" + +# Wildcard: one registration covers all subdomains +roxy register myapp.roxy --wildcard --route "/=3000" +# → myapp.roxy, api.myapp.roxy, admin.myapp.roxy... ``` -All domains work simultaneously. No port conflicts. No config files to manage. +All domains work simultaneously. No port conflicts. +No config files to manage. ## Features @@ -93,6 +102,8 @@ All domains work simultaneously. No port conflicts. No config files to manage. - ✓ **Path-based routing** — Route `/` to port 3000, `/api` to port 3001, `/static` to a directory - ✓ **Built-in DNS server** — No dnsmasq, no external DNS tools needed +- ✓ **Wildcard subdomains** — Register `*.myapp.roxy` and every + subdomain just works, with trusted HTTPS - ✓ **Single binary** — No nginx, no containers, no runtime dependencies **Developer Experience:** @@ -137,6 +148,23 @@ roxy register myapp.roxy \ # https://myapp.roxy/admin → Admin dashboard ``` +### Multi-Tenant SaaS with Wildcard Subdomains + +Your app uses subdomains for tenants? One command: + +```bash +# Register a wildcard — covers myapp.roxy AND *.myapp.roxy +roxy register myapp.roxy --wildcard \ + --route "/=3000" \ + --route "/api=3001" + +# All of these work instantly, trusted HTTPS included: +# https://myapp.roxy → main app +# https://acme.myapp.roxy → tenant "acme" +# https://globex.myapp.roxy → tenant "globex" +# No extra registration needed. Just add a subdomain and go. +``` + ### Mobile App Development ```bash @@ -198,6 +226,9 @@ DEBUG WebSocket upgrade successful target=127.0.0.1:3000 # Register a domain with routes roxy register --route "PATH=TARGET" [--route "PATH=TARGET" ...] +# Register with wildcard subdomains (*.myapp.roxy) +roxy register --wildcard --route "PATH=TARGET" + # Targets can be: # Port: --route "/=3000" → proxies to 127.0.0.1:3000 # Host:Port: --route "/=192.168.1.50:3000" → proxies to a specific host @@ -292,6 +323,7 @@ For configuration details, logging options, and file locations see the [full doc | Single binary | ✓ | ✗ | ✗ | ✓ | N/A | | Any tech stack | ✓ | ✓ | PHP-focused | ✓ | ✓ | | WebSocket support | ✓ | ✓ | ~ | ✓ | ~ | +| Wildcard subdomains | ✓ | Manual setup | ✗ | ✗ | ✗ | | Built-in DNS server | ✓ | ✗ | Uses dnsmasq | N/A | ✗ | **TL;DR:** Roxy gives you the power of nginx with the simplicity of @@ -354,11 +386,14 @@ Roxy is ready for daily development use on macOS. Recent additions and future pl (macOS ARM64) - [x] **Homebrew support** — `brew tap rbas/roxy && brew install roxy` - [x] **Auto-start on boot** — launch daemon via launchd with `brew services` -- [x] **File browser** — automatic directory listing for static file routes -- [ ] **Linux support** — extend to Linux development environments -- [ ] **Docker network DNS** — resolve `.roxy` domains inside containers - without `extra_hosts` -- [ ] **Wildcard subdomains** — support `*.myapp.roxy` patterns +- [x] **File browser** — automatic directory listing + for static file routes +- [x] **Wildcard subdomains** — `*.myapp.roxy` patterns + with automatic certificate generation +- [ ] **Linux support** — extend to Linux development + environments +- [ ] **Docker network DNS** — resolve `.roxy` domains + inside containers without `extra_hosts` Have a feature idea? [Open an issue](https://github.com/rbas/roxy/issues) and let's discuss! diff --git a/docs/README.md b/docs/README.md index 85639e3..5aaa061 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,22 +25,23 @@ open https://myapp.roxy ## Commands Reference -| Command | Description | -| ---------------------------------- | -------------------------- | -| `sudo roxy install` | Initial setup | -| `sudo roxy uninstall [--force]` | Full cleanup | -| `sudo roxy register ...` | Register domain | -| `sudo roxy unregister ` | Remove domain | -| `roxy list` | Show all domains | -| `sudo roxy route add ...` | Add route to domain | -| `roxy route remove ...` | Remove route from domain | -| `roxy route list ` | List routes for domain | -| `sudo roxy start [--foreground]` | Start daemon | -| `sudo roxy stop` | Stop daemon | -| `sudo roxy restart` | Restart daemon | -| `sudo roxy reload` | Reload configuration | -| `roxy status` | Show daemon status | -| `roxy logs [-n N] [-f]` | View or follow daemon logs | +| Command | Description | +| ---------------------------------- | ---------------------- | +| `sudo roxy install` | Initial setup | +| `sudo roxy uninstall [--force]` | Full cleanup | +| `sudo roxy register ...` | Register domain | +| `sudo roxy register --wildcard ..` | Register wildcard | +| `sudo roxy unregister ` | Remove domain | +| `roxy list` | Show all domains | +| `sudo roxy route add ...` | Add route to domain | +| `roxy route remove ...` | Remove route | +| `roxy route list ` | List routes for domain | +| `sudo roxy start [--foreground]` | Start daemon | +| `sudo roxy stop` | Stop daemon | +| `sudo roxy restart` | Restart daemon | +| `sudo roxy reload` | Reload configuration | +| `roxy status` | Show daemon status | +| `roxy logs [-n N] [-f]` | View or follow logs | **Note:** Commands that modify system configuration (CA certs, DNS) or control the daemon (runs on ports @@ -86,6 +87,81 @@ roxy route remove app.roxy /webhooks roxy route list app.roxy ``` +## Wildcard Subdomains + +Register a domain with `--wildcard` to match the base +domain **and** any single-level subdomain. Roxy generates +a wildcard TLS certificate so every subdomain gets +trusted HTTPS automatically. + +```bash +roxy register myapp.roxy --wildcard --route "/=3000" +``` + +This single registration handles all of these: + +| URL | Matches? | +| --- | -------- | +| `https://myapp.roxy` | yes | +| `https://blog.myapp.roxy` | yes | +| `https://api.myapp.roxy` | yes | +| `https://a.b.myapp.roxy` | no (multi-level) | +| `https://other.roxy` | no (different domain) | + +### Combining Exact and Wildcard + +You can register both an exact domain and a wildcard +for the same base domain. The exact registration takes +priority when both match: + +```bash +# Exact: dedicated routes for the base domain +roxy register myapp.roxy --route "/=3000" + +# Wildcard: catch-all for subdomains +roxy register myapp.roxy --wildcard --route "/=4000" + +# myapp.roxy → port 3000 (exact wins) +# blog.myapp.roxy → port 4000 (wildcard) +# api.myapp.roxy → port 4000 (wildcard) +``` + +### Managing Wildcard Routes + +Use `--wildcard` with `route` subcommands to manage +routes on a wildcard registration: + +```bash +roxy route add --wildcard myapp.roxy /api 3001 +roxy route remove --wildcard myapp.roxy /api +roxy route list --wildcard myapp.roxy +``` + +### Unregistering a Wildcard + +```bash +roxy unregister --wildcard myapp.roxy +``` + +This removes the wildcard registration and its +certificate. Any exact registration for the same domain +is left untouched. + +### Configuration + +Wildcard registrations are stored in `config.toml` +with a `*.` prefix on the domain: + +```toml +[domains.wildcard-myapp-roxy] +domain = "*.myapp.roxy" +https_enabled = true + +[[domains.wildcard-myapp-roxy.routes]] +path = "/" +target = "127.0.0.1:3000" +``` + ## Static File Serving When serving static files from a directory, Roxy provides: @@ -256,6 +332,8 @@ target = "127.0.0.1:3001" Domain names must end with `.roxy` and can contain letters, numbers, hyphens, and dots (for subdomains). +Wildcard registrations use a `*.` prefix +(see [Wildcard Subdomains](#wildcard-subdomains)). ### Paths Section diff --git a/src/application/install.rs b/src/application/install.rs new file mode 100644 index 0000000..7850675 --- /dev/null +++ b/src/application/install.rs @@ -0,0 +1,140 @@ +use std::net::Ipv4Addr; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::infrastructure::certs::CertificateService; +use crate::infrastructure::config::{Config, ConfigStore}; +use crate::infrastructure::dns::get_dns_service; +use crate::infrastructure::network::get_lan_ip; +use crate::infrastructure::paths::RoxyPaths; + +use super::StepOutcome; + +/// Result of the install operation. +pub struct InstallResult { + pub lan_ip: Ipv4Addr, + pub steps: Vec<(String, StepOutcome)>, +} + +/// Use case: initial setup — create directories, root CA, DNS. +pub struct Install<'a> { + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, + config_path: &'a Path, + paths: &'a RoxyPaths, + config: &'a Config, +} + +impl<'a> Install<'a> { + pub fn new( + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, + config_path: &'a Path, + paths: &'a RoxyPaths, + config: &'a Config, + ) -> Self { + Self { + config_store, + cert_service, + config_path, + paths, + config, + } + } + + pub fn execute(&self) -> Result { + let mut steps: Vec<(String, StepOutcome)> = Vec::new(); + let lan_ip = get_lan_ip(); + let dns_port = self.config.daemon.dns_port; + + self.create_directories(&mut steps)?; + self.ensure_config_file(&mut steps)?; + self.init_root_ca(&mut steps); + self.configure_dns(dns_port, &mut steps)?; + + Ok(InstallResult { lan_ip, steps }) + } + + fn create_directories(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + std::fs::create_dir_all(&self.paths.data_dir).with_context(|| { + format!( + "Failed to create data directory: {}", + self.paths.data_dir.display() + ) + })?; + std::fs::create_dir_all(&self.paths.certs_dir).with_context(|| { + format!( + "Failed to create certs directory: {}", + self.paths.certs_dir.display() + ) + })?; + + if let Some(log_dir) = self.paths.log_file.parent() { + std::fs::create_dir_all(log_dir).with_context(|| { + format!("Failed to create log directory: {}", log_dir.display()) + })?; + } + + steps.push(( + "Create directories".into(), + StepOutcome::Success("Data and log directories ready.".into()), + )); + Ok(()) + } + + fn ensure_config_file(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + if !self.config_path.exists() { + self.config_store.save(self.config)?; + steps.push(( + "Config file".into(), + StepOutcome::Success(format!( + "Created config file: {}", + self.config_path.display() + )), + )); + } else { + steps.push(( + "Config file".into(), + StepOutcome::Skipped("Config file already exists.".into()), + )); + } + Ok(()) + } + + fn init_root_ca(&self, steps: &mut Vec<(String, StepOutcome)>) { + let ca_outcome = match self.cert_service.is_ca_installed() { + Ok(true) => StepOutcome::Skipped("Root CA already installed.".into()), + _ => match self.cert_service.init_ca() { + Ok(()) => StepOutcome::Success( + "Root CA created and installed in system trust store.".into(), + ), + Err(e) => StepOutcome::Warning(format!( + "Failed to create Root CA: {}. \ + HTTPS certificates will not work. \ + Run 'sudo roxy install' to enable HTTPS.", + e + )), + }, + }; + steps.push(("Root CA".into(), ca_outcome)); + } + + fn configure_dns(&self, dns_port: u16, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + let dns = get_dns_service()?; + let dns_outcome = if dns.is_configured() { + StepOutcome::Skipped("DNS already configured.".into()) + } else { + dns.setup(dns_port)?; + StepOutcome::Success("DNS configured successfully.".into()) + }; + steps.push(("DNS configuration".into(), dns_outcome)); + + dns.validate()?; + steps.push(( + "DNS validation".into(), + StepOutcome::Success("DNS validation passed.".into()), + )); + Ok(()) + } +} diff --git a/src/application/manage_routes.rs b/src/application/manage_routes.rs new file mode 100644 index 0000000..09b6147 --- /dev/null +++ b/src/application/manage_routes.rs @@ -0,0 +1,47 @@ +use anyhow::{Result, anyhow}; + +use crate::domain::{DomainPattern, PathPrefix, Route, RouteTarget}; +use crate::infrastructure::config::ConfigStore; + +/// Use case: manage routes for an existing domain registration. +pub struct ManageRoutes<'a> { + config_store: &'a ConfigStore, +} + +impl<'a> ManageRoutes<'a> { + pub fn new(config_store: &'a ConfigStore) -> Self { + Self { config_store } + } + + /// Add a route to an existing domain. Returns the added route. + pub fn add_route( + &self, + pattern: &DomainPattern, + path_prefix: PathPrefix, + route_target: RouteTarget, + ) -> Result { + let mut registration = self + .config_store + .get_domain(pattern)? + .ok_or_else(|| anyhow!("Domain '{}' not registered", pattern))?; + + let route = Route::new(path_prefix, route_target); + registration.add_route(route.clone())?; + self.config_store.update_domain(registration)?; + + Ok(route) + } + + /// Remove a route from an existing domain. + pub fn remove_route(&self, pattern: &DomainPattern, path_prefix: &PathPrefix) -> Result<()> { + let mut registration = self + .config_store + .get_domain(pattern)? + .ok_or_else(|| anyhow!("Domain '{}' not registered", pattern))?; + + registration.remove_route(path_prefix)?; + self.config_store.update_domain(registration)?; + + Ok(()) + } +} diff --git a/src/application/mod.rs b/src/application/mod.rs new file mode 100644 index 0000000..52707c5 --- /dev/null +++ b/src/application/mod.rs @@ -0,0 +1,28 @@ +pub mod install; +pub mod manage_routes; +pub mod register_domain; +pub mod uninstall; +pub mod unregister_domain; + +use std::fmt; + +/// Outcome of a single step in a multi-step operation. +/// +/// Used by application services to report partial success/failure +/// so the CLI layer can render feedback appropriately. +#[derive(Debug, Clone)] +pub enum StepOutcome { + Success(String), + Warning(String), + Skipped(String), +} + +impl fmt::Display for StepOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Success(msg) => write!(f, "{}", msg), + Self::Warning(msg) => write!(f, "Warning: {}", msg), + Self::Skipped(msg) => write!(f, "Skipped: {}", msg), + } + } +} diff --git a/src/application/register_domain.rs b/src/application/register_domain.rs new file mode 100644 index 0000000..123eb61 --- /dev/null +++ b/src/application/register_domain.rs @@ -0,0 +1,76 @@ +use anyhow::{Result, bail}; + +use crate::domain::{DomainPattern, DomainRegistration, Route}; +use crate::infrastructure::certs::CertificateService; +use crate::infrastructure::config::ConfigStore; + +use super::StepOutcome; + +/// Result of a successful domain registration. +pub struct RegisterResult { + pub registration: DomainRegistration, + pub cert_outcome: StepOutcome, +} + +/// Use case: register a new domain with routes. +pub struct RegisterDomain<'a> { + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, +} + +impl<'a> RegisterDomain<'a> { + pub fn new(config_store: &'a ConfigStore, cert_service: &'a CertificateService) -> Self { + Self { + config_store, + cert_service, + } + } + + /// Validate inputs, generate a certificate, and persist the registration. + pub fn execute(&self, pattern: DomainPattern, routes: Vec) -> Result { + if routes.is_empty() { + bail!( + "At least one route is required. \ + Use --route \"/=PORT\" or --route \"/=PATH\"" + ); + } + + // ConfigStore::add_domain also rejects duplicates, but we + // check here for a friendlier error message with guidance. + if self.config_store.get_domain(&pattern)?.is_some() { + bail!( + "Domain '{}' is already registered. \ + Use 'roxy unregister {}{}' first.", + pattern, + pattern.base_domain(), + if pattern.is_wildcard() { + " --wildcard" + } else { + "" + } + ); + } + + let mut registration = DomainRegistration::new(pattern.clone(), routes); + + // Generate certificate (graceful fallback) + let cert_outcome = match self.cert_service.create_and_install(&pattern) { + Ok(()) => { + registration.enable_https(); + StepOutcome::Success("Certificate installed and trusted.".into()) + } + Err(e) => StepOutcome::Warning(format!( + "Failed to generate certificate: {}. \ + HTTPS will not be available for this domain.", + e + )), + }; + + self.config_store.add_domain(registration.clone())?; + + Ok(RegisterResult { + registration, + cert_outcome, + }) + } +} diff --git a/src/application/uninstall.rs b/src/application/uninstall.rs new file mode 100644 index 0000000..9491285 --- /dev/null +++ b/src/application/uninstall.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::time::Duration; + +use anyhow::Result; + +use crate::infrastructure::certs::CertificateService; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::dns::get_dns_service; +use crate::infrastructure::paths::RoxyPaths; +use crate::infrastructure::pid::PidFile; + +use super::StepOutcome; + +/// What will be removed — shown to the user for confirmation. +pub struct UninstallPreview { + pub domain_count: usize, + pub data_dir: String, +} + +/// Result of the uninstall operation. +pub struct UninstallResult { + pub steps: Vec<(String, StepOutcome)>, +} + +/// Use case: remove all Roxy configuration from the system. +pub struct Uninstall<'a> { + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, + paths: &'a RoxyPaths, +} + +impl<'a> Uninstall<'a> { + pub fn new( + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, + paths: &'a RoxyPaths, + ) -> Self { + Self { + config_store, + cert_service, + paths, + } + } + + /// Build a preview so the CLI can show a confirmation prompt. + pub fn preview(&self) -> Result { + let domain_count = self.config_store.list_domains().unwrap_or_default().len(); + Ok(UninstallPreview { + domain_count, + data_dir: self.paths.data_dir.display().to_string(), + }) + } + + /// Perform the full uninstall: stop daemon, remove certs, DNS, + /// data directory, PID file, and logs. + pub fn execute(&self) -> Result { + let mut steps: Vec<(String, StepOutcome)> = Vec::new(); + + self.stop_daemon(&mut steps)?; + self.remove_certificates(&mut steps); + self.remove_dns(&mut steps)?; + self.remove_data(&mut steps)?; + self.cleanup_files(&mut steps); + + Ok(UninstallResult { steps }) + } + + fn stop_daemon(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + let pid_file = PidFile::new(self.paths.pid_file.clone()); + if pid_file.get_running_pid()?.is_some() { + pid_file.stop_gracefully(Duration::from_millis(500))?; + steps.push(( + "Stop daemon".into(), + StepOutcome::Success("Daemon stopped.".into()), + )); + } else { + steps.push(( + "Stop daemon".into(), + StepOutcome::Skipped("Daemon not running.".into()), + )); + } + Ok(()) + } + + fn remove_certificates(&self, steps: &mut Vec<(String, StepOutcome)>) { + let domains = self.config_store.list_domains().unwrap_or_default(); + + for registration in &domains { + let label = format!("Remove cert: {}", registration.display_pattern()); + let outcome = match self.cert_service.remove(registration.pattern()) { + Ok(_) => StepOutcome::Success("Removed.".into()), + Err(e) => StepOutcome::Warning(format!("Failed: {}", e)), + }; + steps.push((label, outcome)); + } + + let ca_outcome = match self.cert_service.remove_ca() { + Ok(_) => StepOutcome::Success("Root CA removed.".into()), + Err(e) => StepOutcome::Warning(format!("Failed to remove Root CA: {}", e)), + }; + steps.push(("Remove Root CA".into(), ca_outcome)); + } + + fn remove_dns(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + let dns = get_dns_service()?; + if dns.is_configured() { + dns.cleanup()?; + steps.push(( + "Remove DNS".into(), + StepOutcome::Success("DNS configuration removed.".into()), + )); + } else { + steps.push(( + "Remove DNS".into(), + StepOutcome::Skipped("DNS not configured.".into()), + )); + } + Ok(()) + } + + fn remove_data(&self, steps: &mut Vec<(String, StepOutcome)>) -> Result<()> { + if self.paths.data_dir.exists() { + fs::remove_dir_all(&self.paths.data_dir)?; + steps.push(( + "Remove data directory".into(), + StepOutcome::Success("Directory removed.".into()), + )); + } else { + steps.push(( + "Remove data directory".into(), + StepOutcome::Skipped("Directory does not exist.".into()), + )); + } + Ok(()) + } + + fn cleanup_files(&self, steps: &mut Vec<(String, StepOutcome)>) { + if fs::remove_file(&self.paths.pid_file).is_ok() { + steps.push(( + "Remove PID file".into(), + StepOutcome::Success("PID file removed.".into()), + )); + } + + if let Some(log_dir) = self.paths.log_file.parent() + && fs::remove_dir_all(log_dir).is_ok() + { + steps.push(( + "Remove log directory".into(), + StepOutcome::Success("Log directory removed.".into()), + )); + } + } +} diff --git a/src/application/unregister_domain.rs b/src/application/unregister_domain.rs new file mode 100644 index 0000000..3256ab2 --- /dev/null +++ b/src/application/unregister_domain.rs @@ -0,0 +1,57 @@ +use anyhow::{Result, anyhow}; + +use crate::domain::{DomainPattern, DomainRegistration}; +use crate::infrastructure::certs::CertificateService; +use crate::infrastructure::config::ConfigStore; + +use super::StepOutcome; + +/// Result of a successful domain unregistration. +pub struct UnregisterResult { + pub registration: DomainRegistration, + pub cert_outcome: StepOutcome, +} + +/// Use case: unregister a domain and clean up its certificate. +pub struct UnregisterDomain<'a> { + config_store: &'a ConfigStore, + cert_service: &'a CertificateService, +} + +impl<'a> UnregisterDomain<'a> { + pub fn new(config_store: &'a ConfigStore, cert_service: &'a CertificateService) -> Self { + Self { + config_store, + cert_service, + } + } + + /// Look up the registration so the CLI can show a confirmation + /// prompt before proceeding with `execute()`. + pub fn preview(&self, pattern: &DomainPattern) -> Result { + self.config_store + .get_domain(pattern)? + .ok_or_else(|| anyhow!("Domain '{}' is not registered.", pattern)) + } + + /// Remove the domain certificate and config entry. + pub fn execute(&self, pattern: &DomainPattern) -> Result { + let registration = self.preview(pattern)?; + + let cert_outcome = if self.cert_service.exists(pattern) { + match self.cert_service.remove(pattern) { + Ok(()) => StepOutcome::Success("Certificate removed.".into()), + Err(e) => StepOutcome::Warning(format!("Failed to remove certificate: {}", e)), + } + } else { + StepOutcome::Skipped("No certificate to remove.".into()) + }; + + self.config_store.remove_domain(pattern)?; + + Ok(UnregisterResult { + registration, + cert_outcome, + }) + } +} diff --git a/src/cli/install.rs b/src/cli/install.rs index a522d65..5503eee 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,92 +1,35 @@ use std::path::Path; -use anyhow::{Context, Result}; +use anyhow::Result; +use crate::application::StepOutcome; +use crate::application::install::Install; use crate::infrastructure::certs::CertificateService; use crate::infrastructure::config::{Config, ConfigStore}; -use crate::infrastructure::dns::get_dns_service; -use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; pub fn execute(config_path: &Path, paths: &RoxyPaths, config: &Config) -> Result<()> { println!("Setting up Roxy...\n"); - let dns_port = config.daemon.dns_port; + let config_store = ConfigStore::new(config_path.to_path_buf()); + let cert_service = CertificateService::new(paths); + let use_case = Install::new(&config_store, &cert_service, config_path, paths, config); + let result = use_case.execute()?; - // Detect LAN IP - let lan_ip = get_lan_ip(); - println!(" Using IP address: {}", lan_ip); - if lan_ip.is_loopback() { + println!(" Using IP address: {}", result.lan_ip); + if result.lan_ip.is_loopback() { println!(" Warning: No network detected, using localhost."); } - // Ensure data directory exists - std::fs::create_dir_all(&paths.data_dir).with_context(|| { - format!( - "Failed to create data directory: {}", - paths.data_dir.display() - ) - })?; - std::fs::create_dir_all(&paths.certs_dir).with_context(|| { - format!( - "Failed to create certs directory: {}", - paths.certs_dir.display() - ) - })?; - - // Ensure log directory exists - if let Some(log_dir) = paths.log_file.parent() { - std::fs::create_dir_all(log_dir) - .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?; - } - - // Write default config if it doesn't exist - if !config_path.exists() { - let config_store = ConfigStore::new(config_path.to_path_buf()); - config_store.save(config)?; - println!(" Created config file: {}", config_path.display()); - } - - // Step 1: Initialize Root CA - let cert_service = CertificateService::new(paths); - match cert_service.is_ca_installed() { - Ok(true) => { - println!(" Root CA already installed, skipping..."); - } - _ => { - println!(" Creating Root CA for SSL certificates..."); - match cert_service.init_ca() { - Ok(()) => { - println!(" Root CA created and installed in system trust store."); - } - Err(e) => { - eprintln!(" Warning: Failed to create Root CA: {}", e); - eprintln!(" HTTPS certificates will not work."); - eprintln!(" Run 'sudo roxy install' to enable HTTPS."); - } - } + for (label, outcome) in &result.steps { + match outcome { + StepOutcome::Success(msg) => println!(" {} {}", label, msg), + StepOutcome::Warning(msg) => eprintln!(" {} {}", label, msg), + StepOutcome::Skipped(msg) => println!(" {} {}", label, msg), } } - // Step 2: Configure DNS - let dns = get_dns_service()?; - if dns.is_configured() { - println!(" DNS already configured, skipping..."); - } else { - println!( - " Configuring DNS for *.roxy domains (port {})...", - dns_port - ); - dns.setup(dns_port)?; - println!(" DNS configured successfully."); - } - - // Step 3: Validate DNS - println!(" Validating DNS configuration..."); - dns.validate()?; - println!(" DNS validation passed.\n"); - - println!("Roxy installation complete!"); + println!("\nRoxy installation complete!"); println!(); println!("Register domains with: roxy register --port "); diff --git a/src/cli/list.rs b/src/cli/list.rs index bb3f428..fd058bb 100644 --- a/src/cli/list.rs +++ b/src/cli/list.rs @@ -23,9 +23,9 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { println!("Registered domains:\n"); for reg in domains { - // Check actual certificate status - let https_status = if cert_service.exists(®.domain) { - match cert_service.is_trusted(®.domain) { + let has_cert = cert_service.exists(reg.pattern()); + let https_status = if has_cert { + match cert_service.is_trusted() { Ok(true) => "(HTTPS)", Ok(false) => "(HTTPS untrusted)", Err(_) => "(HTTPS error)", @@ -34,9 +34,9 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { "" }; - println!(" {} {}", reg.domain, https_status); + println!(" {} {}", reg.display_pattern(), https_status); - for route in ®.routes { + for route in reg.routes() { let target_str = match &route.target { RouteTarget::Proxy(p) => p.to_string(), RouteTarget::StaticFiles(p) => p.display().to_string(), diff --git a/src/cli/register.rs b/src/cli/register.rs index 11d2909..e85c2f9 100644 --- a/src/cli/register.rs +++ b/src/cli/register.rs @@ -1,25 +1,22 @@ use std::path::Path; -use anyhow::{Result, bail}; +use anyhow::Result; -use crate::domain::{DomainName, DomainRegistration, Route}; +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, ) -> Result<()> { - // Validate domain name - let domain = DomainName::new(&domain)?; - - // Parse routes - if routes.is_empty() { - bail!("At least one route is required. Use --route \"/=PORT\" or --route \"/=PATH\""); - } + let pattern = DomainPattern::from_name(&domain, wildcard)?; let parsed_routes: Vec = routes .iter() @@ -29,45 +26,39 @@ pub fn execute( 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); - // Check if already registered - if config_store.get_domain(&domain)?.is_some() { - bail!( - "Domain '{}' is already registered. Use 'roxy unregister {}' first.", - domain, - domain - ); - } + println!( + "Generating SSL certificate for {}...", + pattern.display_pattern() + ); - // Create registration - let mut registration = DomainRegistration::new(domain.clone(), parsed_routes); + let result = use_case.execute(pattern, parsed_routes)?; - // Generate and install certificate - println!("Generating SSL certificate for {}...", domain); - match cert_service.create_and_install(&domain) { - Ok(()) => { - registration.enable_https(); - println!(" Certificate installed and trusted."); - } - Err(e) => { - // Certificate generation failed - warn but continue - eprintln!(" Warning: Failed to generate certificate: {}", e); - eprintln!(" HTTPS will not be available for this domain."); - eprintln!(" Run 'sudo roxy register {}' to enable HTTPS.", domain); + match &result.cert_outcome { + StepOutcome::Success(msg) => println!(" {}", msg), + StepOutcome::Warning(msg) => { + eprintln!(" {}", msg); + eprintln!( + " Run 'sudo roxy register {}{}' to enable HTTPS.", + result.registration.domain(), + if wildcard { " --wildcard" } else { "" } + ); } + StepOutcome::Skipped(msg) => println!(" {}", msg), } - // Save to config - config_store.add_domain(registration.clone())?; - - println!("\nRegistered domain: {}", domain); + println!( + "\nRegistered domain: {}", + result.registration.display_pattern() + ); println!(" Routes:"); - for route in ®istration.routes { + for route in result.registration.routes() { println!(" {} -> {}", route.path, route.target); } println!( " HTTPS: {}", - if cert_service.exists(&domain) { + if result.registration.is_https_enabled() { "enabled" } else { "disabled" diff --git a/src/cli/route.rs b/src/cli/route.rs index a0e4293..84cdba5 100644 --- a/src/cli/route.rs +++ b/src/cli/route.rs @@ -2,53 +2,43 @@ use std::path::Path; use anyhow::Result; -use crate::domain::{DomainName, PathPrefix, Route, RouteTarget}; +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(domain: String, path: String, target: String, config_path: &Path) -> Result<()> { - let domain = DomainName::new(&domain)?; +pub fn add( + domain: String, + wildcard: bool, + path: String, + target: String, + config_path: &Path, +) -> 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); - // Get existing registration - let mut registration = config_store - .get_domain(&domain)? - .ok_or_else(|| anyhow::anyhow!("Domain '{}' not registered", domain))?; + let route = use_case.add_route(&pattern, path_prefix, route_target)?; - // Add the route - let route = Route::new(path_prefix.clone(), route_target.clone()); - registration.add_route(route)?; - - // Save updated registration - config_store.update_domain(registration)?; - - println!("Added route: {} -> {}", path_prefix, 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, path: String, config_path: &Path) -> Result<()> { - let domain = DomainName::new(&domain)?; +pub fn remove(domain: String, wildcard: bool, path: String, config_path: &Path) -> 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); - // Get existing registration - let mut registration = config_store - .get_domain(&domain)? - .ok_or_else(|| anyhow::anyhow!("Domain '{}' not registered", domain))?; - - // Remove the route - registration.remove_route(&path_prefix)?; - - // Save updated registration - config_store.update_domain(registration)?; + use_case.remove_route(&pattern, &path_prefix)?; println!("Removed route: {}", path_prefix); println!("\nReload the daemon to apply changes: roxy reload"); @@ -57,26 +47,28 @@ pub fn remove(domain: String, path: String, config_path: &Path) -> Result<()> { } /// List all routes for a domain -pub fn list(domain: String, config_path: &Path) -> Result<()> { - let domain = DomainName::new(&domain)?; +pub fn list(domain: String, wildcard: bool, config_path: &Path) -> Result<()> { + let pattern = DomainPattern::from_name(&domain, wildcard)?; let config_store = ConfigStore::new(config_path.to_path_buf()); - // Get existing registration let registration = config_store - .get_domain(&domain)? - .ok_or_else(|| anyhow::anyhow!("Domain '{}' not registered", domain))?; - - if registration.routes.is_empty() { - println!("No routes configured for {}", domain); + .get_domain(&pattern)? + .ok_or_else(|| anyhow::anyhow!("Domain '{}' not registered", pattern))?; + + if registration.routes().is_empty() { + println!( + "No routes configured for {}", + registration.display_pattern() + ); return Ok(()); } - println!("Routes for {}:\n", domain); + println!("Routes for {}:\n", registration.display_pattern()); println!("{:<20} {:<30}", "PATH", "TARGET"); println!("{}", "-".repeat(52)); - for route in ®istration.routes { + for route in registration.routes() { println!("{:<20} {:<30}", route.path, route.target); } diff --git a/src/cli/start.rs b/src/cli/start.rs index 7c9f59f..aaf3a7f 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail}; use std::env; use std::process::{Command, Stdio}; -use crate::infrastructure::config::{Config, ConfigStore}; +use crate::infrastructure::config::Config; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; use crate::infrastructure::pid::PidFile; @@ -33,7 +33,7 @@ pub fn execute( if foreground { // Run in foreground (blocking) - run_server(verbose, config_path, paths) + crate::daemon::lifecycle::run(verbose, config_path, paths) } else { // Fork to background let exe = env::current_exe()?; @@ -77,47 +77,3 @@ pub fn execute( Ok(()) } } - -#[tokio::main] -async fn run_server(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { - use std::io::IsTerminal; - - use crate::daemon::Server; - use crate::infrastructure::pid::PidFile; - use crate::infrastructure::tracing::{TracingOutput, init_tracing}; - use tracing::info; - - // When running interactively (stdout is a TTY), log to stdout - // When running as daemon (stdout is /dev/null), log to file - let output = if std::io::stdout().is_terminal() { - TracingOutput::Stdout - } else { - TracingOutput::File(paths.log_file.clone()) - }; - init_tracing(verbose, output); - - info!("Roxy daemon started"); - - let pid_file = PidFile::new(paths.pid_file.clone()); - pid_file.write()?; - - // Handle Ctrl+C gracefully - let cleanup_pid = PidFile::new(paths.pid_file.clone()); - ctrlc::set_handler(move || { - let _ = cleanup_pid.remove(); - std::process::exit(0); - })?; - - println!("Starting Roxy daemon..."); - - // Load config fresh from disk (this path is used by the forked - // subprocess, so it must re-read from the config file) - let config_store = ConfigStore::new(config_path.to_path_buf()); - let config = config_store.load()?; - - let server = Server::new(&config, paths)?; - let result = server.run().await; - - pid_file.remove()?; - result -} diff --git a/src/cli/status.rs b/src/cli/status.rs index bab9f77..bcca41e 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -62,13 +62,13 @@ pub fn execute(config_path: &Path, paths: &RoxyPaths) -> Result<()> { let domains = config_store.list_domains()?; if !domains.is_empty() { println!("\nRegistered domains: {}", domains.len()); - for domain in domains { - let scheme = if domain.https_enabled { + for reg in domains { + let scheme = if reg.is_https_enabled() { "https" } else { "http" }; - println!(" {}://{}", scheme, domain.domain); + println!(" {}://{}", scheme, reg.display_pattern()); } } diff --git a/src/cli/stop.rs b/src/cli/stop.rs index 24bae4d..73d5c71 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use anyhow::{Result, bail}; use crate::infrastructure::paths::RoxyPaths; @@ -6,35 +8,11 @@ use crate::infrastructure::pid::PidFile; pub fn execute(paths: &RoxyPaths) -> Result<()> { let pid_file = PidFile::new(paths.pid_file.clone()); - let pid = match pid_file.get_running_pid()? { - Some(pid) => pid, - None => bail!("Roxy daemon is not running."), - }; - - // Send SIGTERM to the process - #[cfg(unix)] - { - use std::process::Command; - Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .output()?; - } - - // Wait a moment and check if it stopped - std::thread::sleep(std::time::Duration::from_millis(500)); - - if pid_file.is_running()? { - // Force kill if still running - #[cfg(unix)] - { - use std::process::Command; - Command::new("kill") - .args(["-9", &pid.to_string()]) - .output()?; - } + if pid_file.get_running_pid()?.is_none() { + bail!("Roxy daemon is not running."); } - pid_file.remove()?; + pid_file.stop_gracefully(Duration::from_millis(500))?; println!("Roxy daemon stopped."); Ok(()) diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index ca50d15..e2bd590 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -1,117 +1,44 @@ -use std::fs; use std::path::Path; -use std::process::Command; use anyhow::Result; +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::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 use_case = Uninstall::new(&config_store, &cert_service, paths); + if !force { + let preview = use_case.preview()?; println!("This will remove all Roxy configuration including:"); println!(" - Stop the running daemon"); println!(" - DNS configuration for *.roxy domains"); - println!(" - All registered domains"); + println!(" - All registered domains ({})", preview.domain_count); println!(" - All SSL certificates from system trust store"); - println!(" - All data in {}/", paths.data_dir.display()); + println!(" - All data in {}/", preview.data_dir); println!("\nRun with --force to confirm, or press Ctrl+C to cancel."); return Ok(()); } println!("Uninstalling Roxy...\n"); - // Step 1: Stop daemon if running - let pid_file = PidFile::new(paths.pid_file.clone()); - if let Some(pid) = pid_file.get_running_pid()? { - println!(" Stopping daemon (PID: {})...", pid); - stop_daemon(pid)?; - pid_file.remove()?; - println!(" Daemon stopped."); - } - - // Step 2: Remove certificates from trust store - let config_store = ConfigStore::new(config_path.to_path_buf()); - let domains = config_store.list_domains().unwrap_or_default(); - - let cert_service = CertificateService::new(paths); - - if !domains.is_empty() { - println!(" Removing {} domain certificate(s)...", domains.len()); + let result = use_case.execute()?; - for registration in &domains { - match cert_service.remove(®istration.domain) { - Ok(_) => println!(" - {} removed", registration.domain), - Err(e) => println!(" - {} failed: {}", registration.domain, e), - } + for (label, outcome) in &result.steps { + match outcome { + StepOutcome::Success(msg) => println!(" {}: {}", label, msg), + StepOutcome::Warning(msg) => eprintln!(" {}: {}", label, msg), + StepOutcome::Skipped(msg) => println!(" {}: {}", label, msg), } } - // Remove Root CA from system trust store - println!(" Removing Root CA from system trust store..."); - match cert_service.remove_ca() { - Ok(_) => println!(" Root CA removed."), - Err(e) => println!(" Failed to remove Root CA: {}", e), - } - - // Step 3: Remove DNS configuration - let dns = get_dns_service()?; - if dns.is_configured() { - println!(" Removing DNS configuration..."); - dns.cleanup()?; - println!(" DNS configuration removed."); - } - - // Step 4: Remove data directory entirely - if paths.data_dir.exists() { - println!(" Removing {}...", paths.data_dir.display()); - fs::remove_dir_all(&paths.data_dir)?; - println!(" Directory removed."); - } - - // Step 5: Remove PID file (if it still exists) - if fs::remove_file(&paths.pid_file).is_ok() { - println!(" PID file removed."); - } - - // Step 6: Remove log directory - if let Some(log_dir) = paths.log_file.parent() - && fs::remove_dir_all(log_dir).is_ok() - { - println!(" Log directory removed."); - } - println!("\nRoxy uninstallation complete!"); println!("All configuration and certificates have been removed."); Ok(()) } - -fn stop_daemon(pid: u32) -> Result<()> { - // Send SIGTERM - Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .output()?; - - // Wait briefly for graceful shutdown - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Check if still running, force kill if needed - let still_running = Command::new("kill") - .args(["-0", &pid.to_string()]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if still_running { - // Still running, force kill - Command::new("kill") - .args(["-KILL", &pid.to_string()]) - .output()?; - } - - Ok(()) -} diff --git a/src/cli/unregister.rs b/src/cli/unregister.rs index c4dc682..843a2cb 100644 --- a/src/cli/unregister.rs +++ b/src/cli/unregister.rs @@ -1,56 +1,54 @@ use std::path::Path; -use anyhow::{Result, bail}; +use anyhow::Result; -use crate::domain::DomainName; +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, force: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { - let domain = DomainName::new(&domain)?; +pub fn execute( + domain: String, + wildcard: bool, + force: bool, + config_path: &Path, + paths: &RoxyPaths, +) -> Result<()> { + let pattern = DomainPattern::from_name(&domain, wildcard)?; let config_store = ConfigStore::new(config_path.to_path_buf()); let cert_service = CertificateService::new(paths); - - // Check if domain exists - let registration = config_store.get_domain(&domain)?; - if registration.is_none() { - bail!("Domain '{}' is not registered.", domain); - } - - let registration = registration.unwrap(); + let use_case = UnregisterDomain::new(&config_store, &cert_service); if !force { + let registration = use_case.preview(&pattern)?; println!("This will unregister the domain:"); - println!(" Domain: {}", registration.domain); + println!(" Domain: {}", registration.display_pattern()); println!(" Routes:"); - for route in ®istration.routes { + for route in registration.routes() { println!(" {} -> {}", route.path, route.target); } - if registration.https_enabled { - println!(" HTTPS certificate will be removed from system trust store"); + if registration.is_https_enabled() { + println!(" HTTPS certificate files will be removed"); } println!("\nRun with --force to confirm."); return Ok(()); } - // Remove certificate if exists - if cert_service.exists(&domain) { - println!("Removing SSL certificate..."); - match cert_service.remove(&domain) { - Ok(()) => println!(" Certificate removed."), - Err(e) => { - eprintln!(" Warning: Failed to remove certificate: {}", e); - eprintln!(" You may need to manually remove it from Keychain Access."); - } - } - } + let result = use_case.execute(&pattern)?; - // Remove from config - config_store.remove_domain(&domain)?; + match &result.cert_outcome { + StepOutcome::Success(msg) => println!("{}", msg), + StepOutcome::Warning(msg) => eprintln!("{}", msg), + StepOutcome::Skipped(_) => {} + } - println!("Unregistered domain: {}", domain); + println!( + "Unregistered domain: {}", + result.registration.display_pattern() + ); Ok(()) } diff --git a/src/daemon/lifecycle.rs b/src/daemon/lifecycle.rs new file mode 100644 index 0000000..2265914 --- /dev/null +++ b/src/daemon/lifecycle.rs @@ -0,0 +1,52 @@ +use std::io::IsTerminal; +use std::path::Path; + +use anyhow::Result; +use tracing::info; + +use super::Server; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::paths::RoxyPaths; +use crate::infrastructure::pid::PidFile; +use crate::infrastructure::tracing::{TracingOutput, init_tracing}; + +/// Run the Roxy daemon server. +/// +/// This handles the full daemon lifecycle: tracing initialization, +/// PID file management, signal handling, and server execution. +#[tokio::main] +pub async fn run(verbose: bool, config_path: &Path, paths: &RoxyPaths) -> Result<()> { + // When running interactively (stdout is a TTY), log to stdout + // When running as daemon (stdout is /dev/null), log to file + let output = if std::io::stdout().is_terminal() { + TracingOutput::Stdout + } else { + TracingOutput::File(paths.log_file.clone()) + }; + init_tracing(verbose, output); + + info!("Roxy daemon started"); + + let pid_file = PidFile::new(paths.pid_file.clone()); + pid_file.write()?; + + // Handle Ctrl+C gracefully + let cleanup_pid = PidFile::new(paths.pid_file.clone()); + ctrlc::set_handler(move || { + let _ = cleanup_pid.remove(); + std::process::exit(0); + })?; + + println!("Starting Roxy daemon..."); + + // Load config fresh from disk (this path is used by the forked + // subprocess, so it must re-read from the config file) + let config_store = ConfigStore::new(config_path.to_path_buf()); + let config = config_store.load()?; + + let server = Server::new(&config, paths)?; + let result = server.run().await; + + pid_file.remove()?; + result +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 0568f6c..882dbfa 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -1,5 +1,6 @@ pub mod dns_server; pub mod embedded_assets; +pub mod lifecycle; pub mod proxy; pub mod router; pub mod server; diff --git a/src/daemon/router.rs b/src/daemon/router.rs index 1d19556..8b2772b 100644 --- a/src/daemon/router.rs +++ b/src/daemon/router.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use axum::{ @@ -19,23 +18,32 @@ use super::theme; /// Shared state for the router pub struct AppState { - pub domains: HashMap, + /// All registrations sorted by pattern specificity (most specific first). + registrations: Vec, } impl AppState { - pub fn new(registrations: Vec) -> Self { - let domains: HashMap = registrations - .into_iter() - .map(|r| (r.domain.as_str().to_string(), r)) - .collect(); + pub fn new(mut registrations: Vec) -> Self { + // Most-specific first: longer base domain wins. + // At equal specificity, exact patterns come before wildcards. + registrations.sort_by(|a, b| { + b.pattern() + .specificity() + .cmp(&a.pattern().specificity()) + .then_with(|| a.is_wildcard().cmp(&b.is_wildcard())) + }); - Self { domains } + Self { registrations } } pub fn get_domain(&self, host: &str) -> Option<&DomainRegistration> { // Strip port from host if present let domain = host.split(':').next().unwrap_or(host); - self.domains.get(domain) + let domain = domain.trim_end_matches('.').to_lowercase(); + + self.registrations + .iter() + .find(|r| r.pattern().matches_hostname(&domain)) } } @@ -84,7 +92,7 @@ async fn handle_request(State(state): State>, request: Request) -> Some(r) => r, None => { info!(host = %host, path = %path, "No route found"); - return build_no_route_response(&host, path); + return build_no_route_response(registration, &host, path); } }; @@ -116,7 +124,9 @@ async fn handle_request(State(state): State>, request: Request) -> } fn build_not_registered_response(domain: &str) -> Response { - let domain = theme::html_escape(domain); + let domain = domain.split(':').next().unwrap_or(domain); + let domain_raw = domain.trim_end_matches('.').to_lowercase(); + let domain = theme::html_escape(&domain_raw); let image_data_uri = embedded_assets::roxy_error_data_uri(); let mut body = String::new(); @@ -138,6 +148,14 @@ fn build_not_registered_response(domain: &str) -> Response { body.push_str("roxy register "); body.push_str(&domain); body.push_str(" --route \"/=3000\"
"); + // Suggest wildcard registration when the user hits a subdomain. + if let Some(wildcard_base) = wildcard_base_domain(&domain_raw) { + let wildcard_base = theme::html_escape(&wildcard_base); + body.push_str("# using subdomains? register wildcard:
"); + body.push_str("roxy register "); + body.push_str(&wildcard_base); + body.push_str(" --wildcard --route \"/=3000\"
"); + } body.push_str("# or with multiple routes:
"); body.push_str("roxy register "); body.push_str(&domain); @@ -156,9 +174,12 @@ fn build_not_registered_response(domain: &str) -> Response { .unwrap() } -fn build_no_route_response(domain: &str, path: &str) -> Response { - let domain = theme::html_escape(domain); +fn build_no_route_response(registration: &DomainRegistration, host: &str, path: &str) -> Response { + let domain = host.split(':').next().unwrap_or(host); + let domain = domain.trim_end_matches('.').to_lowercase(); + let domain = theme::html_escape(&domain); let path = theme::html_escape(path); + let reg_domain = theme::html_escape(registration.domain().as_str()); let image_data_uri = embedded_assets::roxy_404_data_uri(); let mut body = String::new(); @@ -179,7 +200,10 @@ fn build_no_route_response(domain: &str, path: &str) -> Response { body.push_str("
\n"); body.push_str("

To add a route for this path, run:

\n"); body.push_str("
roxy route add "); - body.push_str(&domain); + if registration.is_wildcard() { + body.push_str("--wildcard "); + } + body.push_str(®_domain); body.push(' '); body.push_str(&path); body.push_str(" 3000
\n"); @@ -196,6 +220,25 @@ fn build_no_route_response(domain: &str, path: &str) -> Response { .unwrap() } +fn wildcard_base_domain(domain: &str) -> Option { + let domain = domain.trim_end_matches('.'); + if !domain.ends_with(".roxy") { + return None; + } + + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() < 3 { + // myapp.roxy has len=2; no subdomain. + return None; + } + + Some(format!( + "{}.{}", + parts[parts.len() - 2], + parts[parts.len() - 1] + )) +} + const ERROR_CSS: &str = "\ .error-container{\ display:flex;flex-direction:column;align-items:center;\ @@ -240,3 +283,69 @@ const ERROR_CSS: &str = "\ @keyframes fadeInUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}\ @media(max-width:600px){.error-image img{max-width:240px}.error-card{padding:28px}}\ "; + +#[cfg(test)] +mod tests { + use super::AppState; + use crate::domain::{DomainName, DomainPattern, DomainRegistration, Route}; + + fn reg(domain: &str, wildcard: bool) -> DomainRegistration { + let domain = DomainName::new(domain).unwrap(); + let pattern = if wildcard { + DomainPattern::Wildcard(domain) + } else { + DomainPattern::Exact(domain) + }; + let routes = vec![Route::parse("/=3000").unwrap()]; + DomainRegistration::new(pattern, routes) + } + + #[test] + fn test_exact_overrides_wildcard_for_base_domain() { + let wildcard = reg("myapp.roxy", true); + let exact = reg("myapp.roxy", false); + let state = AppState::new(vec![wildcard, exact]); + + let found = state.get_domain("myapp.roxy").unwrap(); + // Exact patterns match before wildcards because both match, + // but the exact match is returned since it's found first. + assert!(!found.is_wildcard()); + } + + #[test] + fn test_wildcard_matches_subdomain() { + let wildcard = reg("myapp.roxy", true); + let state = AppState::new(vec![wildcard]); + + let found = state.get_domain("blog.myapp.roxy").unwrap(); + assert!(found.is_wildcard()); + assert_eq!(found.domain().as_str(), "myapp.roxy"); + } + + #[test] + fn test_wildcard_does_not_match_multi_level_subdomain() { + let wildcard = reg("myapp.roxy", true); + let state = AppState::new(vec![wildcard]); + + // This previously matched (bug DA3) — now fixed by DomainPattern + assert!(state.get_domain("a.b.myapp.roxy").is_none()); + } + + #[test] + fn test_most_specific_wildcard_wins() { + let broad = reg("myapp.roxy", true); + let specific = reg("sub.myapp.roxy", true); + let state = AppState::new(vec![broad, specific]); + + let found = state.get_domain("blog.sub.myapp.roxy").unwrap(); + assert_eq!(found.domain().as_str(), "sub.myapp.roxy"); + } + + #[test] + fn test_host_is_normalized_for_lookup() { + let exact = reg("app.roxy", false); + let state = AppState::new(vec![exact]); + + assert!(state.get_domain("APP.ROXY:443").is_some()); + } +} diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 72f7d92..0e04422 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -9,7 +9,6 @@ use tracing::{error, info, warn}; use super::dns_server::DnsServer; use super::router::{AppState, create_router}; use super::tls::create_tls_acceptor; -use crate::domain::DomainName; use crate::infrastructure::config::Config; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::RoxyPaths; @@ -28,18 +27,18 @@ impl Server { // Validate config before starting config.validate()?; - let registrations: Vec<_> = config.domains.values().cloned().collect(); - let state = Arc::new(AppState::new(registrations)); + let registrations = config.registrations(); - // Get domains with HTTPS enabled - let https_domains: Vec = config - .domains - .values() - .filter(|d| d.https_enabled) - .map(|d| d.domain.clone()) + // Collect patterns for domains with HTTPS enabled + let https_patterns: Vec<_> = registrations + .iter() + .filter(|d| d.is_https_enabled()) + .map(|d| d.pattern().clone()) .collect(); - let tls_acceptor = create_tls_acceptor(&https_domains, &paths.certs_dir)?; + let state = Arc::new(AppState::new(registrations)); + + let tls_acceptor = create_tls_acceptor(&https_patterns, &paths.certs_dir, &paths.data_dir)?; // Get LAN IP for DNS responses (DNS server handles source-based resolution) let lan_ip = get_lan_ip(); diff --git a/src/daemon/static_files.rs b/src/daemon/static_files.rs deleted file mode 100644 index b5b9e45..0000000 --- a/src/daemon/static_files.rs +++ /dev/null @@ -1,942 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use axum::{ - extract::Request, - http::{StatusCode, Uri, header}, - response::{IntoResponse, Response}, -}; -use tower::ServiceExt; -use tower_http::services::ServeDir; - -use super::embedded_assets; -use super::theme; -use tokio::task; - -/// Serve static files from a directory. -/// -/// If the request path maps to a directory without an `index.html`, -/// renders an HTML directory listing with sortable columns. -pub async fn serve_static(route_prefix: &str, root: PathBuf, request: Request) -> Response { - let original_path = request.uri().path().to_string(); - let method = request.method().clone(); - let query = request.uri().query().map(|q| q.to_string()); - - let stripped_path = strip_route_prefix(&original_path, route_prefix); - - // Preserve the typical "directory path should end with '/'" behavior for mount roots. - if (method == axum::http::Method::GET || method == axum::http::Method::HEAD) - && route_prefix != "/" - && original_path == route_prefix - { - let location = if let Some(query) = query { - format!("{route_prefix}/?{query}") - } else { - format!("{route_prefix}/") - }; - return redirect_to(&location); - } - - let mut request_for_service = request; - rewrite_request_uri_path(&mut request_for_service, &stripped_path); - - let service = ServeDir::new(&root).append_index_html_on_directories(true); - - // Non-GET/HEAD methods should keep ServeDir's behavior (typically 405). - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return match service.oneshot(request_for_service).await { - Ok(mut response) => { - rewrite_redirect_location_to_include_mount_prefix(route_prefix, &mut response); - response.into_response() - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), - }; - } - - // Security check: ensure the request resolves within the configured root. - let root_for_resolve = root.clone(); - let stripped_for_resolve = stripped_path.clone(); - let resolved: Option = - task::spawn_blocking(move || resolve_path(&root_for_resolve, &stripped_for_resolve)) - .await - .unwrap_or_default(); - - let Some(resolved) = resolved else { - return build_not_found_response(&original_path); - }; - - match service.oneshot(request_for_service).await { - Ok(mut response) => { - if response.status() == StatusCode::NOT_FOUND { - if let Some(listing) = - try_directory_listing(route_prefix, &original_path, resolved.clone()).await - { - return listing; - } - return build_not_found_response(&original_path); - } - - rewrite_redirect_location_to_include_mount_prefix(route_prefix, &mut response); - response.into_response() - } - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), - } -} - -fn strip_route_prefix(uri_path: &str, route_prefix: &str) -> String { - if route_prefix == "/" { - return uri_path.to_string(); - } - - if uri_path == route_prefix { - return "/".to_string(); - } - - if let Some(rest) = uri_path.strip_prefix(route_prefix) { - if rest.is_empty() { - "/".to_string() - } else { - rest.to_string() - } - } else { - // Shouldn't happen since routing already matched, but keep behavior safe. - uri_path.to_string() - } -} - -fn rewrite_request_uri_path(request: &mut Request, new_path: &str) { - let uri_str = if let Some(query) = request.uri().query() { - format!("{new_path}?{query}") - } else { - new_path.to_string() - }; - - if let Ok(uri) = uri_str.parse::() { - *request.uri_mut() = uri; - } -} - -fn redirect_to(location: &str) -> Response { - Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header(header::LOCATION, location) - .body(axum::body::Body::empty()) - .unwrap() -} - -/// `ServeDir` generates redirects based on the request URI it sees. -/// -/// Since we strip the mount prefix before calling `ServeDir`, redirects such as -/// "add a trailing slash to directory paths" would otherwise point outside the -/// mounted route. This rewrites absolute-path `Location` headers to include the -/// mount prefix. -fn rewrite_redirect_location_to_include_mount_prefix( - mount_prefix: &str, - response: &mut axum::http::Response, -) { - if mount_prefix == "/" || !response.status().is_redirection() { - return; - } - - let Some(location) = response.headers().get(header::LOCATION) else { - return; - }; - let Ok(location_str) = location.to_str() else { - return; - }; - - if !location_str.starts_with('/') { - return; - } - - let new_location = format!("{mount_prefix}{location_str}"); - if let Ok(new_location) = axum::http::HeaderValue::from_str(&new_location) { - response - .headers_mut() - .insert(header::LOCATION, new_location); - } -} - -/// Try to render a directory listing for `resolved` and show it as `display_path`. -async fn try_directory_listing( - route_prefix: &str, - display_path: &str, - resolved: PathBuf, -) -> Option { - let entries = task::spawn_blocking(move || read_directory(&resolved)) - .await - .ok()??; - - let html = render_directory_listing(route_prefix, display_path, &entries); - - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "text/html; charset=utf-8") - .body(axum::body::Body::from(html)) - .ok() -} - -/// Build a themed 404 page for files that don't exist. -fn build_not_found_response(uri_path: &str) -> Response { - let path = theme::html_escape(uri_path); - let image_data_uri = embedded_assets::roxy_404_data_uri(); - - let mut body = String::new(); - body.push_str("
\n"); - body.push_str("
\n"); - body.push_str("\"404\n"); - body.push_str("
\n"); - body.push_str("
\n"); - body.push_str("

File Not Found

\n"); - body.push_str("

The path "); - body.push_str(&path); - body.push_str(" does not exist.

\n"); - body.push_str("

Check the path and try again, or navigate back to the directory listing.

\n"); - body.push_str("
"); - - let html = theme::render_page("File Not Found", &body, NOT_FOUND_CSS, ""); - - Response::builder() - .status(StatusCode::NOT_FOUND) - .header("Content-Type", "text/html; charset=utf-8") - .body(axum::body::Body::from(html)) - .unwrap() -} - -const NOT_FOUND_CSS: &str = "\ -.error-container{\ - display:flex;flex-direction:column;align-items:center;\ - gap:24px;max-width:700px;margin:40px auto;\ -}\ -.error-image{\ - animation:fadeInDown .6s ease-out;\ -}\ -.error-image img{\ - width:100%;max-width:300px;height:auto;\ - border-radius:12px;\ - box-shadow:0 8px 24px rgba(156,180,212,.25);\ -}\ -.error-card{\ - background:var(--card-bg);border-radius:12px;\ - border:1px solid var(--border);padding:36px;\ - box-shadow:0 4px 16px rgba(0,0,0,.04);\ - text-align:center;width:100%;\ - animation:fadeInUp .6s ease-out;\ -}\ -.error-title{\ - color:var(--fox-orange);font-size:1.6em;margin-bottom:16px;\ - font-weight:700;\ -}\ -.error-message{margin-bottom:12px;font-size:1.05em}\ -.error-hint{color:var(--text-light);font-size:.92em;margin-top:8px}\ -@keyframes fadeInDown{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}\ -@keyframes fadeInUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}\ -@media(max-width:600px){.error-image img{max-width:240px}.error-card{padding:28px}}\ -"; - -/// Resolve a URI path to a filesystem path within the root directory. -/// -/// Returns `None` if the path doesn't exist or escapes the root -/// (path traversal protection). -fn resolve_path(root: &Path, uri_path: &str) -> Option { - let decoded = percent_decode(uri_path); - let relative = decoded.trim_start_matches('/'); - let dir_path = if relative.is_empty() { - root.to_path_buf() - } else { - root.join(relative) - }; - - let canonical = dir_path.canonicalize().ok()?; - let canonical_root = root.canonicalize().ok()?; - - if !canonical.starts_with(&canonical_root) { - return None; - } - - Some(canonical) -} - -/// Decode percent-encoded URI path segments. -fn percent_decode(s: &str) -> String { - let mut result = Vec::with_capacity(s.len()); - let bytes = s.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' - && i + 2 < bytes.len() - && let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) - { - result.push(byte); - i += 3; - continue; - } - result.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&result).to_string() -} - -struct DirEntry { - name: String, - is_dir: bool, - size: u64, - modified: u64, -} - -/// Read a directory and collect entries with metadata. -fn read_directory(path: &Path) -> Option> { - let read_dir = fs::read_dir(path).ok()?; - - let mut entries: Vec = read_dir - .filter_map(|entry| { - let entry = entry.ok()?; - let metadata = entry.metadata().ok()?; - let name = entry.file_name().to_string_lossy().to_string(); - let is_dir = metadata.is_dir(); - let size = if is_dir { 0 } else { metadata.len() }; - let modified = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - - Some(DirEntry { - name, - is_dir, - size, - modified, - }) - }) - .collect(); - - // Default: directories first, then case-insensitive alphabetical - entries.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - - Some(entries) -} - -/// Format bytes as a human-readable size string. -fn format_size(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = 1024 * KB; - const GB: u64 = 1024 * MB; - - if bytes >= GB { - format!("{:.1} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1} KB", bytes as f64 / KB as f64) - } else { - format!("{} B", bytes) - } -} - -// ── Breadcrumb ────────────────────────────────────────────── - -/// Build a breadcrumb navigation bar from the URI path. -/// -/// The `route_prefix` is used as the root href to keep navigation within -/// the mounted route (e.g., `/static/` for non-root mounts). -fn build_breadcrumb(route_prefix: &str, uri_path: &str) -> String { - let mut html = String::new(); - - // Root link with home icon - use route_prefix to stay within mount - let root_href = if route_prefix == "/" { - "/" - } else { - route_prefix - }; - html.push_str(""); - html.push_str(theme::HOME_ICON); - html.push_str(""); - - let path = uri_path.trim_matches('/'); - if !path.is_empty() { - let mut href = String::new(); - for segment in path.split('/') { - if segment.is_empty() { - continue; - } - let decoded_segment = percent_decode(segment); - html.push_str("/"); - href.push('/'); - href.push_str(&theme::encode_path_segment(&decoded_segment)); - html.push_str(""); - html.push_str(&theme::html_escape(&decoded_segment)); - html.push_str(""); - } - } - - html -} - -// ── Directory Listing Renderer ────────────────────────────── - -/// Render the full themed HTML page for a directory listing. -/// -/// The `route_prefix` is used to determine the mount root and keep navigation -/// within the mounted route. -fn render_directory_listing(route_prefix: &str, uri_path: &str, entries: &[DirEntry]) -> String { - let display_path = theme::html_escape(uri_path); - let breadcrumb = build_breadcrumb(route_prefix, uri_path); - - let mut body = String::with_capacity(4096); - - // Page heading - body.push_str("

Index of "); - body.push_str(&display_path); - body.push_str("

\n"); - - // Breadcrumb navigation - body.push_str("\n"); - - // File listing card - body.push_str("
\n"); - body.push_str("\n"); - body.push_str( - "", - ); - body.push_str( - "", - ); - body.push_str( - "", - ); - body.push_str("\n\n"); - - // Determine the mount root for comparison - let mount_root = if route_prefix == "/" { - "/" - } else { - // Mount root can be either "/prefix" or "/prefix/" - route_prefix.trim_end_matches('/') - }; - let normalized_uri = uri_path.trim_end_matches('/'); - let is_at_mount_root = normalized_uri == mount_root || normalized_uri.is_empty(); - - // Parent directory link (unless at mount root) - if !is_at_mount_root { - let parent = uri_path.trim_end_matches('/'); - let parent = parent - .rsplit_once('/') - .map(|(p, _)| if p.is_empty() { "/" } else { p }) - .unwrap_or("/"); - - // Only show parent link if it's not outside our mount - let parent_normalized = parent.trim_end_matches('/'); - let should_show_parent = if route_prefix == "/" { - true - } else { - parent_normalized.starts_with(mount_root) || parent_normalized == mount_root - }; - - if should_show_parent { - body.push_str("\n"); - } - } - - // Entry rows - let normalized = if uri_path.ends_with('/') { - uri_path.to_string() - } else { - format!("{uri_path}/") - }; - - if entries.is_empty() { - body.push_str( - "\n", - ); - } - - for entry in entries { - let name = theme::html_escape(&entry.name); - let trailing = if entry.is_dir { "/" } else { "" }; - let icon = if entry.is_dir { - theme::FOLDER_ICON - } else { - theme::FILE_ICON - }; - let size_str = if entry.is_dir { - "\u{2014}".to_string() - } else { - format_size(entry.size) - }; - - body.push_str("\n"); - } - - body.push_str("\n
Name \ - \u{25B2}Size \ - Modified \ -
"); - body.push_str(theme::FOLDER_ICON); - body.push_str("..
\ - This directory is empty
"); - body.push_str(icon); - body.push_str(""); - body.push_str(&name); - body.push_str(trailing); - body.push_str(""); - body.push_str(&size_str); - body.push_str("
\n
"); - - theme::render_page( - &format!("Index of {uri_path}"), - &body, - FILEBROWSER_CSS, - FILEBROWSER_JS, - ) -} - -// ── File Browser Styles ───────────────────────────────────── - -const FILEBROWSER_CSS: &str = "\ -.page-title{\ - font-size:1.1em;color:var(--text-light);margin-bottom:16px;\ - font-weight:400;display:flex;align-items:center;gap:8px;\ -}\ -.page-title code{\ - font-size:1em;font-weight:600;color:var(--text);\ - background:transparent;padding:0;\ -}\ -.breadcrumb{\ - display:flex;flex-wrap:wrap;align-items:center;gap:4px;\ - padding:12px 18px;margin-bottom:24px;\ - background:var(--card-bg);border-radius:10px;\ - border:1px solid var(--border);font-size:.9em;\ - box-shadow:0 2px 8px rgba(0,0,0,.03);\ -}\ -.breadcrumb a{\ - color:var(--fox-orange);padding:4px 8px;border-radius:6px;\ - transition:all .2s ease;\ -}\ -.breadcrumb a:hover{\ - background:rgba(232,133,58,.12);text-decoration:none;\ - transform:translateY(-1px);\ -}\ -.bc-home{vertical-align:middle;color:var(--fox-orange)}\ -.breadcrumb .sep{color:var(--border-hover);margin:0 4px;font-size:.85em}\ -.file-card{\ - background:var(--card-bg);border-radius:12px;\ - border:1px solid var(--border);overflow:hidden;\ - box-shadow:0 4px 16px rgba(0,0,0,.04);\ - animation:fadeIn .5s ease-out;\ -}\ -.file-card table{width:100%;border-collapse:collapse}\ -.file-card th{\ - text-align:left;padding:14px 18px;\ - background:linear-gradient(180deg,#FEFAF6 0%,#FDF6EE 100%);\ - color:var(--text-light);\ - font-size:.75em;font-weight:600;\ - text-transform:uppercase;letter-spacing:.08em;\ - cursor:pointer;user-select:none;\ - border-bottom:2px solid var(--border);\ - transition:all .2s ease;\ -}\ -.file-card th:hover{color:var(--fox-orange);background:#FFF5ED}\ -.file-card td{\ - padding:12px 18px;border-bottom:1px solid #FAF4EE;\ - vertical-align:middle;transition:background .2s ease;\ -}\ -.file-card tr:last-child td{border-bottom:none}\ -.file-card tbody tr:hover td{background:#FFF8F2;cursor:pointer}\ -.file-card a{color:var(--text);font-weight:500;transition:all .2s ease}\ -.file-card a:hover{color:var(--fox-orange);text-decoration:none}\ -.parent-row td{padding:10px 18px;background:rgba(232,133,58,.03)}\ -.parent-row:hover td{background:rgba(232,133,58,.08)!important}\ -.ei{vertical-align:middle;margin-right:10px;transition:transform .2s}\ -.file-card tr:hover .ei{transform:scale(1.1)}\ -.size,.modified{\ - color:var(--text-light);\ - font-family:'SF Mono',Monaco,'Cascadia Code',Menlo,Consolas,monospace;\ - font-size:.82em;white-space:nowrap;\ -}\ -.si{font-size:.75em;margin-left:6px;opacity:.3;transition:all .2s}\ -.si.active{opacity:1;color:var(--fox-orange);font-weight:700}\ -.empty-dir{padding:48px 18px;text-align:center;color:var(--text-light);font-style:italic}\ -.col-size{width:110px}\ -.col-mod{width:200px}\ -@keyframes fadeIn{from{opacity:0}to{opacity:1}}\ -@media(max-width:768px){.col-mod{display:none}.col-size{width:80px}}\ -"; - -// ── File Browser JavaScript ───────────────────────────────── - -const FILEBROWSER_JS: &str = "\ -document.querySelectorAll('.modified').forEach(function(el){\ - var ts=parseInt(el.dataset.ts);\ - if(ts>0){\ - var d=new Date(ts*1000);\ - el.textContent=d.toLocaleDateString(undefined,\ - {year:'numeric',month:'short',day:'numeric'})\ - +' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});\ - }\ -});\ -var col=0,asc=true;\ -function sort(c){\ - if(col===c)asc=!asc;else{col=c;asc=true;}\ - for(var i=0;i<3;i++){\ - var el=document.getElementById('s'+i);\ - el.className='si'+(i===col?' active':'');\ - el.textContent=i===col?(asc?'\\u25B2':'\\u25BC'):'';\ - }\ - var tbody=document.querySelector('#listing tbody');\ - var nonEntryRows=Array.from(tbody.querySelectorAll('tr:not([data-name])'));\ - var rows=Array.from(tbody.querySelectorAll('tr[data-name]'));\ - rows.sort(function(a,b){\ - var ad=parseInt(a.dataset.dir),bd=parseInt(b.dataset.dir);\ - if(ad!==bd)return bd-ad;\ - var av,bv;\ - if(c===0){\ - av=a.dataset.name.toLowerCase();\ - bv=b.dataset.name.toLowerCase();\ - return asc?av.localeCompare(bv):bv.localeCompare(av);\ - }else if(c===1){\ - av=parseInt(a.dataset.size);bv=parseInt(b.dataset.size);\ - }else{\ - av=parseInt(a.dataset.ts);bv=parseInt(b.dataset.ts);\ - }\ - return asc?av-bv:bv-av;\ - });\ - while(tbody.firstChild)tbody.removeChild(tbody.firstChild);\ - nonEntryRows.forEach(function(r){tbody.appendChild(r);});\ - rows.forEach(function(r){tbody.appendChild(r);});\ -}\ -"; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_size_bytes() { - assert_eq!(format_size(0), "0 B"); - assert_eq!(format_size(512), "512 B"); - assert_eq!(format_size(1023), "1023 B"); - } - - #[test] - fn test_format_size_kilobytes() { - assert_eq!(format_size(1024), "1.0 KB"); - assert_eq!(format_size(1536), "1.5 KB"); - assert_eq!(format_size(10240), "10.0 KB"); - } - - #[test] - fn test_format_size_megabytes() { - assert_eq!(format_size(1048576), "1.0 MB"); - assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB"); - } - - #[test] - fn test_format_size_gigabytes() { - assert_eq!(format_size(1073741824), "1.0 GB"); - assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0 GB"); - } - - #[test] - fn test_percent_decode() { - assert_eq!(percent_decode("hello"), "hello"); - assert_eq!(percent_decode("hello%20world"), "hello world"); - assert_eq!(percent_decode("/path%2Fto%2Ffile"), "/path/to/file"); - assert_eq!(percent_decode("%E2%9C%93"), "\u{2713}"); - } - - #[test] - fn test_percent_decode_invalid() { - assert_eq!(percent_decode("%GG"), "%GG"); - assert_eq!(percent_decode("%2"), "%2"); - assert_eq!(percent_decode("trailing%"), "trailing%"); - } - - #[test] - fn test_breadcrumb_root() { - let bc = build_breadcrumb("/", "/"); - // Should have home icon link and no separators - assert!(bc.contains("href=\"/\"")); - assert!(!bc.contains("sep")); - } - - #[test] - fn test_breadcrumb_nested() { - let bc = build_breadcrumb("/", "/images/photos/"); - assert!(bc.contains("href=\"/\"")); - assert!(bc.contains("/images/\">images")); - assert!(bc.contains("/images/photos/\">photos")); - assert_eq!(bc.matches("class=\"sep\"").count(), 2); - } - - #[test] - fn test_breadcrumb_percent_encoded_segments() { - let bc = build_breadcrumb("/", "/my%20dir/child%2Fslash/"); - // Display should be decoded - assert!(bc.contains(">my dir")); - assert!(bc.contains(">child/slash")); - // Hrefs should be encoded once (no %25 double-encoding) - assert!(bc.contains("href=\"/my%20dir/\">")); - assert!(bc.contains("href=\"/my%20dir/child%2Fslash/\">")); - } - - #[test] - fn test_breadcrumb_non_root_mount() { - let bc = build_breadcrumb("/static", "/static/images/"); - // Home icon should link to /static/ not / - assert!(bc.contains("href=\"/static/\"")); - assert!(bc.contains("/images/\">images")); - } - - #[test] - fn test_rewrite_redirect_location_to_include_mount_prefix() { - let mut response = Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header(header::LOCATION, "/docs/") - .body(axum::body::Body::empty()) - .unwrap(); - - rewrite_redirect_location_to_include_mount_prefix("/static", &mut response); - - let location = response - .headers() - .get(header::LOCATION) - .unwrap() - .to_str() - .unwrap(); - assert_eq!(location, "/static/docs/"); - } - - #[test] - fn test_rewrite_redirect_location_to_include_mount_prefix_noop_for_root() { - let mut response = Response::builder() - .status(StatusCode::TEMPORARY_REDIRECT) - .header(header::LOCATION, "/docs/") - .body(axum::body::Body::empty()) - .unwrap(); - - rewrite_redirect_location_to_include_mount_prefix("/", &mut response); - - let location = response - .headers() - .get(header::LOCATION) - .unwrap() - .to_str() - .unwrap(); - assert_eq!(location, "/docs/"); - } - - #[test] - fn test_resolve_path_basic() { - let tmp = tempfile::tempdir().unwrap(); - let sub = tmp.path().join("subdir"); - fs::create_dir(&sub).unwrap(); - - let resolved = resolve_path(tmp.path(), "/subdir"); - assert_eq!(resolved.unwrap(), sub.canonicalize().unwrap()); - } - - #[test] - fn test_resolve_path_root() { - let tmp = tempfile::tempdir().unwrap(); - - let resolved = resolve_path(tmp.path(), "/"); - assert_eq!(resolved.unwrap(), tmp.path().canonicalize().unwrap()); - } - - #[test] - fn test_resolve_path_traversal_blocked() { - let tmp = tempfile::tempdir().unwrap(); - let sub = tmp.path().join("subdir"); - fs::create_dir(&sub).unwrap(); - - let resolved = resolve_path(&sub, "/../../../etc/passwd"); - assert!(resolved.is_none()); - } - - #[test] - fn test_resolve_path_nonexistent() { - let tmp = tempfile::tempdir().unwrap(); - - let resolved = resolve_path(tmp.path(), "/does-not-exist"); - assert!(resolved.is_none()); - } - - #[test] - fn test_resolve_path_percent_encoded() { - let tmp = tempfile::tempdir().unwrap(); - let sub = tmp.path().join("my dir"); - fs::create_dir(&sub).unwrap(); - - let resolved = resolve_path(tmp.path(), "/my%20dir"); - assert_eq!(resolved.unwrap(), sub.canonicalize().unwrap()); - } - - #[test] - fn test_read_directory_sorts_dirs_first() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join("b_file.txt"), "hello").unwrap(); - fs::create_dir(tmp.path().join("a_dir")).unwrap(); - fs::write(tmp.path().join("a_file.txt"), "world").unwrap(); - - let entries = read_directory(tmp.path()).unwrap(); - assert_eq!(entries.len(), 3); - assert!(entries[0].is_dir); - assert_eq!(entries[0].name, "a_dir"); - assert_eq!(entries[1].name, "a_file.txt"); - assert_eq!(entries[2].name, "b_file.txt"); - } - - #[test] - fn test_read_directory_file_sizes() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join("small.txt"), "hi").unwrap(); - fs::create_dir(tmp.path().join("dir")).unwrap(); - - let entries = read_directory(tmp.path()).unwrap(); - let dir = entries.iter().find(|e| e.name == "dir").unwrap(); - let file = entries.iter().find(|e| e.name == "small.txt").unwrap(); - assert_eq!(dir.size, 0); - assert_eq!(file.size, 2); - } - - #[test] - fn test_render_directory_listing_contains_entries() { - let entries = vec![ - DirEntry { - name: "docs".to_string(), - is_dir: true, - size: 0, - modified: 1700000000, - }, - DirEntry { - name: "readme.md".to_string(), - is_dir: false, - size: 4096, - modified: 1700000000, - }, - ]; - - let html = render_directory_listing("/", "/project/", &entries); - - // Themed page structure - assert!(html.contains("roxy-header")); - assert!(html.contains("roxy-footer")); - // Content - assert!(html.contains("Index of")); - assert!(html.contains("/project/")); - assert!(html.contains("docs/")); - assert!(html.contains("readme.md")); - assert!(html.contains("4.0 KB")); - } - - #[test] - fn test_render_directory_listing_parent_link() { - let entries = vec![]; - let html = render_directory_listing("/", "/images/photos/", &entries); - assert!(html.contains(">..")); - assert!(html.contains("/images/\"")); - } - - #[test] - fn test_render_directory_listing_no_parent_at_root() { - let entries = vec![]; - let html = render_directory_listing("/", "/", &entries); - assert!(!html.contains("..")); - } - - #[test] - fn test_render_directory_listing_no_parent_at_mount_root() { - let entries = vec![]; - let html = render_directory_listing("/static", "/static/", &entries); - assert!(!html.contains("..")); - } - - #[test] - fn test_render_directory_listing_empty_state() { - let entries = vec![]; - let html = render_directory_listing("/", "/", &entries); - assert!(html.contains("empty")); - } - - #[test] - fn test_render_directory_listing_uses_svg_icons() { - let entries = vec![ - DirEntry { - name: "folder".to_string(), - is_dir: true, - size: 0, - modified: 0, - }, - DirEntry { - name: "file.txt".to_string(), - is_dir: false, - size: 100, - modified: 0, - }, - ]; - - let html = render_directory_listing("/", "/", &entries); - // Should use SVG icons, not emoji - assert!(html.contains(r##"fill="#E8853A""##)); // folder orange - assert!(html.contains(r##"fill="#3BB8A2""##)); // file teal - } - - #[tokio::test] - async fn test_try_directory_listing_for_directory() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join("file.txt"), "content").unwrap(); - fs::create_dir(tmp.path().join("sub")).unwrap(); - - let resolved = resolve_path(tmp.path(), "/").unwrap(); - let response = try_directory_listing("/", "/", resolved).await; - assert!(response.is_some()); - assert_eq!(response.unwrap().status(), StatusCode::OK); - } - - #[tokio::test] - async fn test_try_directory_listing_none_for_file() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join("file.txt"), "content").unwrap(); - - let resolved = resolve_path(tmp.path(), "/file.txt").unwrap(); - let response = try_directory_listing("/", "/file.txt", resolved).await; - assert!(response.is_none()); - } -} diff --git a/src/daemon/static_files/breadcrumb.rs b/src/daemon/static_files/breadcrumb.rs new file mode 100644 index 0000000..e69a870 --- /dev/null +++ b/src/daemon/static_files/breadcrumb.rs @@ -0,0 +1,87 @@ +use super::path_utils::percent_decode; +use crate::daemon::theme; + +/// Build a breadcrumb navigation bar from the URI path. +/// +/// The `route_prefix` is used as the root href to keep navigation within +/// the mounted route (e.g., `/static/` for non-root mounts). +pub(super) fn build_breadcrumb(route_prefix: &str, uri_path: &str) -> String { + let mut html = String::new(); + + // Root link with home icon - use route_prefix to stay within mount + let root_href = if route_prefix == "/" { + "/" + } else { + route_prefix + }; + html.push_str(""); + html.push_str(theme::HOME_ICON); + html.push_str(""); + + let path = uri_path.trim_matches('/'); + if !path.is_empty() { + let mut href = String::new(); + for segment in path.split('/') { + if segment.is_empty() { + continue; + } + let decoded_segment = percent_decode(segment); + html.push_str("/"); + href.push('/'); + href.push_str(&theme::encode_path_segment(&decoded_segment)); + html.push_str(""); + html.push_str(&theme::html_escape(&decoded_segment)); + html.push_str(""); + } + } + + html +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_breadcrumb_root() { + let bc = build_breadcrumb("/", "/"); + // Should have home icon link and no separators + assert!(bc.contains("href=\"/\"")); + assert!(!bc.contains("sep")); + } + + #[test] + fn test_breadcrumb_nested() { + let bc = build_breadcrumb("/", "/images/photos/"); + assert!(bc.contains("href=\"/\"")); + assert!(bc.contains("/images/\">images")); + assert!(bc.contains("/images/photos/\">photos")); + assert_eq!(bc.matches("class=\"sep\"").count(), 2); + } + + #[test] + fn test_breadcrumb_percent_encoded_segments() { + let bc = build_breadcrumb("/", "/my%20dir/child%2Fslash/"); + // Display should be decoded + assert!(bc.contains(">my dir")); + assert!(bc.contains(">child/slash")); + // Hrefs should be encoded once (no %25 double-encoding) + assert!(bc.contains("href=\"/my%20dir/\">")); + assert!(bc.contains("href=\"/my%20dir/child%2Fslash/\">")); + } + + #[test] + fn test_breadcrumb_non_root_mount() { + let bc = build_breadcrumb("/static", "/static/images/"); + // Home icon should link to /static/ not / + assert!(bc.contains("href=\"/static/\"")); + assert!(bc.contains("/images/\">images")); + } +} diff --git a/src/daemon/static_files/directory.rs b/src/daemon/static_files/directory.rs new file mode 100644 index 0000000..9e2f152 --- /dev/null +++ b/src/daemon/static_files/directory.rs @@ -0,0 +1,358 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use axum::http::StatusCode; +use axum::response::Response; +use tokio::task; + +use super::breadcrumb::build_breadcrumb; +use super::path_utils::format_size; +use super::styles::{FILEBROWSER_CSS, FILEBROWSER_JS}; +use crate::daemon::theme; + +pub(super) struct DirEntry { + pub name: String, + pub is_dir: bool, + pub size: u64, + pub modified: u64, +} + +/// Read a directory and collect entries with metadata. +pub(super) fn read_directory(path: &Path) -> Option> { + let read_dir = fs::read_dir(path).ok()?; + + let mut entries: Vec = read_dir + .filter_map(|entry| { + let entry = entry.ok()?; + let metadata = entry.metadata().ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = metadata.is_dir(); + let size = if is_dir { 0 } else { metadata.len() }; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Some(DirEntry { + name, + is_dir, + size, + modified, + }) + }) + .collect(); + + // Default: directories first, then case-insensitive alphabetical + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Some(entries) +} + +/// Try to render a directory listing for `resolved` and show it as `display_path`. +pub(super) async fn try_directory_listing( + route_prefix: &str, + display_path: &str, + resolved: PathBuf, +) -> Option { + let entries = task::spawn_blocking(move || read_directory(&resolved)) + .await + .ok()??; + + let html = render_directory_listing(route_prefix, display_path, &entries); + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/html; charset=utf-8") + .body(axum::body::Body::from(html)) + .ok() +} + +/// Render the full themed HTML page for a directory listing. +/// +/// The `route_prefix` is used to determine the mount root and keep navigation +/// within the mounted route. +fn render_directory_listing(route_prefix: &str, uri_path: &str, entries: &[DirEntry]) -> String { + let display_path = theme::html_escape(uri_path); + let breadcrumb = build_breadcrumb(route_prefix, uri_path); + + let mut body = String::with_capacity(4096); + + // Page heading + body.push_str("

Index of "); + body.push_str(&display_path); + body.push_str("

\n"); + + // Breadcrumb navigation + body.push_str("\n"); + + // File listing card + body.push_str("
\n"); + body.push_str("\n"); + body.push_str( + "", + ); + body.push_str( + "", + ); + body.push_str( + "", + ); + body.push_str("\n\n"); + + render_parent_link(route_prefix, uri_path, &mut body); + render_entries(uri_path, entries, &mut body); + + body.push_str("\n
Name \ + \u{25B2}Size \ + Modified \ +
\n
"); + + theme::render_page( + &format!("Index of {uri_path}"), + &body, + FILEBROWSER_CSS, + FILEBROWSER_JS, + ) +} + +fn render_parent_link(route_prefix: &str, uri_path: &str, body: &mut String) { + // Determine the mount root for comparison + let mount_root = if route_prefix == "/" { + "/" + } else { + // Mount root can be either "/prefix" or "/prefix/" + route_prefix.trim_end_matches('/') + }; + let normalized_uri = uri_path.trim_end_matches('/'); + let is_at_mount_root = normalized_uri == mount_root || normalized_uri.is_empty(); + + // Parent directory link (unless at mount root) + if is_at_mount_root { + return; + } + + let parent = uri_path.trim_end_matches('/'); + let parent = parent + .rsplit_once('/') + .map(|(p, _)| if p.is_empty() { "/" } else { p }) + .unwrap_or("/"); + + // Only show parent link if it's not outside our mount + let parent_normalized = parent.trim_end_matches('/'); + let should_show_parent = if route_prefix == "/" { + true + } else { + parent_normalized.starts_with(mount_root) || parent_normalized == mount_root + }; + + if should_show_parent { + body.push_str(""); + body.push_str(theme::FOLDER_ICON); + body.push_str("..\n"); + } +} + +fn render_entries(uri_path: &str, entries: &[DirEntry], body: &mut String) { + let normalized = if uri_path.ends_with('/') { + uri_path.to_string() + } else { + format!("{uri_path}/") + }; + + if entries.is_empty() { + body.push_str( + "\ + This directory is empty\n", + ); + } + + for entry in entries { + let name = theme::html_escape(&entry.name); + let trailing = if entry.is_dir { "/" } else { "" }; + let icon = if entry.is_dir { + theme::FOLDER_ICON + } else { + theme::FILE_ICON + }; + let size_str = if entry.is_dir { + "\u{2014}".to_string() + } else { + format_size(entry.size) + }; + + body.push_str(""); + body.push_str(icon); + body.push_str(""); + body.push_str(&name); + body.push_str(trailing); + body.push_str(""); + body.push_str(&size_str); + body.push_str("\n"); + } +} + +#[cfg(test)] +mod tests { + use super::super::path_utils::resolve_path; + use super::*; + + #[test] + fn test_read_directory_sorts_dirs_first() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("b_file.txt"), "hello").unwrap(); + fs::create_dir(tmp.path().join("a_dir")).unwrap(); + fs::write(tmp.path().join("a_file.txt"), "world").unwrap(); + + let entries = read_directory(tmp.path()).unwrap(); + assert_eq!(entries.len(), 3); + assert!(entries[0].is_dir); + assert_eq!(entries[0].name, "a_dir"); + assert_eq!(entries[1].name, "a_file.txt"); + assert_eq!(entries[2].name, "b_file.txt"); + } + + #[test] + fn test_read_directory_file_sizes() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("small.txt"), "hi").unwrap(); + fs::create_dir(tmp.path().join("dir")).unwrap(); + + let entries = read_directory(tmp.path()).unwrap(); + let dir = entries.iter().find(|e| e.name == "dir").unwrap(); + let file = entries.iter().find(|e| e.name == "small.txt").unwrap(); + assert_eq!(dir.size, 0); + assert_eq!(file.size, 2); + } + + #[test] + fn test_render_directory_listing_contains_entries() { + let entries = vec![ + DirEntry { + name: "docs".to_string(), + is_dir: true, + size: 0, + modified: 1700000000, + }, + DirEntry { + name: "readme.md".to_string(), + is_dir: false, + size: 4096, + modified: 1700000000, + }, + ]; + + let html = render_directory_listing("/", "/project/", &entries); + + // Themed page structure + assert!(html.contains("roxy-header")); + assert!(html.contains("roxy-footer")); + // Content + assert!(html.contains("Index of")); + assert!(html.contains("/project/")); + assert!(html.contains("docs/")); + assert!(html.contains("readme.md")); + assert!(html.contains("4.0 KB")); + } + + #[test] + fn test_render_directory_listing_parent_link() { + let entries = vec![]; + let html = render_directory_listing("/", "/images/photos/", &entries); + assert!(html.contains(">..")); + assert!(html.contains("/images/\"")); + } + + #[test] + fn test_render_directory_listing_no_parent_at_root() { + let entries = vec![]; + let html = render_directory_listing("/", "/", &entries); + assert!(!html.contains("..")); + } + + #[test] + fn test_render_directory_listing_no_parent_at_mount_root() { + let entries = vec![]; + let html = render_directory_listing("/static", "/static/", &entries); + assert!(!html.contains("..")); + } + + #[test] + fn test_render_directory_listing_empty_state() { + let entries = vec![]; + let html = render_directory_listing("/", "/", &entries); + assert!(html.contains("empty")); + } + + #[test] + fn test_render_directory_listing_uses_svg_icons() { + let entries = vec![ + DirEntry { + name: "folder".to_string(), + is_dir: true, + size: 0, + modified: 0, + }, + DirEntry { + name: "file.txt".to_string(), + is_dir: false, + size: 100, + modified: 0, + }, + ]; + + let html = render_directory_listing("/", "/", &entries); + // Should use SVG icons, not emoji + assert!(html.contains(r##"fill="#E8853A""##)); // folder orange + assert!(html.contains(r##"fill="#3BB8A2""##)); // file teal + } + + #[tokio::test] + async fn test_try_directory_listing_for_directory() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("file.txt"), "content").unwrap(); + fs::create_dir(tmp.path().join("sub")).unwrap(); + + let resolved = resolve_path(tmp.path(), "/").unwrap(); + let response = try_directory_listing("/", "/", resolved).await; + assert!(response.is_some()); + assert_eq!(response.unwrap().status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_try_directory_listing_none_for_file() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("file.txt"), "content").unwrap(); + + let resolved = resolve_path(tmp.path(), "/file.txt").unwrap(); + let response = try_directory_listing("/", "/file.txt", resolved).await; + assert!(response.is_none()); + } +} diff --git a/src/daemon/static_files/mod.rs b/src/daemon/static_files/mod.rs new file mode 100644 index 0000000..e570555 --- /dev/null +++ b/src/daemon/static_files/mod.rs @@ -0,0 +1,238 @@ +mod breadcrumb; +mod directory; +mod path_utils; +mod styles; + +use std::path::PathBuf; + +use axum::{ + extract::Request, + http::{StatusCode, Uri, header}, + response::{IntoResponse, Response}, +}; +use tokio::task; +use tower::ServiceExt; +use tower_http::services::ServeDir; + +use super::embedded_assets; +use super::theme; +use directory::try_directory_listing; +use path_utils::resolve_path; +use styles::NOT_FOUND_CSS; + +/// Serve static files from a directory. +/// +/// If the request path maps to a directory without an `index.html`, +/// renders an HTML directory listing with sortable columns. +pub async fn serve_static(route_prefix: &str, root: PathBuf, request: Request) -> Response { + let original_path = request.uri().path().to_string(); + let method = request.method().clone(); + let query = request.uri().query().map(|q| q.to_string()); + + let stripped_path = strip_route_prefix(&original_path, route_prefix); + + // Preserve the typical "directory path should end with '/'" behavior for mount roots. + if (method == axum::http::Method::GET || method == axum::http::Method::HEAD) + && route_prefix != "/" + && original_path == route_prefix + { + let location = if let Some(query) = query { + format!("{route_prefix}/?{query}") + } else { + format!("{route_prefix}/") + }; + return redirect_to(&location); + } + + let mut request_for_service = request; + rewrite_request_uri_path(&mut request_for_service, &stripped_path); + + let service = ServeDir::new(&root).append_index_html_on_directories(true); + + // Non-GET/HEAD methods should keep ServeDir's behavior (typically 405). + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return match service.oneshot(request_for_service).await { + Ok(mut response) => { + rewrite_redirect_location_to_include_mount_prefix(route_prefix, &mut response); + response.into_response() + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), + }; + } + + // Security check: ensure the request resolves within the configured root. + let root_for_resolve = root.clone(); + let stripped_for_resolve = stripped_path.clone(); + let resolved: Option = + task::spawn_blocking(move || resolve_path(&root_for_resolve, &stripped_for_resolve)) + .await + .unwrap_or_default(); + + let Some(resolved) = resolved else { + return build_not_found_response(&original_path); + }; + + match service.oneshot(request_for_service).await { + Ok(mut response) => { + if response.status() == StatusCode::NOT_FOUND { + if let Some(listing) = + try_directory_listing(route_prefix, &original_path, resolved.clone()).await + { + return listing; + } + return build_not_found_response(&original_path); + } + + rewrite_redirect_location_to_include_mount_prefix(route_prefix, &mut response); + response.into_response() + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), + } +} + +fn strip_route_prefix(uri_path: &str, route_prefix: &str) -> String { + if route_prefix == "/" { + return uri_path.to_string(); + } + + if uri_path == route_prefix { + return "/".to_string(); + } + + if let Some(rest) = uri_path.strip_prefix(route_prefix) { + if rest.is_empty() { + "/".to_string() + } else { + rest.to_string() + } + } else { + // Shouldn't happen since routing already matched, but keep behavior safe. + uri_path.to_string() + } +} + +fn rewrite_request_uri_path(request: &mut Request, new_path: &str) { + let uri_str = if let Some(query) = request.uri().query() { + format!("{new_path}?{query}") + } else { + new_path.to_string() + }; + + if let Ok(uri) = uri_str.parse::() { + *request.uri_mut() = uri; + } +} + +fn redirect_to(location: &str) -> Response { + Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header(header::LOCATION, location) + .body(axum::body::Body::empty()) + .unwrap() +} + +/// `ServeDir` generates redirects based on the request URI it sees. +/// +/// Since we strip the mount prefix before calling `ServeDir`, redirects such as +/// "add a trailing slash to directory paths" would otherwise point outside the +/// mounted route. This rewrites absolute-path `Location` headers to include the +/// mount prefix. +fn rewrite_redirect_location_to_include_mount_prefix( + mount_prefix: &str, + response: &mut axum::http::Response, +) { + if mount_prefix == "/" || !response.status().is_redirection() { + return; + } + + let Some(location) = response.headers().get(header::LOCATION) else { + return; + }; + let Ok(location_str) = location.to_str() else { + return; + }; + + if !location_str.starts_with('/') { + return; + } + + let new_location = format!("{mount_prefix}{location_str}"); + if let Ok(new_location) = axum::http::HeaderValue::from_str(&new_location) { + response + .headers_mut() + .insert(header::LOCATION, new_location); + } +} + +/// Build a themed 404 page for files that don't exist. +fn build_not_found_response(uri_path: &str) -> Response { + let path = theme::html_escape(uri_path); + let image_data_uri = embedded_assets::roxy_404_data_uri(); + + let mut body = String::new(); + body.push_str("
\n"); + body.push_str("
\n"); + body.push_str("\"404\n"); + body.push_str("
\n"); + body.push_str("
\n"); + body.push_str("

File Not Found

\n"); + body.push_str("

The path "); + body.push_str(&path); + body.push_str(" does not exist.

\n"); + body.push_str("

Check the path and try again, or navigate back to the directory listing.

\n"); + body.push_str("
"); + + let html = theme::render_page("File Not Found", &body, NOT_FOUND_CSS, ""); + + Response::builder() + .status(StatusCode::NOT_FOUND) + .header("Content-Type", "text/html; charset=utf-8") + .body(axum::body::Body::from(html)) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rewrite_redirect_location_to_include_mount_prefix() { + let mut response = Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header(header::LOCATION, "/docs/") + .body(axum::body::Body::empty()) + .unwrap(); + + rewrite_redirect_location_to_include_mount_prefix("/static", &mut response); + + let location = response + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); + assert_eq!(location, "/static/docs/"); + } + + #[test] + fn test_rewrite_redirect_location_to_include_mount_prefix_noop_for_root() { + let mut response = Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header(header::LOCATION, "/docs/") + .body(axum::body::Body::empty()) + .unwrap(); + + rewrite_redirect_location_to_include_mount_prefix("/", &mut response); + + let location = response + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); + assert_eq!(location, "/docs/"); + } +} diff --git a/src/daemon/static_files/path_utils.rs b/src/daemon/static_files/path_utils.rs new file mode 100644 index 0000000..68f712f --- /dev/null +++ b/src/daemon/static_files/path_utils.rs @@ -0,0 +1,154 @@ +use std::path::{Path, PathBuf}; + +/// Resolve a URI path to a filesystem path within the root directory. +/// +/// Returns `None` if the path doesn't exist or escapes the root +/// (path traversal protection). +pub(super) fn resolve_path(root: &Path, uri_path: &str) -> Option { + let decoded = percent_decode(uri_path); + let relative = decoded.trim_start_matches('/'); + let dir_path = if relative.is_empty() { + root.to_path_buf() + } else { + root.join(relative) + }; + + let canonical = dir_path.canonicalize().ok()?; + let canonical_root = root.canonicalize().ok()?; + + if !canonical.starts_with(&canonical_root) { + return None; + } + + Some(canonical) +} + +/// Decode percent-encoded URI path segments. +pub(super) fn percent_decode(s: &str) -> String { + let mut result = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) + { + result.push(byte); + i += 3; + continue; + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).to_string() +} + +/// Format bytes as a human-readable size string. +pub(super) fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_format_size_bytes() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + assert_eq!(format_size(1023), "1023 B"); + } + + #[test] + fn test_format_size_kilobytes() { + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(10240), "10.0 KB"); + } + + #[test] + fn test_format_size_megabytes() { + assert_eq!(format_size(1048576), "1.0 MB"); + assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB"); + } + + #[test] + fn test_format_size_gigabytes() { + assert_eq!(format_size(1073741824), "1.0 GB"); + assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0 GB"); + } + + #[test] + fn test_percent_decode() { + assert_eq!(percent_decode("hello"), "hello"); + assert_eq!(percent_decode("hello%20world"), "hello world"); + assert_eq!(percent_decode("/path%2Fto%2Ffile"), "/path/to/file"); + assert_eq!(percent_decode("%E2%9C%93"), "\u{2713}"); + } + + #[test] + fn test_percent_decode_invalid() { + assert_eq!(percent_decode("%GG"), "%GG"); + assert_eq!(percent_decode("%2"), "%2"); + assert_eq!(percent_decode("trailing%"), "trailing%"); + } + + #[test] + fn test_resolve_path_basic() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("subdir"); + fs::create_dir(&sub).unwrap(); + + let resolved = resolve_path(tmp.path(), "/subdir"); + assert_eq!(resolved.unwrap(), sub.canonicalize().unwrap()); + } + + #[test] + fn test_resolve_path_root() { + let tmp = tempfile::tempdir().unwrap(); + + let resolved = resolve_path(tmp.path(), "/"); + assert_eq!(resolved.unwrap(), tmp.path().canonicalize().unwrap()); + } + + #[test] + fn test_resolve_path_traversal_blocked() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("subdir"); + fs::create_dir(&sub).unwrap(); + + let resolved = resolve_path(&sub, "/../../../etc/passwd"); + assert!(resolved.is_none()); + } + + #[test] + fn test_resolve_path_nonexistent() { + let tmp = tempfile::tempdir().unwrap(); + + let resolved = resolve_path(tmp.path(), "/does-not-exist"); + assert!(resolved.is_none()); + } + + #[test] + fn test_resolve_path_percent_encoded() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("my dir"); + fs::create_dir(&sub).unwrap(); + + let resolved = resolve_path(tmp.path(), "/my%20dir"); + assert_eq!(resolved.unwrap(), sub.canonicalize().unwrap()); + } +} diff --git a/src/daemon/static_files/styles.rs b/src/daemon/static_files/styles.rs new file mode 100644 index 0000000..fdee575 --- /dev/null +++ b/src/daemon/static_files/styles.rs @@ -0,0 +1,142 @@ +pub(super) const NOT_FOUND_CSS: &str = "\ +.error-container{\ + display:flex;flex-direction:column;align-items:center;\ + gap:24px;max-width:700px;margin:40px auto;\ +}\ +.error-image{\ + animation:fadeInDown .6s ease-out;\ +}\ +.error-image img{\ + width:100%;max-width:300px;height:auto;\ + border-radius:12px;\ + box-shadow:0 8px 24px rgba(156,180,212,.25);\ +}\ +.error-card{\ + background:var(--card-bg);border-radius:12px;\ + border:1px solid var(--border);padding:36px;\ + box-shadow:0 4px 16px rgba(0,0,0,.04);\ + text-align:center;width:100%;\ + animation:fadeInUp .6s ease-out;\ +}\ +.error-title{\ + color:var(--fox-orange);font-size:1.6em;margin-bottom:16px;\ + font-weight:700;\ +}\ +.error-message{margin-bottom:12px;font-size:1.05em}\ +.error-hint{color:var(--text-light);font-size:.92em;margin-top:8px}\ +@keyframes fadeInDown{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}\ +@keyframes fadeInUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}\ +@media(max-width:600px){.error-image img{max-width:240px}.error-card{padding:28px}}\ +"; + +pub(super) const FILEBROWSER_CSS: &str = "\ +.page-title{\ + font-size:1.1em;color:var(--text-light);margin-bottom:16px;\ + font-weight:400;display:flex;align-items:center;gap:8px;\ +}\ +.page-title code{\ + font-size:1em;font-weight:600;color:var(--text);\ + background:transparent;padding:0;\ +}\ +.breadcrumb{\ + display:flex;flex-wrap:wrap;align-items:center;gap:4px;\ + padding:12px 18px;margin-bottom:24px;\ + background:var(--card-bg);border-radius:10px;\ + border:1px solid var(--border);font-size:.9em;\ + box-shadow:0 2px 8px rgba(0,0,0,.03);\ +}\ +.breadcrumb a{\ + color:var(--fox-orange);padding:4px 8px;border-radius:6px;\ + transition:all .2s ease;\ +}\ +.breadcrumb a:hover{\ + background:rgba(232,133,58,.12);text-decoration:none;\ + transform:translateY(-1px);\ +}\ +.bc-home{vertical-align:middle;color:var(--fox-orange)}\ +.breadcrumb .sep{color:var(--border-hover);margin:0 4px;font-size:.85em}\ +.file-card{\ + background:var(--card-bg);border-radius:12px;\ + border:1px solid var(--border);overflow:hidden;\ + box-shadow:0 4px 16px rgba(0,0,0,.04);\ + animation:fadeIn .5s ease-out;\ +}\ +.file-card table{width:100%;border-collapse:collapse}\ +.file-card th{\ + text-align:left;padding:14px 18px;\ + background:linear-gradient(180deg,#FEFAF6 0%,#FDF6EE 100%);\ + color:var(--text-light);\ + font-size:.75em;font-weight:600;\ + text-transform:uppercase;letter-spacing:.08em;\ + cursor:pointer;user-select:none;\ + border-bottom:2px solid var(--border);\ + transition:all .2s ease;\ +}\ +.file-card th:hover{color:var(--fox-orange);background:#FFF5ED}\ +.file-card td{\ + padding:12px 18px;border-bottom:1px solid #FAF4EE;\ + vertical-align:middle;transition:background .2s ease;\ +}\ +.file-card tr:last-child td{border-bottom:none}\ +.file-card tbody tr:hover td{background:#FFF8F2;cursor:pointer}\ +.file-card a{color:var(--text);font-weight:500;transition:all .2s ease}\ +.file-card a:hover{color:var(--fox-orange);text-decoration:none}\ +.parent-row td{padding:10px 18px;background:rgba(232,133,58,.03)}\ +.parent-row:hover td{background:rgba(232,133,58,.08)!important}\ +.ei{vertical-align:middle;margin-right:10px;transition:transform .2s}\ +.file-card tr:hover .ei{transform:scale(1.1)}\ +.size,.modified{\ + color:var(--text-light);\ + font-family:'SF Mono',Monaco,'Cascadia Code',Menlo,Consolas,monospace;\ + font-size:.82em;white-space:nowrap;\ +}\ +.si{font-size:.75em;margin-left:6px;opacity:.3;transition:all .2s}\ +.si.active{opacity:1;color:var(--fox-orange);font-weight:700}\ +.empty-dir{padding:48px 18px;text-align:center;color:var(--text-light);font-style:italic}\ +.col-size{width:110px}\ +.col-mod{width:200px}\ +@keyframes fadeIn{from{opacity:0}to{opacity:1}}\ +@media(max-width:768px){.col-mod{display:none}.col-size{width:80px}}\ +"; + +pub(super) const FILEBROWSER_JS: &str = "\ +document.querySelectorAll('.modified').forEach(function(el){\ + var ts=parseInt(el.dataset.ts);\ + if(ts>0){\ + var d=new Date(ts*1000);\ + el.textContent=d.toLocaleDateString(undefined,\ + {year:'numeric',month:'short',day:'numeric'})\ + +' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});\ + }\ +});\ +var col=0,asc=true;\ +function sort(c){\ + if(col===c)asc=!asc;else{col=c;asc=true;}\ + for(var i=0;i<3;i++){\ + var el=document.getElementById('s'+i);\ + el.className='si'+(i===col?' active':'');\ + el.textContent=i===col?(asc?'\\u25B2':'\\u25BC'):'';\ + }\ + var tbody=document.querySelector('#listing tbody');\ + var nonEntryRows=Array.from(tbody.querySelectorAll('tr:not([data-name])'));\ + var rows=Array.from(tbody.querySelectorAll('tr[data-name]'));\ + rows.sort(function(a,b){\ + var ad=parseInt(a.dataset.dir),bd=parseInt(b.dataset.dir);\ + if(ad!==bd)return bd-ad;\ + var av,bv;\ + if(c===0){\ + av=a.dataset.name.toLowerCase();\ + bv=b.dataset.name.toLowerCase();\ + return asc?av.localeCompare(bv):bv.localeCompare(av);\ + }else if(c===1){\ + av=parseInt(a.dataset.size);bv=parseInt(b.dataset.size);\ + }else{\ + av=parseInt(a.dataset.ts);bv=parseInt(b.dataset.ts);\ + }\ + return asc?av-bv:bv-av;\ + });\ + while(tbody.firstChild)tbody.removeChild(tbody.firstChild);\ + nonEntryRows.forEach(function(r){tbody.appendChild(r);});\ + rows.forEach(function(r){tbody.appendChild(r);});\ +}\ +"; diff --git a/src/daemon/tls.rs b/src/daemon/tls.rs index a1ffb2c..9e0ed1e 100644 --- a/src/daemon/tls.rs +++ b/src/daemon/tls.rs @@ -1,8 +1,11 @@ use std::collections::HashMap; +use std::fs; use std::path::Path; use std::sync::Arc; +use std::sync::RwLock; use anyhow::{Context, Result}; +use rcgen::{Issuer, KeyPair, PKCS_ECDSA_P256_SHA256, SanType}; use rustls::ServerConfig; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; @@ -11,61 +14,117 @@ use rustls::sign::CertifiedKey; use tokio_rustls::TlsAcceptor; use tracing::warn; -use crate::domain::DomainName; +use crate::domain::{DomainName, DomainPattern}; +use crate::infrastructure::certs::generator::{build_ca_cert_params, build_leaf_cert_params}; + +const ON_DEMAND_CERT_CACHE_MAX: usize = 256; /// Custom certificate resolver that selects certificates based on SNI hostname. -/// Returns None for unknown domains, causing a clean TLS failure rather than -/// serving a mismatched certificate. +/// +/// For unknown `.roxy` domains, we generate an on-demand certificate signed by +/// Roxy's local Root CA. This allows the HTTP layer to render a friendly +/// "Domain Not Registered" page instead of the browser showing a TLS error. #[derive(Debug)] struct DomainCertResolver { - certs: HashMap>, + /// All registered certificates, stored with their pattern for matching. + certs: Vec<(DomainPattern, Arc)>, + ca_key_pem: Option, + on_demand: RwLock>>, } impl ResolvesServerCert for DomainCertResolver { fn resolve(&self, client_hello: rustls::server::ClientHello) -> Option> { - let hostname = client_hello.server_name()?; + let hostname = client_hello.server_name()?.to_lowercase(); + + // Try cached on-demand certs first (for unregistered but valid .roxy domains). + if let Some(cert) = self.on_demand.read().ok()?.get(hostname.as_str()).cloned() { + return Some(cert); + } - let cert = self.certs.get(hostname).cloned(); - if cert.is_none() { + // Find the first registered cert whose pattern matches the hostname. + // Certs are pre-sorted by specificity (most specific first). + for (pattern, cert) in &self.certs { + if pattern.matches_hostname(&hostname) { + return Some(cert.clone()); + } + } + + // Generate an on-demand cert for valid `.roxy` hostnames if we + // can read the local CA private key. + let ca_key_pem = self.ca_key_pem.as_deref()?; + if DomainName::new(hostname.as_str()).is_err() { warn!(hostname = %hostname, "TLS: no certificate for domain"); + return None; + } + + match generate_on_demand_certified_key(hostname.as_str(), ca_key_pem) { + Ok(cert) => { + if let Ok(mut cache) = self.on_demand.write() { + // Bound memory: on-demand certs are cheap to regenerate. + if cache.len() >= ON_DEMAND_CERT_CACHE_MAX { + cache.clear(); + } + cache.insert(hostname, cert.clone()); + } + Some(cert) + } + Err(e) => { + warn!(hostname = %hostname, error = %e, "TLS: failed to generate on-demand certificate"); + None + } } - cert } } /// Load all domain certificates into a single TLS acceptor with SNI pub fn create_tls_acceptor( - domains: &[DomainName], + patterns: &[DomainPattern], certs_dir: &Path, + data_dir: &Path, ) -> Result> { - if domains.is_empty() { + let ca_key_pem = match load_ca_key_pem(data_dir) { + Ok(pem) => pem, + Err(e) => { + warn!(error = %e, "TLS: failed to load Roxy CA key (on-demand certificates disabled)"); + None + } + }; + + // If we have neither per-domain certificates nor a Root CA to generate + // on-demand certificates, HTTPS can't be served. + if patterns.is_empty() && ca_key_pem.is_none() { return Ok(None); } - let mut certs_map = HashMap::new(); + let mut certs: Vec<(DomainPattern, Arc)> = Vec::new(); - // Load all domain certificates directly from certs_dir - for domain in domains { - let domain_str = domain.as_str(); - let cert_path = certs_dir.join(format!("{}.crt", domain_str)); - let key_path = certs_dir.join(format!("{}.key", domain_str)); + for pattern in patterns { + let stem = pattern.cert_name(); + let cert_path = certs_dir.join(format!("{}.crt", stem)); + let key_path = certs_dir.join(format!("{}.key", stem)); if !cert_path.exists() || !key_path.exists() { - anyhow::bail!("No certificate found for {}", domain); + anyhow::bail!("No certificate found for {}", pattern); } - let certs = load_certs(&cert_path)?; + let loaded_certs = load_certs(&cert_path)?; let key = load_private_key(&key_path)?; - // Create a signing key using aws-lc-rs (default crypto provider) let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key) .context("Failed to create signing key")?; - let certified_key = Arc::new(CertifiedKey::new(certs, signing_key)); - certs_map.insert(domain.as_str().to_string(), certified_key); + let certified_key = Arc::new(CertifiedKey::new(loaded_certs, signing_key)); + certs.push((pattern.clone(), certified_key)); } - let resolver = Arc::new(DomainCertResolver { certs: certs_map }); + // Most-specific pattern wins (longest base domain). + certs.sort_by_key(|(p, _)| std::cmp::Reverse(p.specificity())); + + let resolver = Arc::new(DomainCertResolver { + certs, + ca_key_pem, + on_demand: RwLock::new(HashMap::new()), + }); let config = ServerConfig::builder() .with_no_client_auth() @@ -74,6 +133,46 @@ pub fn create_tls_acceptor( Ok(Some(TlsAcceptor::from(Arc::new(config)))) } +fn load_ca_key_pem(data_dir: &Path) -> Result> { + let ca_key_path = data_dir.join("ca.key"); + if !ca_key_path.exists() { + return Ok(None); + } + + let pem = fs::read_to_string(&ca_key_path) + .with_context(|| format!("Failed to read CA private key: {}", ca_key_path.display()))?; + Ok(Some(pem)) +} + +fn generate_on_demand_certified_key(hostname: &str, ca_key_pem: &str) -> Result> { + let leaf_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .context("Failed to generate leaf key pair")?; + + let san = SanType::DnsName( + hostname + .try_into() + .map_err(|e| anyhow::anyhow!("Invalid hostname for SAN: {}", e))?, + ); + let params = build_leaf_cert_params(hostname, vec![san]); + + let ca_key_pair = KeyPair::from_pem(ca_key_pem).context("Failed to parse CA key")?; + let ca_params = build_ca_cert_params(); + let issuer = Issuer::from_params(&ca_params, &ca_key_pair); + + let cert = params + .signed_by(&leaf_key_pair, &issuer) + .context("Failed to sign on-demand certificate")?; + + let certs = vec![cert.der().clone()]; + let key = PrivateKeyDer::try_from(leaf_key_pair.serialize_der()) + .map_err(|e| anyhow::anyhow!("Failed to parse generated private key: {}", e))?; + + let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key) + .context("Failed to create signing key")?; + + Ok(Arc::new(CertifiedKey::new(certs, signing_key))) +} + fn load_certs(path: &Path) -> Result>> { let certs: Vec<_> = CertificateDer::pem_file_iter(path) .with_context(|| format!("Failed to open cert file: {}", path.display()))? diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 1db5ea5..c8870c4 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -2,4 +2,6 @@ mod registration; pub mod value_objects; pub use registration::DomainRegistration; -pub use value_objects::{DomainName, PathPrefix, ProxyTarget, Route, RouteTarget}; +#[allow(unused_imports)] +pub use registration::RegistrationError; +pub use value_objects::{DomainName, DomainPattern, PathPrefix, ProxyTarget, Route, RouteTarget}; diff --git a/src/domain/registration.rs b/src/domain/registration.rs index 49b922c..4badd54 100644 --- a/src/domain/registration.rs +++ b/src/domain/registration.rs @@ -1,5 +1,4 @@ -use super::{DomainName, PathPrefix, Route, RouteTarget}; -use serde::{Deserialize, Serialize}; +use super::{DomainName, DomainPattern, PathPrefix, Route, RouteTarget}; use std::path::PathBuf; use thiserror::Error; @@ -21,22 +20,56 @@ pub enum RegistrationError { CannotRemoveLastRoute, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DomainRegistration { - pub domain: DomainName, - pub routes: Vec, - pub https_enabled: bool, + pattern: DomainPattern, + routes: Vec, + https_enabled: bool, } impl DomainRegistration { - pub fn new(domain: DomainName, routes: Vec) -> Self { + pub fn new(pattern: DomainPattern, routes: Vec) -> Self { Self { - domain, + pattern, routes, - https_enabled: false, // Will be enabled after cert generation + https_enabled: false, } } + // --- Accessors --- + + pub fn pattern(&self) -> &DomainPattern { + &self.pattern + } + + pub fn domain(&self) -> &DomainName { + self.pattern.base_domain() + } + + pub fn routes(&self) -> &[Route] { + &self.routes + } + + pub fn is_https_enabled(&self) -> bool { + self.https_enabled + } + + pub fn is_wildcard(&self) -> bool { + self.pattern.is_wildcard() + } + + // --- Delegated pattern methods --- + + pub fn display_pattern(&self) -> String { + self.pattern.display_pattern() + } + + pub fn config_key(&self) -> String { + self.pattern.display_pattern() + } + + // --- Mutators --- + pub fn enable_https(&mut self) { self.https_enabled = true; } @@ -93,3 +126,192 @@ impl DomainRegistration { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::ProxyTarget; + + fn make_pattern(name: &str) -> DomainPattern { + DomainPattern::Exact(DomainName::new(name).unwrap()) + } + + 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), + ) + } + + // --- Constructor --- + + #[test] + fn new_creates_registration_with_https_disabled() { + let reg = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + assert!(!reg.is_https_enabled()); + assert_eq!(reg.routes().len(), 1); + assert_eq!(reg.domain().as_str(), "myapp.roxy"); + } + + // --- enable_https --- + + #[test] + fn enable_https_sets_flag() { + let mut reg = + DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + assert!(!reg.is_https_enabled()); + reg.enable_https(); + assert!(reg.is_https_enabled()); + } + + // --- match_route: longest prefix wins --- + + #[test] + 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(), "/"); + } + + #[test] + fn match_route_longest_prefix_wins() { + let reg = DomainRegistration::new( + make_pattern("myapp.roxy"), + vec![proxy_route("/", 3000), proxy_route("/api", 4000)], + ); + + // /api/users should match /api (more specific) not / + let matched = reg.match_route("/api/users").unwrap(); + assert_eq!(matched.path.as_str(), "/api"); + + // / should match root + let matched = reg.match_route("/").unwrap(); + assert_eq!(matched.path.as_str(), "/"); + } + + #[test] + fn match_route_returns_none_when_no_match() { + let reg = + DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/api", 4000)]); + // /other doesn't match /api prefix + assert!(reg.match_route("/other").is_none()); + } + + // --- add_route --- + + #[test] + fn add_route_succeeds_for_new_path() { + let mut reg = + DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + assert!(reg.add_route(proxy_route("/api", 4000)).is_ok()); + assert_eq!(reg.routes().len(), 2); + } + + #[test] + fn add_route_fails_for_duplicate_path() { + let mut reg = + DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + let result = reg.add_route(proxy_route("/", 4000)); + assert!(matches!(result, Err(RegistrationError::RouteExists(_)))); + } + + // --- remove_route --- + + #[test] + fn remove_route_succeeds() { + let mut reg = DomainRegistration::new( + make_pattern("myapp.roxy"), + vec![proxy_route("/", 3000), proxy_route("/api", 4000)], + ); + let path = PathPrefix::new("/api").unwrap(); + assert!(reg.remove_route(&path).is_ok()); + assert_eq!(reg.routes().len(), 1); + } + + #[test] + fn remove_route_fails_for_last_route() { + let mut reg = + DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + let path = PathPrefix::new("/").unwrap(); + let result = reg.remove_route(&path); + assert!(matches!( + result, + Err(RegistrationError::CannotRemoveLastRoute) + )); + } + + #[test] + fn remove_route_fails_for_nonexistent_path() { + let mut reg = DomainRegistration::new( + make_pattern("myapp.roxy"), + vec![proxy_route("/", 3000), proxy_route("/api", 4000)], + ); + let path = PathPrefix::new("/other").unwrap(); + let result = reg.remove_route(&path); + assert!(matches!(result, Err(RegistrationError::RouteNotFound(_)))); + } + + // --- validate --- + + #[test] + fn validate_passes_for_proxy_routes() { + let reg = DomainRegistration::new(make_pattern("myapp.roxy"), vec![proxy_route("/", 3000)]); + 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 --- + + #[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()), + vec![proxy_route("/", 3000)], + ); + assert_eq!(wildcard.display_pattern(), "*.myapp.roxy"); + } +} diff --git a/src/domain/value_objects/domain_pattern.rs b/src/domain/value_objects/domain_pattern.rs new file mode 100644 index 0000000..d27f0e3 --- /dev/null +++ b/src/domain/value_objects/domain_pattern.rs @@ -0,0 +1,301 @@ +use std::fmt; + +use super::domain_name::DomainName; +use crate::infrastructure::certs::WILDCARD_CERT_PREFIX; + +/// Value object representing how a domain is matched — either +/// exactly or as a wildcard pattern covering one-level subdomains. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DomainPattern { + /// Matches only the exact domain (e.g. `myapp.roxy`). + Exact(DomainName), + /// Matches the base domain **and** any single-level subdomain + /// (e.g. `myapp.roxy` + `*.myapp.roxy`). + Wildcard(DomainName), +} + +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 { + let domain = DomainName::new(name)?; + Ok(if wildcard { + Self::Wildcard(domain) + } else { + Self::Exact(domain) + }) + } + + /// The underlying base domain regardless of pattern type. + pub fn base_domain(&self) -> &DomainName { + match self { + Self::Exact(d) | Self::Wildcard(d) => d, + } + } + + pub fn is_wildcard(&self) -> bool { + matches!(self, Self::Wildcard(_)) + } + + /// Single source of truth for hostname matching. + /// + /// - `Exact` matches only when hostname equals the domain. + /// - `Wildcard` matches the base domain itself **and** + /// any single-label subdomain (e.g. `blog.myapp.roxy` + /// but **not** `a.b.myapp.roxy`). + pub fn matches_hostname(&self, hostname: &str) -> bool { + match self { + Self::Exact(domain) => hostname == domain.as_str(), + Self::Wildcard(base) => { + let base_str = base.as_str(); + if hostname == base_str { + return true; + } + + let suffix = format!(".{}", base_str); + if !hostname.ends_with(&suffix) { + return false; + } + + // Only allow a single label before the base domain. + let prefix = &hostname[..hostname.len() - suffix.len()]; + !prefix.is_empty() && !prefix.contains('.') + } + } + } + + /// Human-readable display pattern (e.g. `*.myapp.roxy`). + pub fn display_pattern(&self) -> String { + match self { + Self::Exact(d) => d.as_str().to_string(), + Self::Wildcard(d) => format!("*.{}", d.as_str()), + } + } + + /// 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 { + self.base_domain().as_str().len() + } +} + +impl fmt::Display for DomainPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.display_pattern()) + } +} + +impl serde::Serialize for DomainPattern { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.display_pattern()) + } +} + +impl<'de> serde::Deserialize<'de> for DomainPattern { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if let Some(base) = s.strip_prefix("*.") { + let domain = DomainName::new(base).map_err(serde::de::Error::custom)?; + Ok(DomainPattern::Wildcard(domain)) + } else { + let domain = DomainName::new(&s).map_err(serde::de::Error::custom)?; + Ok(DomainPattern::Exact(domain)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exact(name: &str) -> DomainPattern { + DomainPattern::Exact(DomainName::new(name).unwrap()) + } + + fn wildcard(name: &str) -> DomainPattern { + DomainPattern::Wildcard(DomainName::new(name).unwrap()) + } + + // --- from_name --- + + #[test] + fn from_name_creates_exact_pattern() { + let p = DomainPattern::from_name("myapp.roxy", false).unwrap(); + assert_eq!(p, exact("myapp.roxy")); + } + + #[test] + fn from_name_creates_wildcard_pattern() { + let p = DomainPattern::from_name("myapp.roxy", true).unwrap(); + assert_eq!(p, wildcard("myapp.roxy")); + } + + #[test] + fn from_name_rejects_invalid_domain() { + assert!(DomainPattern::from_name("invalid", false).is_err()); + } + + // --- matches_hostname --- + + #[test] + fn exact_matches_same_hostname() { + assert!(exact("myapp.roxy").matches_hostname("myapp.roxy")); + } + + #[test] + fn exact_does_not_match_subdomain() { + assert!(!exact("myapp.roxy").matches_hostname("blog.myapp.roxy")); + } + + #[test] + fn exact_does_not_match_different_domain() { + assert!(!exact("myapp.roxy").matches_hostname("other.roxy")); + } + + #[test] + fn wildcard_matches_base_domain() { + assert!(wildcard("myapp.roxy").matches_hostname("myapp.roxy")); + } + + #[test] + fn wildcard_matches_single_level_subdomain() { + assert!(wildcard("myapp.roxy").matches_hostname("blog.myapp.roxy")); + assert!(wildcard("myapp.roxy").matches_hostname("api.myapp.roxy")); + } + + #[test] + fn wildcard_does_not_match_multi_level_subdomain() { + assert!(!wildcard("myapp.roxy").matches_hostname("a.b.myapp.roxy")); + } + + #[test] + fn wildcard_does_not_match_unrelated_domain() { + assert!(!wildcard("myapp.roxy").matches_hostname("other.roxy")); + } + + #[test] + fn wildcard_does_not_match_suffix_overlap() { + // "notmyapp.roxy" should NOT match wildcard for "myapp.roxy" + assert!(!wildcard("myapp.roxy").matches_hostname("notmyapp.roxy")); + } + + #[test] + fn wildcard_does_not_match_empty_prefix() { + // ".myapp.roxy" — empty prefix before the dot + assert!(!wildcard("myapp.roxy").matches_hostname(".myapp.roxy")); + } + + // --- display_pattern --- + + #[test] + fn exact_display_pattern() { + assert_eq!(exact("myapp.roxy").display_pattern(), "myapp.roxy"); + } + + #[test] + fn wildcard_display_pattern() { + 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] + fn longer_base_domain_is_more_specific() { + let broad = wildcard("myapp.roxy"); + let specific = wildcard("sub.myapp.roxy"); + assert!(specific.specificity() > broad.specificity()); + } + + // --- Display trait --- + + #[test] + fn display_trait_matches_display_pattern() { + let p = wildcard("myapp.roxy"); + assert_eq!(format!("{}", p), p.display_pattern()); + } + + // --- is_wildcard --- + + #[test] + fn is_wildcard_returns_correctly() { + assert!(!exact("myapp.roxy").is_wildcard()); + assert!(wildcard("myapp.roxy").is_wildcard()); + } + + // --- serde round-trip --- + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] + struct Wrapper { + pattern: DomainPattern, + } + + #[test] + fn serde_roundtrip_exact() { + let w = Wrapper { + pattern: exact("myapp.roxy"), + }; + let toml_str = toml::to_string(&w).unwrap(); + let deserialized: Wrapper = toml::from_str(&toml_str).unwrap(); + assert_eq!(w, deserialized); + } + + #[test] + fn serde_roundtrip_wildcard() { + let w = Wrapper { + pattern: wildcard("myapp.roxy"), + }; + let toml_str = toml::to_string(&w).unwrap(); + let deserialized: Wrapper = toml::from_str(&toml_str).unwrap(); + assert_eq!(w, deserialized); + } + + #[test] + fn serde_deserializes_wildcard_from_string() { + let toml_str = r#"pattern = "*.myapp.roxy""#; + let w: Wrapper = toml::from_str(toml_str).unwrap(); + assert_eq!(w.pattern, wildcard("myapp.roxy")); + } + + #[test] + fn serde_deserializes_exact_from_string() { + let toml_str = r#"pattern = "myapp.roxy""#; + let w: Wrapper = toml::from_str(toml_str).unwrap(); + assert_eq!(w.pattern, exact("myapp.roxy")); + } +} diff --git a/src/domain/value_objects/mod.rs b/src/domain/value_objects/mod.rs index 7d9696e..b8958cc 100644 --- a/src/domain/value_objects/mod.rs +++ b/src/domain/value_objects/mod.rs @@ -1,10 +1,12 @@ mod domain_name; +mod domain_pattern; mod path_prefix; pub mod port; mod proxy_target; mod route; 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}; diff --git a/src/infrastructure/certs/ca.rs b/src/infrastructure/certs/ca.rs index 6124d2d..b7c3847 100644 --- a/src/infrastructure/certs/ca.rs +++ b/src/infrastructure/certs/ca.rs @@ -1,10 +1,6 @@ -use rcgen::{ - BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair, - KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, -}; +use rcgen::{BasicConstraints, CertificateParams, IsCa, Issuer, KeyPair, PKCS_ECDSA_P256_SHA256}; use std::fs; use std::path::PathBuf; -use time::{Duration, OffsetDateTime}; use super::CertError; @@ -49,23 +45,9 @@ impl RootCA { let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) .map_err(|e| CertError::GenerationError(e.to_string()))?; - // Configure CA certificate parameters - let mut params = CertificateParams::default(); - - // Set distinguished name - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Roxy Local Development CA"); - dn.push(DnType::OrganizationName, "Roxy"); - params.distinguished_name = dn; - - // Set validity period (10 years for CA) - let now = OffsetDateTime::now_utc(); - params.not_before = now; - params.not_after = now + Duration::days(3650); - - // Mark as CA certificate + // Configure CA certificate parameters (shared DN + validity + key usage) + let mut params = super::generator::build_ca_cert_params(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; // Generate the self-signed CA certificate let cert = params @@ -125,20 +107,7 @@ impl RootCA { ) -> Result { let ca_key_pair = self.load_key_pair()?; - // Create CA certificate params to sign with - let mut ca_params = CertificateParams::default(); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Roxy Local Development CA"); - dn.push(DnType::OrganizationName, "Roxy"); - ca_params.distinguished_name = dn; - - let now = OffsetDateTime::now_utc(); - ca_params.not_before = now; - ca_params.not_after = now + Duration::days(3650); - ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; - - // Create issuer from CA params and key pair + let ca_params = super::generator::build_ca_cert_params(); let issuer = Issuer::from_params(&ca_params, &ca_key_pair); // Sign the domain certificate diff --git a/src/infrastructure/certs/generator.rs b/src/infrastructure/certs/generator.rs index 560607f..b96ecc1 100644 --- a/src/infrastructure/certs/generator.rs +++ b/src/infrastructure/certs/generator.rs @@ -8,15 +8,60 @@ use time::{Duration, OffsetDateTime}; use super::CertError; use super::ca::RootCA; -use crate::domain::DomainName; +use crate::domain::DomainPattern; /// Represents a generated certificate with its key pair pub struct Certificate { - pub domain: String, + /// File stem used for saving (e.g. "myapp.roxy" or "__wildcard__.myapp.roxy") + pub file_stem: String, pub cert_pem: String, pub key_pem: String, } +/// Build certificate parameters for a domain leaf certificate. +/// +/// Sets up the Distinguished Name, 1-year validity, key usage for +/// server authentication, and the given Subject Alternative Names. +pub(crate) fn build_leaf_cert_params(common_name: &str, sans: Vec) -> CertificateParams { + let mut params = CertificateParams::default(); + + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, common_name); + dn.push(DnType::OrganizationName, "Roxy Local Development"); + params.distinguished_name = dn; + + let now = OffsetDateTime::now_utc(); + params.not_before = now; + params.not_after = now + Duration::days(365); + + params.subject_alt_names = sans; + + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + + params +} + +/// Build the standard Roxy CA certificate parameters. +pub(crate) fn build_ca_cert_params() -> CertificateParams { + let mut params = CertificateParams::default(); + + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Roxy Local Development CA"); + dn.push(DnType::OrganizationName, "Roxy"); + params.distinguished_name = dn; + + let now = OffsetDateTime::now_utc(); + params.not_before = now; + params.not_after = now + Duration::days(3650); + + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + + params +} + pub struct CertificateGenerator { base_dir: PathBuf, certs_dir: PathBuf, @@ -31,53 +76,31 @@ impl CertificateGenerator { } } - /// Generate a new certificate for the given domain, signed by the Root CA - pub fn generate(&self, domain: &DomainName) -> Result { + /// Generate a certificate for the given domain pattern, signed by the Root CA. + /// + /// For exact patterns, generates a single-domain cert. + /// For wildcard patterns, generates a cert with SANs for base + *.base. + pub fn generate(&self, pattern: &DomainPattern) -> Result { let ca = RootCA::new(self.base_dir.clone()); - // Ensure CA exists if !ca.exists() { return Err(CertError::GenerationError( "Root CA not found. Run 'sudo roxy install' first.".to_string(), )); } - let domain_str = domain.as_str(); - - // Generate ECDSA P-256 key pair for the domain (per FR-3.1.2) + // Generate ECDSA P-256 key pair let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) .map_err(|e| CertError::GenerationError(e.to_string()))?; - // Configure certificate parameters - let mut params = CertificateParams::default(); - - // Set distinguished name - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, domain_str); - dn.push(DnType::OrganizationName, "Roxy Local Development"); - params.distinguished_name = dn; - - // Set validity period (1 year per FR-3.1.3) - let now = OffsetDateTime::now_utc(); - params.not_before = now; - params.not_after = now + Duration::days(365); - - // Add Subject Alternative Name (FR-3.1.4) - params.subject_alt_names = vec![SanType::DnsName(domain_str.try_into().map_err(|e| { - CertError::GenerationError(format!("Invalid domain name for SAN: {}", e)) - })?)]; - - // Set key usage for server certificate - params.key_usages = vec![ - KeyUsagePurpose::DigitalSignature, - KeyUsagePurpose::KeyEncipherment, - ]; + let sans = build_sans(pattern)?; + let params = build_leaf_cert_params(pattern.base_domain().as_str(), sans); // Sign certificate with CA let cert_pem = ca.sign_certificate(params, &key_pair)?; Ok(Certificate { - domain: domain_str.to_string(), + file_stem: pattern.cert_name(), cert_pem, key_pem: key_pair.serialize_pem(), }) @@ -91,8 +114,8 @@ impl CertificateGenerator { source: e, })?; - let cert_path = self.certs_dir.join(format!("{}.crt", cert.domain)); - let key_path = self.certs_dir.join(format!("{}.key", cert.domain)); + let cert_path = self.certs_dir.join(format!("{}.crt", cert.file_stem)); + let key_path = self.certs_dir.join(format!("{}.key", cert.file_stem)); // Write certificate fs::write(&cert_path, &cert.cert_pem).map_err(|e| CertError::WriteError { @@ -126,13 +149,12 @@ impl CertificateGenerator { Ok(()) } - /// Delete certificate files for a domain - pub fn delete(&self, domain: &DomainName) -> Result<(), CertError> { - let domain_str = domain.as_str(); - let cert_path = self.certs_dir.join(format!("{}.crt", domain_str)); - let key_path = self.certs_dir.join(format!("{}.key", domain_str)); + /// Delete certificate files for a domain pattern + pub fn delete(&self, pattern: &DomainPattern) -> Result<(), CertError> { + let stem = pattern.cert_name(); + let cert_path = self.certs_dir.join(format!("{}.crt", stem)); + let key_path = self.certs_dir.join(format!("{}.key", stem)); - // Remove certificate file if exists if cert_path.exists() { fs::remove_file(&cert_path).map_err(|e| CertError::DeleteError { path: cert_path, @@ -140,7 +162,6 @@ impl CertificateGenerator { })?; } - // Remove key file if exists if key_path.exists() { fs::remove_file(&key_path).map_err(|e| CertError::DeleteError { path: key_path, @@ -151,37 +172,59 @@ impl CertificateGenerator { Ok(()) } - /// Check if certificate exists for a domain - pub fn exists(&self, domain: &DomainName) -> bool { - let cert_path = self.certs_dir.join(format!("{}.crt", domain.as_str())); - let key_path = self.certs_dir.join(format!("{}.key", domain.as_str())); + /// Check if certificate exists for a domain pattern + pub fn exists(&self, pattern: &DomainPattern) -> bool { + let stem = pattern.cert_name(); + 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() } } +/// Build Subject Alternative Names for the given pattern. +fn build_sans(pattern: &DomainPattern) -> Result, CertError> { + let base_str = pattern.base_domain().as_str(); + + let base_san = + SanType::DnsName(base_str.try_into().map_err(|e| { + CertError::GenerationError(format!("Invalid domain name for SAN: {}", e)) + })?); + + match pattern { + DomainPattern::Exact(_) => Ok(vec![base_san]), + DomainPattern::Wildcard(_) => { + let wildcard_str = format!("*.{}", base_str); + let wildcard_san = SanType::DnsName(wildcard_str.try_into().map_err(|e| { + CertError::GenerationError(format!("Invalid wildcard name for SAN: {}", e)) + })?); + Ok(vec![base_san, wildcard_san]) + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::domain::DomainName; + use crate::infrastructure::certs::ca::RootCA; use tempfile::TempDir; #[test] fn test_certificate_generation() { - // Create an isolated test environment let temp_dir = TempDir::new().expect("Failed to create temp dir"); let base_dir = temp_dir.path().to_path_buf(); let certs_dir = base_dir.join("certs"); - // Generate a test CA in the temp directory let ca = RootCA::new(base_dir.clone()); ca.generate().expect("Failed to generate test CA"); - // Now test certificate generation let domain = DomainName::new("test.roxy").unwrap(); + let pattern = DomainPattern::Exact(domain); let generator = CertificateGenerator::new(base_dir, certs_dir); - let cert = generator.generate(&domain).unwrap(); + let cert = generator.generate(&pattern).unwrap(); - assert_eq!(cert.domain, "test.roxy"); + assert_eq!(cert.file_stem, "test.roxy"); assert!(cert.cert_pem.contains("BEGIN CERTIFICATE")); assert!(cert.key_pem.contains("BEGIN PRIVATE KEY")); } diff --git a/src/infrastructure/certs/mod.rs b/src/infrastructure/certs/mod.rs index 780aab9..427ebb2 100644 --- a/src/infrastructure/certs/mod.rs +++ b/src/infrastructure/certs/mod.rs @@ -9,6 +9,12 @@ pub mod trust_store; pub use generator::CertificateGenerator; pub use service::CertificateService; +/// Filename prefix for wildcard certificates stored in `certs_dir`. +/// +/// We intentionally use underscores so it can't collide with a valid `.roxy` +/// domain (underscores are rejected by `DomainName` validation). +pub const WILDCARD_CERT_PREFIX: &str = "__wildcard__."; + #[derive(Error, Debug)] pub enum CertError { #[error("Failed to generate certificate: {0}")] diff --git a/src/infrastructure/certs/service.rs b/src/infrastructure/certs/service.rs index e389719..c6d3b10 100644 --- a/src/infrastructure/certs/service.rs +++ b/src/infrastructure/certs/service.rs @@ -1,7 +1,7 @@ use super::ca::RootCA; use super::trust_store::get_trust_store; use super::{CertError, CertificateGenerator}; -use crate::domain::DomainName; +use crate::domain::DomainPattern; use crate::infrastructure::paths::RoxyPaths; /// High-level service for certificate operations @@ -45,42 +45,34 @@ impl CertificateService { trust_store.is_ca_trusted() } - /// Generate a certificate for a domain (signed by the Root CA) - /// The certificate is automatically trusted because the CA is trusted - pub fn create_and_install(&self, domain: &DomainName) -> Result<(), CertError> { - // Ensure CA exists + /// Generate a certificate for a domain pattern (signed by the Root CA). + /// + /// For exact patterns, generates a single-domain cert. + /// For wildcard patterns, generates a cert with SANs for base + *.base. + pub fn create_and_install(&self, pattern: &DomainPattern) -> Result<(), CertError> { if !self.ca.exists() { return Err(CertError::GenerationError( "Root CA not found. Run 'sudo roxy install' first.".to_string(), )); } - // Generate certificate (signed by CA) - let cert = self.generator.generate(domain)?; - - // Save to disk (no need to add to trust store - CA is already trusted) + let cert = self.generator.generate(pattern)?; self.generator.save(&cert)?; - Ok(()) } - /// Remove certificate files for a domain - /// Note: No trust store removal needed since we use CA-based trust - pub fn remove(&self, domain: &DomainName) -> Result<(), CertError> { - // Delete certificate files - self.generator.delete(domain)?; - - Ok(()) + /// Remove certificate files for a domain pattern. + pub fn remove(&self, pattern: &DomainPattern) -> Result<(), CertError> { + self.generator.delete(pattern) } - /// Check if certificate exists for a domain - pub fn exists(&self, domain: &DomainName) -> bool { - self.generator.exists(domain) + /// Check if certificate exists for a domain pattern. + pub fn exists(&self, pattern: &DomainPattern) -> bool { + self.generator.exists(pattern) } /// Check if certificate is trusted (CA is trusted = all certs trusted) - pub fn is_trusted(&self, _domain: &DomainName) -> Result { - // If CA is trusted, all certs signed by it are trusted + pub fn is_trusted(&self) -> Result { self.is_ca_installed() } diff --git a/src/infrastructure/config/dto.rs b/src/infrastructure/config/dto.rs new file mode 100644 index 0000000..657feaa --- /dev/null +++ b/src/infrastructure/config/dto.rs @@ -0,0 +1,41 @@ +//! Persistence DTO for `DomainRegistration`. +//! +//! Decouples the on-disk TOML format from the domain entity so that +//! adding or removing domain fields doesn't accidentally change the +//! config file layout, and deserialization can't bypass domain +//! invariants enforced by `DomainRegistration` methods. + +use serde::{Deserialize, Serialize}; + +use crate::domain::{DomainPattern, DomainRegistration, Route}; + +/// Serializable representation of a domain registration in the config +/// file. Converted to/from `DomainRegistration` at the `ConfigStore` +/// boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrationDto { + pub pattern: DomainPattern, + pub routes: Vec, + #[serde(default)] + pub https_enabled: bool, +} + +impl From for RegistrationDto { + fn from(reg: DomainRegistration) -> Self { + Self { + pattern: reg.pattern().clone(), + routes: reg.routes().to_vec(), + https_enabled: reg.is_https_enabled(), + } + } +} + +impl From for DomainRegistration { + fn from(dto: RegistrationDto) -> Self { + let mut reg = DomainRegistration::new(dto.pattern, dto.routes); + if dto.https_enabled { + reg.enable_https(); + } + reg + } +} diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index f3610ff..3c91989 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1,5 +1,8 @@ -use crate::domain::{DomainName, DomainRegistration}; +mod dto; + +use crate::domain::{DomainPattern, DomainRegistration}; use crate::infrastructure::paths::RoxyPaths; +use dto::RegistrationDto; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -118,14 +121,24 @@ pub struct Config { pub paths: RoxyPaths, #[serde(default)] - pub domains: HashMap, + domains: HashMap, } impl Config { + /// Convert all stored DTOs to domain registrations. + pub fn registrations(&self) -> Vec { + self.domains + .values() + .cloned() + .map(DomainRegistration::from) + .collect() + } + pub fn validate(&self) -> Result<(), ConfigError> { self.daemon.validate()?; - for (name, registration) in &self.domains { + for (name, dto) in &self.domains { + let registration = DomainRegistration::from(dto.clone()); registration .validate() .map_err(|e| ConfigError::InvalidDomain(name.clone(), e.to_string()))?; @@ -175,50 +188,190 @@ impl ConfigStore { pub fn add_domain(&self, registration: DomainRegistration) -> Result<(), ConfigError> { let mut config = self.load()?; - let key = registration.domain.as_str().to_string(); + let key = registration.config_key(); if config.domains.contains_key(&key) { return Err(ConfigError::DomainExists(key)); } - config.domains.insert(key, registration); + config.domains.insert(key, registration.into()); self.save(&config) } - pub fn remove_domain(&self, domain: &DomainName) -> Result { + pub fn remove_domain( + &self, + pattern: &DomainPattern, + ) -> Result { let mut config = self.load()?; - let key = domain.as_str(); - let registration = config + let key = pattern.display_pattern(); + let dto = config .domains - .remove(key) - .ok_or_else(|| ConfigError::DomainNotFound(key.to_string()))?; + .remove(&key) + .ok_or(ConfigError::DomainNotFound(key))?; self.save(&config)?; - Ok(registration) + Ok(dto.into()) } pub fn get_domain( &self, - domain: &DomainName, + pattern: &DomainPattern, ) -> Result, ConfigError> { let config = self.load()?; - Ok(config.domains.get(domain.as_str()).cloned()) + let key = pattern.display_pattern(); + Ok(config + .domains + .get(&key) + .cloned() + .map(DomainRegistration::from)) } pub fn update_domain(&self, registration: DomainRegistration) -> Result<(), ConfigError> { let mut config = self.load()?; - let key = registration.domain.as_str().to_string(); + let key = registration.config_key(); if !config.domains.contains_key(&key) { return Err(ConfigError::DomainNotFound(key)); } - config.domains.insert(key, registration); + config.domains.insert(key, registration.into()); self.save(&config) } pub fn list_domains(&self) -> Result, ConfigError> { let config = self.load()?; - Ok(config.domains.into_values().collect()) + Ok(config + .domains + .into_values() + .map(DomainRegistration::from) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- DaemonConfig::validate --- + + #[test] + fn default_config_is_valid() { + let config = DaemonConfig::default(); + assert!(config.validate().is_ok()); + } + + #[test] + fn custom_valid_config() { + let config = DaemonConfig { + http_port: 8080, + https_port: 8443, + dns_port: 5353, + log_level: "debug".to_string(), + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn zero_http_port_is_invalid() { + let config = DaemonConfig { + http_port: 0, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("http_port cannot be 0")); + } + + #[test] + fn zero_https_port_is_invalid() { + let config = DaemonConfig { + https_port: 0, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("https_port cannot be 0")); + } + + #[test] + fn zero_dns_port_is_invalid() { + let config = DaemonConfig { + dns_port: 0, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("dns_port cannot be 0")); + } + + #[test] + fn duplicate_http_and_https_ports_is_invalid() { + let config = DaemonConfig { + http_port: 8080, + https_port: 8080, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("must be different")); + } + + #[test] + fn duplicate_http_and_dns_ports_is_invalid() { + let config = DaemonConfig { + http_port: 1053, + https_port: 443, + dns_port: 1053, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("must all be different")); + } + + #[test] + fn duplicate_https_and_dns_ports_is_invalid() { + let config = DaemonConfig { + http_port: 80, + https_port: 1053, + dns_port: 1053, + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("must all be different")); + } + + #[test] + fn invalid_log_level_is_rejected() { + let config = DaemonConfig { + log_level: "verbose".to_string(), + ..DaemonConfig::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("Invalid log_level")); + } + + #[test] + fn valid_log_levels_are_accepted() { + for level in ["error", "warn", "info", "debug"] { + let config = DaemonConfig { + log_level: level.to_string(), + ..DaemonConfig::default() + }; + assert!( + config.validate().is_ok(), + "level '{}' should be valid", + level + ); + } + } + + // --- Config::validate --- + + #[test] + fn config_validate_delegates_to_daemon() { + let config = Config { + daemon: DaemonConfig { + http_port: 0, + ..DaemonConfig::default() + }, + ..Config::default() + }; + assert!(config.validate().is_err()); } } diff --git a/src/infrastructure/pid.rs b/src/infrastructure/pid.rs index 4d05685..10097ac 100644 --- a/src/infrastructure/pid.rs +++ b/src/infrastructure/pid.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use std::fs; use std::path::PathBuf; use std::process; +use std::time::Duration; pub struct PidFile { path: PathBuf, @@ -55,6 +56,40 @@ impl PidFile { _ => Ok(None), } } + + /// Stop the running daemon gracefully. + /// + /// Sends SIGTERM, waits for the given timeout, then sends SIGKILL + /// if the process is still running. Removes the PID file on success. + pub fn stop_gracefully(&self, timeout: Duration) -> Result<()> { + let pid = match self.get_running_pid()? { + Some(pid) => pid, + None => return Ok(()), + }; + + terminate_process(pid, timeout)?; + self.remove() + } +} + +/// Send SIGTERM, wait, then SIGKILL if still running. +#[cfg(unix)] +fn terminate_process(pid: u32, timeout: Duration) -> Result<()> { + use std::process::Command; + + Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .output()?; + + std::thread::sleep(timeout); + + if process_exists(pid) { + Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .output()?; + } + + Ok(()) } /// Check if a process exists (Unix-specific) diff --git a/src/lib.rs b/src/lib.rs index 4f06c0e..50d0795 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ +pub mod application; pub mod domain; pub mod infrastructure; diff --git a/src/main.rs b/src/main.rs index c791b64..670c6ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use clap::{Parser, Subcommand}; +mod application; mod cli; mod daemon; mod domain; @@ -48,6 +49,10 @@ enum Commands { /// Domain name (must end with .roxy) domain: String, + /// Register wildcard subdomains for this domain (matches myapp.roxy and *.myapp.roxy) + #[arg(long)] + wildcard: bool, + /// Route in format PATH=TARGET (e.g., "/=3000" or "/api=3001") /// TARGET can be: port (3000), host:port (192.168.1.50:3000), or path (/var/www) #[arg(long, short = 'r', value_name = "PATH=TARGET", required = true)] @@ -59,6 +64,10 @@ enum Commands { /// Domain name to unregister domain: String, + /// Unregister wildcard subdomains for this domain (removes *.myapp.roxy registration) + #[arg(long)] + wildcard: bool, + /// Skip confirmation prompt #[arg(long)] force: bool, @@ -112,6 +121,10 @@ enum Commands { enum RouteCommands { /// Add a route to an existing domain Add { + /// Manage wildcard registration for this domain (matches myapp.roxy and *.myapp.roxy) + #[arg(long)] + wildcard: bool, + /// Domain name domain: String, @@ -124,6 +137,10 @@ enum RouteCommands { /// Remove a route from a domain Remove { + /// Manage wildcard registration for this domain (matches myapp.roxy and *.myapp.roxy) + #[arg(long)] + wildcard: bool, + /// Domain name domain: String, @@ -133,6 +150,10 @@ enum RouteCommands { /// List routes for a domain List { + /// Manage wildcard registration for this domain (matches myapp.roxy and *.myapp.roxy) + #[arg(long)] + wildcard: bool, + /// Domain name domain: String, }, @@ -156,20 +177,31 @@ fn main() -> Result<()> { match cli.command { Commands::Install => cli::install::execute(config_path, &paths, &config), Commands::Uninstall { force } => cli::uninstall::execute(force, config_path, &paths), - Commands::Register { domain, route } => { - cli::register::execute(domain, route, config_path, &paths) - } - Commands::Unregister { domain, force } => { - cli::unregister::execute(domain, force, config_path, &paths) - } + Commands::Register { + domain, + wildcard, + route, + } => cli::register::execute(domain, wildcard, route, config_path, &paths), + Commands::Unregister { + domain, + wildcard, + force, + } => cli::unregister::execute(domain, wildcard, force, config_path, &paths), Commands::Route { command } => match command { RouteCommands::Add { + wildcard, domain, path, target, - } => cli::route::add(domain, path, target, config_path), - RouteCommands::Remove { domain, path } => cli::route::remove(domain, path, config_path), - RouteCommands::List { domain } => cli::route::list(domain, config_path), + } => cli::route::add(domain, wildcard, path, target, config_path), + RouteCommands::Remove { + wildcard, + domain, + path, + } => cli::route::remove(domain, wildcard, path, config_path), + RouteCommands::List { wildcard, domain } => { + cli::route::list(domain, wildcard, config_path) + } }, Commands::List => cli::list::execute(config_path, &paths), Commands::Start { foreground } => {