From ddad8d0ba4c2fab834646504a4be3410a692b91f Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Wed, 23 Sep 2026 09:48:44 +0200 Subject: [PATCH 1/3] fix(node,resolver)!: require RESOLVER_BASE_URL --- .env.example | 21 ++-- CHANGELOG.md | 22 ++++ CLAUDE.md | 2 +- .../schemas/passport/PassportResponse.yaml | 2 +- api/openapi.bundled.json | 4 +- api/openapi.bundled.yaml | 4 +- api/paths/resolver/dpp_{dppId}.yaml | 2 +- cli/src/console/setup.rs | 4 + cli/src/core/infra.rs | 35 ++++++ crates/dpp-common/src/config.rs | 114 ++++++++++++++++++ crates/dpp-node/src/config.rs | 45 +++++-- crates/dpp-resolver/src/config.rs | 9 +- crates/dpp-resolver/src/main.rs | 8 +- docker/docker-compose.yml | 9 ++ docs/guides/DEMO-RUNBOOK.md | 15 ++- docs/guides/OPERATOR-SETUP.md | 9 +- scripts/install.sh | 3 + 17 files changed, 264 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 06e54ffa..82e2de61 100644 --- a/.env.example +++ b/.env.example @@ -122,15 +122,20 @@ VAULT_BASE_URL=http://localhost:8001/vault # points at the node's vault sub-pat CACHE_TTL_SECS=30 # worst-case recall-propagation window; raise only with that tradeoff in mind RATE_LIMIT_RPM=120 # per-IP request limit -# The public origin printed onto the product — the host a scanned QR code -# resolves against, baked into every passport's carrier URL at publish time. +# REQUIRED, by both the node and the resolver — neither has a default. # -# The default below is the project's own demo resolver and is not wired up yet. -# Every operator serves their own: set this to your resolver deployment's public -# origin before publishing anything real. It cannot be corrected afterwards — -# publishing starts the ESPR retention window and `odal passport archive` -# refuses inside it, so the labels would have to be reissued. -RESOLVER_BASE_URL=https://id.odal-node.io +# The public origin printed onto the product: the host a scanned QR code +# resolves against, baked into every passport's carrier URL at publish time. +# The node signs it into the carrier; the resolver builds its GS1 Digital Link +# redirects on it. The compose file hands this one value to both. +# +# The value below is the resolver on this machine, which is right for a laptop +# and wrong everywhere else. Every operator serves their own: set this to your +# resolver deployment's public origin before publishing anything real. It +# cannot be corrected afterwards — publishing starts the ESPR retention window +# and `odal passport archive` refuses inside it, so the labels would have to be +# reissued. +RESOLVER_BASE_URL=http://localhost:8003 # OPERATOR_DID_URL= # default: derived from VAULT_BASE_URL host # Private Prometheus metrics endpoint (GET /metrics). Resolver default diff --git a/CHANGELOG.md b/CHANGELOG.md index 8878db84..12fdfcfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,28 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Breaking +- **`RESOLVER_BASE_URL` is required, by the node and the resolver, and the + resolver now receives it.** *(Breaking: a deployment that never set it no + longer starts. Set it to the public origin your resolver serves at — + `.env.example` ships `http://localhost:8003` for a laptop — and both services + read that one line.)* Both binaries fell back to `https://id.odal-node.io`, + which does not resolve, and the compose file handed the resolver an explicit + environment with no `env_file` — so it **never saw the operator's value**. + Measured on a stack configured exactly as the demo runbook said: the node + signed `http://localhost:8003/01/…/21/…` into every carrier, and the resolver + answered that very URL with a `307` to the dead host, as did the AAS + response's canonical `Link`. Every scanned QR code went nowhere while the + node's own configuration looked right. + + The value now has one reader, `dpp_common::config::resolver_base_url`, which + both binaries call: required, an absolute `http`/`https` URL with a host, no + credentials, query or fragment, returned without a trailing `/`. The compose + file passes it to both services with `${RESOLVER_BASE_URL:?}`, so `odal up` + refuses before anything starts; under a production profile, `odal up`'s + preflight also refuses the laptop value as a dev default. A default here was a guess about where + another component lives — which is exactly what neither binary can know, and + a wrong guess is signed into labels that cannot be recalled. + - **The redaction moved to `dpp-domain`, and two things it does differently are visible on the wire.** *(Breaking for **newly published** passports only. Every public and audience route serves the payload decoded out of the stored diff --git a/CLAUDE.md b/CLAUDE.md index 76ec1aad..e02c6867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,7 +322,7 @@ Anything not covered by a recipe is plain cargo — e.g. `cargo run -p dpp-node` to run the node, and `cargo run -p dpp-cli -- bootstrap` to seed operator config and the first API key. -**Environment**: Copy `.env.example` to `.env` before running. Required vars: `DATABASE_URL`, `KEY_STORE_PATH`, `KEY_STORE_PASSPHRASE`, `DID_WEB_BASE_URL`. +**Environment**: Copy `.env.example` to `.env` before running. Required vars: `DATABASE_URL`, `KEY_STORE_PATH`, `KEY_STORE_PASSPHRASE`, `DID_WEB_BASE_URL`, `RESOLVER_BASE_URL` (the resolver requires it too — it has no default in either binary). ## Architecture diff --git a/api/components/schemas/passport/PassportResponse.yaml b/api/components/schemas/passport/PassportResponse.yaml index 97d513e3..805407c5 100644 --- a/api/components/schemas/passport/PassportResponse.yaml +++ b/api/components/schemas/passport/PassportResponse.yaml @@ -132,7 +132,7 @@ properties: GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment - (RESOLVER_BASE_URL, default https://id.odal-node.io). + (RESOLVER_BASE_URL, required, no default). jwsSignature: type: - string diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index 0c1cd094..26f25407 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -4190,7 +4190,7 @@ "application/aas+json": { "schema": { "type": "object", - "description": "An IDTA Asset Administration Shell `Environment` — shells\nand submodels in one self-contained document.\n\n`conceptDescriptions` is **absent**, not empty. This node\ncoins no concept descriptions, and the metamodel constrains\nthat member to `minItems: 1`, so an empty array would make\nthe whole document invalid.\n\n**Public tier only.** The passport is filtered through the\ndisclosure seam before any AAS mapper sees it, so this door\nnever carries a field the JSON-LD door would withhold.\nRestricted and conformity-tier data require a credentialed\nchannel and a different projection.\n\n**Schema-valid, not conformance-certified.** Every\nEnvironment is validated in `dpp-core`'s CI against IDTA's\npublished AAS JSON Schemas for metamodel **3.0, 3.1 and\n3.2**, and must satisfy all three — no single revision is\nthe strictest, so the intersection is the only target that\nmeans \"loadable whichever revision your toolchain\nimplements\".\n\nThat establishes metamodel validity only: it is not a claim\nof IDTA conformance, and it asserts nothing about whether a\nsubmodel matches a published submodel template. Note also\nthat no AAS JSON Schema sets `additionalProperties`, so\nschema validity alone cannot rule out a member the metamodel\ndoes not define; `dpp-core` gates that separately.\n\n**Unsigned, and it says so in a header.** This is a derived\nrepresentation of the signed canonical public view, which is\nwhat `application/ld+json` returns for this same URL. The\npublic proof covers that payload, not this serialisation of\nit, so attaching the signature here would hand a verifier a\nproof that fails against the bytes it arrived with.\n\nEvery `200` therefore carries:\n\n```\nLink: <{resolverBase}/dpp/{dppId}>; rel=\"alternate\"; type=\"application/ld+json\"\n```\n\n`alternate` rather than `canonical`: the two representations\nshare one URL and are separated only by `Accept`, so a\n`canonical` relation would point this resource at itself.\nFollow the link with that `Accept` to obtain the signed\npayload and its proof.\n\n`resolverBase` is per-deployment (`RESOLVER_BASE_URL`,\ndefault `https://id.odal-node.io`). Error responses carry no\n`Link` — an error is not a representation of the passport.\n" + "description": "An IDTA Asset Administration Shell `Environment` — shells\nand submodels in one self-contained document.\n\n`conceptDescriptions` is **absent**, not empty. This node\ncoins no concept descriptions, and the metamodel constrains\nthat member to `minItems: 1`, so an empty array would make\nthe whole document invalid.\n\n**Public tier only.** The passport is filtered through the\ndisclosure seam before any AAS mapper sees it, so this door\nnever carries a field the JSON-LD door would withhold.\nRestricted and conformity-tier data require a credentialed\nchannel and a different projection.\n\n**Schema-valid, not conformance-certified.** Every\nEnvironment is validated in `dpp-core`'s CI against IDTA's\npublished AAS JSON Schemas for metamodel **3.0, 3.1 and\n3.2**, and must satisfy all three — no single revision is\nthe strictest, so the intersection is the only target that\nmeans \"loadable whichever revision your toolchain\nimplements\".\n\nThat establishes metamodel validity only: it is not a claim\nof IDTA conformance, and it asserts nothing about whether a\nsubmodel matches a published submodel template. Note also\nthat no AAS JSON Schema sets `additionalProperties`, so\nschema validity alone cannot rule out a member the metamodel\ndoes not define; `dpp-core` gates that separately.\n\n**Unsigned, and it says so in a header.** This is a derived\nrepresentation of the signed canonical public view, which is\nwhat `application/ld+json` returns for this same URL. The\npublic proof covers that payload, not this serialisation of\nit, so attaching the signature here would hand a verifier a\nproof that fails against the bytes it arrived with.\n\nEvery `200` therefore carries:\n\n```\nLink: <{resolverBase}/dpp/{dppId}>; rel=\"alternate\"; type=\"application/ld+json\"\n```\n\n`alternate` rather than `canonical`: the two representations\nshare one URL and are separated only by `Accept`, so a\n`canonical` relation would point this resource at itself.\nFollow the link with that `Accept` to obtain the signed\npayload and its proof.\n\n`resolverBase` is per-deployment (`RESOLVER_BASE_URL`,\nrequired, no default). Error responses carry no\n`Link` — an error is not a representation of the passport.\n" } } } @@ -5024,7 +5024,7 @@ "null" ], "format": "uri", - "description": "GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io)." + "description": "GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, required, no default)." }, "jwsSignature": { "type": [ diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index 0dc59d80..9c0ee553 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -3410,7 +3410,7 @@ paths: payload and its proof. `resolverBase` is per-deployment (`RESOLVER_BASE_URL`, - default `https://id.odal-node.io`). Error responses carry no + required, no default). Error responses carry no `Link` — an error is not a representation of the passport. '404': description: | @@ -4164,7 +4164,7 @@ components: - string - 'null' format: uri - description: 'GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io).' + description: 'GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, required, no default).' jwsSignature: type: - string diff --git a/api/paths/resolver/dpp_{dppId}.yaml b/api/paths/resolver/dpp_{dppId}.yaml index cbe50d56..7d58eb32 100644 --- a/api/paths/resolver/dpp_{dppId}.yaml +++ b/api/paths/resolver/dpp_{dppId}.yaml @@ -149,7 +149,7 @@ get: `resolverBase` is per-deployment (`RESOLVER_BASE_URL`, - default `https://id.odal-node.io`). Error responses carry no + required, no default). Error responses carry no `Link` — an error is not a representation of the passport. '404': diff --git a/cli/src/console/setup.rs b/cli/src/console/setup.rs index 6fe1be1d..f91faa2e 100644 --- a/cli/src/console/setup.rs +++ b/cli/src/console/setup.rs @@ -125,6 +125,10 @@ async fn step_infrastructure(cfg: &Config) -> Result<()> { " {} (e.g. https://your-domain.example)", style("DID_WEB_BASE_URL").dim() ); + println!( + " {} (your resolver's public origin — signed into every QR code)", + style("RESOLVER_BASE_URL").dim() + ); println!(); } diff --git a/cli/src/core/infra.rs b/cli/src/core/infra.rs index f4116765..99b6257d 100644 --- a/cli/src/core/infra.rs +++ b/cli/src/core/infra.rs @@ -193,6 +193,7 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { "DATABASE_APP_PASS", "KEY_STORE_PASSPHRASE", "DID_WEB_BASE_URL", + "RESOLVER_BASE_URL", "ADMIN_USERNAME", "ADMIN_PASSWORD", ]; @@ -201,6 +202,9 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { "change_me_in_env", "dev-passphrase-change-in-prod", "admin", + // `.env.example`'s laptop resolver. Not a secret, but a production node + // publishing with it signs a carrier no customer can scan, permanently. + "http://localhost:8003", ]; if !env_path.exists() { @@ -581,6 +585,37 @@ mod tests { assert!(missing_scaffold_files(root.path()).is_empty()); } + /// The laptop value `.env.example` ships is right for a demo and wrong for a + /// production node, where it would be signed into every carrier it prints. + #[test] + fn a_production_env_left_on_the_laptop_resolver_is_refused() { + let root = tempfile::TempDir::new().unwrap(); + let compose = root.path().join("docker").join(COMPOSE_FILE); + std::fs::write( + root.path().join(".env"), + "DATABASE_POSTGRES_PASS=pg-strong\n\ + DATABASE_APP_PASS=app-strong\n\ + KEY_STORE_PASSPHRASE=ks-strong\n\ + DID_WEB_BASE_URL=https://acme.example\n\ + RESOLVER_BASE_URL=http://localhost:8003\n\ + ADMIN_USERNAME=acme-admin\n\ + ADMIN_PASSWORD=admin-strong\n", + ) + .unwrap(); + + let msg = preflight_prod_env(&compose).unwrap_err().to_string(); + assert!(msg.contains("RESOLVER_BASE_URL"), "{msg}"); + + std::fs::write( + root.path().join(".env"), + std::fs::read_to_string(root.path().join(".env")) + .unwrap() + .replace("http://localhost:8003", "https://dpp.acme.example"), + ) + .unwrap(); + preflight_prod_env(&compose).expect("a real resolver origin passes"); + } + /// Re-running `odal init` on a configured install must not overwrite an /// operator's edited compose file. #[test] diff --git a/crates/dpp-common/src/config.rs b/crates/dpp-common/src/config.rs index c2d8cb6b..f47be4e5 100644 --- a/crates/dpp-common/src/config.rs +++ b/crates/dpp-common/src/config.rs @@ -36,6 +36,69 @@ pub fn required_var(name: &str) -> Result { std::env::var(name).with_context(|| format!("missing required env var: {name}")) } +/// The public origin every printed data carrier resolves at. +pub const RESOLVER_BASE_URL: &str = "RESOLVER_BASE_URL"; + +/// Read [`RESOLVER_BASE_URL`], the one value the node and the resolver must +/// agree on. +/// +/// **Required, with no default, in both binaries.** The node writes it into +/// every passport's carrier URL at publish, inside the signature, so a wrong +/// value cannot be corrected afterwards. The resolver builds its GS1 Digital +/// Link redirects and canonical links from it. Both used to fall back to a +/// hosted address that does not resolve, and the resolver's copy was never +/// handed the operator's value by the compose file — so every scanned carrier +/// redirected to a dead host while the node's own configuration looked right. +/// A default here is a guess about where another component lives, which is +/// exactly what neither binary can know. +/// +/// # Errors +/// +/// When the variable is unset or blank, or is not a URL this value can be (see +/// [`parse_resolver_base_url`]). +pub fn resolver_base_url() -> Result { + parse_resolver_base_url(std::env::var(RESOLVER_BASE_URL).ok().as_deref()) +} + +/// Validate a raw `RESOLVER_BASE_URL` value and return it without a trailing +/// `/`, so every caller joins paths onto the same form. +/// +/// Refuses anything that is not an absolute `http` or `https` URL with a host, +/// and anything carrying credentials, a query or a fragment: each would be +/// concatenated into every carrier this node prints. +/// +/// # Errors +/// +/// Names [`RESOLVER_BASE_URL`] and says what is wrong with the value. +pub fn parse_resolver_base_url(raw: Option<&str>) -> Result { + let value = raw + .map(str::trim) + .filter(|s| !s.is_empty()) + .with_context(|| { + format!( + "missing required env var: {RESOLVER_BASE_URL} — the public origin \ + this deployment's resolver serves at (e.g. http://localhost:8003 \ + on a laptop). It is written into every carrier at publish and \ + cannot be changed afterwards, so there is no default" + ) + })?; + let url = url::Url::parse(value) + .with_context(|| format!("{RESOLVER_BASE_URL} is not an absolute URL: {value}"))?; + if !matches!(url.scheme(), "http" | "https") { + anyhow::bail!("{RESOLVER_BASE_URL} must be an http or https URL: {value}"); + } + if url.host_str().is_none_or(str::is_empty) { + anyhow::bail!("{RESOLVER_BASE_URL} names no host: {value}"); + } + if !url.username().is_empty() || url.password().is_some() { + anyhow::bail!("{RESOLVER_BASE_URL} must not carry credentials"); + } + if url.query().is_some() || url.fragment().is_some() { + anyhow::bail!("{RESOLVER_BASE_URL} must not carry a query or fragment: {value}"); + } + Ok(value.trim_end_matches('/').to_owned()) +} + /// Read a `u16` port from an environment variable, defaulting to `default_port`. pub fn port_var(name: &str, default_port: u16) -> Result { std::env::var(name) @@ -116,4 +179,55 @@ mod tests { assert_eq!(redact_url_credentials("user@host"), "host"); assert_eq!(redact_url_credentials("user:pw@host"), "host"); } + + /// No value means no carrier anyone can scan, so there is nothing to fall + /// back to — and the refusal has to say which variable, and why. + #[test] + fn an_absent_or_blank_resolver_base_url_is_refused_by_name() { + for raw in [None, Some(""), Some(" ")] { + let msg = parse_resolver_base_url(raw).unwrap_err().to_string(); + assert!(msg.contains(RESOLVER_BASE_URL), "{msg}"); + assert!(msg.contains("no default"), "{msg}"); + } + } + + /// `localhost:8003` parses as a URL whose *scheme* is `localhost`, so the + /// scheme check is what refuses the most likely typo rather than signing a + /// relative-looking carrier into every passport. + #[test] + fn a_resolver_base_url_that_is_not_http_is_refused() { + for raw in [ + "localhost:8003", + "ftp://resolver.example", + "/dpp", + "file:///tmp/resolver", + ] { + assert!(parse_resolver_base_url(Some(raw)).is_err(), "{raw}"); + } + } + + #[test] + fn a_resolver_base_url_carrying_credentials_a_query_or_a_fragment_is_refused() { + for raw in [ + "https://user:pw@resolver.example", + "https://resolver.example/?tenant=a", + "https://resolver.example/#top", + ] { + assert!(parse_resolver_base_url(Some(raw)).is_err(), "{raw}"); + } + } + + /// Both consumers join paths onto the value, and the GTIN redirect used it + /// as-is — so a trailing `/` is dropped here, once, rather than by each. + #[test] + fn a_resolver_base_url_is_returned_without_a_trailing_slash() { + assert_eq!( + parse_resolver_base_url(Some(" http://localhost:8003/ ")).unwrap(), + "http://localhost:8003" + ); + assert_eq!( + parse_resolver_base_url(Some("https://dpp.example.com/resolve/")).unwrap(), + "https://dpp.example.com/resolve" + ); + } } diff --git a/crates/dpp-node/src/config.rs b/crates/dpp-node/src/config.rs index 241cebe0..3b77dbf3 100644 --- a/crates/dpp-node/src/config.rs +++ b/crates/dpp-node/src/config.rs @@ -78,9 +78,9 @@ pub struct NodeConfig { // ── Resolver ──────────────────────────────────────────────────────────── /// Base URL the public resolver serves on, stamped into each passport's - /// carrier (QR) URL at publish. Defaults to `https://id.odal-node.io`; a - /// self-hoster sets `RESOLVER_BASE_URL` to their own domain so printed - /// labels carry it. Must match the resolver deployment's own base. + /// carrier (QR) URL at publish. Required, with no default — read through + /// [`dpp_common::config::resolver_base_url`], which the resolver reads its + /// own copy through too, so the two cannot disagree about the rule. pub resolver_base_url: String, /// Public base URL under which this deployment serves its continuity /// snapshots, declared to the EU registry as each passport's back-up link. @@ -135,13 +135,12 @@ impl NodeConfig { /// Load unified node configuration from environment variables. /// /// **Required**: `DATABASE_URL`, `KEY_STORE_PATH`, `KEY_STORE_PASSPHRASE`, - /// `DID_WEB_BASE_URL`. + /// `DID_WEB_BASE_URL`, `RESOLVER_BASE_URL`. /// /// **Optional**: `DATABASE_MIGRATE_URL`, `NODE_PORT` / `PORT` (default 8001), /// `LOG_LEVEL` (default "info"), `CORS_ALLOWED_ORIGINS`, `ADMIN_USERNAME`, /// `ADMIN_PASSWORD`, `BATCH_CONCURRENCY` (default 20), `NATS_URL`, - /// `PLUGINS_DIR` (default "./plugins"), `METRICS_ADDR` (default "127.0.0.1:9100"), - /// `RESOLVER_BASE_URL` (default `https://id.odal-node.io`). + /// `PLUGINS_DIR` (default "./plugins"), `METRICS_ADDR` (default "127.0.0.1:9100"). /// /// # Errors /// @@ -156,6 +155,7 @@ impl NodeConfig { let key_store_path = var("KEY_STORE_PATH")?; let key_store_passphrase = var("KEY_STORE_PASSPHRASE")?; let did_web_base_url = var("DID_WEB_BASE_URL")?; + let resolver_base_url = dpp_common::config::resolver_base_url()?; let admin_username = std::env::var("ADMIN_USERNAME") .ok() .filter(|s| !s.is_empty()); @@ -213,10 +213,7 @@ impl NodeConfig { webhook_allow_private_targets: std::env::var("WEBHOOK_ALLOW_PRIVATE_TARGETS") .map(|s| matches!(s.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) .unwrap_or(false), - resolver_base_url: std::env::var("RESOLVER_BASE_URL") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "https://id.odal-node.io".into()), + resolver_base_url, snapshot_public_base_url: std::env::var("SNAPSHOT_PUBLIC_BASE_URL") .ok() .map(|s| s.trim().to_owned()) @@ -535,7 +532,7 @@ mod tests { use super::*; use serial_test::serial; - /// Reset to a clean baseline, then set only the four required vars. Clearing + /// Reset to a clean baseline, then set only the five required vars. Clearing /// first makes these tests hermetic: a `.env` loaded into the process (e.g. /// via `just`'s `set dotenv-load`) cannot leak optional vars such as /// `NODE_PORT` or `DATABASE_MIGRATE_URL` into the assertions below. @@ -550,6 +547,7 @@ mod tests { unsafe { std::env::set_var("KEY_STORE_PATH", "/tmp/keys.json") }; unsafe { std::env::set_var("KEY_STORE_PASSPHRASE", "test-passphrase") }; unsafe { std::env::set_var("DID_WEB_BASE_URL", "http://localhost") }; + unsafe { std::env::set_var("RESOLVER_BASE_URL", "http://localhost:8003") }; } fn clear_env() { @@ -570,6 +568,7 @@ mod tests { "PLUGINS_DIR", "METRICS_ADDR", "WEBHOOK_ALLOW_PRIVATE_TARGETS", + "RESOLVER_BASE_URL", ] { unsafe { std::env::remove_var(key) }; } @@ -649,6 +648,29 @@ mod tests { assert!(msg.contains("DATABASE_URL")); } + /// The value lands inside every signed carrier, so a node that would have + /// to guess it does not boot. Everything else required is present here — + /// this is the only reason the load can fail. + #[test] + #[serial] + fn a_node_without_a_resolver_base_url_does_not_boot() { + set_required_env(); + unsafe { std::env::remove_var("RESOLVER_BASE_URL") }; + let msg = NodeConfig::from_env().unwrap_err().to_string(); + clear_env(); + assert!(msg.contains("RESOLVER_BASE_URL"), "{msg}"); + } + + #[test] + #[serial] + fn the_resolver_base_url_is_carried_without_a_trailing_slash() { + set_required_env(); + unsafe { std::env::set_var("RESOLVER_BASE_URL", "https://dpp.example.com/") }; + let cfg = NodeConfig::from_env().unwrap(); + clear_env(); + assert_eq!(cfg.resolver_base_url, "https://dpp.example.com"); + } + #[test] #[serial] fn invalid_port_errors() { @@ -678,6 +700,7 @@ mod tests { std::env::set_var("KEY_STORE_PATH", "/tmp/ks.json"); std::env::set_var("KEY_STORE_PASSPHRASE", "passphrase-must-not-leak"); std::env::set_var("DID_WEB_BASE_URL", "https://node.example.com"); + std::env::set_var("RESOLVER_BASE_URL", "https://dpp.example.com"); std::env::set_var("ADMIN_USERNAME", "odal-admin"); std::env::set_var("ADMIN_PASSWORD", "admin-pass-must-not-leak"); } diff --git a/crates/dpp-resolver/src/config.rs b/crates/dpp-resolver/src/config.rs index 0543412e..7e5023b6 100644 --- a/crates/dpp-resolver/src/config.rs +++ b/crates/dpp-resolver/src/config.rs @@ -28,12 +28,18 @@ pub struct Config { /// Loopback by default so it is never served on the public resolver port; set /// to a private interface for remote scraping, or empty to disable. pub metrics_addr: Option, + + /// This resolver's own public origin — what the GS1 Digital Link redirects + /// and canonical links are built on. Required, with no default, and read + /// through [`dpp_common::config::resolver_base_url`], the same reader the + /// node stamps carriers with. + pub resolver_base_url: String, } impl Config { /// Load configuration from environment variables. /// - /// Required: `REDIS_URL`. + /// Required: `REDIS_URL`, `RESOLVER_BASE_URL`. /// Optional with defaults: `CACHE_TTL_SECS` (30), `RESOLVER_PORT`/`PORT` (8003), /// `LOG_LEVEL` (info), `METRICS_ADDR` (127.0.0.1:9101). /// @@ -43,6 +49,7 @@ impl Config { pub fn from_env() -> Result { Ok(Self { redis_url: var("REDIS_URL")?, + resolver_base_url: dpp_common::config::resolver_base_url()?, cache_ttl_secs: std::env::var("CACHE_TTL_SECS") .unwrap_or_else(|_| "30".into()) .parse() diff --git a/crates/dpp-resolver/src/main.rs b/crates/dpp-resolver/src/main.rs index a29aa535..37e14ff8 100644 --- a/crates/dpp-resolver/src/main.rs +++ b/crates/dpp-resolver/src/main.rs @@ -195,12 +195,6 @@ async fn main() -> anyhow::Result<()> { "verifying passport signatures against operator DID" ); - // The resolver's own public GS1 Digital Link host — printed on carriers and - // used to build /01/{gtin} and /dpp/{id} links. Defaults to the shared - // Odal-hosted resolver; self-hosters override with their own domain. - let resolver_base_url = - std::env::var("RESOLVER_BASE_URL").unwrap_or_else(|_| "https://id.odal-node.io".into()); - // Scan telemetry (privacy-safe aggregate resolution counts). Off unless // SCAN_INGEST_URL is configured: the resolver then accumulates counts in // memory and a background task flushes them to the node's internal, @@ -239,7 +233,7 @@ async fn main() -> anyhow::Result<()> { let state = AppState { vault_base_url, operator_did_url, - resolver_base_url, + resolver_base_url: cfg.resolver_base_url.clone(), cache, http, scan_counter, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2e6b5e68..92dc97ba 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -99,6 +99,11 @@ services: # a recreate would mint a new key and invalidate every passport ever # signed. Pin it onto the node-data volume so the key survives restarts. KEY_STORE_PATH: keystore.enc + # Stamped into every carrier at publish, inside the signature. Named here + # as well as in `.env` so both services below read the one value, and so + # `odal up` refuses before anything starts rather than a node booting on + # a guess. + RESOLVER_BASE_URL: ${RESOLVER_BASE_URL:?RESOLVER_BASE_URL must be set in .env} volumes: - node-data:/data - node-plugins:/plugins @@ -120,6 +125,10 @@ services: PORT: "8003" VAULT_BASE_URL: http://node:8000/vault OPERATOR_DID_URL: http://node:8000/identity/.well-known/did.json + # The same value the node signs into each carrier. This service reads no + # `env_file`, so without this line every GS1 Digital Link it answered + # redirected to a host the operator never configured. + RESOLVER_BASE_URL: ${RESOLVER_BASE_URL:?RESOLVER_BASE_URL must be set in .env} REDIS_URL: redis://redis:6379 LOG_LEVEL: ${LOG_LEVEL:-info} depends_on: diff --git a/docs/guides/DEMO-RUNBOOK.md b/docs/guides/DEMO-RUNBOOK.md index 4a3d36e9..5a0a0a65 100644 --- a/docs/guides/DEMO-RUNBOOK.md +++ b/docs/guides/DEMO-RUNBOOK.md @@ -37,19 +37,24 @@ Two things that are hard to convey any other way: Two of the three fail **silently**. Read this section before running anything. -### 1. `RESOLVER_BASE_URL` — the default does not resolve +### 1. `RESOLVER_BASE_URL` — required, and it must be the right one ```bash RESOLVER_BASE_URL=http://localhost:8003 # or your demo host ``` -The built-in default is `https://id.odal-node.io`, which **does not exist**. +There is no default: the node and the resolver both refuse to start without it, +and `odal up` refuses before either does. `.env.example` ships the value above, +which is right for a laptop. + `build_carrier_url` writes this value into every passport's `qr_code_url` at publish, and the passport is then signed — so a wrong value is **inside the -signature** and cannot be corrected without reissuing. Every QR code would scan -to nothing. +signature** and cannot be corrected without reissuing. The resolver builds its +GS1 Digital Link redirects on the same value, and the compose file hands both +services the one line in `.env`. -**Set it before the first publish.** Not after. +**Point it at the host the demo will be scanned from before the first +publish.** Not after. ### 2. `CREDENTIAL_ISSUERS_SELF` — without it the demo shows nothing diff --git a/docs/guides/OPERATOR-SETUP.md b/docs/guides/OPERATOR-SETUP.md index d8be2d6c..2b6d8107 100644 --- a/docs/guides/OPERATOR-SETUP.md +++ b/docs/guides/OPERATOR-SETUP.md @@ -53,11 +53,10 @@ DID_WEB_BASE_URL=https://your-domain.example ADMIN_USERNAME= # NOT "admin" — see below ADMIN_PASSWORD= -# --- Required before you publish anything. --- +# --- Required. There is no default; the node and resolver refuse to start. --- # The public origin printed onto the product. Every passport's QR code resolves -# against this, permanently. Each operator serves their own resolver — the -# built-in default is the project's demo resolver and is not wired up, so -# leaving it prints codes that resolve to nothing. +# against this, permanently, and the resolver builds its redirects on it. Each +# operator serves their own resolver. RESOLVER_BASE_URL=https://dpp.your-domain.example # --- Optional. Defaults shown. --- @@ -444,7 +443,7 @@ location / { | `odal passport import` rejects every row on `gtin` | GTINs are 13-digit, or their check digit is wrong | Use GTIN-14; the error names the expected check digit | | `odal passport archive` fails: *"retention policy forbids archiving before …"* | ESPR retention is still running on that passport | Use `odal passport suspend ` to withdraw it from public view instead | | `odal verify ` says *"Dossier not found"* | A **passport** id was passed | `verify` takes a **dossier** id — generate one with `odal passport evidence ` | -| Scanned QR codes resolve to nothing | `RESOLVER_BASE_URL` was left at its default when those passports were published | Set it to your own resolver before publishing; already-published carriers cannot be changed | +| Scanned QR codes resolve to nothing | `RESOLVER_BASE_URL` named an origin nobody serves when those passports were published — a laptop's `localhost`, or a host that was never deployed | Set it to your own resolver before publishing; already-published carriers cannot be changed | | A second `odal up` elsewhere on the host took over the first deployment | The compose project name is fixed, so all install roots share one deployment | Run one deployment per host, or set `COMPOSE_PROJECT_NAME` | | Console shows "not running (connection refused)" | Node not started | Run `odal up` or **Infrastructure › Start** | | `odal status` shows vault healthy but identity unhealthy | Identity sub-router not responding | Check node logs: `docker compose logs node` | diff --git a/scripts/install.sh b/scripts/install.sh index 082ce1c3..5c37a03b 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -119,6 +119,9 @@ RESOLVER_PORT=${RESOLVER_PORT} KEY_STORE_PATH=keystore.enc KEY_STORE_PASSPHRASE=${KS_PASS} DID_WEB_BASE_URL=http://localhost:${NODE_PORT} +# Written into every carrier at publish and never changeable afterwards — +# replace with your public resolver origin before issuing real labels. +RESOLVER_BASE_URL=http://localhost:${RESOLVER_PORT} JWKS_URL= JWT_AUDIENCE=odal-api From 40c7ff4d4bdbfa1def20283c67d9b91bb9206b54 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Wed, 23 Sep 2026 10:20:10 +0200 Subject: [PATCH 2/3] fix(config): harden the resolver URL checks --- CHANGELOG.md | 4 +- cli/src/core/infra.rs | 90 +++++++++++++++++++++++---------- crates/dpp-common/src/config.rs | 44 +++++++++++++--- crates/dpp-node/src/config.rs | 5 ++ docs/guides/DEMO-RUNBOOK.md | 4 +- 5 files changed, 113 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fdfcfc..7fe9f203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,9 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): credentials, query or fragment, returned without a trailing `/`. The compose file passes it to both services with `${RESOLVER_BASE_URL:?}`, so `odal up` refuses before anything starts; under a production profile, `odal up`'s - preflight also refuses the laptop value as a dev default. A default here was a guess about where + preflight also refuses any value naming this machine — `localhost` or a + loopback address, however spelled. A refusal never echoes a password, query + or fragment from the value. A default here was a guess about where another component lives — which is exactly what neither binary can know, and a wrong guess is signed into labels that cannot be recalled. diff --git a/cli/src/core/infra.rs b/cli/src/core/infra.rs index 99b6257d..bfceca46 100644 --- a/cli/src/core/infra.rs +++ b/cli/src/core/infra.rs @@ -202,9 +202,6 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { "change_me_in_env", "dev-passphrase-change-in-prod", "admin", - // `.env.example`'s laptop resolver. Not a secret, but a production node - // publishing with it signs a carrier no customer can scan, permanently. - "http://localhost:8003", ]; if !env_path.exists() { @@ -227,6 +224,12 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { _ => {} } } + if let Some(host) = vars.get("RESOLVER_BASE_URL").and_then(|v| loopback_host(v)) { + problems.push(format!( + " • RESOLVER_BASE_URL points at this machine ({host}) — a production node \ + signs it into every carrier, and no customer can scan one" + )); + } if !problems.is_empty() { anyhow::bail!( "production .env at {} is not safe to start:\n{}\nEdit it and try again.", @@ -237,6 +240,24 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { Ok(()) } +/// The host of `value` when it names this machine — `localhost` (or a +/// subdomain of it), or a loopback address — however it is spelled: a trailing +/// `/`, a path, `127.0.0.2`, `[::1]` all normalise to the same answer. `None` +/// for anything else, including a value that does not parse: whether it is a +/// usable URL is the node's refusal to make at boot, not this preflight's. +fn loopback_host(value: &str) -> Option { + let url = url::Url::parse(value.trim()).ok()?; + let loopback = match url.host()? { + url::Host::Domain(d) => { + let d = d.trim_end_matches('.').to_ascii_lowercase(); + d == "localhost" || d.ends_with(".localhost") + } + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + loopback.then(|| url.host_str().unwrap_or_default().to_owned()) +} + /// Read a single variable from the deployment `.env` at the install root. /// Returns `None` if the install root, the file, or the key is absent. pub fn deployment_env_var(key: &str) -> Option { @@ -591,29 +612,46 @@ mod tests { fn a_production_env_left_on_the_laptop_resolver_is_refused() { let root = tempfile::TempDir::new().unwrap(); let compose = root.path().join("docker").join(COMPOSE_FILE); - std::fs::write( - root.path().join(".env"), - "DATABASE_POSTGRES_PASS=pg-strong\n\ - DATABASE_APP_PASS=app-strong\n\ - KEY_STORE_PASSPHRASE=ks-strong\n\ - DID_WEB_BASE_URL=https://acme.example\n\ - RESOLVER_BASE_URL=http://localhost:8003\n\ - ADMIN_USERNAME=acme-admin\n\ - ADMIN_PASSWORD=admin-strong\n", - ) - .unwrap(); - - let msg = preflight_prod_env(&compose).unwrap_err().to_string(); - assert!(msg.contains("RESOLVER_BASE_URL"), "{msg}"); - - std::fs::write( - root.path().join(".env"), - std::fs::read_to_string(root.path().join(".env")) - .unwrap() - .replace("http://localhost:8003", "https://dpp.acme.example"), - ) - .unwrap(); - preflight_prod_env(&compose).expect("a real resolver origin passes"); + let with_resolver = |value: &str| { + std::fs::write( + root.path().join(".env"), + format!( + "DATABASE_POSTGRES_PASS=pg-strong\n\ + DATABASE_APP_PASS=app-strong\n\ + KEY_STORE_PASSPHRASE=ks-strong\n\ + DID_WEB_BASE_URL=https://acme.example\n\ + RESOLVER_BASE_URL={value}\n\ + ADMIN_USERNAME=acme-admin\n\ + ADMIN_PASSWORD=admin-strong\n" + ), + ) + .unwrap(); + preflight_prod_env(&compose) + }; + + // Every spelling of "this machine", not only the one `.env.example` + // ships — the node trims a trailing `/` before signing, so a literal + // comparison would have passed the second one straight through. + for local in [ + "http://localhost:8003", + "http://localhost:8003/", + "http://LOCALHOST.:8003", + "http://demo.localhost:8003", + "http://127.0.0.1:8003", + "http://127.0.0.2:8003/resolve", + "http://[::1]:8003", + ] { + let msg = with_resolver(local).unwrap_err().to_string(); + assert!(msg.contains("RESOLVER_BASE_URL"), "{local}: {msg}"); + } + + for public in [ + "https://dpp.acme.example", + "https://localhost.acme.example", + "http://192.0.2.10:8003", + ] { + with_resolver(public).unwrap_or_else(|e| panic!("{public} was refused: {e}")); + } } /// Re-running `odal init` on a configured install must not overwrite an diff --git a/crates/dpp-common/src/config.rs b/crates/dpp-common/src/config.rs index f47be4e5..d335f8ef 100644 --- a/crates/dpp-common/src/config.rs +++ b/crates/dpp-common/src/config.rs @@ -69,7 +69,10 @@ pub fn resolver_base_url() -> Result { /// /// # Errors /// -/// Names [`RESOLVER_BASE_URL`] and says what is wrong with the value. +/// Names [`RESOLVER_BASE_URL`] and says what is wrong with the value. The value +/// is echoed only with its credentials, query and fragment removed: a refusal +/// is a startup error that lands in logs, and a mistyped value is exactly the +/// one likely to carry something that should not. pub fn parse_resolver_base_url(raw: Option<&str>) -> Result { let value = raw .map(str::trim) @@ -82,23 +85,34 @@ pub fn parse_resolver_base_url(raw: Option<&str>) -> Result { cannot be changed afterwards, so there is no default" ) })?; + let shown = shown_without_secrets(value); let url = url::Url::parse(value) - .with_context(|| format!("{RESOLVER_BASE_URL} is not an absolute URL: {value}"))?; + .with_context(|| format!("{RESOLVER_BASE_URL} is not an absolute URL: {shown}"))?; if !matches!(url.scheme(), "http" | "https") { - anyhow::bail!("{RESOLVER_BASE_URL} must be an http or https URL: {value}"); + anyhow::bail!("{RESOLVER_BASE_URL} must be an http or https URL: {shown}"); } if url.host_str().is_none_or(str::is_empty) { - anyhow::bail!("{RESOLVER_BASE_URL} names no host: {value}"); + anyhow::bail!("{RESOLVER_BASE_URL} names no host: {shown}"); } if !url.username().is_empty() || url.password().is_some() { - anyhow::bail!("{RESOLVER_BASE_URL} must not carry credentials"); + anyhow::bail!("{RESOLVER_BASE_URL} must not carry credentials: {shown}"); } if url.query().is_some() || url.fragment().is_some() { - anyhow::bail!("{RESOLVER_BASE_URL} must not carry a query or fragment: {value}"); + anyhow::bail!( + "{RESOLVER_BASE_URL} must not carry a query or fragment (not shown): {shown}" + ); } Ok(value.trim_end_matches('/').to_owned()) } +/// A rejected value, fit to print: userinfo stripped by +/// [`redact_url_credentials`], and everything from the first `?` or `#` cut. +fn shown_without_secrets(value: &str) -> String { + let redacted = redact_url_credentials(value); + let end = redacted.find(['?', '#']).unwrap_or(redacted.len()); + redacted[..end].to_owned() +} + /// Read a `u16` port from an environment variable, defaulting to `default_port`. pub fn port_var(name: &str, default_port: u16) -> Result { std::env::var(name) @@ -217,6 +231,24 @@ mod tests { } } + /// A refusal is a startup error, so it lands in logs. Every path that + /// echoes the value must leave out a password or a query token — including + /// the refusals that are about something else entirely (here, the scheme). + #[test] + fn a_refused_resolver_base_url_never_echoes_its_secrets() { + for raw in [ + "ftp://user:s3cret@resolver.example", + "https://user:s3cret@resolver.example", + "https://resolver.example/?token=s3cret", + "https://resolver.example/#s3cret", + "not a url with user:s3cret@", + ] { + let msg = parse_resolver_base_url(Some(raw)).unwrap_err().to_string(); + assert!(!msg.contains("s3cret"), "{raw} leaked into: {msg}"); + assert!(msg.contains(RESOLVER_BASE_URL), "{msg}"); + } + } + /// Both consumers join paths onto the value, and the GTIN redirect used it /// as-is — so a trailing `/` is dropped here, once, rather than by each. #[test] diff --git a/crates/dpp-node/src/config.rs b/crates/dpp-node/src/config.rs index 3b77dbf3..c506e901 100644 --- a/crates/dpp-node/src/config.rs +++ b/crates/dpp-node/src/config.rs @@ -532,6 +532,11 @@ mod tests { use super::*; use serial_test::serial; + // SAFETY, for every `set_var`/`remove_var` in this module: mutating the + // environment is unsound only while another thread reads or writes it. + // Every test here that touches it is `#[serial]`, so under `cargo test`'s + // shared process no two run at once, and nextest runs each test in a + // process of its own. Nothing in `NodeConfig::from_env` spawns a thread. /// Reset to a clean baseline, then set only the five required vars. Clearing /// first makes these tests hermetic: a `.env` loaded into the process (e.g. /// via `just`'s `set dotenv-load`) cannot leak optional vars such as diff --git a/docs/guides/DEMO-RUNBOOK.md b/docs/guides/DEMO-RUNBOOK.md index 5a0a0a65..62e71d00 100644 --- a/docs/guides/DEMO-RUNBOOK.md +++ b/docs/guides/DEMO-RUNBOOK.md @@ -35,7 +35,9 @@ Two things that are hard to convey any other way: ## 🚨 The three settings that decide whether this works -Two of the three fail **silently**. Read this section before running anything. +Left unset, the first refuses to start and the second fails **silently**. Set +to the wrong host, the first fails silently too — inside every signature. Read +this section before running anything. ### 1. `RESOLVER_BASE_URL` — required, and it must be the right one From 0b2bcf61363052a9d75851fe824bbe3aa17c2602 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Wed, 23 Sep 2026 11:21:16 +0200 Subject: [PATCH 3/3] fix(cli): judge the value compose will interpolate --- CHANGELOG.md | 9 +- cli/src/core/infra.rs | 209 +++++++++++++++++++++++++++++------------- 2 files changed, 151 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe9f203..10af8336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,12 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): credentials, query or fragment, returned without a trailing `/`. The compose file passes it to both services with `${RESOLVER_BASE_URL:?}`, so `odal up` refuses before anything starts; under a production profile, `odal up`'s - preflight also refuses any value naming this machine — `localhost` or a - loopback address, however spelled. A refusal never echoes a password, query - or fragment from the value. A default here was a guess about where + preflight also refuses any value naming this machine — `localhost`, a + loopback or unspecified address, IPv4-mapped included, however spelled. It + judges the value compose will actually interpolate: a variable exported in the + shell overrides `.env`, so a stale export was what got signed while the file + read fine — true of every key the preflight checks, not only this one. A + refusal never echoes a password, query or fragment from the value. A default here was a guess about where another component lives — which is exactly what neither binary can know, and a wrong guess is signed into labels that cannot be recalled. diff --git a/cli/src/core/infra.rs b/cli/src/core/infra.rs index bfceca46..ac69b84d 100644 --- a/cli/src/core/infra.rs +++ b/cli/src/core/infra.rs @@ -177,9 +177,21 @@ pub fn compose_file() -> Result { Ok(path) } +/// What a production stack's environment must carry before it starts. +const PROD_REQUIRED: &[&str] = &[ + "DATABASE_POSTGRES_PASS", + "DATABASE_APP_PASS", + "KEY_STORE_PASSPHRASE", + "DID_WEB_BASE_URL", + "RESOLVER_BASE_URL", + "ADMIN_USERNAME", + "ADMIN_PASSWORD", +]; + /// Production preflight: a prod stack must not boot on missing or dev-default /// secrets. Verifies the deployment `.env` (next to the compose file's parent) -/// has every required secret set to a non-default value. +/// has every required secret set to a non-default value — judged on the value +/// compose will actually use, which is the shell's where it exports one. pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { // compose lives at /docker/; the deployment .env is at /.env. let root = compose_file @@ -188,48 +200,17 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { .unwrap_or_else(|| Path::new(".")); let env_path = root.join(".env"); - const REQUIRED: &[&str] = &[ - "DATABASE_POSTGRES_PASS", - "DATABASE_APP_PASS", - "KEY_STORE_PASSPHRASE", - "DID_WEB_BASE_URL", - "RESOLVER_BASE_URL", - "ADMIN_USERNAME", - "ADMIN_PASSWORD", - ]; - const INSECURE_DEFAULTS: &[&str] = &[ - "dev_only_password", - "change_me_in_env", - "dev-passphrase-change-in-prod", - "admin", - ]; - if !env_path.exists() { anyhow::bail!( "no .env found at {} — a production node needs its secrets set first.\n\ Required: {}", env_path.display(), - REQUIRED.join(", ") + PROD_REQUIRED.join(", ") ); } let vars = parse_env(&fs::read_to_string(&env_path)?); - let mut problems = Vec::new(); - for key in REQUIRED { - match vars.get(*key).map(String::as_str) { - None | Some("") => problems.push(format!(" • {key} is missing or empty")), - Some(v) if INSECURE_DEFAULTS.contains(&v) => { - problems.push(format!(" • {key} is still a dev default ({v})")) - } - _ => {} - } - } - if let Some(host) = vars.get("RESOLVER_BASE_URL").and_then(|v| loopback_host(v)) { - problems.push(format!( - " • RESOLVER_BASE_URL points at this machine ({host}) — a production node \ - signs it into every carrier, and no customer can scan one" - )); - } + let problems = prod_env_problems(&vars, &|key| std::env::var(key).ok()); if !problems.is_empty() { anyhow::bail!( "production .env at {} is not safe to start:\n{}\nEdit it and try again.", @@ -240,22 +221,79 @@ pub fn preflight_prod_env(compose_file: &Path) -> Result<()> { Ok(()) } +/// Every reason [`preflight_prod_env`] refuses, judged on the value compose +/// will interpolate for each key. +/// +/// 🚨 **A variable exported in the invoking shell overrides the same key in +/// `.env`** for compose interpolation, and `odal up` hands compose its own +/// environment. Reading `.env` alone passed a file holding a real resolver +/// origin while a stale `RESOLVER_BASE_URL=http://localhost:8003` in the shell +/// was what got signed into every carrier — and the same for a shell-exported +/// `ADMIN_PASSWORD=admin`. `shell` is that environment, a parameter so the rule +/// is testable without mutating the process's own. +fn prod_env_problems( + file: &HashMap, + shell: &dyn Fn(&str) -> Option, +) -> Vec { + const INSECURE_DEFAULTS: &[&str] = &[ + "dev_only_password", + "change_me_in_env", + "dev-passphrase-change-in-prod", + "admin", + ]; + + let mut problems = Vec::new(); + for key in PROD_REQUIRED { + let (value, source) = match shell(key) { + Some(v) => (Some(v), " (exported in your shell, which overrides .env)"), + None => (file.get(*key).cloned(), ""), + }; + match value.as_deref() { + None | Some("") => problems.push(format!(" • {key} is missing or empty{source}")), + Some(v) if INSECURE_DEFAULTS.contains(&v) => { + problems.push(format!(" • {key} is still a dev default ({v}){source}")) + } + Some(v) if *key == "RESOLVER_BASE_URL" => { + if let Some(host) = loopback_host(v) { + problems.push(format!( + " • RESOLVER_BASE_URL points at this machine ({host}){source} — a \ + production node signs it into every carrier, and no customer can \ + scan one" + )); + } + } + _ => {} + } + } + problems +} + /// The host of `value` when it names this machine — `localhost` (or a -/// subdomain of it), or a loopback address — however it is spelled: a trailing -/// `/`, a path, `127.0.0.2`, `[::1]` all normalise to the same answer. `None` -/// for anything else, including a value that does not parse: whether it is a -/// usable URL is the node's refusal to make at boot, not this preflight's. +/// subdomain of it), a loopback or unspecified address, or a loopback address +/// in IPv4-mapped IPv6 form — however it is spelled: a trailing `/`, a path, +/// `127.0.0.2`, `[::1]`, `[::ffff:127.0.0.1]`, `0.0.0.0` all normalise to the +/// same answer. `None` for anything else, including a value that does not +/// parse: whether it is a usable URL is the node's refusal to make at boot, not +/// this preflight's. fn loopback_host(value: &str) -> Option { let url = url::Url::parse(value.trim()).ok()?; - let loopback = match url.host()? { + let this_machine = match url.host()? { url::Host::Domain(d) => { let d = d.trim_end_matches('.').to_ascii_lowercase(); d == "localhost" || d.ends_with(".localhost") } - url::Host::Ipv4(ip) => ip.is_loopback(), - url::Host::Ipv6(ip) => ip.is_loopback(), + url::Host::Ipv4(ip) => ip.is_loopback() || ip.is_unspecified(), + // `Ipv6Addr::is_loopback` is `::1` only, so `::ffff:127.0.0.1` — the + // same address, mapped — has to be unwrapped to be recognised. + url::Host::Ipv6(ip) => { + ip.is_loopback() + || ip.is_unspecified() + || ip + .to_ipv4_mapped() + .is_some_and(|v4| v4.is_loopback() || v4.is_unspecified()) + } }; - loopback.then(|| url.host_str().unwrap_or_default().to_owned()) + this_machine.then(|| url.host_str().unwrap_or_default().to_owned()) } /// Read a single variable from the deployment `.env` at the install root. @@ -606,29 +644,31 @@ mod tests { assert!(missing_scaffold_files(root.path()).is_empty()); } + /// A production `.env` that passes every check, with the resolver origin + /// left for the caller to fill in. + fn prod_env_file(resolver: &str) -> HashMap { + parse_env(&format!( + "DATABASE_POSTGRES_PASS=pg-strong\n\ + DATABASE_APP_PASS=app-strong\n\ + KEY_STORE_PASSPHRASE=ks-strong\n\ + DID_WEB_BASE_URL=https://acme.example\n\ + RESOLVER_BASE_URL={resolver}\n\ + ADMIN_USERNAME=acme-admin\n\ + ADMIN_PASSWORD=admin-strong\n" + )) + } + + /// An empty shell, so these tests judge the file alone. The real one is not + /// hermetic: `just` loads the developer's own `.env` into the environment + /// every recipe — and so every test — inherits. + fn no_shell(_: &str) -> Option { + None + } + /// The laptop value `.env.example` ships is right for a demo and wrong for a /// production node, where it would be signed into every carrier it prints. #[test] fn a_production_env_left_on_the_laptop_resolver_is_refused() { - let root = tempfile::TempDir::new().unwrap(); - let compose = root.path().join("docker").join(COMPOSE_FILE); - let with_resolver = |value: &str| { - std::fs::write( - root.path().join(".env"), - format!( - "DATABASE_POSTGRES_PASS=pg-strong\n\ - DATABASE_APP_PASS=app-strong\n\ - KEY_STORE_PASSPHRASE=ks-strong\n\ - DID_WEB_BASE_URL=https://acme.example\n\ - RESOLVER_BASE_URL={value}\n\ - ADMIN_USERNAME=acme-admin\n\ - ADMIN_PASSWORD=admin-strong\n" - ), - ) - .unwrap(); - preflight_prod_env(&compose) - }; - // Every spelling of "this machine", not only the one `.env.example` // ships — the node trims a trailing `/` before signing, so a literal // comparison would have passed the second one straight through. @@ -640,20 +680,61 @@ mod tests { "http://127.0.0.1:8003", "http://127.0.0.2:8003/resolve", "http://[::1]:8003", + "http://[::ffff:127.0.0.1]:8003", + "http://0.0.0.0:8003", + "http://[::]:8003", ] { - let msg = with_resolver(local).unwrap_err().to_string(); - assert!(msg.contains("RESOLVER_BASE_URL"), "{local}: {msg}"); + let problems = prod_env_problems(&prod_env_file(local), &no_shell); + assert!( + problems.iter().any(|p| p.contains("RESOLVER_BASE_URL")), + "{local} passed: {problems:?}" + ); } for public in [ "https://dpp.acme.example", "https://localhost.acme.example", "http://192.0.2.10:8003", + "http://[2001:db8::10]:8003", ] { - with_resolver(public).unwrap_or_else(|e| panic!("{public} was refused: {e}")); + let problems = prod_env_problems(&prod_env_file(public), &no_shell); + assert!(problems.is_empty(), "{public} was refused: {problems:?}"); } } + /// Compose interpolates the shell's value over `.env`'s, so a stale export + /// in the operator's shell is what gets signed — the file is not the whole + /// answer. Judged in both directions, and the refusal says where the value + /// came from, since the operator will look in `.env` and find it fine. + #[test] + fn a_shell_export_is_judged_over_the_env_file() { + let file = prod_env_file("https://dpp.acme.example"); + + let laptop_in_shell = + |key: &str| (key == "RESOLVER_BASE_URL").then(|| "http://localhost:8003".to_owned()); + let problems = prod_env_problems(&file, &laptop_in_shell); + assert!( + problems + .iter() + .any(|p| p.contains("RESOLVER_BASE_URL") && p.contains("shell")), + "a shell export of the laptop resolver passed: {problems:?}" + ); + + let weak_admin_in_shell = |key: &str| (key == "ADMIN_PASSWORD").then(|| "admin".to_owned()); + let problems = prod_env_problems(&file, &weak_admin_in_shell); + assert!( + problems.iter().any(|p| p.contains("ADMIN_PASSWORD")), + "a shell-exported dev default passed: {problems:?}" + ); + + // And the other way: a real origin exported in the shell rescues a + // laptop value left in the file, because the shell's is what is used. + let public_in_shell = + |key: &str| (key == "RESOLVER_BASE_URL").then(|| "https://dpp.acme.example".to_owned()); + let problems = prod_env_problems(&prod_env_file("http://localhost:8003"), &public_in_shell); + assert!(problems.is_empty(), "{problems:?}"); + } + /// Re-running `odal init` on a configured install must not overwrite an /// operator's edited compose file. #[test]