use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::{Mutex, MutexGuard, PoisonError};
use crate::config::{Egress, EgressEndpoint};
use crate::daemon::machines::{MACHINE_DIR_NAME, Marker, machine_marker, read_marker};
use crate::machine::TOKEN_LEN;
use crate::{Error, Result};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub(super) struct Token(pub(super) [u8; TOKEN_LEN]);
impl TryFrom<String> for Token {
type Error = &'static str;
fn try_from(text: String) -> Result<Self, Self::Error> {
let mut bytes = [0; TOKEN_LEN];
unhex(&text, &mut bytes).ok_or("a token is 64 hexadecimal digits")?;
Ok(Token(bytes))
}
}
impl From<Token> for String {
fn from(token: Token) -> Self {
hex(&token.0)
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Token(<redacted>)")
}
}
impl Token {
pub(super) fn mint() -> Result<Self> {
let mut bytes = [0; TOKEN_LEN];
getrandom::fill(&mut bytes).map_err(|e| Error::TokenMint(io::Error::other(e)))?;
Ok(Token(bytes))
}
}
pub(super) const EGRESS_DIR_NAME: &str = "egress";
const PORT_FILE_NAME: &str = "port";
const FILE_MODE: u32 = 0o600;
pub(super) struct State {
port: u16,
listener: TcpListener,
entries: Mutex<HashMap<String, Entry>>,
}
impl State {
pub(super) fn bind(state_dir: &Path) -> Result<Self> {
let port_path = state_dir.join(EGRESS_DIR_NAME).join(PORT_FILE_NAME);
let saved_port = match fs::read_to_string(&port_path) {
Ok(contents) => contents.trim().parse::<u16>().ok(),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) if error.kind() == io::ErrorKind::InvalidData => None,
Err(error) => return Err(Error::io(&port_path, error)),
};
let dir = state_dir.join(MACHINE_DIR_NAME);
let markers = fs::read_dir(&dir).map_err(|error| Error::io(&dir, error))?;
let mut entries = HashMap::new();
let mut has_markers = false;
for entry in markers {
let entry = entry.map_err(|error| Error::io(&dir, error))?;
let Ok(name) = entry.file_name().into_string() else {
continue;
};
let Ok(path) = machine_marker(&dir, &name) else {
continue;
};
has_markers = true;
if let Ok(Some(marker)) = read_marker(&path) {
entries.insert(
name,
Entry {
token: marker.egress.token,
egress: marker.spec.egress,
},
);
}
}
let port = saved_port.unwrap_or(0);
let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, port)) {
Ok(listener) => listener,
Err(_) if port != 0 && !has_markers => TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.map_err(|source| Error::EgressListen { port: 0, source })?,
Err(source) => return Err(Error::EgressListen { port, source }),
};
let port = listener
.local_addr()
.map_err(|source| Error::EgressListen { port, source })?
.port();
if saved_port != Some(port) {
write_file(&port_path, &format!("{port}\n"))?;
}
Ok(Self {
port,
listener,
entries: Mutex::new(entries),
})
}
pub(super) fn register(&self, name: &str, marker: &Marker) -> EgressEndpoint {
self.entries().insert(
name.to_string(),
Entry {
token: marker.egress.token.clone(),
egress: marker.spec.egress.clone(),
},
);
EgressEndpoint {
address: SocketAddr::from((Ipv4Addr::LOCALHOST, self.port())),
token: marker.egress.token.clone().into(),
}
}
pub(super) fn retire(&self, name: &str) {
self.entries().remove(name);
}
#[must_use]
pub(super) fn lookup(&self, token: &Token) -> Option<(String, Egress)> {
self.entries()
.iter()
.find(|(_, entry)| &entry.token == token)
.map(|(name, entry)| (name.clone(), entry.egress.clone()))
}
#[must_use]
pub(super) fn port(&self) -> u16 {
self.port
}
pub(super) fn listener(&self) -> io::Result<TcpListener> {
self.listener.try_clone()
}
fn entries(&self) -> MutexGuard<'_, HashMap<String, Entry>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn write_file(path: &Path, contents: &str) -> Result<()> {
let dir = path
.parent()
.ok_or_else(|| Error::io(path, io::ErrorKind::InvalidInput.into()))?;
let mut file = tempfile::Builder::new()
.permissions(fs::Permissions::from_mode(FILE_MODE))
.tempfile_in(dir)
.map_err(|error| Error::io(path, error))?;
file.write_all(contents.as_bytes())
.map_err(|error| Error::io(path, error))?;
file.persist(path)
.map_err(|error| Error::io(path, error.error))?;
Ok(())
}
struct Entry {
token: Token,
egress: Egress,
}
fn hex(bytes: &[u8]) -> String {
bytes
.iter()
.flat_map(|byte| [byte >> 4, byte & 0xf])
.filter_map(|nibble| char::from_digit(u32::from(nibble), 16))
.collect()
}
fn unhex(text: &str, out: &mut [u8]) -> Option<()> {
if text.len() != out.len().saturating_mul(2) || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
for (byte, pair) in out.iter_mut().zip(text.as_bytes().as_chunks::<2>().0) {
*byte = u8::from_str_radix(str::from_utf8(pair).ok()?, 16).ok()?;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Pattern;
use crate::daemon::machines::MarkerEgress;
use crate::machine;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use tempfile::TempDir;
const MARKER: &str = r#"[egress]
token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[spec.image]
name = "ghcr.io/org/name:latest"
digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
[spec.resources]
cpus = 1
"#;
#[test]
fn hex_round_trips() {
assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
let mut bytes = [0; 3];
assert_eq!(unhex("00abff", &mut bytes), Some(()));
assert_eq!(bytes, [0x00, 0xab, 0xff]);
}
#[test]
fn unhex_refuses_anything_but_two_hex_digits_per_byte() {
for text in ["0g00", "+a+b", "ab", "abcdef", "abcde", "aéa"] {
assert_eq!(unhex(text, &mut [0; 2]), None, "{text:?}");
}
}
#[test]
fn a_token_is_64_hex_digits() {
let token = Token::try_from("ab".repeat(32)).unwrap();
assert_eq!(token, Token([0xab; 32]));
assert_eq!(String::from(token), "ab".repeat(32));
assert_eq!(
Token::try_from("short".to_string()),
Err("a token is 64 hexadecimal digits")
);
}
#[test]
fn debug_output_hides_the_token() {
assert_eq!(format!("{:?}", Token([0xab; 32])), "Token(<redacted>)");
}
#[test]
fn minting_draws_a_new_token() {
assert_ne!(Token::mint().unwrap(), Token::mint().unwrap());
}
#[test]
fn bind_picks_an_ephemeral_port() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let state = State::bind(&dir).unwrap();
let egress = dir.join("egress");
assert_eq!(
fs::read_to_string(egress.join("port")).unwrap(),
format!("{}\n", state.port())
);
assert_eq!(
fs::metadata(egress.join("port")).unwrap().mode() & 0o7777,
0o600
);
assert_eq!(
state.listener().unwrap().local_addr().unwrap(),
SocketAddr::from(([127, 0, 0, 1], state.port()))
);
}
#[test]
fn bind_reuses_the_persisted_port() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let (listener, port) = held_port();
drop(listener);
port_file(&dir, format!("{port}\n"));
let state = State::bind(&dir).unwrap();
assert_eq!(state.port(), port);
}
#[test]
fn bind_refuses_a_held_port_while_a_marker_remains() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let (_held, port) = held_port();
port_file(&dir, format!("{port}\n"));
marker(&dir, "alpha", MARKER);
let Err(error) = State::bind(&dir) else {
panic!("bind took a port another socket holds");
};
assert_eq!(
error.to_string(),
format!("failed to listen for egress decisions on 127.0.0.1:{port}")
);
}
#[test]
fn bind_moves_to_a_new_port_when_no_machine_depends_on_it() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let (_held, port) = held_port();
port_file(&dir, format!("{port}\n"));
let state = State::bind(&dir).unwrap();
assert_ne!(state.port(), port);
assert_eq!(
fs::read_to_string(dir.join("egress").join("port")).unwrap(),
format!("{}\n", state.port())
);
}
#[test]
fn bind_picks_a_new_port_when_the_port_file_holds_no_port() {
for contents in [&b""[..], b"\xff\n"] {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
port_file(&dir, contents);
marker(&dir, "alpha", MARKER);
let state = State::bind(&dir).unwrap();
assert_eq!(
fs::read_to_string(dir.join("egress").join("port")).unwrap(),
format!("{}\n", state.port())
);
}
}
#[test]
fn bind_loads_the_token_of_every_marker() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
marker(&dir, "alpha", MARKER);
marker(&dir, "beta", &with_token(&"b".repeat(64)));
marker(&dir, ".tmpK3jx9a", &with_token(&"c".repeat(64)));
let state = State::bind(&dir).unwrap();
assert_eq!(
state.lookup(&Token([0xaa; 32])),
Some(("alpha".to_string(), Egress::default()))
);
assert_eq!(
state.lookup(&Token([0xbb; 32])),
Some(("beta".to_string(), Egress::default()))
);
assert_eq!(state.lookup(&Token([0xcc; 32])), None);
}
#[test]
fn bind_skips_a_marker_it_cannot_read() {
for text in ["not a marker\n".to_string(), with_token("short")] {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
marker(&dir, "odd", &text);
let state = State::bind(&dir).unwrap();
assert_eq!(
fs::read_to_string(dir.join("machines").join("odd")).unwrap(),
text
);
assert_eq!(state.lookup(&Token([0; 32])), None);
}
}
#[test]
fn register_returns_the_binding_and_answers_for_its_token() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let state = State::bind(&dir).unwrap();
let binding = state.register("alpha", &minted(0xaa, &["api.github.com"]));
assert_eq!(
binding,
EgressEndpoint {
address: SocketAddr::from(([127, 0, 0, 1], state.port())),
token: "aa".repeat(32),
}
);
assert_eq!(
state.lookup(&Token([0xaa; 32])),
Some((
"alpha".to_string(),
Egress {
allow: vec![Pattern::parse("api.github.com").unwrap()],
deny: vec![],
}
))
);
}
#[test]
fn registering_again_replaces_the_token() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let state = State::bind(&dir).unwrap();
state.register("alpha", &minted(0xaa, &[]));
state.register("alpha", &minted(0xbb, &[]));
assert_eq!(state.lookup(&Token([0xaa; 32])), None);
assert_eq!(
state.lookup(&Token([0xbb; 32])),
Some(("alpha".to_string(), Egress::default()))
);
}
#[test]
fn retire_removes_the_entry() {
let tmp = tempfile::tempdir().unwrap();
let dir = laid_out(&tmp);
let state = State::bind(&dir).unwrap();
state.register("alpha", &minted(0xaa, &[]));
state.retire("alpha");
assert_eq!(state.lookup(&Token([0xaa; 32])), None);
}
fn laid_out(dir: &TempDir) -> PathBuf {
let uid = rustix::process::geteuid().as_raw();
crate::daemon::state::prepare_in(&dir.path().join("state"), uid).unwrap()
}
fn with_token(token: &str) -> String {
MARKER.replacen(&"a".repeat(64), token, 1)
}
fn marker(dir: &Path, name: &str, text: &str) {
fs::write(dir.join("machines").join(name), text).unwrap();
}
fn port_file(dir: &Path, contents: impl AsRef<[u8]>) {
fs::write(dir.join("egress").join("port"), contents).unwrap();
}
fn held_port() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
fn minted(token: u8, allow: &[&str]) -> Marker {
Marker {
egress: MarkerEgress {
token: Token([token; 32]),
},
spec: machine::Spec {
egress: Egress {
allow: allow.iter().map(|p| Pattern::parse(p).unwrap()).collect(),
deny: vec![],
},
..machine::Spec::default()
},
}
}
}