read the cache facts on the hive and let hives read the closure bucket
hive-c0re reads its cache facts from the store every five minutes and writes /run/hyperhive/attic/{netrc,substituter,public-key}, each 0600. it accepts only an https url, so the pull token never travels in clear. hives get a read grant on the agent-closures bucket, and swarm-queue-client a reader for it. part 2 of 3: nothing uses the files or the reader until the probe lands.
This commit is contained in:
parent
1e5070796d
commit
a55d55e494
5 changed files with 425 additions and 3 deletions
4
hive-c0re/src/attic/mod.rs
Normal file
4
hive-c0re/src/attic/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! The swarm's binary cache, as this hive sees it: the pull credentials it is
|
||||
//! given ([`reader`]).
|
||||
|
||||
pub mod reader;
|
||||
276
hive-c0re/src/attic/reader.rs
Normal file
276
hive-c0re/src/attic/reader.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
//! What this hive pulls from the swarm's binary cache with: four facts
|
||||
//! `swarm-controller` copies to `swarm/hives/<hive>/attic/*`
|
||||
//! ([`swarm_secret_client::attic`]), read here under the hive's own identity.
|
||||
//!
|
||||
//! [`Facts::publish`] puts them in `/run/hyperhive/attic/` as a netrc, the
|
||||
//! substituter URL and the public key, where nix and the root helper read them.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use swarm_secret_client::attic::{Fact, Leaf, hive_path};
|
||||
|
||||
/// The pull token, the cache's address and its signing key.
|
||||
///
|
||||
/// No `Debug` derive: `token` reads from the cache.
|
||||
pub struct Facts {
|
||||
token: String,
|
||||
url: String,
|
||||
cache: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
impl Facts {
|
||||
/// The facts as stored, checked: each is one token without whitespace,
|
||||
/// since they land in a netrc line and a URL.
|
||||
///
|
||||
/// # Errors
|
||||
/// A value that is empty, holds whitespace, or is not what its leaf is
|
||||
/// (a URL with an `https` scheme and a host; a public key `<name>:<base64>`).
|
||||
/// An `http` URL is refused because the token would travel in clear.
|
||||
pub fn parse(token: &str, url: &str, cache: &str, public_key: &str) -> Result<Self> {
|
||||
let [token, url, cache, public_key] = [token, url, cache, public_key].map(str::trim);
|
||||
for (what, v) in [
|
||||
("pull token", token),
|
||||
("url", url),
|
||||
("cache", cache),
|
||||
("public key", public_key),
|
||||
] {
|
||||
if v.is_empty() || v.contains(char::is_whitespace) {
|
||||
bail!("the stored {what} is empty or holds whitespace");
|
||||
}
|
||||
}
|
||||
let parsed = url::Url::parse(url).context("the stored url does not parse")?;
|
||||
if parsed.scheme() != "https" || parsed.host_str().is_none() {
|
||||
bail!("the stored url is not an https address");
|
||||
}
|
||||
if public_key
|
||||
.split_once(':')
|
||||
.is_none_or(|(n, k)| n.is_empty() || k.is_empty())
|
||||
{
|
||||
bail!("the stored public key is not <name>:<key>");
|
||||
}
|
||||
Ok(Self {
|
||||
token: token.to_owned(),
|
||||
url: url.trim_end_matches('/').to_owned(),
|
||||
cache: cache.to_owned(),
|
||||
public_key: public_key.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The URL nix names the cache by: the server, then the cache.
|
||||
pub fn substituter(&self) -> String {
|
||||
format!("{}/{}", self.url, self.cache)
|
||||
}
|
||||
|
||||
/// The netrc line the server's host authenticates with. The token is the
|
||||
/// password; the server ignores the login.
|
||||
fn netrc(&self) -> String {
|
||||
let host = url::Url::parse(&self.url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
format!("machine {host} password {}\n", self.token)
|
||||
}
|
||||
|
||||
/// Write the three files into `dir`: `netrc`, `substituter` and
|
||||
/// `public-key`. Each is `0600`, and one that already holds its content is
|
||||
/// left alone.
|
||||
///
|
||||
/// # Errors
|
||||
/// A file that cannot be written. The ones before it are written.
|
||||
pub fn publish(&self, dir: &Path) -> Result<()> {
|
||||
for (name, content) in [
|
||||
("netrc", self.netrc()),
|
||||
("substituter", format!("{}\n", self.substituter())),
|
||||
("public-key", format!("{}\n", self.public_key)),
|
||||
] {
|
||||
let path = dir.join(name);
|
||||
if std::fs::read(&path).is_ok_and(|have| have == content.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
crate::secret_file::write_secret_0600(&path, content.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How often [`follow`] reads the facts.
|
||||
pub const READ_INTERVAL: Duration = Duration::from_mins(5);
|
||||
|
||||
/// Read the facts and put them in [`crate::paths::ATTIC_DIR`]. The facts, or
|
||||
/// `None` when the swarm has none for this hive.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`stored`]. A file that can't be written is logged, not an error.
|
||||
pub async fn sync() -> Result<Option<Facts>> {
|
||||
let facts = stored().await?;
|
||||
if let Some(facts) = &facts
|
||||
&& let Err(e) = facts.publish(Path::new(crate::paths::ATTIC_DIR))
|
||||
{
|
||||
tracing::warn!(error = %format!("{e:#}"), "attic: could not write the cache files");
|
||||
}
|
||||
Ok(facts)
|
||||
}
|
||||
|
||||
/// [`sync`] now and every [`READ_INTERVAL`] until shutdown. A failed pass is
|
||||
/// logged and leaves the files as they are.
|
||||
pub async fn follow(mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
if let Err(e) = sync().await {
|
||||
tracing::warn!(error = %format!("{e:#}"), "attic: reading the cache facts failed");
|
||||
}
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(READ_INTERVAL) => {}
|
||||
_ = shutdown.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This hive's facts from the store.
|
||||
///
|
||||
/// `Ok(None)` when the swarm has none for it: no cache configured, the
|
||||
/// controller has not copied them yet (or is too old to), or one of the four is
|
||||
/// missing or unusable. The last is logged; the others are the ordinary states.
|
||||
///
|
||||
/// # Errors
|
||||
/// The store cannot be reached or refuses a read, which says nothing about
|
||||
/// whether the facts exist.
|
||||
pub async fn stored() -> Result<Option<Facts>> {
|
||||
let Some(hive) = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let store = swarm_secret_client::SecretStore::from_env(&hive)
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let mut values = Vec::with_capacity(Leaf::ALL.len());
|
||||
for leaf in Leaf::ALL {
|
||||
let path = hive_path(&hive, leaf)?;
|
||||
let fact = store
|
||||
.read_optional::<Fact>(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let Some(fact) = fact else {
|
||||
tracing::debug!(%path, "attic: no such fact in the store");
|
||||
return Ok(None);
|
||||
};
|
||||
values.push(fact.value);
|
||||
}
|
||||
let [token, url, cache, key] = values.as_slice() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match Facts::parse(token, url, cache, key) {
|
||||
Ok(facts) => Ok(Some(facts)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "attic: the stored cache facts are unusable");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn facts() -> Facts {
|
||||
Facts::parse(
|
||||
"tok-secret",
|
||||
"https://cache.example.org/",
|
||||
"swarm",
|
||||
"swarm:AAAA",
|
||||
)
|
||||
.expect("valid")
|
||||
}
|
||||
|
||||
fn mode(path: &Path) -> u32 {
|
||||
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_hold_the_facts_and_every_one_is_0600() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let out = dir.path().join("attic");
|
||||
facts().publish(&out).expect("published");
|
||||
let read = |n: &str| std::fs::read_to_string(out.join(n)).expect(n);
|
||||
assert_eq!(
|
||||
read("netrc"),
|
||||
"machine cache.example.org password tok-secret\n"
|
||||
);
|
||||
assert_eq!(read("substituter"), "https://cache.example.org/swarm\n");
|
||||
assert_eq!(read("public-key"), "swarm:AAAA\n");
|
||||
for name in ["netrc", "substituter", "public-key"] {
|
||||
assert_eq!(mode(&out.join(name)), 0o600, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_token_replaces_the_netrc_and_a_current_file_is_left_alone() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
facts().publish(dir.path()).expect("first");
|
||||
let ino = |n: &str| {
|
||||
std::os::unix::fs::MetadataExt::ino(
|
||||
&std::fs::metadata(dir.path().join(n)).expect("stat"),
|
||||
)
|
||||
};
|
||||
let (netrc, substituter) = (ino("netrc"), ino("substituter"));
|
||||
let rotated = Facts::parse(
|
||||
"tok-new",
|
||||
"https://cache.example.org",
|
||||
"swarm",
|
||||
"swarm:AAAA",
|
||||
)
|
||||
.expect("valid");
|
||||
rotated.publish(dir.path()).expect("second");
|
||||
assert_ne!(ino("netrc"), netrc, "the changed file is rewritten");
|
||||
assert_eq!(ino("substituter"), substituter, "the unchanged one is not");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("netrc")).expect("read"),
|
||||
"machine cache.example.org password tok-new\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// The token is sent as a header and written to a netrc, so a URL that
|
||||
/// isn't https is refused whatever the host, loopback included.
|
||||
#[test]
|
||||
fn an_http_url_is_refused_and_an_https_one_is_not() {
|
||||
for url in [
|
||||
"http://cache.example",
|
||||
"http://127.0.0.1:8910",
|
||||
"HTTP://cache.example",
|
||||
] {
|
||||
let err = Facts::parse("tok", url, "swarm", "swarm:AAAA")
|
||||
.err()
|
||||
.expect(url);
|
||||
assert!(err.to_string().contains("https"), "{err:#}");
|
||||
}
|
||||
assert!(Facts::parse("tok", "https://cache.example", "swarm", "swarm:AAAA").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fact_that_would_break_a_netrc_or_a_url_is_refused() {
|
||||
let ok = ("tok", "https://c.example", "swarm", "swarm:AAAA");
|
||||
for (token, url, cache, key) in [
|
||||
("to k", ok.1, ok.2, ok.3),
|
||||
("tok\npassword x", ok.1, ok.2, ok.3),
|
||||
("", ok.1, ok.2, ok.3),
|
||||
(ok.0, "ftp://c.example", ok.2, ok.3),
|
||||
(ok.0, "http://c.example", ok.2, ok.3),
|
||||
(ok.0, "not a url", ok.2, ok.3),
|
||||
(ok.0, ok.1, "sw arm", ok.3),
|
||||
(ok.0, ok.1, ok.2, "nokeyname"),
|
||||
] {
|
||||
assert!(
|
||||
Facts::parse(token, url, cache, key).is_err(),
|
||||
"{token:?} {url:?}"
|
||||
);
|
||||
}
|
||||
// The control: the same call accepts good values.
|
||||
assert!(Facts::parse(ok.0, ok.1, ok.2, ok.3).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ use clap::{Parser, Subcommand};
|
|||
mod actions;
|
||||
mod agent_config;
|
||||
mod agent_set;
|
||||
mod attic;
|
||||
mod container_view;
|
||||
mod coordinator;
|
||||
mod dashboard;
|
||||
|
|
|
|||
|
|
@ -231,6 +231,11 @@ pub fn gateway_agents_conf() -> PathBuf {
|
|||
// `nix/host-modules/lib/hive-forge-git.nix` — must match.
|
||||
pub const FORGE_HIVE_TOKEN: &str = "/run/hyperhive/forge-hive-token";
|
||||
|
||||
/// `attic/` — the swarm cache's pull credentials for nix: `netrc`, `substituter`
|
||||
/// and `public-key`, each `0600`, copied from the swarm secret store. Tmpfs:
|
||||
/// re-published on every boot.
|
||||
pub const ATTIC_DIR: &str = "/run/hyperhive/attic";
|
||||
|
||||
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
|
||||
#[must_use]
|
||||
pub fn runtime_root() -> PathBuf {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ pub fn spawn(
|
|||
shutdown.clone(),
|
||||
));
|
||||
|
||||
// The cache facts this hive pulls with, read into /run/hyperhive/attic.
|
||||
tokio::spawn(crate::attic::reader::follow(shutdown.clone()));
|
||||
|
||||
// The permissions the swarm grants, and the import of the meta repo's.
|
||||
tokio::spawn(crate::workers::swarm_permissions::follow(
|
||||
client.clone(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue