remove the swarm wireguard mesh; tombstone its options
This commit is contained in:
parent
fbfc6f392a
commit
adc61c9043
4 changed files with 330 additions and 1068 deletions
|
|
@ -51,28 +51,11 @@ pub enum Cmd {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// WireGuard inter-hive mesh setup helpers.
|
||||
///
|
||||
/// Generate this hive's mesh key and print the nix to enable the mesh,
|
||||
/// add a peer, or inspect live interface state.
|
||||
Wg {
|
||||
#[command(subcommand)]
|
||||
cmd: WgCmd,
|
||||
},
|
||||
/// Generate the federation peer-config block for THIS hive.
|
||||
///
|
||||
/// Prints the nix a peer operator pastes into their swarm config to
|
||||
/// trust and reach this hive.
|
||||
PeerConfig {
|
||||
/// This hive's WireGuard mesh address (for example `10.42.0.1/32`),
|
||||
/// emitted as `wireguardAddress`. Omit when not running the mesh.
|
||||
#[arg(long)]
|
||||
wg_address: Option<String>,
|
||||
/// This hive's public WireGuard endpoint (`host:port`), emitted as
|
||||
/// `wireguardEndpoint`. Omit when peers dial in / no mesh.
|
||||
#[arg(long)]
|
||||
wg_endpoint: Option<String>,
|
||||
},
|
||||
PeerConfig,
|
||||
/// Stop containers hive-wide in one operator action.
|
||||
///
|
||||
/// Bare `hivectl stop` stops every agent and the gateway; scope flags
|
||||
|
|
@ -164,35 +147,6 @@ impl ScopeArgs {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum WgCmd {
|
||||
/// Generate this hive's WireGuard key (if absent) and print its public
|
||||
/// key plus the nix to enable the mesh.
|
||||
Init {
|
||||
/// This hive's mesh address (for example `10.42.0.1/32`) to bake into the
|
||||
/// printed snippet. Omit to get a placeholder you fill in.
|
||||
#[arg(long)]
|
||||
address: Option<String>,
|
||||
},
|
||||
/// Print the nix to add a peer hive to the mesh.
|
||||
Peer {
|
||||
/// Peer hive's DNS domain (its `swarm.hives` entry's `domain`)
|
||||
domain: String,
|
||||
/// Peer's WireGuard public key (from its `hivectl wg init`).
|
||||
#[arg(long)]
|
||||
pubkey: String,
|
||||
/// Peer's mesh address (for example `10.42.0.2/32`).
|
||||
#[arg(long)]
|
||||
address: String,
|
||||
/// Peer's `host:port` endpoint (omit for a peer that only dials out,
|
||||
/// for example one behind NAT — it must set an endpoint pointing back here).
|
||||
#[arg(long)]
|
||||
endpoint: Option<String>,
|
||||
},
|
||||
/// Show the live mesh interface state.
|
||||
Status,
|
||||
}
|
||||
|
||||
// Default host admin socket path. Shared with `hive-c0re`'s `main.rs`
|
||||
// default via `hive_host_sock::HOST_SOCKET` — the daemon binds there
|
||||
// and `hivectl` connects to it.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! `gateway`) all forward to the daemon, which owns the broker,
|
||||
//! the credentials, and the provisioning logic — a running daemon is
|
||||
//! required for those. A couple of verbs work off local host state
|
||||
//! directly instead (`wg` / `peer-config` read the mesh key + TLS CA), so
|
||||
//! directly instead (`peer-config` reads the TLS CA), so
|
||||
//! they don't need the socket. `agent <name> choom` execs into a container
|
||||
//! rather than asking the daemon to do anything, but still uses the socket
|
||||
//! for its "is this an agent?" pre-flight — that answer lives in a
|
||||
|
|
@ -26,12 +26,12 @@ mod client;
|
|||
/// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
|
||||
/// plain renderers), split out to keep this file manageable.
|
||||
mod dag_progress;
|
||||
use cli::{Cli, Cmd, WgCmd};
|
||||
use cli::{Cli, Cmd};
|
||||
mod completions;
|
||||
mod util;
|
||||
use completions::generate_completions;
|
||||
mod wg;
|
||||
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
|
||||
mod peer_config;
|
||||
use peer_config::{peer_config, require_hive_domain};
|
||||
mod agents;
|
||||
mod choom;
|
||||
mod watch;
|
||||
|
|
@ -53,25 +53,9 @@ async fn main() -> Result<()> {
|
|||
match cli.cmd {
|
||||
Cmd::Agent { name, cmd } => run_agent(&socket, &name, cmd).await,
|
||||
Cmd::ListAgents { json } => agents_list(&socket, json).await,
|
||||
Cmd::Wg { cmd } => match cmd {
|
||||
WgCmd::Init { address } => wg_init(&socket, address.as_deref()).await,
|
||||
WgCmd::Peer {
|
||||
domain,
|
||||
pubkey,
|
||||
address,
|
||||
endpoint,
|
||||
} => {
|
||||
wg_peer(&domain, &pubkey, &address, endpoint.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
WgCmd::Status => wg_status(),
|
||||
},
|
||||
Cmd::PeerConfig {
|
||||
wg_address,
|
||||
wg_endpoint,
|
||||
} => {
|
||||
Cmd::PeerConfig => {
|
||||
let domain = require_hive_domain(&socket).await?;
|
||||
peer_config(&domain, wg_address.as_deref(), wg_endpoint.as_deref());
|
||||
peer_config(&domain);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Stop {
|
||||
|
|
|
|||
143
hivectl/src/peer_config.rs
Normal file
143
hivectl/src/peer_config.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! `hivectl peer-config` — print the block a peer operator pastes to
|
||||
//! federate with this hive.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::util::query_hive_urls;
|
||||
|
||||
/// Host path of this hive's TLS trust bundle (matches the
|
||||
/// `services.hyperhive.deploy.hive-controller.tls.stateDir` default in
|
||||
/// hive-tls.nix). Its existence means the gateway is in self-signed mode,
|
||||
/// serving a leaf the secret store issued, so a federating peer needs an
|
||||
/// anchor for it. Absent = ACME / operator cert, trusted by the default CA
|
||||
/// bundle with nothing to distribute.
|
||||
const HIVE_TLS_TRUST_BUNDLE_PATH: &str = "/var/lib/hive-tls/trust-bundle.pem";
|
||||
|
||||
/// Host path of the secret store's `pki` root (hive-tls.nix's
|
||||
/// `$svcroot`, folded into the trust bundle). The anchor a whole swarm
|
||||
/// shares: every hive's gateway leaf and the swarm's service names are
|
||||
/// issued under it, so a peer host installs it once and every present
|
||||
/// *and future* hive validates.
|
||||
const STORE_PKI_ROOT_PATH: &str = "/var/lib/hive-tls/swarm-services-root.pem";
|
||||
|
||||
/// The option whose value keys a hive's entry in `services.hyperhive.swarm.hives`.
|
||||
const HIVE_NAME_OPTION: &str = "services.hyperhive.deploy.hive-controller.hiveName";
|
||||
|
||||
/// Attrset key for a hive in `services.hyperhive.swarm.hives`, derived
|
||||
/// from its domain's first DNS label.
|
||||
///
|
||||
/// A hive occupies `<hiveName>.<swarm.domain>`, so the first label *is*
|
||||
/// the hive name in any deployment that hasn't overridden `domain` by
|
||||
/// hand. Where it has, a wrong key fails loudly rather than quietly: on
|
||||
/// that hive's own host the `hives.<hiveName>` assertion fires, because
|
||||
/// the directory is supposed to be the same attrset everywhere. The
|
||||
/// snippet below says so rather than presenting the guess as fact.
|
||||
fn hive_key(domain: &str) -> &str {
|
||||
domain.split('.').next().unwrap_or(domain)
|
||||
}
|
||||
|
||||
/// Require this hive's domain from the daemon for snippet generation.
|
||||
/// Errors with a clear hint when it can't be resolved, so `peer-config`
|
||||
/// never silently emits a wrong key. An unreachable daemon propagates its
|
||||
/// own (classified) connect error; only a *reachable* daemon with no domain
|
||||
/// gets the config hint.
|
||||
pub(crate) async fn require_hive_domain(socket: &Path) -> Result<String> {
|
||||
query_hive_urls(socket)
|
||||
.await
|
||||
.context("could not determine this hive's domain from the daemon")?
|
||||
.and_then(|u| u.domain)
|
||||
.context("the daemon reported no domain — set `services.hyperhive.domain`")
|
||||
}
|
||||
|
||||
/// The first closing comment line of the `peer-config` snippet.
|
||||
fn peer_config_key_note() -> String {
|
||||
format!("# the key must be this hive's {HIVE_NAME_OPTION}, and the")
|
||||
}
|
||||
|
||||
/// The `peer-config` step carrying the secret store's `pki` root: the
|
||||
/// certificate itself, or where it will appear when this hive has not
|
||||
/// fetched it yet.
|
||||
fn store_root_step(pem: Option<&str>) -> String {
|
||||
const HEAD: &str = "# 1. trust the SECRET STORE's pki root on the peer host (once, not per hive);\n\
|
||||
# every hive's gateway leaf and the swarm's service names are issued under it:\n";
|
||||
match pem.map(str::trim_end).filter(|p| !p.is_empty()) {
|
||||
Some(p) => format!(
|
||||
"{HEAD}{p}\n\
|
||||
# (a self-signed hive in this swarm fetches it itself, into {STORE_PKI_ROOT_PATH})\n"
|
||||
),
|
||||
None => format!(
|
||||
"{HEAD}# not on this host yet: swarm-services-cert.service writes it to\n\
|
||||
# {STORE_PKI_ROOT_PATH}; rerun once that unit has succeeded\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `peer-config` — print the `swarm.hives."<name>"` block a peer
|
||||
/// operator pastes to federate with THIS hive, plus the store-root step
|
||||
/// when this hive serves a self-signed chain. Reads local state only (the
|
||||
/// TLS trust bundle and the store root); prints, never mutates.
|
||||
pub(crate) fn peer_config(domain: &str) {
|
||||
let self_signed = Path::new(HIVE_TLS_TRUST_BUNDLE_PATH).exists();
|
||||
let key = hive_key(domain);
|
||||
|
||||
if self_signed {
|
||||
let store_root = std::fs::read_to_string(STORE_PKI_ROOT_PATH).ok();
|
||||
print!("{}", store_root_step(store_root.as_deref()));
|
||||
println!();
|
||||
println!("# 2. paste into the peer hive's NixOS config:");
|
||||
} else {
|
||||
println!("# paste into the peer hive's NixOS config:");
|
||||
}
|
||||
println!("services.hyperhive.swarm.hives.\"{key}\" = {{");
|
||||
println!(" domain = \"{domain}\";");
|
||||
println!("}};");
|
||||
println!("{}", peer_config_key_note());
|
||||
println!("# same entry belongs in every hive's config — `hives` is the swarm.");
|
||||
if !self_signed {
|
||||
println!(
|
||||
"# (this hive's cert chains to a public CA — nothing to install; \
|
||||
it's trusted by the default bundle.)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{STORE_PKI_ROOT_PATH, peer_config_key_note, store_root_step};
|
||||
|
||||
const PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
|
||||
|
||||
/// The snippet tells the operator to set the deploy option, never the
|
||||
/// removed `services.hyperhive` spelling.
|
||||
#[test]
|
||||
fn key_note_names_the_deploy_option() {
|
||||
let note = peer_config_key_note();
|
||||
assert!(
|
||||
note.contains("services.hyperhive.deploy.hive-controller.hiveName"),
|
||||
"{note}"
|
||||
);
|
||||
assert!(!note.contains("services.hyperhive.hiveName"), "{note}");
|
||||
}
|
||||
|
||||
/// The certificate is printed whole, on lines of its own, so a peer
|
||||
/// operator can copy it out of the output as a PEM file.
|
||||
#[test]
|
||||
fn store_root_is_printed_verbatim() {
|
||||
let s = store_root_step(Some(&format!("{PEM}\n")));
|
||||
assert!(s.contains(&format!("\n{PEM}\n")), "{s}");
|
||||
}
|
||||
|
||||
/// A hive that has not fetched the root yet says where it will be,
|
||||
/// rather than printing an empty step.
|
||||
#[test]
|
||||
fn missing_store_root_names_the_path_and_the_unit() {
|
||||
for pem in [None, Some(""), Some("\n")] {
|
||||
let s = store_root_step(pem);
|
||||
assert!(s.contains(STORE_PKI_ROOT_PATH), "{s}");
|
||||
assert!(s.contains("swarm-services-cert.service"), "{s}");
|
||||
assert!(!s.contains("BEGIN CERTIFICATE"), "{s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
//! `hivectl wg` / `peer-config` — WireGuard mesh helpers: generate this
|
||||
//! hive's key + the nix snippets to enable the mesh, add a peer, print the
|
||||
//! block a peer pastes to federate with us, and show the live interface.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use crate::util::query_hive_urls;
|
||||
|
||||
/// Host path of this hive's WireGuard private key (matches the
|
||||
/// `privateKeyFile` example in the deploy.wireguard nix options).
|
||||
const WG_KEY_PATH: &str = "/etc/wireguard/hive.key";
|
||||
/// The mesh interface name hive-c0re's nix module brings up.
|
||||
const WG_INTERFACE: &str = "wg-hive";
|
||||
|
||||
/// Host path of this hive's TLS trust bundle (matches the
|
||||
/// `services.hyperhive.deploy.hive-controller.tls.stateDir` default in
|
||||
/// hive-tls.nix). Its existence means the gateway is in self-signed mode,
|
||||
/// serving a leaf the secret store issued, so a federating peer needs an
|
||||
/// anchor for it. Absent = ACME / operator cert, trusted by the default CA
|
||||
/// bundle with nothing to distribute.
|
||||
const HIVE_TLS_TRUST_BUNDLE_PATH: &str = "/var/lib/hive-tls/trust-bundle.pem";
|
||||
|
||||
/// Host path of the secret store's `pki` root (hive-tls.nix's
|
||||
/// `$svcroot`, folded into the trust bundle). The anchor a whole swarm
|
||||
/// shares: every hive's gateway leaf and the swarm's service names are
|
||||
/// issued under it, so a peer host installs it once and every present
|
||||
/// *and future* hive validates.
|
||||
const STORE_PKI_ROOT_PATH: &str = "/var/lib/hive-tls/swarm-services-root.pem";
|
||||
|
||||
/// The option whose value keys a hive's entry in `services.hyperhive.swarm.hives`.
|
||||
const HIVE_NAME_OPTION: &str = "services.hyperhive.deploy.hive-controller.hiveName";
|
||||
|
||||
/// Attrset key for a hive in `services.hyperhive.swarm.hives`, derived
|
||||
/// from its domain's first DNS label.
|
||||
///
|
||||
/// A hive occupies `<hiveName>.<swarm.domain>`, so the first label *is*
|
||||
/// the hive name in any deployment that hasn't overridden `domain` by
|
||||
/// hand. Where it has, a wrong key fails loudly rather than quietly: on
|
||||
/// that hive's own host the `hives.<hiveName>` assertion fires, because
|
||||
/// the directory is supposed to be the same attrset everywhere. The
|
||||
/// snippets below say so rather than presenting the guess as fact.
|
||||
fn hive_key(domain: &str) -> &str {
|
||||
domain.split('.').next().unwrap_or(domain)
|
||||
}
|
||||
|
||||
/// Best-effort query for this hive's domain from the running daemon
|
||||
/// (`HostRequest::Urls`, which reads `HYPERHIVE_HIVE_DOMAIN` from c0re's
|
||||
/// service env). `None` when the daemon is unreachable or the domain is
|
||||
/// unset — for the callers that treat both as "skip the optional block".
|
||||
/// Use [`require_hive_domain`] where the distinction matters: it keeps the
|
||||
/// connect error (and its hint) instead of flattening it away.
|
||||
async fn query_hive_domain(socket: &Path) -> Option<String> {
|
||||
query_hive_urls(socket).await.ok().flatten()?.domain
|
||||
}
|
||||
|
||||
/// Require this hive's domain from the daemon for snippet generation.
|
||||
/// Errors with a clear hint when it can't be resolved, so `peer-config`
|
||||
/// never silently emits a wrong key. An unreachable daemon propagates its
|
||||
/// own (classified) connect error; only a *reachable* daemon with no domain
|
||||
/// gets the config hint.
|
||||
pub(crate) async fn require_hive_domain(socket: &Path) -> Result<String> {
|
||||
query_hive_urls(socket)
|
||||
.await
|
||||
.context("could not determine this hive's domain from the daemon")?
|
||||
.and_then(|u| u.domain)
|
||||
.context("the daemon reported no domain — set `services.hyperhive.domain`")
|
||||
}
|
||||
|
||||
/// `wg init` — generate (if absent) the hive's WireGuard key, print its
|
||||
/// public key + the nix snippet to enable the mesh, then (best-effort)
|
||||
/// the `peer-config` block peers paste to federate with this hive, so a
|
||||
/// fresh setup is one command. The domain comes from the daemon; if it
|
||||
/// can't be resolved, the peer block is skipped (init still succeeds —
|
||||
/// its core job is enabling the mesh locally).
|
||||
pub(crate) async fn wg_init(socket: &Path, address: Option<&str>) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let key_path = Path::new(WG_KEY_PATH);
|
||||
if key_path.exists() {
|
||||
println!(
|
||||
"WireGuard private key already present at {WG_KEY_PATH} — reusing (not regenerating)."
|
||||
);
|
||||
} else {
|
||||
if let Some(parent) = key_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok();
|
||||
}
|
||||
let out = std::process::Command::new("wg")
|
||||
.arg("genkey")
|
||||
.output()
|
||||
.context("run `wg genkey` (is wireguard-tools installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"wg genkey failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
write_key_0400(key_path, &out.stdout)?;
|
||||
println!("Generated WireGuard private key at {WG_KEY_PATH} (0400).");
|
||||
}
|
||||
|
||||
let privkey =
|
||||
std::fs::read(key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||
let pubkey = wg_pubkey(&privkey)?;
|
||||
let addr = address.unwrap_or("<MESH_ADDRESS e.g. 10.42.0.1/32>");
|
||||
println!("\nPublic key (share this with peer hives — they pass it to `hivectl wg peer`):");
|
||||
println!(" {pubkey}");
|
||||
println!("\nAdd to this hive's NixOS config:");
|
||||
println!(" services.hyperhive.deploy.wireguard = {{");
|
||||
println!(" enable = true;");
|
||||
println!(" privateKeyFile = \"{WG_KEY_PATH}\";");
|
||||
println!(" address = \"{addr}\";");
|
||||
println!(" # listenPort = 51820; # default");
|
||||
println!(" }};");
|
||||
|
||||
// Also print the block a peer pastes to federate with us (CA + this
|
||||
// mesh key) — one-stop setup. Domain comes from the daemon;
|
||||
// best-effort, so init still succeeds when it can't be resolved.
|
||||
if let Some(d) = query_hive_domain(socket).await {
|
||||
println!();
|
||||
peer_config(&d, address, None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `key` to a sibling temp file created `0400` and rename it over
|
||||
/// `path`, so the key is never readable by anyone else and a crash never
|
||||
/// leaves half of it at `path`. Errors carry paths, never the key.
|
||||
fn write_key_0400(path: &Path, key: &[u8]) -> Result<()> {
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
|
||||
let mut tmp_name = path
|
||||
.file_name()
|
||||
.with_context(|| format!("{} has no file name", path.display()))?
|
||||
.to_owned();
|
||||
tmp_name.push(".tmp");
|
||||
let tmp = path.with_file_name(tmp_name);
|
||||
// A temp file left by a crashed earlier write would fail `create_new`.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o400)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("create {}", tmp.display()))?;
|
||||
f.write_all(key)
|
||||
.and_then(|()| f.sync_all())
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display()))
|
||||
}
|
||||
|
||||
/// Derive a WireGuard public key from a private key by piping it through
|
||||
/// `wg pubkey`.
|
||||
fn wg_pubkey(privkey: &[u8]) -> Result<String> {
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut child = Command::new("wg")
|
||||
.arg("pubkey")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("run `wg pubkey` (is wireguard-tools installed?)")?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.context("wg pubkey: stdin unavailable")?
|
||||
.write_all(privkey)
|
||||
.context("write private key to `wg pubkey`")?;
|
||||
let out = child.wait_with_output().context("wait for `wg pubkey`")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"wg pubkey failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
/// The closing comment of the `wg peer` snippet.
|
||||
fn wg_peer_key_note() -> String {
|
||||
format!(" # the key must be that hive's {HIVE_NAME_OPTION}")
|
||||
}
|
||||
|
||||
/// The first closing comment line of the `peer-config` snippet.
|
||||
fn peer_config_key_note() -> String {
|
||||
format!("# the key must be this hive's {HIVE_NAME_OPTION}, and the")
|
||||
}
|
||||
|
||||
/// `wg peer` — print the nix snippet to add a peer hive to the mesh.
|
||||
/// Pure output (no fallible work), so it returns `()`; the dispatch arm
|
||||
/// wraps it in `Ok` to match the sibling verbs.
|
||||
pub(crate) fn wg_peer(domain: &str, pubkey: &str, address: &str, endpoint: Option<&str>) {
|
||||
let key = hive_key(domain);
|
||||
println!("Add to this hive's NixOS config:");
|
||||
println!(" # `hives` describes the whole swarm and is meant to be the same");
|
||||
println!(" # attrset on every host — add this entry to all of them.");
|
||||
println!(" services.hyperhive.swarm.hives.\"{key}\" = {{");
|
||||
println!(" domain = \"{domain}\";");
|
||||
println!(" wireguardPublicKey = \"{pubkey}\";");
|
||||
println!(" wireguardAddress = \"{address}\";");
|
||||
if let Some(ep) = endpoint {
|
||||
println!(" wireguardEndpoint = \"{ep}\";");
|
||||
}
|
||||
println!(" }};");
|
||||
println!("{}", wg_peer_key_note());
|
||||
}
|
||||
|
||||
/// The `peer-config` step carrying the secret store's `pki` root: the
|
||||
/// certificate itself, or where it will appear when this hive has not
|
||||
/// fetched it yet.
|
||||
fn store_root_step(pem: Option<&str>) -> String {
|
||||
const HEAD: &str = "# 1. trust the SECRET STORE's pki root on the peer host (once, not per hive);\n\
|
||||
# every hive's gateway leaf and the swarm's service names are issued under it:\n";
|
||||
match pem.map(str::trim_end).filter(|p| !p.is_empty()) {
|
||||
Some(p) => format!(
|
||||
"{HEAD}{p}\n\
|
||||
# (a self-signed hive in this swarm fetches it itself, into {STORE_PKI_ROOT_PATH})\n"
|
||||
),
|
||||
None => format!(
|
||||
"{HEAD}# not on this host yet: swarm-services-cert.service writes it to\n\
|
||||
# {STORE_PKI_ROOT_PATH}; rerun once that unit has succeeded\n"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `peer-config` — print the `swarm.hives."<name>"` block a peer
|
||||
/// operator pastes to federate with THIS hive, plus the store-root step
|
||||
/// when this hive serves a self-signed chain. Reads
|
||||
/// local state only (the TLS trust bundle, the store root + the wg key);
|
||||
/// prints, never mutates.
|
||||
pub(crate) fn peer_config(domain: &str, wg_address: Option<&str>, wg_endpoint: Option<&str>) {
|
||||
let self_signed = Path::new(HIVE_TLS_TRUST_BUNDLE_PATH).exists();
|
||||
let key = hive_key(domain);
|
||||
// WireGuard public key, when this hive has a mesh key. Best-effort:
|
||||
// a missing key or absent `wg` binary just omits the mesh lines.
|
||||
let wg_pub = std::fs::read(WG_KEY_PATH)
|
||||
.ok()
|
||||
.and_then(|k| wg_pubkey(&k).ok());
|
||||
|
||||
if self_signed {
|
||||
let store_root = std::fs::read_to_string(STORE_PKI_ROOT_PATH).ok();
|
||||
print!("{}", store_root_step(store_root.as_deref()));
|
||||
println!();
|
||||
println!("# 2. paste into the peer hive's NixOS config:");
|
||||
} else {
|
||||
println!("# paste into the peer hive's NixOS config:");
|
||||
}
|
||||
println!("services.hyperhive.swarm.hives.\"{key}\" = {{");
|
||||
println!(" domain = \"{domain}\";");
|
||||
if let Some(pk) = &wg_pub {
|
||||
println!(" wireguardPublicKey = \"{pk}\";");
|
||||
}
|
||||
if let Some(addr) = wg_address {
|
||||
println!(" wireguardAddress = \"{addr}\";");
|
||||
}
|
||||
if let Some(ep) = wg_endpoint {
|
||||
println!(" wireguardEndpoint = \"{ep}\";");
|
||||
}
|
||||
println!("}};");
|
||||
println!("{}", peer_config_key_note());
|
||||
println!("# same entry belongs in every hive's config — `hives` is the swarm.");
|
||||
if !self_signed {
|
||||
println!(
|
||||
"# (this hive's cert chains to a public CA — nothing to install; \
|
||||
it's trusted by the default bundle.)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `wg status` — show the live mesh interface (`wg show wg-hive`),
|
||||
/// inheriting stdout so the operator sees it directly.
|
||||
pub(crate) fn wg_status() -> Result<()> {
|
||||
let status = std::process::Command::new("wg")
|
||||
.args(["show", WG_INTERFACE])
|
||||
.status()
|
||||
.context("run `wg show` (is wireguard-tools installed?)")?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"`wg show {WG_INTERFACE}` failed — is the mesh enabled + up? \
|
||||
(services.hyperhive.deploy.wireguard.enable = true, then deploy)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
STORE_PKI_ROOT_PATH, peer_config_key_note, store_root_step, wg_peer_key_note,
|
||||
write_key_0400,
|
||||
};
|
||||
|
||||
const PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----";
|
||||
|
||||
/// Both snippets tell the operator to set the deploy option, never the
|
||||
/// removed `services.hyperhive` spelling.
|
||||
#[test]
|
||||
fn key_notes_name_the_deploy_option() {
|
||||
for note in [wg_peer_key_note(), peer_config_key_note()] {
|
||||
assert!(
|
||||
note.contains("services.hyperhive.deploy.hive-controller.hiveName"),
|
||||
"{note}"
|
||||
);
|
||||
assert!(!note.contains("services.hyperhive.hiveName"), "{note}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The certificate is printed whole, on lines of its own, so a peer
|
||||
/// operator can copy it out of the output as a PEM file.
|
||||
#[test]
|
||||
fn store_root_is_printed_verbatim() {
|
||||
let s = store_root_step(Some(&format!("{PEM}\n")));
|
||||
assert!(s.contains(&format!("\n{PEM}\n")), "{s}");
|
||||
}
|
||||
|
||||
/// A hive that has not fetched the root yet says where it will be,
|
||||
/// rather than printing an empty step.
|
||||
#[test]
|
||||
fn missing_store_root_names_the_path_and_the_unit() {
|
||||
for pem in [None, Some(""), Some("\n")] {
|
||||
let s = store_root_step(pem);
|
||||
assert!(s.contains(STORE_PKI_ROOT_PATH), "{s}");
|
||||
assert!(s.contains("swarm-services-cert.service"), "{s}");
|
||||
assert!(!s.contains("BEGIN CERTIFICATE"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The key reaches its path only by a rename from a sibling temp: with
|
||||
/// the temp path blocked, the write fails and the existing key is left
|
||||
/// byte-for-byte as it was. Once unblocked, the key lands at `0400` with
|
||||
/// its bytes intact and no temp file beside it.
|
||||
#[test]
|
||||
fn key_is_written_through_a_temp_and_lands_0400() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("hivectl-wg-key-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
let path = dir.join("hive.key");
|
||||
let tmp = dir.join("hive.key.tmp");
|
||||
std::fs::write(&path, "old-key\n").expect("seed");
|
||||
std::fs::create_dir(&tmp).expect("block tmp");
|
||||
|
||||
let blocked = write_key_0400(&path, b"key-bytes\n");
|
||||
assert!(blocked.is_err(), "a blocked temp must fail the write");
|
||||
assert_eq!(std::fs::read(&path).expect("read seed"), b"old-key\n");
|
||||
std::fs::remove_dir(&tmp).expect("unblock tmp");
|
||||
|
||||
write_key_0400(&path, b"key-bytes\n").expect("write");
|
||||
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
|
||||
let content = std::fs::read(&path).expect("read");
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.expect("readdir")
|
||||
.map(|e| e.expect("entry").file_name())
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
assert_eq!(mode, 0o400);
|
||||
assert_eq!(content, b"key-bytes\n");
|
||||
assert_eq!(entries, [std::ffi::OsString::from("hive.key")]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue