Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion adrs/004_stelae_snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down
77 changes: 77 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StelaeRegistryConfig>,
}

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<String>,

/// 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<String>,

/// 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<String>,
}

/// 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<String>| value.as_ref().map(|_| "<redacted>");

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,
Expand Down Expand Up @@ -1143,6 +1217,9 @@ pub struct RootConfig {

pub snapshot: Option<SnapshotConfig>,

#[serde(default, skip_serializing_if = "StelaeConfig::is_default")]
pub stelae: StelaeConfig,

pub chain: ChainConfig,

#[serde(default, skip_serializing_if = "LoggingConfig::is_default")]
Expand Down
22 changes: 20 additions & 2 deletions crates/snapshot/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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},
Expand Down Expand Up @@ -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<Registry, Error> {
pub fn open(repository: &Repository, insecure: bool, auth: Auth) -> Result<Registry, Error> {
Ok(Registry::open(
repository,
Options {
insecure,
scratch_dir: None,
auth,
},
)?)
}
Expand Down
126 changes: 114 additions & 12 deletions crates/snapshot/tests/registry_fixture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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 {
Expand All @@ -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<String> = ["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");

Expand All @@ -86,10 +117,15 @@ impl Fixture {
.and_then(|port| port.trim().parse::<u16>().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
}
Expand Down Expand Up @@ -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()
}
}

Expand All @@ -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<String> {
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" }
}
"#;
Loading
Loading