feat(http): support path-mounted GRASP services

Allow operators to share an authority by configuring a normalized public base path. Route HTTP, WebSocket, Git, and GRASP-06 traffic beneath that prefix and carry the path through service validation, generated relay identity, self-sync, NIP-11 icons, and landing links.

Treat NIP-05 as a domain-root capability: path-mounted services neither expose the well-known document nor advertise NIP-05 in NIP-11, and generated owner profiles omit the nip05 field. Exact relay URL matching keeps sibling mounts distinct while clone URLs may descend below the configured prefix.

This assumes reverse proxies preserve the configured public prefix. Proxy-side prefix stripping remains deliberately out of scope.

Validation:
- nix develop -c cargo test
- nix develop -c cargo clippy --all-targets -- -D warnings
- cargo fmt --all -- --check
- git diff --check
- nix-instantiate --parse nix/module.nix
This commit is contained in:
DanConwayDev
2026-08-18 11:36:24 +00:00
parent 27f9eaaf98
commit 1ad06bae22
27 changed files with 668 additions and 102 deletions
+6
View File
@@ -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 <path>
# Default: /
# NGIT_BASE_PATH=/
# ============================================================================
# SERVER CONFIGURATION
# ============================================================================
+12 -1
View File
@@ -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
+4 -2
View File
@@ -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 `<base-path>/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` |
+6 -4
View File
@@ -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 `/<npub>/<identifier>.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
`<base-path>/<npub>/<identifier>.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:**
+2 -1
View File
@@ -273,6 +273,7 @@ git ls-remote https://ngit.example.com/<npub>/<repo>.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/<npub>/<repo>.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)
+4
View File
@@ -4,6 +4,10 @@ GRASP-06 adds an opt-in endpoint at `/prs/<npub>/<identifier>.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.
+32 -2
View File
@@ -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/<npub>/<repo>.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/<npub>/<identifier>.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/<npub>/<identifier>.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`
+11
View File
@@ -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";
+131
View File
@@ -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([
+43 -3
View File
@@ -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: `<npub>` and `<repo>.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();
+19 -5
View File
@@ -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##"<!DOCTYPE html>
<html lang="en">
@@ -631,7 +632,7 @@ pub fn get_generic_404_html(config: &Config, path: &str) -> String {
<div class="path-label">Requested Path</div>
<code>{path}</code>
</div>
<a href="/">&larr; Back to {relay_name}</a>
<a href="{home_path}">&larr; Back to {relay_name}</a>
<div class="footer"><span id="footer-domain"></span><span class="footer-separator">•</span>powered by <a href="https://gitworkshop.dev/danconwaydev.com/ngit-grasp"><strong>ngit-grasp</strong></a><span class="footer-separator">•</span>{version}<span class="footer-separator">•</span>MIT Licensed</div>
</div>
{footer_script}
@@ -640,6 +641,7 @@ pub fn get_generic_404_html(config: &Config, path: &str) -> String {
</html>"##,
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##"<!DOCTYPE html>
<html lang="en">
@@ -730,7 +733,7 @@ pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String {
</div>
</div>
<div class="hint">The repository may not have been announced to this server, or the URL may be incorrect.</div>
<a href="/">&larr; Back to {relay_name}</a>
<a href="{home_path}">&larr; Back to {relay_name}</a>
<div class="footer"><span id="footer-domain"></span><span class="footer-separator">•</span>powered by <a href="https://gitworkshop.dev/danconwaydev.com/ngit-grasp"><strong>ngit-grasp</strong></a><span class="footer-separator">•</span>{version}<span class="footer-separator">•</span>MIT Licensed</div>
</div>
{footer_script}
@@ -739,6 +742,7 @@ pub fn get_404_html(config: &Config, npub: &str, identifier: &str) -> String {
</html>"##,
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##"<!DOCTYPE html>
<html lang="en">
@@ -830,7 +836,7 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String {
{theme_toggle}
<div class="container">
<div class="back-link">
<a href="/">&larr; {relay_name}</a>
<a href="{home_path}">&larr; {relay_name}</a>
</div>
<div class="header">
<h1>{identifier}</h1>
@@ -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 {
</html>"##,
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),
+32 -11
View File
@@ -163,7 +163,24 @@ impl Service<Request<Incoming>> for HttpService {
fn call(&self, req: Request<Incoming>) -> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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,
+14 -15
View File
@@ -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]
+1 -1
View File
@@ -126,7 +126,7 @@ impl Nip34WritePolicy {
private_access: Option<PrivateAccess>,
) -> 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(),
+36 -2
View File
@@ -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;
+1 -1
View File
@@ -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
{
+1 -1
View File
@@ -80,7 +80,7 @@ impl DeletionPolicy {
) -> BTreeSet<PathBuf> {
crate::grasp06::policy::prs_identifiers_named_by_event_clone_tags(
event,
&self.ctx.config.domain,
&self.ctx.config.service_address(),
)
.into_iter()
.map(|identifier| {
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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);
}
}
+37 -10
View File
@@ -100,16 +100,20 @@ impl IdentityPublicationPlan {
pub fn build(config: &Config) -> Result<RelayIdentityEvents> {
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<RelayUrl> {
fn public_relay_url(domain: &str, base_path: &str) -> Result<RelayUrl> {
let scheme = if is_loopback_authority(domain) {
"ws"
} else {
@@ -635,8 +639,10 @@ fn public_relay_url(domain: &str) -> Result<RelayUrl> {
} 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}");
}
}
+78 -19
View File
@@ -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<u16>)> {
/// Parse a configured service into normalized host, optional port, and path.
fn parse_service_location(service: &str) -> Option<(String, Option<u16>, 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
));
}
}
+4 -1
View File
@@ -136,7 +136,10 @@ pub fn unauthorized_response(config: &Config) -> Response<GitResponseBody> {
.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")
+4 -4
View File
@@ -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(),
+19 -5
View File
@@ -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<String, String> {
}
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]
+132
View File
@@ -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;
}
+32 -2
View File
@@ -78,6 +78,7 @@ struct RelayOptions {
/// when unset so [`TestRelay::restart`] keeps the same identity, as a
/// production restart would.
owner_keys: Option<Keys>,
base_path: Option<String>,
bootstrap_relay_url: Option<String>,
sync_plus_fallback_relays: Option<String>,
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?;
+3 -9
View File
@@ -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!(