//! Server configuration and the API key that guards every mutating route.
use std::fs;
use std::io::Write as _;
use std::net::SocketAddr;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use rand::RngCore as _;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq as _;
/// A shared secret. Compared in constant time and never rendered by `Debug`.
#[derive(Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct ApiKey(String);
impl ApiKey {
/// Generates a fresh 256-bit key rendered as lowercase hex.
pub(crate) fn generate() -> Self {
let mut bytes = [0_u8; 32];
rand::rng().fill_bytes(&mut bytes);
let mut hex = String::with_capacity(64);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
Self(hex)
}
/// Constant-time equality against a candidate presented by a client.
pub(crate) fn matches(&self, candidate: &str) -> bool {
let expected = self.0.as_bytes();
let actual = candidate.as_bytes();
// Length is not secret; the byte comparison below is what must not leak.
expected.len() == actual.len() && expected.ct_eq(actual).into()
}
/// Reveals the key so it can be printed once at first start.
pub(crate) fn reveal(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for ApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ApiKey(redacted)")
}
}
/// On-disk configuration file contents.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct ServerConfig {
/// Address the HTTP server binds to.
pub bind: SocketAddr,
/// Shared secret required by the `x-api-key` header.
pub(crate) api_key: ApiKey,
/// Path to the `once` binary this server drives.
pub once_binary: PathBuf,
/// Container namespace passed to `once -n`.
pub namespace: String,
}
impl ServerConfig {
/// Loads the config file, creating it with a fresh key on first run, then
/// applies environment overrides.
pub(crate) fn load_or_create(path: &Path) -> Result<(Self, bool)> {
let (mut config, created) = if path.exists() {
let raw = fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
let parsed: Self = toml::from_str(&raw)
.with_context(|| format!("parsing config {}", path.display()))?;
(parsed, false)
} else {
let fresh = Self::fresh();
fresh.write_private(path)?;
(fresh, true)
};
config.apply_env()?;
Ok((config, created))
}
fn fresh() -> Self {
Self {
bind: SocketAddr::from(([127, 0, 0, 1], 7373)),
api_key: ApiKey::generate(),
once_binary: PathBuf::from("/usr/bin/once"),
namespace: "once".to_owned(),
}
}
fn apply_env(&mut self) -> Result<()> {
if let Ok(bind) = std::env::var("TWICE_BIND") {
self.bind = bind.parse().with_context(|| format!("TWICE_BIND={bind}"))?;
}
if let Ok(key) = std::env::var("TWICE_API_KEY") {
if key.len() < 16 {
bail!("TWICE_API_KEY must be at least 16 characters");
}
self.api_key = ApiKey(key);
}
if let Ok(binary) = std::env::var("TWICE_ONCE_BINARY") {
self.once_binary = PathBuf::from(binary);
}
if let Ok(namespace) = std::env::var("TWICE_NAMESPACE") {
self.namespace = namespace;
}
Ok(())
}
fn write_private(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let rendered = toml::to_string_pretty(self).context("rendering config")?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating config {}", path.display()))?;
file.write_all(rendered.as_bytes())
.with_context(|| format!("writing config {}", path.display()))?;
Ok(())
}
}
/// Default config location: `$XDG_CONFIG_HOME/twice/server.toml`.
pub(crate) fn default_config_path() -> Result<PathBuf> {
if let Ok(explicit) = std::env::var("TWICE_CONFIG") {
return Ok(PathBuf::from(explicit));
}
let base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|_| {
std::env::var("HOME")
.map(|home| PathBuf::from(home).join(".config"))
.context("neither XDG_CONFIG_HOME nor HOME is set")
})?;
Ok(base.join("twice").join("server.toml"))
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use super::*;
#[test]
fn generated_keys_are_64_hex_characters_and_unique() {
let first = ApiKey::generate();
let second = ApiKey::generate();
assert_eq!(first.reveal().len(), 64);
assert!(first.reveal().chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(first.reveal(), second.reveal());
}
#[test]
fn key_matching_accepts_only_the_exact_secret() {
let key = ApiKey("s3cret-value-1234".to_owned());
assert!(key.matches("s3cret-value-1234"));
assert!(!key.matches("s3cret-value-1235"));
assert!(!key.matches("s3cret-value-123"));
assert!(!key.matches(""));
}
#[test]
fn debug_never_renders_the_secret() {
let key = ApiKey("super-secret-value".to_owned());
assert_eq!(format!("{key:?}"), "ApiKey(redacted)");
}
#[test]
fn first_run_creates_a_private_config_with_a_fresh_key() {
let dir = std::env::temp_dir().join(format!("twice-cfg-{}", ApiKey::generate().reveal()));
let path = dir.join("server.toml");
let (config, created) = ServerConfig::load_or_create(&path).unwrap();
assert!(created);
assert_eq!(config.api_key.reveal().len(), 64);
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"config must not be group/world readable"
);
let (reloaded, created_again) = ServerConfig::load_or_create(&path).unwrap();
assert!(!created_again);
assert_eq!(reloaded.api_key.reveal(), config.api_key.reveal());
fs::remove_dir_all(&dir).unwrap();
}
}