diff --git a/adrs/004_stelae_snapshots.md b/adrs/004_stelae_snapshots.md index a00db41e..b9be6c2a 100644 --- a/adrs/004_stelae_snapshots.md +++ b/adrs/004_stelae_snapshots.md @@ -240,7 +240,9 @@ The arithmetic is counted in layers, because layers are what the ceiling counts: #### What the transport requires of its host - **A process that opens a registry client must have installed a process-default rustls `CryptoProvider` first.** The transport ships no crypto backend of its own (`reqwest/rustls-no-provider`): the backend the client library would otherwise pick, `aws-lc-rs`, wants `cmake` on every build machine — the dependency this workspace already goes out of its way to avoid — so it stays out of the tree and the choice of provider moves to the program. In Dolos, `main()` installs `ring`. Omitting the install is a panic when the registry client opens, not a link error. -- **Authentication is a bearer token from `STELAE_REGISTRY_TOKEN`, or anonymous.** Read once, when the registry client opens. Nothing else: registry credentials are a publisher's concern with a policy of its own. +- **Authentication is the host's decision, in one of three shapes.** The client is opened with credentials its caller supplies — anonymous, a bearer token, or an HTTP Basic pair — and never sources them itself. Which identity a program authenticates as is that program's credential policy, and where it keeps its credentials is that program's deployment: a protocol library that read an environment variable would be deciding both on its host's behalf, and naming the variable would freeze that decision into a published API. **So this specification names no environment variable and no configuration key**, and `stelae::oci::Options::auth` is the whole of the interface. Dolos's own answer is under "CLI and configuration" below. + + Anonymous remains legitimate and is what a genuinely public repository wants. It is not what a registry that authenticates every request wants, and that is the deployment Dolos is heading for: read access to a stele repository is free and identity-less, and still credentialed. ### Code layout @@ -286,8 +288,29 @@ download_url = "https://…" # legacy, kept working, deprecated in docs source = "oci://ghcr.io/txpipe/dolos-snapshots/mainnet" # new, takes precedence require_signatures = 0 # k-of-n enforcement trusted_keys = ["ed25519:…"] # mirrors mithril genesis_key style + +[stelae.registry] # who this node reads the registry as +user = "…" # seeded by `dolos init` +# password = "…" # optional; omitted means the official + # registry's, compiled into the binary +# token = "…" # a bearer registry instead; excludes `user` ``` +The official registry's read-only password is a **published secret**: it is what makes stele distribution free and identity-less while still authenticated. It is compiled into the binary rather than seeded into the file, so `dolos init` writes a `user` and no password and a rotation reaches every node that takes a release, instead of having to be found again in every generated `dolos.toml`. A node pointing at a private registry sets `password` and gets its own. + +A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file. **Dolos introduces no environment variable for it**: `RootConfig` is already loaded through a `config::Environment` layer with the `DOLOS` prefix, so a publisher exports + +```sh +DOLOS_STELAE_REGISTRY_USER=… +DOLOS_STELAE_REGISTRY_PASSWORD=… +``` + +and the override applies by the same mechanism and with the same precedence as every other setting. That is the whole of the environment story — nothing in Dolos reads a registry credential by hand, which is what keeps one answer to "where does configuration come from" rather than two. A node carrying the read-only user in `dolos.toml` therefore publishes with nothing to remove first. + +Two shapes are refusals rather than precedence rules, checked once the configuration has resolved: `token` together with `user`, and `password` with no `user`. An operator who supplied two identities meant one of them, and a client that guessed would authenticate as one nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. + +The resolution is `dolos::common::stele_registry_auth`, a pure function of `[stelae.registry]` that hands the answer to the transport as a value. Another host embedding `stelae` decides its own credential sources, and this specification constrains none of them. + ### Publisher pipeline 1. Restore the publisher node from the previous stele (self-hosting delta pull; first run via Mithril). diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 89d83974..127f6e36 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -699,6 +699,80 @@ pub struct SnapshotConfig { pub download_url: String, } +/// `[stelae]` — how this node reaches a stele registry. +/// +/// One section carrying one credential, and that is the whole of the consumer +/// surface: a node restoring from a stele repository needs to authenticate, and +/// nothing more about a registry belongs in a node's configuration. +/// +/// **Which registry is the official one, and what it is read as, is not decided +/// here.** That is a hardcoded default of the same kind as a network's relay +/// address or its Mithril aggregator, and it lives where those live: beside +/// `KnownNetwork` in the binary, which is both what seeds a generated +/// `dolos.toml` and what answers for a section naming a user and no password. +/// This crate carries the shape and none of the values. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +pub struct StelaeConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry: Option, +} + +impl StelaeConfig { + pub fn is_default(&self) -> bool { + self.registry.is_none() + } +} + +/// `[stelae.registry]` — the credentials a stele registry is reached with. +/// +/// **Every field is settable from the environment as `DOLOS_STELAE_REGISTRY_*`, +/// and that is not a feature this section implements.** `RootConfig` is loaded +/// through a `config::Environment` layer with the `DOLOS` prefix, so a +/// publisher exports `DOLOS_STELAE_REGISTRY_USER` and +/// `DOLOS_STELAE_REGISTRY_PASSWORD` the same way any other setting is +/// overridden, and the precedence is the one the whole configuration already +/// has. Nothing in Dolos reads a registry credential out of the environment by +/// hand. +/// +/// That is what keeps a publisher's real secret out of the file while a +/// consumer's published user stays in it. +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +pub struct StelaeRegistryConfig { + /// The identity to authenticate as, sent with [a + /// password](StelaeRegistryConfig::password) as HTTP Basic. + /// + /// Seeded by `dolos init`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + + /// Omitted by `dolos init`, and by anything reading the official registry — + /// the binary supplies that one rather than copying it into every generated + /// file. Set it for a registry that is not the official one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + + /// A bearer token, for a registry that issues them rather than accepting a + /// pair. Mutually exclusive with `user`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +/// Names the user and never a secret. +/// +/// `RootConfig` is printed in diagnostics; a derived `Debug` here would put a +/// publisher's credentials in whatever a bug report happens to include. +impl std::fmt::Debug for StelaeRegistryConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let redacted = |value: &Option| value.as_ref().map(|_| ""); + + f.debug_struct("StelaeRegistryConfig") + .field("user", &self.user) + .field("password", &redacted(&self.password)) + .field("token", &redacted(&self.token)) + .finish() + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct OuroborosConfig { pub listen_path: PathBuf, @@ -1143,6 +1217,9 @@ pub struct RootConfig { pub snapshot: Option, + #[serde(default, skip_serializing_if = "StelaeConfig::is_default")] + pub stelae: StelaeConfig, + pub chain: ChainConfig, #[serde(default, skip_serializing_if = "LoggingConfig::is_default")] diff --git a/crates/snapshot/src/registry.rs b/crates/snapshot/src/registry.rs index d4e37e43..58491146 100644 --- a/crates/snapshot/src/registry.rs +++ b/crates/snapshot/src/registry.rs @@ -76,6 +76,14 @@ //! protocol takes the string and validates it. An operator naming `epoch-500` //! is naming a sequence, so that is what [`Point`] parses to, and the round //! trip back to a tag goes through the profile like every other one. +//! +//! ## Who the client authenticates as +//! +//! A registry that charges nothing for reads may still refuse an unidentified +//! one, so both directions carry credentials — and this module takes them as an +//! argument rather than finding them. Which identity a Dolos node authenticates +//! as is the node's policy, not the profile's: the `dolos` binary reads its own +//! configuration and its own environment and hands the answer to [`open`]. use std::{cell::Cell, collections::BTreeMap}; @@ -93,7 +101,11 @@ use stelae::{ /// usable — the distribution grammar lives with the client that defines it. /// The profile is the only thing in `dolos` that reaches into `stelae`, here as /// everywhere else. -pub use stelae::oci::{Repository, SCHEME}; +/// +/// [`Auth`] rides along for the same reason: a host resolving its own +/// credentials should not have to name the protocol crate to say what it +/// resolved them to. +pub use stelae::oci::{Auth, Repository, SCHEME}; use crate::{ export::{self, Plan, Predecessor}, @@ -211,15 +223,21 @@ where /// or a mirror inside a cluster, and for nothing that is reachable from outside /// one. /// +/// `auth` is who to authenticate as, decided by the caller. A node resolves it +/// from its own configuration and environment; nothing here goes looking, for +/// the reason `stelae::oci` states one layer down and this crate has no more +/// standing to override than that one does. +/// /// **Never call any of this from inside an async context.** The transport owns /// a current-thread runtime and enters it with `block_on`; `stelae::oci`'s /// module documentation states the rule and the reason. -pub fn open(repository: &Repository, insecure: bool) -> Result { +pub fn open(repository: &Repository, insecure: bool, auth: Auth) -> Result { Ok(Registry::open( repository, Options { insecure, scratch_dir: None, + auth, }, )?) } diff --git a/crates/snapshot/tests/registry_fixture/mod.rs b/crates/snapshot/tests/registry_fixture/mod.rs index 57417270..b276c7f1 100644 --- a/crates/snapshot/tests/registry_fixture/mod.rs +++ b/crates/snapshot/tests/registry_fixture/mod.rs @@ -17,7 +17,26 @@ #![allow(dead_code)] use dolos_snapshot::registry; -use stelae::oci::Registry; +use stelae::oci::{Auth, Registry}; + +/// The credentials the fixture's registry demands, and the ones these suites +/// hand `registry::open` as a node's configured pair. +/// +/// A test credential, not a secret: it exists for the length of one container. +/// The bcrypt hash below is this pair, and `distribution` accepts no other +/// hash algorithm in an htpasswd file. +pub const USER: &str = "stelae"; +pub const PASSWORD: &str = "stelae-fixture"; + +const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsTlsNrusMYTW6\n"; + +/// The same pair, as the value a host hands `registry::open`. +pub fn credentials() -> Auth { + Auth::Basic { + user: USER.to_owned(), + password: PASSWORD.to_owned(), + } +} /// Install `ring` as the process-default crypto provider. /// @@ -42,9 +61,18 @@ fn install_crypto_provider() { /// A container running an OCI Distribution server, removed when this is /// dropped. +/// +/// It demands Basic credentials, because the registry these suites exist to +/// stand in for does: access to a stele repository is free and identity-less, +/// and still authenticated. An anonymous fixture would leave the credential +/// plumbing in `registry::open` exercised by unit tests alone. pub struct Fixture { container: String, port: u16, + /// The htpasswd file — and, for a registry that reads a file rather than + /// the environment, the configuration naming it. Held so it outlives the + /// container that has it mounted. + _auth: tempfile::TempDir, } impl Fixture { @@ -54,15 +82,18 @@ impl Fixture { let image = std::env::var("STELAE_TEST_REGISTRY_IMAGE").unwrap_or_else(|_| "registry:2".to_owned()); + let auth = auth_dir(); + + let mut args: Vec = ["run", "--detach", "--rm", "--publish", "127.0.0.1::5000"] + .iter() + .map(|arg| (*arg).to_owned()) + .collect(); + + args.extend(auth_args(auth.path())); + args.push(image.clone()); + let run = std::process::Command::new("docker") - .args([ - "run", - "--detach", - "--rm", - "--publish", - "127.0.0.1::5000", - &image, - ]) + .args(&args) .output() .expect("docker is required to run the registry tests"); @@ -86,10 +117,15 @@ impl Fixture { .and_then(|port| port.trim().parse::().ok()) .unwrap_or_else(|| panic!("no published port in {mapped:?}")); - let fixture = Self { container, port }; + let fixture = Self { + container, + port, + _auth: auth, + }; + fixture.wait_until_ready(); - eprintln!("registry: {image} on 127.0.0.1:{port}"); + eprintln!("registry: {image} on 127.0.0.1:{port}, basic auth as {USER:?}"); fixture } @@ -147,12 +183,20 @@ impl Fixture { /// A fresh transport per call, even for a name already opened: the pending /// layers and the transfer counters live in the transport, and a test /// comparing two publishes wants two of them. + /// + /// Through `registry::open`, credentials and all, so these suites exercise + /// the call a node makes rather than a transport assembled beside it. pub fn repository(&self, name: &str) -> Registry { + self.repository_as(name, credentials()) + } + + /// The same, with whatever credentials a caller wants to try. + pub fn repository_as(&self, name: &str, auth: Auth) -> Registry { let repository = format!("oci://127.0.0.1:{}/{name}", self.port) .parse() .expect("the fixture named a usable repository"); - registry::open(&repository, true).unwrap() + registry::open(&repository, true, auth).unwrap() } } @@ -163,3 +207,61 @@ impl Drop for Fixture { .output(); } } + +/// An htpasswd file, plus the configuration a registry that wants one in a file +/// rather than in the environment reads. +/// +/// Returned as a directory the caller holds: the container has both mounted, +/// and a `TempDir` dropped early would unlink them out from under it. +pub fn auth_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("a temporary directory for the htpasswd file"); + + std::fs::write(dir.path().join("htpasswd"), HTPASSWD).expect("writing the htpasswd file"); + std::fs::write(dir.path().join("zot.json"), ZOT_CONFIG).expect("writing the zot config"); + + dir +} + +/// The `docker run` arguments that make a registry demand +/// [`USER`]/[`PASSWORD`]. +/// +/// **Both configurations, unconditionally, and no per-image branch.** The two +/// server families this suite is pointed at read their auth from different +/// places and each ignores the other's: `distribution` reads `REGISTRY_AUTH_*` +/// out of the environment and never opens `/etc/zot/config.json`, while `zot` +/// reads that file and knows nothing about `REGISTRY_*`. Applying both is +/// therefore not a guess about which image is running — it is the union of two +/// settings that cannot collide. +/// +/// A registry that reads neither would run anonymous, which is why every suite +/// that uses this fixture also asserts that credentials are actually required. +pub fn auth_args(dir: &std::path::Path) -> Vec { + let path = |name: &str| dir.join(name).display().to_string(); + + vec![ + "--volume".to_owned(), + format!("{}:/auth/htpasswd:ro", path("htpasswd")), + "--volume".to_owned(), + format!("{}:/etc/zot/config.json:ro", path("zot.json")), + "--env".to_owned(), + "REGISTRY_AUTH=htpasswd".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_REALM=stelae".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd".to_owned(), + ] +} + +/// zot's whole configuration, which is a file or nothing — it has no +/// environment equivalent, and the image's own default has no auth in it. +const ZOT_CONFIG: &str = r#"{ + "distSpecVersion": "1.1.1", + "storage": { "rootDirectory": "/var/lib/registry" }, + "http": { + "address": "0.0.0.0", + "port": "5000", + "auth": { "htpasswd": { "path": "/auth/htpasswd" } } + }, + "log": { "level": "warn" } +} +"#; diff --git a/crates/snapshot/tests/restore_registry.rs b/crates/snapshot/tests/restore_registry.rs index 82871332..37781094 100644 --- a/crates/snapshot/tests/restore_registry.rs +++ b/crates/snapshot/tests/restore_registry.rs @@ -27,6 +27,12 @@ //! 4. **A point names a stele.** `epoch-N` resolves to that sequence and //! `latest` to the newest, which is what makes a repository holding a //! history restorable at any of them. +//! 5. **Credentials are what opens the repository.** The fixture's registry +//! demands Basic credentials, and every restore above reaches it through +//! `registry::open` carrying them — so the four properties are all evidence +//! for this fifth. The test named for it states the same thing directly, +//! from both sides. Where a node's credentials *come from* is the `dolos` +//! binary's decision and is tested there. //! //! ## Why the interruption is a layer boundary //! @@ -57,7 +63,7 @@ use registry_fixture::Fixture; use stelae::{ frame::Limits, inscription::LayerDescriptor, - oci::{Registry, Stele}, + oci::{Auth, Registry, Stele}, plan::RestoreProgress, transport::BlobIndex, Digest, LayerReader, Profile, SteleReader, @@ -514,6 +520,60 @@ fn a_point_that_names_no_stele_is_refused() { ); } +/// The credentials handed to `registry::open` are what a restore authenticates +/// with — and a node handing none does not get in. +/// +/// This is the consumer half of the access policy the registry exists under: +/// pulling a stele costs nothing and identifies nobody, and is still refused +/// without a credential. Where a node's credentials come from is the `dolos` +/// binary's business and is tested there; what is tested here is that they +/// reach the wire and decide the outcome. +/// +/// The negative half doubles as the fixture's own honesty check: a registry +/// this fixture did not manage to put behind htpasswd would run anonymous, and +/// every other test in this file would pass unchanged. +#[test] +#[ignore] +fn a_node_authenticates_with_the_credentials_it_was_given() { + let fixture = Fixture::spawn(); + let node = Node::build(); + + let repository = fixture.repository("dolos/credentialed"); + node.publish(&repository, &node.first); + + let reader = fixture.repository_as("dolos/credentialed", registry_fixture::credentials()); + + let stele = Point::Latest.pull(&reader).unwrap(); + println!("with the right credentials: {stele:?}"); + + // Without them, and with the wrong ones, the repository refuses — and the + // refusal is an error rather than an empty repository. `latest` reading a + // 401 as absence is what would let a publisher restart a history chain + // against a registry that merely did not recognise it. + let wrong = Auth::Basic { + user: registry_fixture::USER.to_owned(), + password: "not-the-password".to_owned(), + }; + + for (who, credentials) in [ + ("no credentials", Auth::Anonymous), + ("the wrong pair", wrong), + ] { + let refused = fixture.repository_as("dolos/credentialed", credentials); + + let err = Point::Latest + .pull(&refused) + .expect_err("the registry answered an unauthenticated request"); + + println!("{who}: {err}"); + + assert!( + refused.latest(&dolos_snapshot::DolosProfile).is_err(), + "{who}: a refusal read as an empty repository", + ); + } +} + // --------------------------------------------------------------------------- // The interruption // --------------------------------------------------------------------------- diff --git a/crates/stelae/src/oci.rs b/crates/stelae/src/oci.rs index 54dc2c23..59ea19e0 100644 --- a/crates/stelae/src/oci.rs +++ b/crates/stelae/src/oci.rs @@ -99,9 +99,20 @@ //! //! ## Authentication //! -//! Anonymous, or a bearer token read from [`TOKEN_ENV`]. Nothing else: registry -//! credentials are a publisher's concern with a policy of their own, and a -//! transport that has no secrets policy should not invent one. +//! Anonymous, a bearer token, or a Basic credential pair — whichever the caller +//! puts in [`Options::auth`]. That is the whole of it: [`Auth`] is a value the +//! caller constructs and hands over. +//! +//! **Where those credentials came from is not this crate's business, and it has +//! no way to ask.** A protocol library that read an environment variable would +//! be deciding its host's credential policy for it, and naming the variable +//! would freeze that decision into a published API — a program embedding this +//! transport gets no say in either. So a host reads its own environment, its +//! own configuration file, its own secret manager, or all three in whatever +//! order it has decided, and the answer arrives here as an [`Auth`]. +//! +//! In Dolos that host is the `dolos` binary; `dolos::common` holds the +//! variables and the precedence between them. use std::{ collections::BTreeMap, @@ -133,11 +144,64 @@ use crate::{ Digest, Error, ARTIFACT_TYPE, INSCRIPTION_MEDIA_TYPE, MANIFEST_SIZE_LIMIT, }; -/// Environment variable holding a bearer token for the registry. +/// How a [`Registry`] authenticates. /// -/// Read once, when a [`Registry`] is opened. Absent means anonymous, which is -/// what a public read-only repository wants. -pub const TOKEN_ENV: &str = "STELAE_REGISTRY_TOKEN"; +/// The three shapes `oci-client` implements, named here rather than re-exported +/// so that a caller assembling credentials does not have to depend on the +/// registry client this transport happens to be built on. Constructing one is +/// the caller's whole side of the arrangement: this crate never sources +/// credentials, so there is no `from_env` here and no variable name for a host +/// to inherit. +#[derive(Clone, Default, PartialEq, Eq)] +pub enum Auth { + /// No credentials. What a genuinely public repository wants, and what a + /// registry that authenticates every request will answer with a 401. + #[default] + Anonymous, + /// A bearer token, as GHCR and the token-exchange registries issue. + Bearer(String), + /// A user and password, sent as HTTP Basic. What a registry fronted by + /// htpasswd — or by a Worker checking a credential table — expects. + Basic { user: String, password: String }, +} + +/// Says which shape it is and never what is in it. +/// +/// A transport is held in structures that get logged and printed in error +/// context; a derived `Debug` would put a publisher's password in the first +/// backtrace anybody pastes into an issue. +impl std::fmt::Debug for Auth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Anonymous => f.write_str("Anonymous"), + Self::Bearer(_) => f.write_str("Bearer()"), + Self::Basic { user, .. } => f + .debug_struct("Basic") + .field("user", user) + .field("password", &"") + .finish(), + } + } +} + +impl Auth { + /// Whether these credentials name anybody. + /// + /// The question a host layering credential sources asks — "did that one say + /// anything, or do I fall through to the next?" — so it is answered here + /// rather than by every host matching on the variant. + pub fn is_anonymous(&self) -> bool { + matches!(self, Self::Anonymous) + } + + fn to_registry_auth(&self) -> RegistryAuth { + match self { + Self::Anonymous => RegistryAuth::Anonymous, + Self::Bearer(token) => RegistryAuth::Bearer(token.clone()), + Self::Basic { user, password } => RegistryAuth::Basic(user.clone(), password.clone()), + } + } +} /// Annotation naming a layer's profile-defined kind. /// @@ -217,6 +281,13 @@ pub struct Options { /// compressed, and the platform temporary directory is not always on the /// volume with room for sixteen of them. pub scratch_dir: Option, + + /// How to authenticate, decided entirely by the caller. + /// + /// Defaults to [`Auth::Anonymous`]. Nothing in this crate sources + /// credentials — see the module documentation for why that is a boundary + /// rather than an omission. + pub auth: Auth, } /// A repository an operator named, as `oci://HOST/PATH`. @@ -377,9 +448,9 @@ impl Registry { /// parses it into a [`Repository`] and hands that over; nothing outside /// this module needs to know where the host ends. /// - /// Builds the current-thread runtime the whole transport runs on. Reads - /// [`TOKEN_ENV`] once, here, so a token never has to be threaded through a - /// profile's call stack. + /// Builds the current-thread runtime the whole transport runs on, and + /// stores the credentials [`Options::auth`] carries so they never have to + /// be threaded through a profile's call stack. /// /// # Panics /// @@ -409,10 +480,7 @@ impl Registry { crate::MOVING_TAG.to_owned(), ); - let auth = match std::env::var(TOKEN_ENV) { - Ok(token) if !token.is_empty() => RegistryAuth::Bearer(token), - _ => RegistryAuth::Anonymous, - }; + let auth = options.auth.to_registry_auth(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -1543,4 +1611,35 @@ mod tests { })); assert!(!is_absent(&OciDistributionError::GenericError(None))); } + + /// A password never reaches a log through this type. + /// + /// [`Options`] derives `Debug` and error context is printed freely, so this + /// redaction is what stands between a publisher's credentials and the first + /// backtrace anybody pastes into an issue. + #[test] + fn credentials_are_redacted_in_debug_output() { + let basic = Auth::Basic { + user: "reader".to_owned(), + password: "hunter2".to_owned(), + }; + + let printed = format!("{basic:?}"); + assert!(printed.contains("reader"), "{printed}"); + assert!(!printed.contains("hunter2"), "{printed}"); + + let printed = format!("{:?}", Auth::Bearer("ghp_x".to_owned())); + assert!(!printed.contains("ghp_x"), "{printed}"); + + // And through the structure a caller actually holds, which is where it + // would leak from. + let printed = format!( + "{:?}", + Options { + auth: basic, + ..Default::default() + } + ); + assert!(!printed.contains("hunter2"), "{printed}"); + } } diff --git a/crates/stelae/tests/oci.rs b/crates/stelae/tests/oci.rs index 0d5208ff..aff2c073 100644 --- a/crates/stelae/tests/oci.rs +++ b/crates/stelae/tests/oci.rs @@ -24,6 +24,19 @@ //! the same suite can be pointed at another implementation — which is the only //! way to find out whether a given registry accepts an OCI 1.1 `artifactType`. //! +//! ## The fixture demands credentials +//! +//! Every registry this suite spawns is behind htpasswd, and every transport it +//! opens carries the pair. That is not incidental hardening: the registry this +//! transport is aimed at authenticates every request — access to a stele +//! repository is free and identity-less, and still credentialed — so a suite +//! that only ever spoke to an anonymous server would prove the round trip +//! against a server unlike the one it runs against. +//! +//! [`credentials_are_required`] is what keeps that honest. A server the fixture +//! does not know how to configure would run anonymous and every other test here +//! would pass regardless; that one fails instead, and says so. +//! //! ## Running them over TLS //! //! The fixture speaks plaintext by default, which is enough for everything @@ -63,7 +76,7 @@ use stelae::{ frame::{encode, CanonicalCbor, Limits}, inscription::LayerDescriptor, oci::{ - build_manifest, manifest_bytes, read_manifest, Options, Registry, Transfer, + build_manifest, manifest_bytes, read_manifest, Auth, Options, Registry, Transfer, DIFF_ID_ANNOTATION, KIND_ANNOTATION, SCOPE_ANNOTATION, }, Compression, Digest, Error, HistoryEntry, Inscription, LayerDigests, LayerSpec, Profile, @@ -558,6 +571,31 @@ fn install_crypto_provider() { }); } +/// The credentials the fixture's registry demands. +/// +/// A test credential, not a secret: it lives as long as one container. +/// [`HTPASSWD`] is the bcrypt encoding of this pair — `distribution` accepts no +/// other hash algorithm in an htpasswd file — so the two move together or not +/// at all. +const USER: &str = "stelae"; +const PASSWORD: &str = "stelae-fixture"; + +const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsTlsNrusMYTW6\n"; + +/// zot's whole configuration, which is a file or nothing: it has no environment +/// equivalent, and the image's own default carries no auth. +const ZOT_CONFIG: &str = r#"{ + "distSpecVersion": "1.1.1", + "storage": { "rootDirectory": "/var/lib/registry" }, + "http": { + "address": "0.0.0.0", + "port": "5000", + "auth": { "htpasswd": { "path": "/auth/htpasswd" } } + }, + "log": { "level": "warn" } +} +"#; + /// A container running an OCI Distribution server, removed when this is /// dropped. /// @@ -568,6 +606,9 @@ struct Fixture { container: String, port: u16, tls: bool, + /// The htpasswd file and the configuration naming it, held so they outlive + /// the container that has them mounted. + _auth: tempfile::TempDir, } impl Fixture { @@ -578,6 +619,7 @@ impl Fixture { std::env::var("STELAE_TEST_REGISTRY_IMAGE").unwrap_or_else(|_| "registry:2".to_owned()); let tls = Tls::from_env(); + let auth = auth_dir(); let mut args: Vec = ["run", "--detach", "--rm", "--publish", "127.0.0.1::5000"] .iter() @@ -588,6 +630,7 @@ impl Fixture { args.extend(tls.docker_args()); } + args.extend(auth_args(auth.path())); args.push(image.clone()); let run = std::process::Command::new("docker") @@ -619,12 +662,13 @@ impl Fixture { container, port, tls: tls.is_some(), + _auth: auth, }; fixture.wait_until_ready(); eprintln!( - "registry: {image} on {}, {}", + "registry: {image} on {}, {}, basic auth as {USER:?}", fixture.address(), if fixture.tls { "TLS" } else { "plaintext" } ); @@ -681,10 +725,23 @@ impl Fixture { } /// Every transport in this file is built here, so that whether the fixture - /// is speaking TLS is decided in exactly one place. A test that assembled - /// its own [`Options`] to set a scratch directory would keep working - /// against a plaintext fixture and quietly send `http://` at a TLS one. + /// is speaking TLS — and which credentials it presents — is decided in + /// exactly one place. A test that assembled its own [`Options`] to set a + /// scratch directory would keep working against a plaintext fixture and + /// quietly send `http://` at a TLS one. fn registry_staging_in(&self, repository: &str, scratch_dir: Option) -> Registry { + self.registry_as(repository, scratch_dir, self.credentials()) + } + + /// The pair the fixture's registry accepts. + fn credentials(&self) -> Auth { + Auth::Basic { + user: USER.to_owned(), + password: PASSWORD.to_owned(), + } + } + + fn registry_as(&self, repository: &str, scratch_dir: Option, auth: Auth) -> Registry { Registry::open( &format!("oci://{}/{repository}", self.address()) .parse() @@ -692,12 +749,58 @@ impl Fixture { Options { insecure: !self.tls, scratch_dir, + auth, }, ) .unwrap() } } +/// An htpasswd file, plus the configuration a registry that wants one in a file +/// rather than in the environment reads. +/// +/// Returned as a directory the caller holds: the container has both mounted, +/// and a `TempDir` dropped early would unlink them out from under it. +fn auth_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("a temporary directory for the htpasswd file"); + + std::fs::write(dir.path().join("htpasswd"), HTPASSWD).expect("writing the htpasswd file"); + std::fs::write(dir.path().join("zot.json"), ZOT_CONFIG).expect("writing the zot config"); + + dir +} + +/// The `docker run` arguments that make a registry demand +/// [`USER`]/[`PASSWORD`]. +/// +/// **Both configurations, unconditionally, and no per-image branch.** The two +/// server families this suite is pointed at read their auth from different +/// places and each ignores the other's: `distribution` reads `REGISTRY_AUTH_*` +/// out of the environment and never opens `/etc/zot/config.json`, while `zot` +/// reads that file and knows nothing about `REGISTRY_*`. Applying both is +/// therefore not a guess about which image is running — it is the union of two +/// settings that cannot collide. +/// +/// A registry that reads neither would run anonymous, which every other test +/// here would be perfectly happy with. [`credentials_are_required`] is what +/// notices. +fn auth_args(dir: &std::path::Path) -> Vec { + let path = |name: &str| dir.join(name).display().to_string(); + + vec![ + "--volume".to_owned(), + format!("{}:/auth/htpasswd:ro", path("htpasswd")), + "--volume".to_owned(), + format!("{}:/etc/zot/config.json:ro", path("zot.json")), + "--env".to_owned(), + "REGISTRY_AUTH=htpasswd".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_REALM=stelae".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd".to_owned(), + ] +} + impl Drop for Fixture { fn drop(&mut self) { let _ = std::process::Command::new("docker") @@ -1403,6 +1506,59 @@ fn latest_is_absent_until_something_is_published() { assert!(empty.latest(&ToyProfile).unwrap().is_none()); } +/// The registry the fixture spawns actually demands credentials, and a refusal +/// is never read as absence. +/// +/// Two claims, and the second is the one with teeth. `Registry::latest` turns +/// "no such manifest" into `None`, and a publisher reads `None` as "nothing to +/// chain to" and starts a fresh history — so a 401 widening into absence would +/// silently restart the attestation chain against a registry that simply did +/// not recognise the caller. `is_absent` is written not to, and this is that +/// claim against a server that really answers 401 rather than against a +/// hand-built error value. +/// +/// The first claim is what keeps the rest of this file honest: the fixture +/// configures htpasswd for the two server families it knows, and a registry +/// that read neither would run anonymous with every other test here passing +/// exactly as before. This one fails instead — which, for an operator pointing +/// `STELAE_TEST_REGISTRY_IMAGE` at a fourth implementation, is the fixture +/// saying it does not know how to make that one ask for credentials. +#[test] +#[ignore = "spawns a registry"] +fn credentials_are_required() { + let _serial = exclusive(); + + let fixture = Fixture::spawn(); + + // The pair the fixture configured reads the repository, which is the + // baseline every other test in this file rests on. + let allowed = fixture.registry("stelae/credentials"); + assert!(allowed.latest(&ToyProfile).unwrap().is_none()); + + for (who, auth) in [ + ("anonymous", Auth::Anonymous), + ( + "the wrong password", + Auth::Basic { + user: USER.to_owned(), + password: "not-the-password".to_owned(), + }, + ), + ] { + let refused = fixture.registry_as("stelae/credentials", None, auth); + + let err = refused + .latest(&ToyProfile) + .expect_err("the registry answered an unauthenticated request"); + + println!("{who}: {err}"); + + // And a publish through this transport is refused rather than starting + // a chain, which is the consequence that matters. + assert!(refused.pull_latest(&ToyProfile).is_err(), "{who}"); + } +} + /// A layer cannot be carried forward into a repository that does not hold its /// blob. /// diff --git a/docs/content/configuration/schema.mdx b/docs/content/configuration/schema.mdx index 1b7af645..320046f0 100644 --- a/docs/content/configuration/schema.mdx +++ b/docs/content/configuration/schema.mdx @@ -72,6 +72,9 @@ genesis_key = "5b3...45d" # redacted [snapshot] download_url = "https://example.com/snapshot.tar.zst" +[stelae.registry] +user = "dolos" + ``` Each section of the toml controls a different aspect of Dolos' processes. The rest of this document describes in detail each of these sections. @@ -394,6 +397,55 @@ The `snapshot` section controls bootstrap from a snapshot archive. - `download_url`: URL of the snapshot archive to download. +## `stelae` section + +The `stelae` section controls how this node reaches a **stele registry** — the OCI +repositories `dolos bootstrap stelae --source oci://…` restores from and +`dolos snapshot publish --repo oci://…` publishes into. + +### `stelae.registry` section + +| property | type | example | +| -------- | ------ | ------- | +| user | string | "dolos" | +| password | string | "a-private-registry-credential" | +| token | string | "ghp_…" | + +- `user`: the identity the registry is read as, sent as HTTP Basic. +- `password`: optional. Omitted, Dolos uses the official registry's published + read-only password, which is compiled into the binary. +- `token`: a bearer token, for a registry that issues them rather than accepting + a pair. Mutually exclusive with `user`. + +A registry may charge nothing for reads and still refuse an unidentified one. The +credential the official registry hands out is not a secret — it is what makes +free, identity-less access possible without leaving the registry unrestricted — +but it is not written into your config either: `dolos init` seeds the `user` and +leaves the password to the binary, so a rotation reaches every node that takes a +release instead of having to be found again in every generated file. Set +`password` to point at a registry of your own. Omit the section entirely to +connect anonymously, which is what a genuinely public repository wants. + +Setting `token` and `user` at the same time is an error rather than a precedence +puzzle — Dolos refuses to open the registry and names both — and so is setting +`password` with no `user`. + +#### A publisher's credentials + +Those are real secrets and never belong in this file. Export them instead, using +the same `DOLOS_` environment overrides that apply to [every other +setting](./introduction#environmental-variables): + +```sh +export DOLOS_STELAE_REGISTRY_USER=publisher +export DOLOS_STELAE_REGISTRY_PASSWORD=… +dolos snapshot publish --repo oci://… +``` + +There is nothing registry-specific about that: the environment layer overrides +the file for every field, so a node that carries the read-only `user` in +`dolos.toml` can publish with nothing to remove first. + ## `logging` section The `logging` section controls the logging options to define the level of details to output. diff --git a/src/bin/dolos/bootstrap/stelae.rs b/src/bin/dolos/bootstrap/stelae.rs index 56252545..d7f3b9a4 100644 --- a/src/bin/dolos/bootstrap/stelae.rs +++ b/src/bin/dolos/bootstrap/stelae.rs @@ -205,7 +205,12 @@ fn restore_repo( ) -> miette::Result<()> { let node = Node::open(config)?; - let registry = registry::open(repo, insecure) + // Resolved here rather than inside the transport: which identity this node + // reads a registry as is the node's policy, and `crate::common` is where + // this program keeps its own. + let auth = crate::common::stele_registry_auth(&config.stelae)?; + + let registry = registry::open(repo, insecure, auth) .into_diagnostic() .context("opening the repository")?; diff --git a/src/bin/dolos/common.rs b/src/bin/dolos/common.rs index 5de335b1..0d92e453 100644 --- a/src/bin/dolos/common.rs +++ b/src/bin/dolos/common.rs @@ -1,5 +1,8 @@ -use dolos_core::config::{ChainConfig, GenesisConfig, LoggingConfig, RootConfig, TelemetryConfig}; +use dolos_core::config::{ + ChainConfig, GenesisConfig, LoggingConfig, RootConfig, StelaeConfig, TelemetryConfig, +}; use dolos_core::BootstrapExt; +use dolos_snapshot::registry::Auth; use miette::{Context as _, IntoDiagnostic}; use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithExportConfig as _; @@ -61,6 +64,69 @@ pub fn load_config( s.build()?.try_deserialize() } +/// Who this node authenticates to a stele registry as. +/// +/// A pure function of `[stelae.registry]`, and deliberately nothing more. +/// **Dolos reads no environment variable of its own here**, because it does not +/// have to: `load_config` layers `config::Environment` with the `DOLOS` prefix +/// over every setting, so `DOLOS_STELAE_REGISTRY_USER` and +/// `DOLOS_STELAE_REGISTRY_PASSWORD` already override what the file says, by the +/// same mechanism and with the same precedence as every other field. A second, +/// hand-rolled set of variables would be a second answer to a question the +/// configuration has already answered — and this binary has no other. +/// +/// So the two sources the operator sees are the two the configuration has: a +/// consumer's published user in `dolos.toml`, and a publisher's real +/// credentials exported into the environment and never written down. +/// +/// Two refusals, because both are operator mistakes worth a sentence rather +/// than a precedence rule: +/// +/// - **a token and a user together** — two identities, and which was meant is +/// not something to guess at. On a registry whose credentials carry different +/// capabilities, guessing is the difference between a publish and a 403 +/// nobody can explain. +/// - **a password with no user** — a secret that arrived with nobody to be. It +/// is a typo or half an export, and the half that arrived cannot be sent on +/// its own. +pub fn stele_registry_auth(config: &StelaeConfig) -> miette::Result { + let Some(registry) = &config.registry else { + return Ok(Auth::Anonymous); + }; + + if registry.token.is_some() && registry.user.is_some() { + miette::bail!( + "[stelae.registry] sets both `token` and `user`; a registry client authenticates as \ + one identity and which one was meant is not something to guess at — drop the one \ + you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER" + ); + } + + if let Some(token) = ®istry.token { + return Ok(Auth::Bearer(token.clone())); + } + + match ®istry.user { + Some(user) => Ok(Auth::Basic { + user: user.clone(), + // A user and no password means the official registry's, which is + // compiled in beside the rest of the hardcoded defaults rather than + // written into every generated `dolos.toml`. + password: registry + .password + .clone() + .unwrap_or_else(|| crate::init::OFFICIAL_REGISTRY_PASSWORD.to_owned()), + }), + // A password with nobody to be. Anonymous would be the quiet answer and + // the wrong one: the operator supplied a secret and it would go unused. + None if registry.password.is_some() => miette::bail!( + "[stelae.registry] sets `password` with no `user`; basic registry credentials are a \ + pair" + ), + None => Ok(Auth::Anonymous), + } +} + pub fn setup_domain(config: &RootConfig) -> miette::Result { let stores = open_data_stores(config).map_err(|e| match e { Error::WalError(WalError::IncompatibleVersion { found, expected }) => miette::miette!( @@ -320,3 +386,156 @@ pub fn cleanup_data(config: &RootConfig) -> Result<(), std::io::Error> { } Ok(()) } + +#[cfg(test)] +mod tests { + use dolos_core::config::StelaeRegistryConfig; + + use super::*; + use crate::init::OFFICIAL_REGISTRY_PASSWORD; + + fn config(registry: Option) -> StelaeConfig { + StelaeConfig { registry } + } + + fn basic(user: &str, password: Option<&str>) -> StelaeRegistryConfig { + StelaeRegistryConfig { + user: Some(user.to_owned()), + password: password.map(str::to_owned), + token: None, + } + } + + /// The three shapes `[stelae.registry]` can name, and the one it names by + /// saying nothing. + #[test] + fn the_section_names_a_user_a_token_or_nobody() { + assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); + + assert_eq!( + stele_registry_auth(&config(Some(basic("dolos-reader", Some("published"))))).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: "published".to_owned(), + } + ); + + let bearer = StelaeRegistryConfig { + token: Some("ghp_x".to_owned()), + ..Default::default() + }; + + assert_eq!( + stele_registry_auth(&config(Some(bearer))).unwrap(), + Auth::Bearer("ghp_x".to_owned()) + ); + } + + /// A user with no password is the shape `dolos init` seeds: the file says + /// who, the binary says with what. + #[test] + fn a_seeded_user_takes_the_compiled_in_password() { + assert_eq!( + stele_registry_auth(&config(Some(basic("dolos-reader", None)))).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: OFFICIAL_REGISTRY_PASSWORD.to_owned(), + } + ); + } + + #[test] + fn two_identities_at_once_are_refused() { + let both = StelaeRegistryConfig { + user: Some("dolos-reader".to_owned()), + password: None, + token: Some("ghp_x".to_owned()), + }; + + let message = stele_registry_auth(&config(Some(both))) + .unwrap_err() + .to_string(); + + assert!(message.contains("token"), "{message}"); + assert!(message.contains("user"), "{message}"); + } + + /// A password with nobody to be. Anonymous would be the quiet answer and + /// the wrong one: the operator supplied a secret and it would go unused. + #[test] + fn a_password_with_no_user_is_refused() { + let orphan = StelaeRegistryConfig { + password: Some("full-access".to_owned()), + ..Default::default() + }; + + let message = stele_registry_auth(&config(Some(orphan))) + .unwrap_err() + .to_string(); + + assert!(message.contains("password"), "{message}"); + assert!(message.contains("user"), "{message}"); + } + + /// The environment reaches this section by the same route as every other + /// setting, and *this* is the assertion that says so. + /// + /// It is the whole of Dolos's registry-credential environment story — a + /// publisher exports `DOLOS_STELAE_REGISTRY_USER` and + /// `DOLOS_STELAE_REGISTRY_PASSWORD` and nothing in this binary reads them — + /// so it is worth pinning rather than trusting. The source is built exactly + /// as [`load_config`] builds it; only the file layers are left off, because + /// those would make the test depend on the working directory. + /// + /// What would break it is a rename of either field or a change to the + /// prefix or separator, and all three are silent failures at run time: the + /// override would simply stop applying, and a publisher would authenticate + /// as the read-only user. + #[test] + fn the_dolos_environment_prefix_reaches_the_registry_section() { + #[derive(serde::Deserialize)] + struct Root { + stelae: StelaeConfig, + } + + // Process-wide, so this test owns these three names for its duration. + // Nothing else in this binary reads them. + static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + + let names = [ + "DOLOS_STELAE_REGISTRY_USER", + "DOLOS_STELAE_REGISTRY_PASSWORD", + "DOLOS_STELAE_REGISTRY_TOKEN", + ]; + + let previous: Vec> = names.iter().map(|n| std::env::var(n).ok()).collect(); + + std::env::set_var("DOLOS_STELAE_REGISTRY_USER", "publisher"); + std::env::set_var("DOLOS_STELAE_REGISTRY_PASSWORD", "full-access"); + std::env::remove_var("DOLOS_STELAE_REGISTRY_TOKEN"); + + let built: Root = ::config::Config::builder() + .add_source(::config::Environment::with_prefix("DOLOS").separator("_")) + .build() + .expect("the environment source builds") + .try_deserialize() + .expect("DOLOS_STELAE_REGISTRY_* deserializes into [stelae.registry]"); + + for (name, value) in names.iter().zip(previous) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + assert_eq!( + stele_registry_auth(&built.stelae).unwrap(), + Auth::Basic { + user: "publisher".to_owned(), + password: "full-access".to_owned(), + }, + "the DOLOS_ prefix no longer reaches [stelae.registry]", + ); + } +} diff --git a/src/bin/dolos/init.rs b/src/bin/dolos/init.rs index 44e69522..297a1fcd 100644 --- a/src/bin/dolos/init.rs +++ b/src/bin/dolos/init.rs @@ -3,8 +3,8 @@ use dolos_cardano::{include, mutable_slots}; use dolos_core::{ config::{ CardanoConfig, ChainConfig, GenesisConfig, GrpcConfig, MinibfConfig, MinikupoConfig, - MithrilConfig, PeerConfig, RelayConfig, RootConfig, StorageConfig, StorageVersion, - TrpConfig, UpstreamConfig, + MithrilConfig, PeerConfig, RelayConfig, RootConfig, StelaeConfig, StelaeRegistryConfig, + StorageConfig, StorageVersion, TrpConfig, UpstreamConfig, }, Genesis, }; @@ -189,6 +189,54 @@ impl From<&KnownNetwork> for MithrilConfig { } } +/// The official stele registry's published read-only user. +/// +/// A hardcoded default of the same kind as the relay addresses and the Mithril +/// aggregators above, and here for the same reason: this module is where a +/// generated `dolos.toml` gets everything it points at. Written into the file, +/// so a config says which identity it reads the registry as. +/// +/// Empty until the registry that issues it exists. +const OFFICIAL_REGISTRY_USER: &str = ""; + +/// The password that goes with [`OFFICIAL_REGISTRY_USER`], compiled in rather +/// than written to `dolos.toml`. +/// +/// **Deliberately a published secret**, and the only one this project has: +/// stele distribution is free and identity-less, but never unrestricted — the +/// registry authenticates every request, and this is what a consumer +/// authenticates with. So it gates out-of-band tooling and nothing else. +/// +/// Compiled in rather than seeded into the file because a password copied into +/// every generated `dolos.toml` is a password that has to be found again in +/// every one of them. Here, a rotation reaches every node that takes the +/// release; a node that overrode it in its own config keeps its override. +/// +/// That makes this the one default here with a runtime reader as well as an +/// init-time one: [`crate::common::stele_registry_auth`] answers a section that +/// names a user and no password with it, reaching into this module the way +/// `doctor` reaches into it for [`KnownNetwork`] rather than keeping a second +/// account of what the defaults are. +/// +/// Empty until the registry exists. Filling both constants is a two-line change +/// here and nowhere else. +pub const OFFICIAL_REGISTRY_PASSWORD: &str = ""; + +/// `[stelae]` as a generated config carries it: the official registry's user, +/// and no password. +/// +/// Empty while [`OFFICIAL_REGISTRY_USER`] is, which is why it is a function +/// rather than a `Default` impl on the config type — it asks for the official +/// registry specifically, and gives the honest answer while there is not one. +fn official_stelae() -> StelaeConfig { + StelaeConfig { + registry: (!OFFICIAL_REGISTRY_USER.is_empty()).then(|| StelaeRegistryConfig { + user: Some(OFFICIAL_REGISTRY_USER.to_owned()), + ..Default::default() + }), + } +} + #[derive(Debug, Clone)] pub enum HistoryPrunningOptions { Keep1Day, @@ -352,6 +400,13 @@ impl Default for ConfigEditor { upstream: From::from(&KnownNetwork::CardanoMainnet), mithril: Some(From::from(&KnownNetwork::CardanoMainnet)), snapshot: Default::default(), + // Seeded, so a node created here restores from the official + // stele registry without an operator having to find a + // credential first. Only on a *fresh* config: an existing + // `dolos.toml` keeps whatever it carries, because a section + // an operator removed and one that predates the field look + // the same from here, and overwriting would undo the first. + stelae: official_stelae(), storage: StorageConfig { version: StorageVersion::V3, ..Default::default() @@ -746,3 +801,70 @@ pub fn run( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A freshly initialized node carries the stelae registry section the + /// official registry needs, and no password. + /// + /// The section appears once [`OFFICIAL_REGISTRY_USER`] names one; until + /// then there is no user to write and the file says nothing. Either way the + /// generated config has to parse and to round-trip to exactly what + /// [`official_stelae`] is, which is what will still hold on the day the + /// constants are filled in. + #[test] + fn a_fresh_config_seeds_the_official_registry_and_no_password() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dolos.toml"); + + ConfigEditor::default().save(&path).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + println!("{written}"); + + // The password is compiled in, so a generated config never carries one + // however the constants are set. + assert!(!written.contains("password"), "{written}"); + + let parsed: RootConfig = toml::from_str(&written).expect("the generated config parses"); + assert_eq!(parsed.stelae, official_stelae()); + + if let Some(registry) = &parsed.stelae.registry { + assert_eq!(registry.user.as_deref(), Some(OFFICIAL_REGISTRY_USER)); + assert_eq!(registry.password, None); + } + } + + /// A password an operator wrote is kept, so a private registry is + /// configurable in the file the same command generates. + /// + /// Only the round trip is asserted here. What a section with *no* password + /// authenticates with is [`crate::common::stele_registry_auth`]'s question, + /// and is answered by its tests against + /// [`OFFICIAL_REGISTRY_PASSWORD`]. + #[test] + fn a_configured_password_survives_the_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dolos.toml"); + + let mut editor = ConfigEditor::default(); + editor.0.stelae.registry = Some(StelaeRegistryConfig { + user: Some("dolos".to_owned()), + password: Some("a-private-registry".to_owned()), + token: None, + }); + + editor.save(&path).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("[stelae.registry]"), "{written}"); + + let parsed: RootConfig = toml::from_str(&written).unwrap(); + let registry = parsed.stelae.registry.expect("the section round-trips"); + + assert_eq!(registry.user.as_deref(), Some("dolos")); + assert_eq!(registry.password.as_deref(), Some("a-private-registry")); + } +} diff --git a/src/bin/dolos/snapshot/publish.rs b/src/bin/dolos/snapshot/publish.rs index 993c668d..b0d3af83 100644 --- a/src/bin/dolos/snapshot/publish.rs +++ b/src/bin/dolos/snapshot/publish.rs @@ -159,7 +159,7 @@ pub fn run(config: &RootConfig, args: &Args) -> miette::Result<()> { } match (&args.repo, &args.output_dir) { - (Some(repo), _) => to_repository(args, repo, &plan, &stores), + (Some(repo), _) => to_repository(config, args, repo, &plan, &stores), (None, Some(dir)) => to_directory(args, dir, &plan, &stores), // The required `destination` group already refuses this. (None, None) => unreachable!("one of --output-dir and --repo is required"), @@ -207,12 +207,20 @@ fn to_directory( /// this stele was inherited rather than built, and how much of it moved. Both /// are numbers the code counted, not an inference from a duration. fn to_repository( + config: &RootConfig, args: &Args, repo: &Repository, plan: &export::Plan, stores: &crate::common::Stores, ) -> miette::Result<()> { - let registry = registry::open(repo, args.insecure) + // A publisher's credentials come from `STELAE_REGISTRY_USER` / + // `STELAE_REGISTRY_PASSWORD`, which override anything configured. The + // configured user is still the fallback: it is read-only, so authenticating + // with it fails the push at the registry rather than a step earlier — which + // is the honest place for "these credentials cannot publish" to be said. + let auth = crate::common::stele_registry_auth(&config.stelae)?; + + let registry = registry::open(repo, args.insecure, auth) .into_diagnostic() .context("opening the repository")?;