diff --git a/.env.example b/.env.example index 445fc55..b0ea643 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ # No default - must be set # NGIT_DOMAIN= +# Public URL path where this instance is mounted +# Use / for a domain-root service or a normalized path such as /grasp +# CLI: --base-path +# Default: / +# NGIT_BASE_PATH=/ + # ============================================================================ # SERVER CONFIGURATION # ============================================================================ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf6226..9069306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `trusted_proxy_cidrs` to the public `Config` struct. Rust consumers that construct `Config` with a struct literal must provide it. +- Added `base_path` to the public `Config` struct. Rust consumers that + construct `Config` with a struct literal must provide it. ### Security @@ -24,8 +26,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `NGIT_BASE_PATH` / `--base-path` (NixOS `basePath`) so one GRASP server + can be mounted below a shared domain path. WebSocket, Git Smart HTTP, + GRASP-06, NIP-11, metrics, icons, landing pages, service matching, generated + NIP-65 identity, and self-sync all use the configured prefix. Requests + outside it are rejected. Path-mounted servers neither serve nor advertise + the root-domain NIP-05 identity, and their generated kind-0 profile omits + the `nip05` field. + - Publish the relay-owner identity on startup as a minimal kind-0 profile with - the scheme-less public URL as `name`, `_@domain` NIP-05, and `bot: true`, + the scheme-less public URL as `name`, `bot: true`, and `_@domain` NIP-05 for + domain-root deployments, plus a kind-10002 list naming this relay as its sole read/write relay. An operator-customized profile survives restarts, and no identity event — stored or generated — is published before the local database and at least diff --git a/README.md b/README.md index 9b8a654..be84c9a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A [GRASP](https://gitworkshop.dev/danconwaydev.com/grasp) (Git Relays Authorized - **Git Smart HTTP Backend**: Serves Git repositories over HTTP - **Nostr Relay**: Stores and validates repository announcements and state events -- **Root Nostr Identity**: Serves `_@domain` through NIP-05, seeds a minimal bot profile and single-relay NIP-65 list, and trusts service events signed by the relay owner +- **Relay Owner Identity**: Root-mounted relays serve `_@domain` through NIP-05; every deployment seeds a minimal bot profile and single-relay NIP-65 list and trusts service events signed by the relay owner - **Integrated Authorization**: Validates Git pushes against Nostr state events without requiring external hooks Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-relay)) which uses nginx + git-http-backend + pre-receive hooks + Khatru (Go), `ngit-grasp` provides a unified Rust service that handles both Git and Nostr protocols natively. @@ -401,6 +401,7 @@ nix develop -c cargo build --release cp .env.example .env # Edit .env with your settings # Required: NGIT_DOMAIN=your-domain.com +# Optional: NGIT_BASE_PATH=/grasp # Share a domain at wss://your-domain.com/grasp # Optional: NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.example.com # Run @@ -416,7 +417,7 @@ nix develop -c cargo test --lib - Nostr relay begins accepting WebSocket connections - If bootstrap relay configured, sync system connects and discovers repositories - Purgatory system activates, ready to hunt for missing git data -- Prometheus metrics exposed at `/metrics` +- Prometheus metrics exposed at `/metrics` **Don't have Nix?** See [Getting Started Tutorial](docs/tutorials/getting-started.md) for alternative setup methods. @@ -456,6 +457,7 @@ passes the key through a protected systemd credential. | Option | CLI Flag | Environment Variable | Default | | ------------------- | ------------------------ | --------------------------- | -------------------------------------------- | | Domain | `--domain` | `NGIT_DOMAIN` | (required) | +| Base path | `--base-path` | `NGIT_BASE_PATH` | `/` | | Relay owner nsec | — | `NGIT_RELAY_OWNER_NSEC` | systemd credential, then `.relay-owner.nsec` | | Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` | | Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` | diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 86b9137..be4f50f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -109,10 +109,12 @@ nostr-lmdb = "0.45.0-alpha.3" **Responsibilities:** - Route HTTP requests to appropriate handlers -- WebSocket upgrade for Nostr relay at `/` -- Git Smart HTTP endpoints at `//.git/*` -- Landing pages, the root-path-only NIP-05 `_@domain` well-known mapping, and - path-aware NIP-11 document serving that omits NIP-05 below the domain root +- WebSocket upgrade for the Nostr relay at the configured `NGIT_BASE_PATH` +- Git Smart HTTP endpoints below + `//.git/*` +- Prefix-scoped landing, metrics, icon, Git, GRASP-06, NIP-11, and WebSocket + routing. The NIP-05 `_@domain` mapping is served and advertised only when + `NGIT_BASE_PATH=/`; path-mounted owner profiles also omit `nip05`. - CORS headers on all responses (GRASP-01 requirement) **Key Implementation Details:** diff --git a/docs/how-to/deploy.md b/docs/how-to/deploy.md index 09931ec..41cf889 100644 --- a/docs/how-to/deploy.md +++ b/docs/how-to/deploy.md @@ -273,6 +273,7 @@ git ls-remote https://ngit.example.com//.git - `domain` - Domain where relay is hosted ### Network +- `basePath` - Public URL mount path (default: `/`) - `bindAddress` - IP to bind to (default: "127.0.0.1") - `port` - Port to listen on (default: 7334) - `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client @@ -296,7 +297,7 @@ git ls-remote https://ngit.example.com//.git - `syncBaseBackoffSecs` - Base backoff time (default: 5) ### Metrics -- `metricsEnabled` - Enable /metrics endpoint (default: true) +- `metricsEnabled` - Enable `/metrics` below the configured base path (default: true) - `metricsConnectionPerIpAbuseThreshold` - Abuse threshold (default: 10) - `metricsTopNRepos` - Number of top repos to track (default: 10) diff --git a/docs/how-to/enable-grasp-06.md b/docs/how-to/enable-grasp-06.md index c7377b1..c510e7a 100644 --- a/docs/how-to/enable-grasp-06.md +++ b/docs/how-to/enable-grasp-06.md @@ -4,6 +4,10 @@ GRASP-06 adds an opt-in endpoint at `/prs//.git` that any cont This page covers the operator-facing steps to turn it on and verify it. +All paths below are relative to `NGIT_BASE_PATH`. For a relay mounted at +`/grasp`, use `/grasp`, `/grasp/prs/...`, and matching path-prefixed `clone` +tags in the examples. + ## 1. Flip the feature flag Set `NGIT_GRASP06_ENABLE=true` for the relay process. The flag is off by default; everything below is a no-op without it. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 769dc65..e0bf3c4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -127,7 +127,37 @@ NGIT_DOMAIN=localhost:7334 # Development only - Must be accessible from the internet for production - Include port if non-standard (e.g., `localhost:7334`) -- Used in repository clone URLs: `https://{NGIT_DOMAIN}/{npub}/{repo}.git` +- Combined with `NGIT_BASE_PATH` in repository clone and relay URLs + +--- + +#### `NGIT_BASE_PATH` + +**Description:** Public URL path where this GRASP instance is mounted +**Type:** String (normalized absolute URL path) +**Default:** `/` +**Required:** No + +**Examples:** + +```bash +NGIT_BASE_PATH=/ +NGIT_BASE_PATH=/grasp +NGIT_BASE_PATH=/services/git +``` + +When set to a non-root path, every service endpoint is scoped below it. For +example, `NGIT_DOMAIN=example.org` with `NGIT_BASE_PATH=/grasp` exposes the +Nostr relay at `wss://example.org/grasp`, Git repositories below +`https://example.org/grasp//.git`, and metrics at +`https://example.org/grasp/metrics`. Requests outside the prefix are rejected. + +The value must start with `/`, must not end with `/` unless it is exactly `/`, +and must not contain empty, `.` or `..` segments, a query, or a fragment. +Path-mounted relays do not serve or advertise the root-domain `_@domain` +NIP-05 identity. Their generated owner profile omits its `nip05` field. + +The corresponding NixOS option is `basePath`. --- @@ -981,7 +1011,7 @@ NGIT_ARCHIVE_READ_ONLY=true # Default ### GRASP-06 Contributor PR Submission -These options control the optional `/prs//.git` contributor pull-request submission endpoint per the [GRASP-06 specification](https://github.com/DanConwayDev/grasp/blob/main/06.md). +These options control the optional `/prs//.git` contributor pull-request submission endpoint per the [GRASP-06 specification](https://github.com/DanConwayDev/grasp/blob/main/06.md). The route is below `NGIT_BASE_PATH` when a non-root mount is configured. #### `NGIT_GRASP06_ENABLE` diff --git a/nix/module.nix b/nix/module.nix index 47e6887..5ff25d0 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -35,6 +35,16 @@ let "Domain where this relay is hosted (used in GRASP validation)"; }; + basePath = mkOption { + type = types.str; + default = "/"; + example = "/grasp"; + description = '' + Public URL path where this relay is mounted. Use "/" for a + domain-root deployment or a normalized path such as "/grasp". + ''; + }; + bindAddress = mkOption { type = types.str; default = "127.0.0.1"; @@ -565,6 +575,7 @@ let environment = { NGIT_DOMAIN = cfg.domain; + NGIT_BASE_PATH = cfg.basePath; NGIT_BIND_ADDRESS = "${cfg.bindAddress}:${toString cfg.port}"; NGIT_GIT_DATA_PATH = "${cfg.dataDir}/git"; NGIT_RELAY_DATA_PATH = "${cfg.dataDir}/relay"; diff --git a/src/config.rs b/src/config.rs index 89ecb1c..ae5e056 100644 --- a/src/config.rs +++ b/src/config.rs @@ -325,6 +325,13 @@ pub struct Config { #[arg(long, env = "NGIT_DOMAIN")] pub domain: String, + /// Public URL path where this instance is mounted. + /// + /// Use `/` for a domain-root deployment or an absolute path such as + /// `/grasp` when sharing a domain with another service. + #[arg(long, env = "NGIT_BASE_PATH", default_value = "/")] + pub base_path: String, + /// Relay operator's nsec (private key) for signing and authentication. /// /// Used for: @@ -871,6 +878,59 @@ impl Config { .unwrap_or_else(|| format!("{} grasp relay", self.domain)) } + /// Whether the public relay endpoint is mounted at the domain root. + pub fn is_domain_root(&self) -> bool { + self.base_path == "/" + } + + /// Scheme-less public service address used in GRASP event matching. + /// + /// Root deployments return just the configured authority. Path-mounted + /// deployments append their canonical base path. + pub fn service_address(&self) -> String { + let domain = self.domain.trim().trim_end_matches('/'); + if self.is_domain_root() { + domain.to_string() + } else { + format!("{domain}{}", self.base_path) + } + } + + /// Remove the configured public mount prefix from an incoming URL path. + /// + /// The returned path always starts with `/`. The exact non-root mount + /// path and its trailing-slash form both map to `/`; sibling prefixes do + /// not match. + pub fn strip_base_path<'a>(&self, request_path: &'a str) -> Option<&'a str> { + if self.is_domain_root() { + return Some(request_path); + } + if request_path == self.base_path + || request_path.strip_suffix('/') == Some(self.base_path.as_str()) + { + return Some("/"); + } + request_path.strip_prefix(&self.base_path).and_then(|rest| { + if rest.starts_with('/') { + Some(rest) + } else { + None + } + }) + } + + /// Prefix one root-relative application route with the public mount path. + pub fn public_path(&self, route: &str) -> String { + debug_assert!(route.starts_with('/')); + if self.is_domain_root() { + route.to_string() + } else if route == "/" { + self.base_path.clone() + } else { + format!("{}{route}", self.base_path) + } + } + /// Get effective git data path /// Returns a temp directory when using memory backend with default path, otherwise the configured path pub fn effective_git_data_path(&self) -> String { @@ -897,6 +957,27 @@ impl Config { .context("relay_owner_nsec not set (should be set by Config::load())")?; Keys::parse(nsec).context("Invalid relay_owner_nsec format")?; + let normalized_base_path = Url::parse(&format!( + "https://base-path-validation.invalid{}", + self.base_path + )) + .ok() + .map(|url| url.path().to_string()); + if !self.base_path.starts_with('/') + || (self.base_path.len() > 1 && self.base_path.ends_with('/')) + || self.base_path.contains("//") + || self.base_path.contains(['?', '#']) + || self + .base_path + .split('/') + .any(|segment| matches!(segment, "." | "..")) + || normalized_base_path.as_deref() != Some(self.base_path.as_str()) + { + return Err(anyhow!( + "NGIT_BASE_PATH must be '/' or a normalized absolute URL path without a trailing slash, empty segment, query, fragment, '.' or '..'" + )); + } + // Validate archive configuration let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); let archive_grasp_services = self.parse_archive_grasp_services(); @@ -1222,6 +1303,7 @@ impl Config { Self { domain: "localhost:7334".to_string(), + base_path: "/".to_string(), relay_owner_nsec: Some(nsec), relay_name_override: Some("test relay".to_string()), relay_description: "test description".to_string(), @@ -1330,12 +1412,61 @@ mod tests { fn test_default_values() { let config = Config::for_testing(); assert_eq!(config.domain, "localhost:7334"); + assert_eq!(config.base_path, "/"); assert_eq!(config.bind_address, "127.0.0.1:7334"); assert!(config.trusted_proxy_cidrs.is_empty()); // for_testing() uses Memory, but the actual default is Lmdb assert_eq!(config.database_backend, DatabaseBackend::Memory); } + #[test] + fn base_path_parses_and_builds_public_routes() { + let config = Config::try_parse_from([ + "ngit-grasp", + "--domain", + "example.com", + "--base-path", + "/services/grasp", + ]) + .expect("base path should parse"); + + assert_eq!(config.base_path, "/services/grasp"); + assert_eq!(config.service_address(), "example.com/services/grasp"); + assert_eq!(config.public_path("/"), "/services/grasp"); + assert_eq!( + config.public_path("/npub/repo.git"), + "/services/grasp/npub/repo.git" + ); + assert_eq!(config.strip_base_path("/services/grasp"), Some("/")); + assert_eq!( + config.strip_base_path("/services/grasp/npub/repo.git"), + Some("/npub/repo.git") + ); + assert_eq!(config.strip_base_path("/services/graspy"), None); + } + + #[test] + fn base_path_validation_rejects_ambiguous_paths() { + for base_path in [ + "grasp", + "/grasp/", + "/grasp//relay", + "/grasp/../relay", + "/grasp/%2e%2e/relay", + "/grasp?mode=relay", + "/grasp#relay", + ] { + let config = Config { + base_path: base_path.to_string(), + ..Config::for_testing() + }; + let error = config + .validate() + .expect_err("ambiguous base path must fail validation"); + assert!(error.to_string().contains("NGIT_BASE_PATH"), "{base_path}"); + } + } + #[test] fn test_trusted_proxy_cidrs_parse_from_cli() { let config = Config::try_parse_from([ diff --git a/src/grasp06/policy.rs b/src/grasp06/policy.rs index c5fc2d9..b6b492a 100644 --- a/src/grasp06/policy.rs +++ b/src/grasp06/policy.rs @@ -41,7 +41,7 @@ pub fn event_qualifies_for_pr_relaxation(event: &Event, config: &Config) -> bool return false; } - event_names_relays_prs_endpoint(event, &config.domain) + event_names_relays_prs_endpoint(event, &config.service_address()) } /// Returns true when `event` is a PR/PR-Update event whose `clone` tag names @@ -202,13 +202,23 @@ fn clone_url_prs_identifier_for_relay( let authority = &rest[..slash_idx]; let path = &rest[slash_idx..]; // includes leading `/`. - if !authority.eq_ignore_ascii_case(domain) { + let configured = domain + .strip_prefix("https://") + .or_else(|| domain.strip_prefix("http://")) + .unwrap_or(domain) + .trim_end_matches('/'); + let (configured_authority, configured_path) = configured + .split_once('/') + .map(|(authority, path)| (authority, format!("/{path}"))) + .unwrap_or((configured, String::new())); + + if !authority.eq_ignore_ascii_case(configured_authority) { return None; } let path = path.trim_end_matches('/'); - let inner = path.strip_prefix(&format!("/{}/", PRS_URL_PREFIX))?; + let inner = path.strip_prefix(&format!("{configured_path}/{}/", PRS_URL_PREFIX))?; // Exactly two segments: `` and `.git`. let segments: Vec<&str> = inner.split('/').collect(); @@ -317,6 +327,36 @@ mod tests { assert!(event_qualifies_for_pr_relaxation(&event, &cfg)); } + #[test] + fn path_mounted_service_requires_base_path_before_prs_endpoint() { + let signer = Keys::generate(); + let target_hex = Keys::generate().public_key().to_hex(); + let npub = signer.public_key().to_bech32().unwrap(); + let matching_url = format!("http://relay.example/grasp/prs/{npub}/my-repo.git"); + let root_url = format!("http://relay.example/prs/{npub}/my-repo.git"); + let matching = pr_event( + &signer, + &[(&target_hex, "my-repo")], + &[&matching_url], + Kind::GitPullRequest, + ); + let root_only = pr_event( + &signer, + &[(&target_hex, "my-repo")], + &[&root_url], + Kind::GitPullRequest, + ); + let cfg = Config { + domain: "relay.example".to_string(), + base_path: "/grasp".to_string(), + grasp06_enable: true, + ..Config::for_testing() + }; + + assert!(event_qualifies_for_pr_relaxation(&matching, &cfg)); + assert!(!event_qualifies_for_pr_relaxation(&root_only, &cfg)); + } + #[test] fn accepts_matching_pr_update_event() { let signer = Keys::generate(); diff --git a/src/http/landing.rs b/src/http/landing.rs index 042be5e..3286fe0 100644 --- a/src/http/landing.rs +++ b/src/http/landing.rs @@ -577,6 +577,7 @@ pub fn get_html(config: &Config) -> String { /// /// Used for any path that doesn't match a known route pub fn get_generic_404_html(config: &Config, path: &str) -> String { + let home_path = config.public_path("/"); format!( r##" @@ -631,7 +632,7 @@ pub fn get_generic_404_html(config: &Config, path: &str) -> String {
Requested Path
{path} - ← Back to {relay_name} + ← Back to {relay_name} {footer_script} @@ -640,6 +641,7 @@ pub fn get_generic_404_html(config: &Config, path: &str) -> String { "##, base_css = get_base_css(), relay_name = config.relay_name(), + home_path = escape_html(&home_path), path = path, version = get_version(), footer_script = get_footer_script(), @@ -652,6 +654,7 @@ pub fn get_generic_404_html(config: &Config, path: &str) -> String { /// /// GRASP-01: "...and a 404 page for repositories it doesn't host" pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String { + let home_path = config.public_path("/"); format!( r##" @@ -730,7 +733,7 @@ pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String {
The repository may not have been announced to this server, or the URL may be incorrect.
- ← Back to {relay_name} + ← Back to {relay_name} {footer_script} @@ -739,6 +742,7 @@ pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String { "##, base_css = get_base_css(), relay_name = config.relay_name(), + home_path = escape_html(&home_path), npub = npub, identifier = identifier, version = get_version(), @@ -753,6 +757,8 @@ pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String { /// GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) /// to browse the repository" pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { + let home_path = config.public_path("/"); + let relay_path = serde_json::to_string(&home_path).expect("serialize configured base path"); format!( r##" @@ -830,7 +836,7 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { {theme_toggle}

{identifier}

@@ -858,10 +864,16 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { // Detect protocol and construct relayref const protocol = window.location.protocol; // 'http:' or 'https:' const host = window.location.host; // 'domain.com' or 'domain.com:port' + const relayPath = {relay_path}; + const websocketPath = relayPath === '/' ? '' : relayPath; - // For http, use ws:// prefix and URL encode; for https, just use host (implies wss://) + // A path relay hint must carry the complete WebSocket URL. Root HTTPS + // deployments retain the compact host-only relay hint. let relayref = host; - if (protocol === 'http:') relayref = encodeURIComponent("ws://" + host); + if (protocol === 'http:' || websocketPath) {{ + const websocketProtocol = protocol === 'http:' ? 'ws://' : 'wss://'; + relayref = encodeURIComponent(websocketProtocol + host + websocketPath); + }} // Update the relayref in the clone URL document.getElementById('relayref').textContent = relayref; @@ -881,6 +893,8 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String { "##, base_css = get_base_css(), relay_name = config.relay_name(), + home_path = escape_html(&home_path), + relay_path = relay_path, npub = npub, identifier = identifier, encoded_identifier = percent_encode(identifier), diff --git a/src/http/mod.rs b/src/http/mod.rs index e46ed2b..bedd3dc 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -163,7 +163,24 @@ impl Service> for HttpService { fn call(&self, req: Request) -> Self::Future { let base = add_cors_headers(Response::builder().header("server", "ngit-grasp")); - let path = req.uri().path().to_string(); + let request_path = req.uri().path().to_string(); + let Some(path) = self + .config + .strip_base_path(&request_path) + .map(str::to_string) + else { + let config = self.config.clone(); + return Box::pin(async move { + let html = landing::get_generic_404_html(&config, &request_path); + Ok( + add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(404) + .header("content-type", "text/html; charset=utf-8") + .body(full_body(html)) + .unwrap(), + ) + }); + }; let query = req.uri().query().map(|s| s.to_string()); let method = req.method().clone(); let git_data_path = self.config.effective_git_data_path(); @@ -191,7 +208,10 @@ impl Service> for HttpService { // public key. This exact-path route must remain ahead of generic NIP-11 // content negotiation so clients receive the well-known document even // if they send a broad or unusual Accept header. - if path == "/.well-known/nostr.json" && (method == Method::GET || method == Method::HEAD) { + if self.config.is_domain_root() + && path == "/.well-known/nostr.json" + && (method == Method::GET || method == Method::HEAD) + { let method = method.clone(); let document = nip05::Nip05Document::from_config(&self.config); @@ -370,7 +390,7 @@ impl Service> for HttpService { &git_data_path, git_protocol.as_deref(), repo_init_locks.clone(), - &config_clone.domain, + &config_clone.service_address(), metrics_clone.clone(), ) .await; @@ -425,7 +445,7 @@ impl Service> for HttpService { // Check for Git HTTP requests first if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) { if let Some(access) = &self.private_access { - let authorized = nip98::canonical_repository_url(&self.config, &path) + let authorized = nip98::canonical_repository_url(&self.config, &request_path) .is_some_and(|url| nip98::validate_request(&req, &url, access).is_ok()); if !authorized { let response = nip98::unauthorized_response(&self.config); @@ -688,13 +708,13 @@ impl Service> for HttpService { } // Check for NIP-11 relay information request (Accept: application/nostr+json) - if let Some(accept) = req.headers().get("accept") { + if let ("/", Some(accept)) = (path.as_str(), req.headers().get("accept")) { if accept .to_str() .map(|s| s.contains("application/nostr+json")) .unwrap_or(false) { - let doc = nip11::RelayInformationDocument::from_config_at_path(&self.config, &path); + let doc = nip11::RelayInformationDocument::from_config(&self.config); let json = doc.to_json().unwrap_or_else(|e| { tracing::error!("Failed to serialize NIP-11 document: {}", e); "{}".to_string() @@ -722,7 +742,7 @@ impl Service> for HttpService { // to browse the repository and a 404 page for repositories it doesn't host" if let Some((npub, identifier)) = parse_repo_url(&path) { if let Some(access) = &self.private_access { - let authorized = nip98::canonical_repository_url(&self.config, &path) + let authorized = nip98::canonical_repository_url(&self.config, &request_path) .is_some_and(|url| nip98::validate_request(&req, &url, access).is_ok()); if !authorized { let response = nip98::unauthorized_response(&self.config); @@ -778,7 +798,8 @@ impl Service> for HttpService { } // Check if this is a WebSocket upgrade request - if let (Some(c), Some(w)) = ( + if let ("/", Some(c), Some(w)) = ( + path.as_str(), req.headers().get("connection"), req.headers().get("upgrade"), ) { @@ -801,7 +822,7 @@ impl Service> for HttpService { let relay = self.relay.clone(); let metrics_clone = self.metrics.clone(); let private_access = self.private_access.clone(); - let relay_domain = self.config.domain.clone(); + let relay_domain = self.config.service_address(); tokio::spawn(async move { match hyper::upgrade::on(req).await { @@ -953,7 +974,7 @@ impl Service> for HttpService { ) } else { // Serve generic 404 for unknown paths - let html = landing::get_generic_404_html(&config, &path); + let html = landing::get_generic_404_html(&config, &request_path); Ok( add_cors_headers(Response::builder().header("server", "ngit-grasp")) .status(404) @@ -1042,7 +1063,7 @@ pub async fn run_server_on_listener( ) -> anyhow::Result<()> { tracing::info!("Starting HTTP server on {}", listener.local_addr()?); tracing::info!("Relay name: {}", config.relay_name()); - tracing::info!("Domain: {}", config.domain); + tracing::info!("Public service: {}", config.service_address()); if !config.trusted_proxy_cidrs.is_empty() { tracing::info!( trusted_proxy_cidrs = ?config.trusted_proxy_cidrs, diff --git a/src/http/nip11.rs b/src/http/nip11.rs index 4a365e8..d8b4b98 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -67,17 +67,8 @@ pub struct RelayLimitation { } impl RelayInformationDocument { - /// Create a NIP-11 document for a relay served at the domain root. + /// Create a NIP-11 document for the configured public mount path. pub fn from_config(config: &Config) -> Self { - Self::from_config_at_path(config, "/") - } - - /// Create a NIP-11 document for the path where the relay was requested. - /// - /// NIP-05 `_@domain` can only resolve a relay served at the domain root, - /// because clients always request `/.well-known/nostr.json`. A relay - /// mounted below any other path must therefore not advertise NIP-05. - pub fn from_config_at_path(config: &Config, relay_path: &str) -> Self { // Get validated configuration (config.validate() must be called at startup) let archive_config = config.archive_config(); let archive_enabled = archive_config.enabled(); @@ -140,7 +131,7 @@ impl RelayInformationDocument { 34, // NIP-34: Git repository announcements 77, // NIP-77: Negentropy sync (reconciliation protocol) ]; - if relay_path == "/" { + if config.is_domain_root() { nips.push(5); // NIP-05: Root-domain identity } // NIP-09 (deletion) and NIP-62 (request to vanish) are honoured @@ -165,7 +156,7 @@ impl RelayInformationDocument { Some(commit) => format!("{}-{}", env!("CARGO_PKG_VERSION"), commit), None => env!("CARGO_PKG_VERSION").to_string(), }, - icon: Some(format!("https://{}/icon.png", config.domain)), + icon: Some(format!("https://{}/icon.png", config.service_address())), limitation: RelayLimitation { max_message_length: 5 * 1024 * 1024, max_subscriptions: config.relay_max_subscriptions, @@ -422,13 +413,21 @@ mod tests { #[test] fn test_nip11_advertises_nip05_only_at_domain_root() { - let config = Config::for_testing(); + let root_config = Config::for_testing(); + let nested_config = Config { + base_path: "/relay".to_string(), + ..Config::for_testing() + }; - let root_doc = RelayInformationDocument::from_config_at_path(&config, "/"); - let nested_doc = RelayInformationDocument::from_config_at_path(&config, "/relay"); + let root_doc = RelayInformationDocument::from_config(&root_config); + let nested_doc = RelayInformationDocument::from_config(&nested_config); assert!(root_doc.supported_nips.contains(&5)); assert!(!nested_doc.supported_nips.contains(&5)); + assert_eq!( + nested_doc.icon.as_deref(), + Some("https://localhost:7334/relay/icon.png") + ); } #[test] diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 02510f4..99fda7a 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -126,7 +126,7 @@ impl Nip34WritePolicy { private_access: Option, ) -> Self { let git_data_path = git_data_path.into(); - let domain = config.domain.clone(); + let domain = config.service_address(); let deletion_ctx = DeletionContext::new( domain.clone(), database.clone(), diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 9ece1f6..b34cf53 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -150,7 +150,7 @@ impl RepositoryAnnouncement { pub fn has_relay(&self, relay: &str) -> bool { self.relays .iter() - .any(|r| crate::outbound::url_matches_service_domain(r, relay)) + .any(|r| crate::outbound::relay_url_matches_service(r, relay)) } /// Check if this announcement lists the service (both clone and relay) @@ -427,7 +427,7 @@ pub fn validate_announcement( let blacklist_config = config.blacklist_config(); let npub = announcement.owner_npub(); - let lists_service = announcement.lists_service(&config.domain); + let lists_service = announcement.lists_service(&config.service_address()); // Check blacklist FIRST - it overrides everything if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) { @@ -628,6 +628,40 @@ mod tests { assert!(matches!(result, AnnouncementResult::Accept)); } + #[test] + fn path_mounted_service_requires_path_in_clone_and_relay_urls() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let config = Config { + domain: "gitnostr.com".to_string(), + base_path: "/grasp".to_string(), + ..Config::for_testing() + }; + let matching = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/grasp/alice/test-repo.git"], + vec!["wss://gitnostr.com/grasp"], + ); + let root_only = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + assert!(matches!( + validate_announcement(&matching, &config), + AnnouncementResult::Accept + )); + assert!(matches!( + validate_announcement(&root_only, &config), + AnnouncementResult::Reject(_) + )); + } + #[test] fn test_validate_announcement_missing_clone() { use crate::config::Config; diff --git a/src/nostr/lifecycle/deletion/cascade.rs b/src/nostr/lifecycle/deletion/cascade.rs index 5e58391..4cce172 100644 --- a/src/nostr/lifecycle/deletion/cascade.rs +++ b/src/nostr/lifecycle/deletion/cascade.rs @@ -161,7 +161,7 @@ impl DeletionPolicy { let plan = match plan_deleted_announcement_cascade( &self.ctx.database, &deletable_announcements, - &self.ctx.config.domain, + &self.ctx.config.service_address(), ) .await { diff --git a/src/nostr/lifecycle/deletion/pr_refs.rs b/src/nostr/lifecycle/deletion/pr_refs.rs index bb46bc4..84ca06e 100644 --- a/src/nostr/lifecycle/deletion/pr_refs.rs +++ b/src/nostr/lifecycle/deletion/pr_refs.rs @@ -80,7 +80,7 @@ impl DeletionPolicy { ) -> BTreeSet { crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags( event, - &self.ctx.config.domain, + &self.ctx.config.service_address(), ) .into_iter() .map(|identifier| { diff --git a/src/nostr/lifecycle/deletion/recovery.rs b/src/nostr/lifecycle/deletion/recovery.rs index 11d134f..121b636 100644 --- a/src/nostr/lifecycle/deletion/recovery.rs +++ b/src/nostr/lifecycle/deletion/recovery.rs @@ -264,7 +264,7 @@ impl DeletionService { for identifier in crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags( event, - &self.ctx.config.domain, + &self.ctx.config.service_address(), ) { paths.insert(crate::grasp06::paths::prs_repo_path( &self.ctx.git_data_path, diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index a311354..60961bc 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -105,7 +105,8 @@ impl AnnouncementPolicy { // announcement never listed this service, so its // replacement must remain eligible for the maintainer // exception instead of being mistaken for a de-list. - let lists_service = announcement.lists_service(&self.config.domain); + let service = self.config.service_address(); + let lists_service = announcement.lists_service(&service); if !lists_service { match self .db_announcement(&event.pubkey, &announcement.identifier) @@ -117,7 +118,7 @@ impl AnnouncementPolicy { Ok(current) => current, Err(_) => return AnnouncementResult::Reject(reason), }; - if current.lists_service(&self.config.domain) { + if current.lists_service(&service) { return AnnouncementResult::Reject(reason); } } diff --git a/src/nostr/relay_identity.rs b/src/nostr/relay_identity.rs index a07a474..ac3be6f 100644 --- a/src/nostr/relay_identity.rs +++ b/src/nostr/relay_identity.rs @@ -100,16 +100,20 @@ impl IdentityPublicationPlan { pub fn build(config: &Config) -> Result { let keys = config.relay_owner_keys()?; let domain = config.domain.trim().trim_end_matches('/'); - let relay_url = public_relay_url(domain)?; + let service = config.service_address(); + let relay_url = public_relay_url(domain, &config.base_path)?; let created_at = Timestamp::now(); - let profile = Metadata::new() + let mut metadata = Metadata::new() // The scheme-less public URL (authority plus any mount path) so // clients that ignore the NIP-05 fallback still display something // meaningful. NIP-24 expects `name` to always be set. - .name(domain) - .nip05(format!("_@{domain}")) - .custom_field("bot", true) + .name(service) + .custom_field("bot", true); + if config.is_domain_root() { + metadata = metadata.nip05(format!("_@{domain}")); + } + let profile = metadata .into_event_builder() .custom_created_at(created_at) .finalize(&keys) @@ -623,7 +627,7 @@ fn publication_failure_disposition(message: &str) -> PublicationFailureDispositi } } -fn public_relay_url(domain: &str) -> Result { +fn public_relay_url(domain: &str, base_path: &str) -> Result { let scheme = if is_loopback_authority(domain) { "ws" } else { @@ -635,8 +639,10 @@ fn public_relay_url(domain: &str) -> Result { } else { domain.to_string() }; - RelayUrl::parse(&format!("{scheme}://{authority}")) - .with_context(|| format!("derive public relay URL from NGIT_DOMAIN={domain}")) + let path = if base_path == "/" { "" } else { base_path }; + RelayUrl::parse(&format!("{scheme}://{authority}{path}")).with_context(|| { + format!("derive public relay URL from NGIT_DOMAIN={domain} and NGIT_BASE_PATH={base_path}") + }) } fn is_loopback_authority(domain: &str) -> bool { @@ -689,10 +695,31 @@ mod tests { assert_eq!(relays[0].1, None, "an unmarked relay is read and write"); } + #[test] + fn path_mounted_identity_omits_nip05_and_lists_path_relay() { + let config = Config { + domain: "relay.example.com".to_string(), + base_path: "/grasp".to_string(), + ..Config::for_testing() + }; + let events = build(&config).expect("build path-mounted relay identity"); + let profile: serde_json::Value = + serde_json::from_str(&events.profile.content).expect("parse profile metadata"); + + assert_eq!(profile["name"], "relay.example.com/grasp"); + assert!(profile.get("nip05").is_none()); + + let relays: Vec<_> = nip65::extract_relay_list(&events.relay_list).collect(); + assert_eq!(relays.len(), 1); + assert_eq!(relays[0].0.as_str(), "wss://relay.example.com/grasp"); + } + #[test] fn production_domain_defaults_to_secure_websocket_url() { assert_eq!( - public_relay_url("relay.example.com").unwrap().to_string(), + public_relay_url("relay.example.com", "/") + .unwrap() + .to_string(), "wss://relay.example.com" ); } @@ -700,7 +727,7 @@ mod tests { #[test] fn loopback_authorities_use_plain_websocket_urls() { for domain in ["localhost:7334", "127.0.0.1:8080", "[::1]:7334", "::1"] { - let url = public_relay_url(domain).unwrap().to_string(); + let url = public_relay_url(domain, "/").unwrap().to_string(); assert!(url.starts_with("ws://"), "{domain} -> {url}"); } } diff --git a/src/outbound.rs b/src/outbound.rs index 7d27fd4..514c4d2 100644 --- a/src/outbound.rs +++ b/src/outbound.rs @@ -311,21 +311,35 @@ fn is_ipv6_globally_reachable(address: Ipv6Addr) -> bool { || (segments[..6].iter().all(|segment| *segment == 0))) } -/// Whether a URL's authority is exactly the given service domain. +/// Whether a URL belongs to the given GRASP service. /// -/// `service` is a bare authority such as `gitnostr.com` or `127.0.0.1:7334` -/// (an optional scheme prefix and trailing slash are tolerated). The URL -/// matches only when its parsed host equals the service host and its -/// effective port agrees: an explicit service port must match the URL's -/// effective port, while a service without a port requires the URL to use its -/// scheme's default port. +/// `service` is a bare authority with an optional mount path, such as +/// `gitnostr.com`, `127.0.0.1:7334`, or `example.com/grasp` (an optional +/// scheme prefix and trailing slash are tolerated). The authority must match +/// exactly. A configured mount path must equal the URL path or be its +/// segment-delimited prefix, allowing repository URLs below that mount while +/// preventing sibling mounts from being mistaken for this service. /// /// Substring tricks - `gitnostr.com.attacker.example`, /// `https://evil.example/gitnostr.com/x.git`, `user@gitnostr.com` credentials /// pointing elsewhere - do not match, unlike the `contains()` checks this /// replaces. pub fn url_matches_service_domain(url: &str, service: &str) -> bool { - let Some((service_host, service_port)) = parse_service_authority(service) else { + url_matches_service(url, service, false) +} + +/// Whether a relay URL names the exact configured GRASP relay endpoint. +/// +/// Unlike [`url_matches_service_domain`], child paths do not match. This is +/// used for Nostr relay tags and own-relay suppression; Git clone URLs use the +/// prefix-aware matcher above because their repository coordinates are below +/// the service mount. +pub fn relay_url_matches_service(url: &str, service: &str) -> bool { + url_matches_service(url, service, true) +} + +fn url_matches_service(url: &str, service: &str, exact_path: bool) -> bool { + let Some((service_host, service_port, service_path)) = parse_service_location(service) else { return false; }; let Ok(parsed) = parse_lenient_url(url) else { @@ -337,28 +351,42 @@ pub fn url_matches_service_domain(url: &str, service: &str) -> bool { if normalize_host(host) != service_host { return false; } - match service_port { + let authority_matches = match service_port { Some(port) => parsed.port_or_known_default() == Some(port), // url::Url normalizes an explicit default port to None, so a bare // service domain matches exactly the scheme-default port. None => parsed.port().is_none(), + }; + if !authority_matches { + return false; } + + let url_path = parsed.path().trim_end_matches('/'); + if exact_path { + return url_path == service_path; + } + service_path.is_empty() + || url_path == service_path + || url_path + .strip_prefix(&service_path) + .is_some_and(|suffix| suffix.starts_with('/')) } -/// Parse a configured service authority into (host, optional port). -fn parse_service_authority(service: &str) -> Option<(String, Option)> { +/// Parse a configured service into normalized host, optional port, and path. +fn parse_service_location(service: &str) -> Option<(String, Option, String)> { let trimmed = service.trim().trim_end_matches('/'); - let without_scheme = trimmed - .split_once("://") - .map(|(_, rest)| rest) - .unwrap_or(trimmed); - if without_scheme.is_empty() { + let normalized = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + if trimmed.is_empty() { return None; } - // Borrow the url parser for authority handling (IPv6 brackets, ports). - let parsed = Url::parse(&format!("http://{without_scheme}")).ok()?; + let parsed = Url::parse(&normalized).ok()?; let host = normalize_host(parsed.host_str()?); - Some((host, parsed.port())) + let path = parsed.path().trim_end_matches('/').to_string(); + Some((host, parsed.port(), path)) } /// Parse a URL, assuming `https://` when no scheme is present. @@ -666,4 +694,35 @@ mod tests { service )); } + + #[test] + fn service_matching_respects_mount_path_boundaries() { + let service = "git.example/grasp"; + + assert!(url_matches_service_domain( + "https://git.example/grasp/npub/repo.git", + service + )); + assert!(relay_url_matches_service( + "wss://git.example/grasp", + service + )); + assert!(relay_url_matches_service( + "wss://git.example/grasp/", + service + )); + + assert!(!url_matches_service_domain( + "https://git.example/npub/repo.git", + service + )); + assert!(!url_matches_service_domain( + "https://git.example/graspy/npub/repo.git", + service + )); + assert!(!relay_url_matches_service( + "wss://git.example/grasp/another-relay", + service + )); + } } diff --git a/src/private/nip98.rs b/src/private/nip98.rs index f526e92..c51be91 100644 --- a/src/private/nip98.rs +++ b/src/private/nip98.rs @@ -136,7 +136,10 @@ pub fn unauthorized_response(config: &Config) -> Response { .status(StatusCode::UNAUTHORIZED) .header( WWW_AUTHENTICATE, - format!("Nostr realm=\"{}\", method=\"GET\"", config.domain), + format!( + "Nostr realm=\"{}\", method=\"GET\"", + config.service_address() + ), ) .body(empty_body()) .expect("static unauthorized response") diff --git a/src/server.rs b/src/server.rs index 3ac3620..48bff84 100644 --- a/src/server.rs +++ b/src/server.rs @@ -117,7 +117,7 @@ impl RelayServer { "Configuration loaded and validated: {}", config.bind_address ); - info!("Domain: {}", config.domain); + info!("Public service: {}", config.service_address()); info!("Relay name: {}", config.relay_name()); info!("Git data directory: {}", config.effective_git_data_path()); if config.database_backend != DatabaseBackend::Memory { @@ -202,7 +202,7 @@ impl RelayServer { info!( "Relay created with NIP-34 validation for domain: {}", - config.domain + config.service_address() ); // Set the local relay on the write policy for purgatory notifications @@ -278,7 +278,7 @@ impl RelayServer { let sync_manager = SyncManager::new( config.sync_bootstrap_relay_url.clone(), - config.domain.clone(), + config.service_address(), relay_runtime.stores.database.clone(), relay_runtime.write_policy.clone(), relay_runtime.relay.clone(), @@ -411,7 +411,7 @@ impl RelayServer { purgatory.clone(), relay_runtime.stores.database.clone(), PathBuf::from(config.effective_git_data_path()), - Some(config.domain.clone()), + Some(config.service_address()), Some(relay_runtime.relay.clone()), Some(relay_runtime.write_policy.clone()), git_naughty_list.clone(), diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 41ddb12..de8dea9 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -55,9 +55,7 @@ use tokio::sync::{broadcast, Mutex, RwLock, Semaphore}; use crate::config::Config; use crate::nostr::builder::Nip34WritePolicy; use crate::nostr::SharedDatabase; -use crate::outbound::{ - url_matches_service_domain, OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource, -}; +use crate::outbound::{OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource}; use crate::private::PrivateAccess; use nostr_sdk::prelude::LocalRelay; @@ -300,7 +298,7 @@ pub(crate) fn canonical_relay_key(relay_url: &str) -> Result { } fn is_own_sync_target(relay_url: &str, service_domain: &str) -> bool { - url_matches_service_domain(relay_url, service_domain) + crate::outbound::relay_url_matches_service(relay_url, service_domain) } #[cfg(test)] @@ -4104,7 +4102,11 @@ impl SyncManager { // 5. Spawn self-subscriber with shutdown receiver let self_subscriber = SelfSubscriber::new( - format!("ws://{}", self.config.bind_address), + format!( + "ws://{}{}", + self.config.bind_address, + self.config.public_path("/") + ), self.service_domain.clone(), Arc::clone(&self.repo_sync_index), Arc::clone(&self.root_candidate_index), @@ -9683,12 +9685,24 @@ mod tests { assert!(is_own_sync_target("wss://gitnostr.com", "gitnostr.com")); assert!(is_own_sync_target("ws://gitnostr.com", "gitnostr.com")); assert!(is_own_sync_target("ws://127.0.0.1:7334", "127.0.0.1:7334")); + assert!(is_own_sync_target( + "wss://gitnostr.com/grasp", + "gitnostr.com/grasp" + )); assert!(!is_own_sync_target( "wss://gitnostr.com.attacker.example", "gitnostr.com" )); assert!(!is_own_sync_target("ws://127.0.0.1:7335", "127.0.0.1:7334")); + assert!(!is_own_sync_target( + "wss://gitnostr.com", + "gitnostr.com/grasp" + )); + assert!(!is_own_sync_target( + "wss://gitnostr.com/grasp/other", + "gitnostr.com/grasp" + )); } #[test] diff --git a/tests/base_path.rs b/tests/base_path.rs new file mode 100644 index 0000000..401f69a --- /dev/null +++ b/tests/base_path.rs @@ -0,0 +1,132 @@ +//! Integration coverage for path-mounted GRASP services. + +mod common; + +use std::time::Duration; + +use common::TestRelay; +use nostr_sdk::prelude::{Keys, ToBech32}; + +#[tokio::test] +async fn path_mount_scopes_http_websocket_and_nip05_behavior() { + let relay = TestRelay::start_at_base_path("/grasp").await; + let client = reqwest::Client::new(); + let root = format!("http://{}", relay.domain()); + + assert_eq!(relay.service_address(), format!("{}/grasp", relay.domain())); + assert_eq!(relay.url(), format!("ws://{}/grasp", relay.domain())); + + let outside = client.get(&root).send().await.expect("request domain root"); + assert_eq!(outside.status(), reqwest::StatusCode::NOT_FOUND); + assert!(outside + .text() + .await + .expect("read outside-prefix response") + .contains("href=\"/grasp\"")); + + let landing = client + .get(format!("{root}/grasp")) + .send() + .await + .expect("request mounted landing page"); + assert_eq!(landing.status(), reqwest::StatusCode::OK); + + let nip11: serde_json::Value = client + .get(format!("{root}/grasp")) + .header("Accept", "application/nostr+json") + .send() + .await + .expect("request mounted NIP-11 document") + .json() + .await + .expect("parse mounted NIP-11 document"); + assert!(!nip11["supported_nips"] + .as_array() + .expect("supported_nips should be an array") + .contains(&serde_json::json!(5))); + assert_eq!( + nip11["icon"], + format!("https://{}/grasp/icon.png", relay.domain()) + ); + + for path in ["/.well-known/nostr.json", "/grasp/.well-known/nostr.json"] { + let response = client + .get(format!("{root}{path}")) + .send() + .await + .expect("request unavailable path-mounted NIP-05 document"); + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND, "{path}"); + } + + let metrics = client + .get(format!("{root}/grasp/metrics")) + .send() + .await + .expect("request mounted metrics"); + assert_eq!(metrics.status(), reqwest::StatusCode::OK); + + let npub = Keys::generate() + .public_key() + .to_bech32() + .expect("encode npub"); + let repo = client + .get(format!("{root}/grasp/{npub}/missing.git")) + .send() + .await + .expect("request mounted repository landing page"); + assert_eq!(repo.status(), reqwest::StatusCode::NOT_FOUND); + assert!(repo + .text() + .await + .expect("read repository response") + .contains("Repository Not Found")); + + let mounted_repo = relay.git_data_path().join(&npub).join("mounted.git"); + std::fs::create_dir_all(mounted_repo.parent().expect("repository parent")) + .expect("create repository owner directory"); + let init = tokio::process::Command::new("git") + .args(["init", "--bare"]) + .arg(&mounted_repo) + .output() + .await + .expect("initialize mounted bare repository"); + assert!( + init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + let ls_remote = tokio::process::Command::new("git") + .arg("ls-remote") + .arg(format!("{root}/grasp/{npub}/mounted.git")) + .output() + .await + .expect("run git ls-remote through mounted Smart HTTP route"); + assert!( + ls_remote.status.success(), + "git ls-remote failed: {}", + String::from_utf8_lossy(&ls_remote.stderr) + ); + + let (mut socket, _) = tokio::time::timeout( + Duration::from_secs(5), + tokio_tungstenite::connect_async(relay.url()), + ) + .await + .expect("mounted WebSocket connection timed out") + .expect("connect to mounted WebSocket relay"); + socket.close(None).await.expect("close mounted WebSocket"); + + let root_websocket = format!("ws://{}", relay.domain()); + let root_result = tokio::time::timeout( + Duration::from_secs(5), + tokio_tungstenite::connect_async(&root_websocket), + ) + .await + .expect("root WebSocket rejection timed out"); + assert!( + root_result.is_err(), + "domain-root WebSocket must not upgrade" + ); + + relay.stop().await; +} diff --git a/tests/common/relay.rs b/tests/common/relay.rs index 540e97d..a097d36 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -78,6 +78,7 @@ struct RelayOptions { /// when unset so [`TestRelay::restart`] keeps the same identity, as a /// production restart would. owner_keys: Option, + base_path: Option, bootstrap_relay_url: Option, sync_plus_fallback_relays: Option, disable_negentropy: bool, @@ -141,6 +142,18 @@ impl TestRelay { Self::start_internal(port::reserve_port(), RelayOptions::default()).await } + /// Start a relay mounted at an explicit public URL path. + pub async fn start_at_base_path(base_path: &str) -> Self { + Self::start_internal( + port::reserve_port(), + RelayOptions { + base_path: Some(base_path.to_string()), + ..RelayOptions::default() + }, + ) + .await + } + /// Start one GRASP-08 private service whose static membership contains /// `member`. pub async fn start_private(member: &nostr_sdk::prelude::PublicKey) -> Self { @@ -758,7 +771,9 @@ impl TestRelay { async fn try_start_once(reservation: PortReservation, options: &RelayOptions) -> StartOutcome { let port = reservation.port(); let bind_address = format!("127.0.0.1:{}", port); - let url = format!("ws://127.0.0.1:{}", port); + let base_path = options.base_path.as_deref().unwrap_or("/"); + let url_path = if base_path == "/" { "" } else { base_path }; + let url = format!("ws://127.0.0.1:{port}{url_path}"); // Create temporary directories unless caller provided explicit paths. let (git_data_dir, git_data_path) = if let Some(path) = options.git_data_path.clone() { @@ -834,6 +849,10 @@ impl TestRelay { ) .stderr(Stdio::inherit()); // Inherit stderr for test output + if let Some(base_path) = &options.base_path { + cmd.env("NGIT_BASE_PATH", base_path); + } + cmd.env( "NGIT_DATABASE_BACKEND", if options.lmdb_backend { @@ -986,6 +1005,16 @@ impl TestRelay { format!("127.0.0.1:{}", self.port) } + /// Get the scheme-less public service address, including any mount path. + pub fn service_address(&self) -> String { + let base_path = self.options.base_path.as_deref().unwrap_or("/"); + if base_path == "/" { + self.domain() + } else { + format!("{}{base_path}", self.domain()) + } + } + /// Keys configured as this test relay's operator identity. pub fn owner_keys(&self) -> &Keys { &self.owner_keys @@ -1095,8 +1124,9 @@ impl TestRelay { // Use the relay's HTTP handler rather than a bare TCP connect: // this verifies the accept loop and Hyper service are both live, // which is what the immediately-following WebSocket tests need. + let base_path = self.options.base_path.as_deref().unwrap_or("/"); let request = format!( - "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + "GET {base_path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", self.port ); stream.write_all(request.as_bytes()).await?; diff --git a/tests/nip05_identity.rs b/tests/nip05_identity.rs index 0782441..ed16cbd 100644 --- a/tests/nip05_identity.rs +++ b/tests/nip05_identity.rs @@ -58,19 +58,13 @@ async fn well_known_root_identity_matches_relay_pubkey() { .expect("supported_nips should be an array") .contains(&serde_json::json!(5))); - let nested_nip11: serde_json::Value = client + let nested_nip11 = client .get(format!("http://{}/relay", relay.domain())) .header("Accept", "application/nostr+json") .send() .await - .expect("request NIP-11 document at a non-root relay path") - .json() - .await - .expect("parse nested-path NIP-11 document"); - assert!(!nested_nip11["supported_nips"] - .as_array() - .expect("nested-path supported_nips should be an array") - .contains(&serde_json::json!(5))); + .expect("request NIP-11 document at an unconfigured relay path"); + assert_eq!(nested_nip11.status(), reqwest::StatusCode::NOT_FOUND); let nested_response = client .get(format!(