bxcore, gatewayd, loopd: the secret store moves to bxcore::secrets
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -13,7 +13,20 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
humantime.workspace = true
|
||||
emsha.workspace = true
|
||||
zeroize = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
toml.workspace = true
|
||||
|
||||
# The secret store (M5b spec, section 5). A role that reads secrets enables it; `loopd` never does,
|
||||
# so its binary does not contain the code that reads them.
|
||||
[features]
|
||||
secrets = ["dep:zeroize"]
|
||||
|
||||
[[test]]
|
||||
name = "secrets"
|
||||
required-features = ["secrets"]
|
||||
|
||||
[[test]]
|
||||
name = "secrets_race"
|
||||
required-features = ["secrets"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames.
|
||||
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames, except
|
||||
//! the secret store (`secrets`), behind the feature of that name.
|
||||
|
||||
pub mod audit;
|
||||
pub mod chain;
|
||||
@@ -12,6 +13,8 @@ pub mod hosts;
|
||||
pub mod ids;
|
||||
pub mod log;
|
||||
pub mod memory;
|
||||
#[cfg(feature = "secrets")]
|
||||
pub mod secrets;
|
||||
pub mod sha1;
|
||||
pub mod tools;
|
||||
pub mod wire;
|
||||
|
||||
@@ -1,18 +1,100 @@
|
||||
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
|
||||
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
|
||||
//! The secret store, for every role that needs one: a secret from a systemd credential, an
|
||||
//! environment variable, or an owner-only file (M4a spec, section 4; M5b spec, section 5). A
|
||||
//! `Secret` cannot be printed. Behind the feature `secrets`: `loopd` holds no credentials and never
|
||||
//! enables it.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::SecretSource;
|
||||
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
|
||||
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
|
||||
|
||||
/// `[secrets.<name>]` in a role's configuration: exactly one of the three is set (checked by
|
||||
/// `source`). Our own format: unknown keys are errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SecretSpec {
|
||||
#[serde(default)]
|
||||
pub credential: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A checked `SecretSpec`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SecretSource {
|
||||
Credential(String),
|
||||
Env(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
impl SecretSpec {
|
||||
/// Exactly one source, well formed.
|
||||
pub fn source(&self) -> Result<SecretSource, String> {
|
||||
// Exactly one of the three set, else "needs exactly one of credential, env and file".
|
||||
// credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9,
|
||||
// _. file: an absolute path.
|
||||
let mut count = 0;
|
||||
if self.credential.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.env.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.file.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err("needs exactly one of credential, env and file".to_string());
|
||||
}
|
||||
if let Some(credential) = &self.credential {
|
||||
if credential.is_empty()
|
||||
|| !credential.bytes().all(|b| {
|
||||
b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
|
||||
})
|
||||
{
|
||||
return Err(format!(
|
||||
"credential {:?} is not a credential name (letters, digits, _ . -)",
|
||||
credential
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Credential(credential.clone()));
|
||||
}
|
||||
if let Some(env) = &self.env {
|
||||
if env.is_empty()
|
||||
|| !env
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_'))
|
||||
{
|
||||
return Err(format!(
|
||||
"env {:?} is not a variable name (A-Z, 0-9, _)",
|
||||
env
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Env(env.clone()));
|
||||
}
|
||||
// `count == 1` and the two above are `None`, so this is `Some`.
|
||||
let file = match &self.file {
|
||||
Some(file) => file,
|
||||
None => return Err("needs exactly one of credential, env and file".to_string()),
|
||||
};
|
||||
if !file.is_absolute() {
|
||||
return Err(format!(
|
||||
"file {:?} must be an absolute path",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
Ok(SecretSource::File(file.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped.
|
||||
pub struct Secret(Zeroizing<String>);
|
||||
|
||||
@@ -54,27 +136,32 @@ impl std::fmt::Display for SecretError {
|
||||
|
||||
impl std::error::Error for SecretError {}
|
||||
|
||||
/// Load secret `name` from `source`. `env` reads an environment variable (in `gatewayd`,
|
||||
/// `std::env::var_os`); tests pass their own.
|
||||
/// Load secret `name` from `source` for the program `role` (`"gatewayd"`), which the sentences
|
||||
/// name. `env` reads an environment variable (in a role, `std::env::var_os`); tests pass their
|
||||
/// own.
|
||||
pub fn load(
|
||||
role: &str,
|
||||
name: &str,
|
||||
source: &SecretSource,
|
||||
env: &dyn Fn(&str) -> Option<OsString>,
|
||||
) -> Result<Loaded, SecretError> {
|
||||
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
|
||||
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
|
||||
// By source (M4a spec, section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through
|
||||
// `env`, not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
|
||||
// variable, UTF-8; unset is an error. File: `read_checked` checks the path, opens and re-checks
|
||||
// the same file handle, reads it, and set `warning`
|
||||
// to the exact text in the task. Every value goes through `value`. Every error is a
|
||||
// `SecretError` naming the secret, never the value.
|
||||
// the same file handle, reads it, and a warning is set. Every value goes through `value`.
|
||||
// Every error is a `SecretError` naming the secret, never the value.
|
||||
match source {
|
||||
SecretSource::Credential(cred) => {
|
||||
let dir = match env("CREDENTIALS_DIRECTORY") {
|
||||
Some(dir) => dir,
|
||||
None => return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: "CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=".to_string(),
|
||||
}),
|
||||
None => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: format!(
|
||||
"CREDENTIALS_DIRECTORY is not set: {role} was not started by systemd with LoadCredentialEncrypted="
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
let path = Path::new(dir.as_os_str()).join(cred);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
@@ -124,7 +211,7 @@ pub fn load(
|
||||
})
|
||||
}
|
||||
SecretSource::File(path) => {
|
||||
let mut bytes = match read_checked(path, &|| {}) {
|
||||
let mut bytes = match read_checked(role, path, &|| {}) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(why) => {
|
||||
return Err(SecretError {
|
||||
@@ -140,7 +227,7 @@ pub fn load(
|
||||
Ok(Loaded {
|
||||
secret,
|
||||
warning: Some(format!(
|
||||
"gatewayd: warning: secret {} is read in plaintext from {}; a systemd credential keeps it encrypted at rest ({RUNBOOK_FILE})",
|
||||
"{role}: warning: secret {} is read in plaintext from {}; a systemd credential keeps it encrypted at rest ({RUNBOOK_FILE})",
|
||||
name,
|
||||
path.display()
|
||||
)),
|
||||
@@ -149,24 +236,17 @@ pub fn load(
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of an owner-only regular file, read from the very file that was checked. `between`
|
||||
/// runs after the path is checked and before it is opened: `load` passes `&|| {}`; tests use it
|
||||
/// to swap the file.
|
||||
pub fn read_checked(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
// 1. `path.is_absolute()`, else "<path> is not an absolute path".
|
||||
// 2. `let named = std::fs::symlink_metadata(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `named.file_type().is_symlink()` is "<path> is a symbolic link";
|
||||
// `!named.file_type().is_file()` is "<path> is not a regular file".
|
||||
// 3. `between();`
|
||||
// 4. `let mut file = std::fs::File::open(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `let opened = file.metadata()`, the same error text.
|
||||
// 5. `if (opened.dev(), opened.ino()) != (named.dev(), named.ino())`:
|
||||
// "<path> changed while it was read".
|
||||
// 6. The owner and mode checks from the original file check, word for word, on `opened` (not
|
||||
// on `named`): the uid of `user_id()`; "<path> is not owned by the user gatewayd runs as";
|
||||
// "<path> has mode <mode:03o>; only the owner may read it (0600 or 0400)".
|
||||
// 7. `let mut bytes = Zeroizing::new(Vec::new());` then `file.read_to_end(&mut bytes)`, an
|
||||
// Err(e) is "cannot read <path>: <e>". Ok(bytes).
|
||||
/// The bytes of an owner-only regular file, read from the very file that was checked. `role`
|
||||
/// names the program in the owner check's sentence. `between` runs after the path is checked and
|
||||
/// before it is opened: `load` passes `&|| {}`; tests use it to swap the file.
|
||||
pub fn read_checked(
|
||||
role: &str,
|
||||
path: &Path,
|
||||
between: &dyn Fn(),
|
||||
) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
// The path is checked (absolute, not a link, a regular file), then opened, and the checks
|
||||
// that matter are made on the open handle: the same file as the path named, owned by this
|
||||
// user, no group or other bits. The race it closes is M4a review, finding 2.
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{} is not an absolute path", path.display()));
|
||||
}
|
||||
@@ -190,7 +270,7 @@ pub fn read_checked(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>
|
||||
let owner = user_id()?;
|
||||
if opened.uid() != owner {
|
||||
return Err(format!(
|
||||
"{} is not owned by the user gatewayd runs as",
|
||||
"{} is not owned by the user {role} runs as",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
@@ -221,8 +301,7 @@ fn user_id() -> Result<u32, String> {
|
||||
|
||||
/// The text without one trailing newline; not empty; UTF-8.
|
||||
fn value(bytes: Vec<u8>) -> Result<Secret, String> {
|
||||
// UTF-8 (else an error), one trailing newline removed, not empty. Keep the bytes in `Zeroizing`
|
||||
// until they are inside the `Secret`.
|
||||
// Keep the bytes in `Zeroizing` until they are inside the `Secret`.
|
||||
let bytes = Zeroizing::new(bytes);
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "the value is not UTF-8".to_string())?;
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
|
||||
//! changes the process's environment. Do not edit.
|
||||
//! The secret store (M4a spec, section 4), moved from `gatewayd` to `bxcore` in M5b (spec, section
|
||||
//! 5) with its behaviour unchanged. The environment is passed in as a function, so no test changes
|
||||
//! the process's environment. Run with `--features secrets`. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
@@ -9,8 +10,7 @@ use std::ffi::OsString;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::SecretSource;
|
||||
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
|
||||
use bxcore::secrets::{RUNBOOK, RUNBOOK_FILE, SecretSource, SecretSpec, load};
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "s3cret-t0ken-value";
|
||||
@@ -30,7 +30,7 @@ fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
|
||||
}
|
||||
|
||||
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
|
||||
let e = load("mattermost_token", source, env).expect_err(word);
|
||||
let e = load("gatewayd", "mattermost_token", source, env).expect_err(word);
|
||||
let text = e.to_string();
|
||||
assert!(text.contains(word), "{word}: {text}");
|
||||
assert!(text.starts_with("secret mattermost_token: "), "{text}");
|
||||
@@ -48,6 +48,7 @@ fn a_systemd_credential() {
|
||||
let creds = dir.path().join("creds");
|
||||
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
|
||||
let got = load(
|
||||
"gatewayd",
|
||||
"mattermost_token",
|
||||
&SecretSource::Credential("mattermost-token".into()),
|
||||
&env,
|
||||
@@ -76,6 +77,7 @@ fn a_credential_outside_systemd_or_missing_is_refused() {
|
||||
#[test]
|
||||
fn an_environment_variable() {
|
||||
let got = load(
|
||||
"gatewayd",
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
@@ -102,6 +104,7 @@ fn an_owner_only_file_is_read_with_a_warning() {
|
||||
mode,
|
||||
);
|
||||
let got = load(
|
||||
"gatewayd",
|
||||
"mattermost_token",
|
||||
&SecretSource::File(path.clone()),
|
||||
&env_of(&[]),
|
||||
@@ -159,6 +162,16 @@ fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_another_user_owns_is_refused() {
|
||||
// A regular file that root owns on Linux, NixOS and macOS; the tests never run as root.
|
||||
refused(
|
||||
&SecretSource::File(PathBuf::from("/etc/passwd")),
|
||||
&env_of(&[]),
|
||||
"/etc/passwd is not owned by the user gatewayd runs as",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_non_utf8_values_are_refused() {
|
||||
let dir = TempDir::new("value");
|
||||
@@ -182,13 +195,14 @@ fn empty_and_non_utf8_values_are_refused() {
|
||||
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
|
||||
let dir = TempDir::new("trim");
|
||||
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
|
||||
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
|
||||
let got = load("gatewayd", "x", &SecretSource::File(path), &env_of(&[])).unwrap();
|
||||
assert_eq!(got.secret.expose(), " a b \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_prints_nothing_of_itself() {
|
||||
let got = load(
|
||||
"gatewayd",
|
||||
"mattermost_token",
|
||||
&SecretSource::Env("MM".into()),
|
||||
&env_of(&[("MM", TOKEN)]),
|
||||
@@ -198,3 +212,74 @@ fn a_secret_prints_nothing_of_itself() {
|
||||
assert!(!shown.contains(TOKEN), "{shown}");
|
||||
assert!(shown.contains("Secret(…)"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sentences_name_the_role_that_loads() {
|
||||
let e = load(
|
||||
"memoryd",
|
||||
"honcho_key",
|
||||
&SecretSource::Credential("honcho-key".into()),
|
||||
&env_of(&[]),
|
||||
)
|
||||
.expect_err("no CREDENTIALS_DIRECTORY");
|
||||
assert!(
|
||||
e.to_string()
|
||||
.contains("memoryd was not started by systemd with LoadCredentialEncrypted="),
|
||||
"{e}"
|
||||
);
|
||||
let dir = TempDir::new("role");
|
||||
let path = owner_file(&dir, "key", TOKEN, 0o600);
|
||||
let got = load(
|
||||
"memoryd",
|
||||
"honcho_key",
|
||||
&SecretSource::File(path),
|
||||
&env_of(&[]),
|
||||
)
|
||||
.unwrap();
|
||||
let warning = got.warning.expect("a file secret warns");
|
||||
assert!(
|
||||
warning.starts_with("memoryd: warning: secret honcho_key is read in plaintext from "),
|
||||
"{warning}"
|
||||
);
|
||||
let e = load(
|
||||
"memoryd",
|
||||
"honcho_key",
|
||||
&SecretSource::File(PathBuf::from("/etc/passwd")),
|
||||
&env_of(&[]),
|
||||
)
|
||||
.expect_err("root owns it");
|
||||
assert!(
|
||||
e.to_string()
|
||||
.contains("/etc/passwd is not owned by the user memoryd runs as"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spec_names_exactly_one_well_formed_source() {
|
||||
let spec = |text: &str| toml::from_str::<SecretSpec>(text).unwrap().source();
|
||||
assert_eq!(
|
||||
spec("credential = \"mattermost-token\""),
|
||||
Ok(SecretSource::Credential("mattermost-token".into()))
|
||||
);
|
||||
assert_eq!(spec("env = \"MM_2\""), Ok(SecretSource::Env("MM_2".into())));
|
||||
assert_eq!(
|
||||
spec("file = \"/run/token\""),
|
||||
Ok(SecretSource::File(PathBuf::from("/run/token")))
|
||||
);
|
||||
for (text, word) in [
|
||||
("", "needs exactly one of credential, env and file"),
|
||||
("env = \"A\"\nfile = \"/t\"", "needs exactly one"),
|
||||
("credential = \"\"", "is not a credential name"),
|
||||
("credential = \"a/b\"", "is not a credential name"),
|
||||
("env = \"mm\"", "is not a variable name"),
|
||||
("file = \"token\"", "must be an absolute path"),
|
||||
] {
|
||||
let why = spec(text).expect_err(text);
|
||||
assert!(why.contains(word), "{text:?}: {why}");
|
||||
}
|
||||
assert!(
|
||||
toml::from_str::<SecretSpec>("env = \"A\"\npath = \"/t\"").is_err(),
|
||||
"unknown keys are refused"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! A secret file is read from the file that was checked, never from whatever the path names a
|
||||
//! moment later (M4a review, finding 2). `read_checked` runs `between` after checking the path and
|
||||
//! before opening it; each test swaps something there. Do not edit.
|
||||
//! before opening it; each test swaps something there. Moved from `gatewayd` in M5b; run with
|
||||
//! `--features secrets`. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
@@ -8,7 +9,7 @@ mod tmp;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gatewayd::secrets::read_checked;
|
||||
use bxcore::secrets::read_checked;
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "the-real-token";
|
||||
@@ -21,7 +22,7 @@ fn owner_file(dir: &TempDir, name: &str, text: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
fn refused(path: &Path, between: &dyn Fn(), word: &str) {
|
||||
let why = read_checked(path, between).expect_err(word);
|
||||
let why = read_checked("gatewayd", path, between).expect_err(word);
|
||||
assert!(why.contains(word), "{word}: {why}");
|
||||
assert!(why.contains(&path.display().to_string()), "{why}");
|
||||
assert!(
|
||||
@@ -34,7 +35,7 @@ fn refused(path: &Path, between: &dyn Fn(), word: &str) {
|
||||
fn an_untouched_file_is_read() {
|
||||
let dir = TempDir::new("race-ok");
|
||||
let path = owner_file(&dir, "token", &format!("{TOKEN}\n"));
|
||||
let bytes = read_checked(&path, &|| {}).unwrap();
|
||||
let bytes = read_checked("gatewayd", &path, &|| {}).unwrap();
|
||||
assert_eq!(bytes.as_slice(), format!("{TOKEN}\n").as_bytes());
|
||||
}
|
||||
|
||||
@@ -86,6 +87,6 @@ fn the_path_checks_still_come_first() {
|
||||
"is not a regular file",
|
||||
);
|
||||
let relative = Path::new("relative/token");
|
||||
let why = read_checked(relative, &|| panic!("never reached")).unwrap_err();
|
||||
let why = read_checked("gatewayd", relative, &|| panic!("never reached")).unwrap_err();
|
||||
assert!(why.contains("is not an absolute path"), "{why}");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Temporary directories for tests. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses a different part of this module
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
pub struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> TempDir {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let path = std::env::temp_dir().join(format!("bx-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
TempDir(path)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Writes `text` to `name` inside the directory and returns the full path.
|
||||
pub fn write(&self, name: &str, text: &str) -> PathBuf {
|
||||
let path = self.0.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,12 @@ publish.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bxcore.workspace = true
|
||||
bxcore = { workspace = true, features = ["secrets"] }
|
||||
rustls.workspace = true
|
||||
rustls-native-certs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rustls.workspace = true
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::secrets::{SecretSource, SecretSpec};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -39,26 +40,6 @@ pub struct MattermostConfig {
|
||||
pub ca_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Where one secret comes from: exactly one of the three is set (checked by `Config::load`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SecretSpec {
|
||||
#[serde(default)]
|
||||
pub credential: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A checked `SecretSpec`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SecretSource {
|
||||
Credential(String),
|
||||
Env(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AllowConfig {
|
||||
@@ -267,66 +248,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretSpec {
|
||||
/// Exactly one source, well formed.
|
||||
pub fn source(&self) -> Result<SecretSource, String> {
|
||||
// Exactly one of the three set, else "needs exactly one of credential, env and file".
|
||||
// credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9,
|
||||
// _. file: an absolute path. The messages are in the task.
|
||||
let mut count = 0;
|
||||
if self.credential.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.env.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.file.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err("needs exactly one of credential, env and file".to_string());
|
||||
}
|
||||
if let Some(credential) = &self.credential {
|
||||
if credential.is_empty()
|
||||
|| !credential.bytes().all(|b| {
|
||||
b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
|
||||
})
|
||||
{
|
||||
return Err(format!(
|
||||
"credential {:?} is not a credential name (letters, digits, _ . -)",
|
||||
credential
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Credential(credential.clone()));
|
||||
}
|
||||
if let Some(env) = &self.env {
|
||||
if env.is_empty()
|
||||
|| !env
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_'))
|
||||
{
|
||||
return Err(format!(
|
||||
"env {:?} is not a variable name (A-Z, 0-9, _)",
|
||||
env
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Env(env.clone()));
|
||||
}
|
||||
// `count == 1` and the two above are `None`, so this is `Some`.
|
||||
let file = match &self.file {
|
||||
Some(file) => file,
|
||||
None => return Err("needs exactly one of credential, env and file".to_string()),
|
||||
};
|
||||
if !file.is_absolute() {
|
||||
return Err(format!(
|
||||
"file {:?} must be an absolute path",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
Ok(SecretSource::File(file.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Mattermost id: 26 characters of `a-z0-9`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
// 26 bytes, each a-z or 0-9.
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod gateway;
|
||||
pub mod http;
|
||||
pub mod mm;
|
||||
pub mod net;
|
||||
pub mod secrets;
|
||||
pub mod serve;
|
||||
pub mod sessions;
|
||||
pub mod state;
|
||||
|
||||
@@ -7,8 +7,8 @@ use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use bxcore::secrets;
|
||||
use gatewayd::config::{Config, MATTERMOST_TOKEN};
|
||||
use gatewayd::secrets;
|
||||
use gatewayd::serve::{START_FAILED, Tuning, run};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -30,8 +30,9 @@ fn serve(path: &Path) -> ExitCode {
|
||||
// "gatewayd: {e}\n{START_FAILED}".
|
||||
// 2. `let source = match config.token_source() { ... }`: an Err(why) prints
|
||||
// "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
|
||||
// 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
|
||||
// an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
|
||||
// 3. `let env = |k: &str| std::env::var_os(k);`, then
|
||||
// `let loaded = match secrets::load("gatewayd", MATTERMOST_TOKEN, &source, &env)`: an Err(e)
|
||||
// prints "gatewayd: {e}" (the error carries its own pointer).
|
||||
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
|
||||
// 4. The state file's directory:
|
||||
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
|
||||
@@ -54,7 +55,8 @@ fn serve(path: &Path) -> ExitCode {
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k)) {
|
||||
let env = |k: &str| std::env::var_os(k);
|
||||
let loaded = match secrets::load("gatewayd", MATTERMOST_TOKEN, &source, &env) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
eprintln!("gatewayd: {e}");
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bxcore::secrets::Secret;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::valid_id;
|
||||
use crate::http::{self, Response, rate_limit_wait};
|
||||
use crate::mm::{Me, MmError, Post, Since, json, since_list};
|
||||
use crate::net::Connector;
|
||||
use crate::secrets::Secret;
|
||||
use crate::ws::conn::host_header;
|
||||
|
||||
/// The pause before trying a call again after a 5xx.
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bxcore::SessionId;
|
||||
use bxcore::secrets::Secret;
|
||||
|
||||
use crate::approvals::Registry;
|
||||
use crate::config::Config;
|
||||
@@ -20,7 +21,6 @@ use crate::gateway::{self, ToLoop};
|
||||
use crate::mm::rest::Client;
|
||||
use crate::mm::{Event, Me, MmError, parse_event};
|
||||
use crate::net::Connector;
|
||||
use crate::secrets::Secret;
|
||||
use crate::sessions::{Queues, Router};
|
||||
use crate::state::{State, StateError};
|
||||
use crate::ws::conn::{Timing, Ws};
|
||||
|
||||
@@ -5,7 +5,8 @@ mod tmp;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gatewayd::config::{Config, ConfigError, SecretSource, ServerUrl, parse_url, valid_id};
|
||||
use bxcore::secrets::SecretSource;
|
||||
use gatewayd::config::{Config, ConfigError, ServerUrl, parse_url, valid_id};
|
||||
use tmp::TempDir;
|
||||
|
||||
const OWNER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
@@ -9,11 +9,11 @@ mod tls_server;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::secrets::Secret;
|
||||
use gatewayd::config::ServerUrl;
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::mm::rest::Client;
|
||||
use gatewayd::net::Connector;
|
||||
use gatewayd::secrets::Secret;
|
||||
use http_server::{reply, serve_http};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ use std::net::SocketAddr;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bxcore::secrets::Secret;
|
||||
use gatewayd::config::ServerUrl;
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::mm::rest::Client;
|
||||
use gatewayd::net::Connector;
|
||||
use gatewayd::secrets::Secret;
|
||||
use http_server::{Request, reply, serve_http};
|
||||
use serde_json::json;
|
||||
use tls_server::{fixture, server_config};
|
||||
|
||||
@@ -9,8 +9,8 @@ use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::Turn;
|
||||
use bxcore::secrets::Secret;
|
||||
use gatewayd::config::Config;
|
||||
use gatewayd::secrets::Secret;
|
||||
use gatewayd::serve::{Stop, Tuning, run};
|
||||
|
||||
use crate::fake_loop::{done, serve_loop};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `loopd` holds no credentials, so no build of it enables `bxcore`'s feature `secrets` (M5b spec,
|
||||
//! section 5), and its binary does not contain the code that reads them. Three manifests decide
|
||||
//! that: `loopd`'s own, the workspace's entry for `bxcore`, and `bxcore`'s feature table. They are
|
||||
//! parsed, so every spelling TOML allows is read. `make gate` builds the workspace with the feature
|
||||
//! on (for `gatewayd`), so it cannot see a module that is not behind the feature; the last test
|
||||
//! checks that line of `bxcore`'s `lib.rs`. Do not edit.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn manifest(path: &Path) -> toml::Table {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||
text.parse::<toml::Table>()
|
||||
.unwrap_or_else(|e| panic!("cannot parse {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn crates() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
/// Every string in `value`, at any depth, that turns the feature on: `secrets`, `bxcore/secrets`
|
||||
/// or `bxcore?/secrets`.
|
||||
fn enabling(value: &toml::Value, found: &mut Vec<String>) {
|
||||
match value {
|
||||
toml::Value::String(s) if s == "secrets" || s.ends_with("/secrets") => {
|
||||
found.push(s.clone())
|
||||
}
|
||||
toml::Value::Array(items) => items.iter().for_each(|v| enabling(v, found)),
|
||||
toml::Value::Table(table) => table.values().for_each(|v| enabling(v, found)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopd_never_names_the_feature() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
let table = manifest(&path);
|
||||
assert!(table.contains_key("dependencies"), "{}", path.display());
|
||||
let mut found = Vec::new();
|
||||
enabling(&toml::Value::Table(table), &mut found);
|
||||
assert!(found.is_empty(), "{} enables {found:?}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_entry_for_bxcore_enables_nothing() {
|
||||
let path = crates().join("../Cargo.toml");
|
||||
let table = manifest(&path);
|
||||
let entry = table
|
||||
.get("workspace")
|
||||
.and_then(|w| w.get("dependencies"))
|
||||
.and_then(|d| d.get("bxcore"))
|
||||
.unwrap_or_else(|| panic!("{}: no [workspace.dependencies] bxcore", path.display()));
|
||||
assert_eq!(entry.get("features"), None, "{entry:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_feature_itself_turns_the_store_on() {
|
||||
let path = crates().join("bxcore/Cargo.toml");
|
||||
let table = manifest(&path);
|
||||
let features = table
|
||||
.get("features")
|
||||
.and_then(toml::Value::as_table)
|
||||
.unwrap_or_else(|| panic!("{}: no [features]", path.display()));
|
||||
assert!(features.contains_key("secrets"), "{features:?}");
|
||||
for (name, list) in features {
|
||||
if name == "secrets" {
|
||||
continue;
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
enabling(list, &mut found);
|
||||
assert!(found.is_empty(), "feature {name} turns on {found:?}");
|
||||
}
|
||||
let zeroize = table
|
||||
.get("dependencies")
|
||||
.and_then(|d| d.get("zeroize"))
|
||||
.unwrap_or_else(|| panic!("{}: no zeroize", path.display()));
|
||||
assert_eq!(
|
||||
zeroize.get("optional").and_then(toml::Value::as_bool),
|
||||
Some(true),
|
||||
"zeroize is built only with the feature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_module_is_compiled_only_with_the_feature() {
|
||||
let path = crates().join("bxcore/src/lib.rs");
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||
assert!(
|
||||
text.contains("#[cfg(feature = \"secrets\")]\npub mod secrets;\n"),
|
||||
"{}: `pub mod secrets;` must follow `#[cfg(feature = \"secrets\")]`",
|
||||
path.display()
|
||||
);
|
||||
assert_eq!(text.matches("mod secrets").count(), 1, "{}", path.display());
|
||||
}
|
||||
Reference in New Issue
Block a user