b2e5878711
android / android (push) Failing after 21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1s
ci / rust (push) Failing after 2m27s
ci / web (push) Failing after 10s
ci / docs-site (push) Failing after 0s
ci / bench (push) Failing after 1s
deb / build-publish (push) Failing after 0s
decky / build-publish (push) Failing after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 0s
docker / deploy-docs (push) Has been skipped
flatpak / build-publish (push) Failing after 0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 0s
apple / swift (push) Successful in 53s
The mgmt API already always serves HTTPS (the host identity cert), but on a loopback bind with no token it ran unauthenticated — any local process could drive it. Make auth required ALWAYS: - new mgmt_token::load_or_generate(): token precedence is --mgmt-token > env PUNKTFUNK_MGMT_TOKEN > persisted ~/.config/punktfunk/mgmt-token > freshly generated 32-byte hex, persisted 0600 in KEY=VALUE form (so the bundled web console can source it directly as a systemd EnvironmentFile — one source of truth). config_dir() made pub(crate). - parse_serve() resolves the token via load_or_generate() when unset, so a bare `serve` Just Works with auth on and no operator step. - mgmt::run() drops the loopback no-token exemption and requires a token; require_auth()'s unauthenticated fallback now returns 401. The paired-cert (mTLS) branch is unchanged — Apple client + library auth unaffected. - web /api proxy: 503 (legible) instead of forwarding an empty bearer. - tests: test_app/test_app_native default a token, send() auto-attaches the bearer; blank-token test asserts the new "no token" refusal. 80 pass. - docs: mgmt module doc + host.env.example reflect always-on auth + auto-gen. Compiles, clippy/fmt clean, openapi no drift. Part B (bundle the web console into apt, auto-wired to this token) follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
104 lines
3.9 KiB
Rust
104 lines
3.9 KiB
Rust
//! Management-API bearer token resolution.
|
|
//!
|
|
//! The mgmt API always serves HTTPS (the host's identity cert) and now always requires auth — even
|
|
//! on a loopback bind. This module guarantees a token always exists: an explicit
|
|
//! `PUNKTFUNK_MGMT_TOKEN` env wins (operator override, not persisted); otherwise the persisted
|
|
//! `~/.config/punktfunk/mgmt-token` is used; otherwise a fresh 32-byte hex token is generated and
|
|
//! persisted. The file is written in `PUNKTFUNK_MGMT_TOKEN=<hex>` form (0600) so the bundled web
|
|
//! console can source it directly as a systemd `EnvironmentFile` — a single source of truth shared
|
|
//! between the host and the console with no copying.
|
|
|
|
use anyhow::{Context, Result};
|
|
use rand::RngCore;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
use std::path::Path;
|
|
|
|
const ENV_VAR: &str = "PUNKTFUNK_MGMT_TOKEN";
|
|
const FILE: &str = "mgmt-token";
|
|
|
|
/// Resolve the mgmt token (env > persisted file > generate+persist). Hex (not base64) so the
|
|
/// persisted `KEY=VALUE` line is safe to source from a shell / systemd `EnvironmentFile`.
|
|
pub fn load_or_generate() -> Result<String> {
|
|
if let Ok(v) = std::env::var(ENV_VAR) {
|
|
let v = v.trim();
|
|
if !v.is_empty() {
|
|
return Ok(v.to_string());
|
|
}
|
|
}
|
|
let path = crate::gamestream::config_dir().join(FILE);
|
|
if let Ok(contents) = fs::read_to_string(&path) {
|
|
if let Some(tok) = parse_token(&contents) {
|
|
return Ok(tok);
|
|
}
|
|
}
|
|
let mut buf = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut buf);
|
|
let token = hex::encode(buf);
|
|
let dir = crate::gamestream::config_dir();
|
|
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
|
write_token(&path, &token)?;
|
|
tracing::info!(path = %path.display(), "generated and persisted management API token (0600)");
|
|
Ok(token)
|
|
}
|
|
|
|
/// Parse the token from the persisted file: accept either a bare token line or a
|
|
/// `PUNKTFUNK_MGMT_TOKEN=<token>` line (the form we write, also valid as an EnvironmentFile).
|
|
fn parse_token(contents: &str) -> Option<String> {
|
|
let line = contents.lines().find(|l| !l.trim().is_empty())?.trim();
|
|
let tok = line
|
|
.strip_prefix("PUNKTFUNK_MGMT_TOKEN=")
|
|
.unwrap_or(line)
|
|
.trim();
|
|
(!tok.is_empty()).then(|| tok.to_string())
|
|
}
|
|
|
|
/// Write `PUNKTFUNK_MGMT_TOKEN=<token>` to `path`, mode 0600 (never briefly world-readable).
|
|
fn write_token(path: &Path, token: &str) -> Result<()> {
|
|
let mut opts = fs::OpenOptions::new();
|
|
opts.write(true).create(true).truncate(true);
|
|
#[cfg(unix)]
|
|
opts.mode(0o600);
|
|
let mut f = opts
|
|
.open(path)
|
|
.with_context(|| format!("write {}", path.display()))?;
|
|
writeln!(f, "PUNKTFUNK_MGMT_TOKEN={token}")?;
|
|
#[cfg(unix)]
|
|
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_bare_and_keyvalue_forms() {
|
|
assert_eq!(parse_token("abc123\n").as_deref(), Some("abc123"));
|
|
assert_eq!(
|
|
parse_token("PUNKTFUNK_MGMT_TOKEN=deadbeef\n").as_deref(),
|
|
Some("deadbeef")
|
|
);
|
|
assert_eq!(parse_token("\n \n"), None);
|
|
assert_eq!(parse_token("PUNKTFUNK_MGMT_TOKEN=\n"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_token_round_trips_through_the_file() {
|
|
let dir = std::env::temp_dir().join(format!("pf-mgmt-token-test-{}", std::process::id()));
|
|
let _ = fs::create_dir_all(&dir);
|
|
let path = dir.join(FILE);
|
|
write_token(&path, "cafef00d").unwrap();
|
|
let read = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(parse_token(&read).as_deref(), Some("cafef00d"));
|
|
#[cfg(unix)]
|
|
{
|
|
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600);
|
|
}
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
}
|