diff --git a/matrix/Cargo.lock b/matrix/Cargo.lock index d7bdda63..75e1605e 100644 --- a/matrix/Cargo.lock +++ b/matrix/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -85,6 +135,52 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "colored" version = "3.0.0" @@ -317,6 +413,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -572,6 +674,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.15" @@ -721,6 +829,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -817,6 +931,7 @@ name = "potatomesh-matrix-bridge" version = "0.5.9" dependencies = [ "anyhow", + "clap", "mockito", "reqwest", "serde", @@ -1309,6 +1424,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1681,6 +1802,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" diff --git a/matrix/Cargo.toml b/matrix/Cargo.toml index 185e0a3f..816bebfa 100644 --- a/matrix/Cargo.toml +++ b/matrix/Cargo.toml @@ -27,6 +27,7 @@ anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } urlencoding = "2" +clap = { version = "4", features = ["derive"] } [dev-dependencies] tempfile = "3" diff --git a/matrix/README.md b/matrix/README.md index 1ba7aaed..4709a075 100644 --- a/matrix/README.md +++ b/matrix/README.md @@ -54,7 +54,9 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee ## Configuration -All configuration is in `Config.toml` in the project root. +Configuration can come from TOML, CLI flags, and environment variables. The TOML +file is optional as long as every required setting is supplied via CLI/env/secret +overrides. Example: @@ -80,6 +82,66 @@ room_id = "!yourroomid:example.org" state_file = "bridge_state.json" ```` +### CLI Overrides + +Run `potatomesh-matrix-bridge --help` for the full list. The most common flags: + +- `--config` (or `--config-path`) to point at a TOML file +- `--state-file` +- `--potatomesh-base-url` +- `--potatomesh-poll-interval-secs` +- `--matrix-homeserver` +- `--matrix-as-token` +- `--matrix-server-name` +- `--matrix-room-id` +- `--container-defaults` / `--no-container-defaults` + +### Environment Overrides + +Environment variables override CLI and TOML values: + +- `POTATOMESH_BASE_URL` +- `POTATOMESH_POLL_INTERVAL_SECS` +- `MATRIX_HOMESERVER` +- `MATRIX_AS_TOKEN` +- `MATRIX_SERVER_NAME` +- `MATRIX_ROOM_ID` +- `STATE_FILE` +- `POTATOMESH_CONFIG_PATH` (optional TOML path) +- `POTATOMESH_CONTAINER_DEFAULTS` (`1/0`, `true/false`) +- `POTATOMESH_SECRETS_DIR` (default secrets directory) +- `CONTAINER` (container detection hint) + +### Docker Secrets + +Every env var above supports a `*_FILE` companion (for example, `MATRIX_AS_TOKEN_FILE`). +When present, the bridge reads the file contents and uses them instead of the plain env var. +If `POTATOMESH_SECRETS_DIR` is set (or container defaults are enabled), the bridge also +checks for files named after the env vars (for example, `/run/secrets/MATRIX_AS_TOKEN`) +even when the `*_FILE` variable is not set. + +### Precedence + +From highest to lowest: + +1. `*_FILE` secret values (explicit or default secrets directory) +2. Environment variables +3. CLI flags +4. TOML config +5. Built-in defaults + +### Container Defaults + +When container defaults are enabled (auto-detected or forced): + +- Default config path: `/app/Config.toml` +- Default state file: `/app/bridge_state.json` +- Default secrets directory: `/run/secrets` +- Default poll interval: 120 seconds + +Disable container defaults with `--no-container-defaults` or set +`POTATOMESH_CONTAINER_DEFAULTS=0`. + ### PotatoMesh API The bridge assumes: diff --git a/matrix/docker-entrypoint.sh b/matrix/docker-entrypoint.sh index deec1ec7..3e15a5b1 100644 --- a/matrix/docker-entrypoint.sh +++ b/matrix/docker-entrypoint.sh @@ -15,6 +15,11 @@ set -e +# Surface container detection for the bridge and set default secret directory. +export CONTAINER="${CONTAINER:-1}" +export POTATOMESH_CONTAINER_DEFAULTS="${POTATOMESH_CONTAINER_DEFAULTS:-1}" +export POTATOMESH_SECRETS_DIR="${POTATOMESH_SECRETS_DIR:-/run/secrets}" + # Default state file path from Config.toml unless overridden. STATE_FILE="${STATE_FILE:-/app/bridge_state.json}" STATE_DIR="$(dirname "$STATE_FILE")" diff --git a/matrix/src/cli.rs b/matrix/src/cli.rs new file mode 100644 index 00000000..54a7ef39 --- /dev/null +++ b/matrix/src/cli.rs @@ -0,0 +1,159 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use clap::Parser; + +use crate::config::{ + BootstrapOverrides, ConfigOverrides, MatrixOverrides, PotatomeshOverrides, StateOverrides, +}; + +/// Command-line overrides for the Matrix bridge. +#[derive(Debug, Parser)] +#[command(name = "potatomesh-matrix-bridge", version)] +pub struct Cli { + /// TOML config path (optional, defaults to Config.toml or /app/Config.toml in containers). + #[arg(long = "config", alias = "config-path")] + pub config_path: Option, + + /// Override the state file path. + #[arg(long)] + pub state_file: Option, + + /// Override the PotatoMesh base URL. + #[arg(long)] + pub potatomesh_base_url: Option, + + /// Override the PotatoMesh poll interval in seconds. + #[arg(long)] + pub potatomesh_poll_interval_secs: Option, + + /// Override the Matrix homeserver URL. + #[arg(long)] + pub matrix_homeserver: Option, + + /// Override the Matrix appservice access token. + #[arg(long)] + pub matrix_as_token: Option, + + /// Override the Matrix server name. + #[arg(long)] + pub matrix_server_name: Option, + + /// Override the Matrix room ID. + #[arg(long)] + pub matrix_room_id: Option, + + /// Force container defaults on even if container detection is false. + #[arg(long, conflicts_with = "no_container_defaults")] + pub container_defaults: bool, + + /// Disable container defaults even if a container is detected. + #[arg(long, conflicts_with = "container_defaults")] + pub no_container_defaults: bool, +} + +impl Cli { + /// Convert CLI flags to bootstrap overrides for config loading. + pub fn into_overrides(self) -> BootstrapOverrides { + let container_defaults = if self.container_defaults { + Some(true) + } else if self.no_container_defaults { + Some(false) + } else { + None + }; + + BootstrapOverrides { + config_path: self.config_path, + container_defaults, + values: ConfigOverrides { + potatomesh: PotatomeshOverrides { + base_url: self.potatomesh_base_url, + poll_interval_secs: self.potatomesh_poll_interval_secs, + }, + matrix: MatrixOverrides { + homeserver: self.matrix_homeserver, + as_token: self.matrix_as_token, + server_name: self.matrix_server_name, + room_id: self.matrix_room_id, + }, + state: StateOverrides { + state_file: self.state_file, + }, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_overrides_map_to_config() { + let cli = Cli::parse_from([ + "bridge", + "--config", + "/tmp/Config.toml", + "--state-file", + "/tmp/state.json", + "--potatomesh-base-url", + "https://potato.example/", + "--potatomesh-poll-interval-secs", + "15", + "--matrix-homeserver", + "https://matrix.example.org", + "--matrix-as-token", + "token", + "--matrix-server-name", + "example.org", + "--matrix-room-id", + "!room:example.org", + "--container-defaults", + ]); + + let overrides = cli.into_overrides(); + assert_eq!(overrides.config_path.as_deref(), Some("/tmp/Config.toml")); + assert_eq!(overrides.container_defaults, Some(true)); + assert_eq!( + overrides.values.potatomesh.base_url.as_deref(), + Some("https://potato.example/") + ); + assert_eq!(overrides.values.potatomesh.poll_interval_secs, Some(15)); + assert_eq!( + overrides.values.matrix.homeserver.as_deref(), + Some("https://matrix.example.org") + ); + assert_eq!(overrides.values.matrix.as_token.as_deref(), Some("token")); + assert_eq!( + overrides.values.matrix.server_name.as_deref(), + Some("example.org") + ); + assert_eq!( + overrides.values.matrix.room_id.as_deref(), + Some("!room:example.org") + ); + assert_eq!( + overrides.values.state.state_file.as_deref(), + Some("/tmp/state.json") + ); + } + + #[test] + fn cli_can_disable_container_defaults() { + let cli = Cli::parse_from(["bridge", "--no-container-defaults"]); + let overrides = cli.into_overrides(); + assert_eq!(overrides.container_defaults, Some(false)); + } +} diff --git a/matrix/src/config.rs b/matrix/src/config.rs index 21c21183..a8651803 100644 --- a/matrix/src/config.rs +++ b/matrix/src/config.rs @@ -13,14 +13,40 @@ // limitations under the License. use serde::Deserialize; -use std::{fs, path::Path}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; +const DEFAULT_CONFIG_PATH: &str = "Config.toml"; +const DEFAULT_CONTAINER_CONFIG_PATH: &str = "/app/Config.toml"; +const DEFAULT_STATE_FILE: &str = "bridge_state.json"; +const DEFAULT_CONTAINER_STATE_FILE: &str = "/app/bridge_state.json"; +const DEFAULT_POLL_INTERVAL_SECS: u64 = 60; +const DEFAULT_CONTAINER_POLL_INTERVAL_SECS: u64 = 120; +const DEFAULT_SECRETS_DIR: &str = "/run/secrets"; + +const ENV_CONTAINER: &str = "CONTAINER"; +const ENV_CONTAINER_DEFAULTS: &str = "POTATOMESH_CONTAINER_DEFAULTS"; +const ENV_CONFIG_PATH: &str = "POTATOMESH_CONFIG_PATH"; +const ENV_SECRETS_DIR: &str = "POTATOMESH_SECRETS_DIR"; + +const ENV_POTATOMESH_BASE_URL: &str = "POTATOMESH_BASE_URL"; +const ENV_POTATOMESH_POLL_INTERVAL: &str = "POTATOMESH_POLL_INTERVAL_SECS"; +const ENV_MATRIX_HOMESERVER: &str = "MATRIX_HOMESERVER"; +const ENV_MATRIX_AS_TOKEN: &str = "MATRIX_AS_TOKEN"; +const ENV_MATRIX_SERVER_NAME: &str = "MATRIX_SERVER_NAME"; +const ENV_MATRIX_ROOM_ID: &str = "MATRIX_ROOM_ID"; +const ENV_STATE_FILE: &str = "STATE_FILE"; + +/// Configuration for the PotatoMesh API access. #[derive(Debug, Deserialize, Clone)] pub struct PotatomeshConfig { pub base_url: String, pub poll_interval_secs: u64, } +/// Configuration for Matrix appservice access. #[derive(Debug, Deserialize, Clone)] pub struct MatrixConfig { pub homeserver: String, @@ -29,11 +55,13 @@ pub struct MatrixConfig { pub room_id: String, } +/// Configuration for persisted bridge state. #[derive(Debug, Deserialize, Clone)] pub struct StateConfig { pub state_file: String, } +/// Complete bridge configuration, merged from file and overrides. #[derive(Debug, Deserialize, Clone)] pub struct Config { pub potatomesh: PotatomeshConfig, @@ -41,20 +69,466 @@ pub struct Config { pub state: StateConfig, } +/// Optional configuration overrides for a single section. +#[derive(Debug, Clone, Default)] +pub struct PotatomeshOverrides { + pub base_url: Option, + pub poll_interval_secs: Option, +} + +/// Optional Matrix overrides. +#[derive(Debug, Clone, Default)] +pub struct MatrixOverrides { + pub homeserver: Option, + pub as_token: Option, + pub server_name: Option, + pub room_id: Option, +} + +/// Optional state overrides. +#[derive(Debug, Clone, Default)] +pub struct StateOverrides { + pub state_file: Option, +} + +/// Override bundle merged from TOML, CLI, env, and secret files. +#[derive(Debug, Clone, Default)] +pub struct ConfigOverrides { + pub potatomesh: PotatomeshOverrides, + pub matrix: MatrixOverrides, + pub state: StateOverrides, +} + +/// Runtime context discovered while bootstrapping configuration. +#[derive(Debug, Clone)] +pub struct RuntimeContext { + pub in_container: bool, + pub container_defaults: bool, + pub config_path: String, + pub secrets_dir: Option, +} + +/// Bootstrapped configuration and runtime context. +#[derive(Debug, Clone)] +pub struct ConfigBootstrap { + pub config: Config, + pub context: RuntimeContext, +} + +/// CLI-provided override bundle with container defaults toggles. +#[derive(Debug, Clone, Default)] +pub struct BootstrapOverrides { + pub config_path: Option, + pub container_defaults: Option, + pub values: ConfigOverrides, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct PotatomeshFileOverrides { + #[serde(default)] + base_url: Option, + #[serde(default)] + poll_interval_secs: Option, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct MatrixFileOverrides { + #[serde(default)] + homeserver: Option, + #[serde(default)] + as_token: Option, + #[serde(default)] + server_name: Option, + #[serde(default)] + room_id: Option, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct StateFileOverrides { + #[serde(default)] + state_file: Option, +} + +#[derive(Debug, Deserialize, Clone, Default)] +struct ConfigFileOverrides { + #[serde(default)] + potatomesh: PotatomeshFileOverrides, + #[serde(default)] + matrix: MatrixFileOverrides, + #[serde(default)] + state: StateFileOverrides, +} + +impl ConfigOverrides { + /// Merge another override set, replacing only fields present in `other`. + pub fn merge(&mut self, other: ConfigOverrides) { + self.potatomesh.merge(other.potatomesh); + self.matrix.merge(other.matrix); + self.state.merge(other.state); + } +} + +impl PotatomeshOverrides { + /// Merge optional fields, keeping existing values when the override is empty. + fn merge(&mut self, other: PotatomeshOverrides) { + if other.base_url.is_some() { + self.base_url = other.base_url; + } + if other.poll_interval_secs.is_some() { + self.poll_interval_secs = other.poll_interval_secs; + } + } +} + +impl MatrixOverrides { + /// Merge optional fields, keeping existing values when the override is empty. + fn merge(&mut self, other: MatrixOverrides) { + if other.homeserver.is_some() { + self.homeserver = other.homeserver; + } + if other.as_token.is_some() { + self.as_token = other.as_token; + } + if other.server_name.is_some() { + self.server_name = other.server_name; + } + if other.room_id.is_some() { + self.room_id = other.room_id; + } + } +} + +impl StateOverrides { + /// Merge optional fields, keeping existing values when the override is empty. + fn merge(&mut self, other: StateOverrides) { + if other.state_file.is_some() { + self.state_file = other.state_file; + } + } +} + +impl From for ConfigOverrides { + fn from(value: ConfigFileOverrides) -> Self { + Self { + potatomesh: PotatomeshOverrides { + base_url: value.potatomesh.base_url, + poll_interval_secs: value.potatomesh.poll_interval_secs, + }, + matrix: MatrixOverrides { + homeserver: value.matrix.homeserver, + as_token: value.matrix.as_token, + server_name: value.matrix.server_name, + room_id: value.matrix.room_id, + }, + state: StateOverrides { + state_file: value.state.state_file, + }, + } + } +} + +/// Detect container context from env or cgroup hints. +fn detect_container() -> bool { + let env_value = env::var(ENV_CONTAINER).ok(); + let cgroup_contents = fs::read_to_string("/proc/1/cgroup").ok(); + detect_container_from(env_value.as_deref(), cgroup_contents.as_deref()) +} + +/// Detect container context from provided inputs (used for testing). +fn detect_container_from(env_value: Option<&str>, cgroup_contents: Option<&str>) -> bool { + if let Some(value) = env_value.map(str::trim).filter(|v| !v.is_empty()) { + let normalized = value.to_ascii_lowercase(); + return normalized != "0" && normalized != "false"; + } + + if let Some(cgroup) = cgroup_contents { + let haystack = cgroup.to_lowercase(); + return haystack.contains("docker") + || haystack.contains("containerd") + || haystack.contains("kubepods") + || haystack.contains("podman") + || haystack.contains("lxc"); + } + + false +} + +/// Read an environment variable, trimming whitespace and ignoring empty values. +fn read_env_string(key: &str) -> Option { + env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Parse a boolean env var, accepting common truthy/falsey values. +fn read_env_bool(key: &str) -> anyhow::Result> { + let raw = match read_env_string(key) { + Some(value) => value, + None => return Ok(None), + }; + + let normalized = raw.to_ascii_lowercase(); + let parsed = match normalized.as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => { + return Err(anyhow::anyhow!( + "Invalid boolean value for {}: {}", + key, + raw + )) + } + }; + + Ok(Some(parsed)) +} + +/// Parse a u64 env var with context in error messages. +fn read_env_u64(key: &str) -> anyhow::Result> { + let raw = match read_env_string(key) { + Some(value) => value, + None => return Ok(None), + }; + let parsed = raw + .parse::() + .map_err(|err| anyhow::anyhow!("Invalid integer value for {}: {} ({})", key, raw, err))?; + Ok(Some(parsed)) +} + +/// Load a secret value from a file path and trim trailing whitespace. +fn read_secret_file(path: &Path) -> anyhow::Result { + let raw = fs::read_to_string(path)?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + anyhow::bail!("Secret file {} is empty", path.display()); + } + Ok(trimmed) +} + +/// Resolve a *_FILE env var or default secrets file. +fn read_secret_value(var_name: &str, secrets_dir: Option<&Path>) -> anyhow::Result> { + let file_env = format!("{}_FILE", var_name); + if let Some(path) = read_env_string(&file_env) { + return Ok(Some(read_secret_file(Path::new(&path))?)); + } + + if let Some(dir) = secrets_dir { + let path = dir.join(var_name); + if path.exists() { + return Ok(Some(read_secret_file(&path)?)); + } + } + + Ok(None) +} + +/// Load a config file if it exists, returning overrides for present fields. +fn load_optional_config(path: &str) -> anyhow::Result> { + if !Path::new(path).exists() { + return Ok(None); + } + let contents = fs::read_to_string(path)?; + let cfg: ConfigFileOverrides = toml::from_str(&contents)?; + Ok(Some(cfg.into())) +} + +/// Build overrides from environment variables (non-secret values). +fn env_overrides() -> anyhow::Result { + Ok(ConfigOverrides { + potatomesh: PotatomeshOverrides { + base_url: read_env_string(ENV_POTATOMESH_BASE_URL), + poll_interval_secs: read_env_u64(ENV_POTATOMESH_POLL_INTERVAL)?, + }, + matrix: MatrixOverrides { + homeserver: read_env_string(ENV_MATRIX_HOMESERVER), + as_token: read_env_string(ENV_MATRIX_AS_TOKEN), + server_name: read_env_string(ENV_MATRIX_SERVER_NAME), + room_id: read_env_string(ENV_MATRIX_ROOM_ID), + }, + state: StateOverrides { + state_file: read_env_string(ENV_STATE_FILE), + }, + }) +} + +/// Build overrides from secret files. +fn secret_overrides(secrets_dir: Option<&Path>) -> anyhow::Result { + let poll_interval = match read_secret_value(ENV_POTATOMESH_POLL_INTERVAL, secrets_dir)? { + Some(value) => Some(value.parse::().map_err(|err| { + anyhow::anyhow!( + "Invalid integer value for {} in secret file: {}", + ENV_POTATOMESH_POLL_INTERVAL, + err + ) + })?), + None => None, + }; + + Ok(ConfigOverrides { + potatomesh: PotatomeshOverrides { + base_url: read_secret_value(ENV_POTATOMESH_BASE_URL, secrets_dir)?, + poll_interval_secs: poll_interval, + }, + matrix: MatrixOverrides { + homeserver: read_secret_value(ENV_MATRIX_HOMESERVER, secrets_dir)?, + as_token: read_secret_value(ENV_MATRIX_AS_TOKEN, secrets_dir)?, + server_name: read_secret_value(ENV_MATRIX_SERVER_NAME, secrets_dir)?, + room_id: read_secret_value(ENV_MATRIX_ROOM_ID, secrets_dir)?, + }, + state: StateOverrides { + state_file: read_secret_value(ENV_STATE_FILE, secrets_dir)?, + }, + }) +} + +/// Resolve the effective secrets directory for default *_FILE lookups. +fn resolve_secrets_dir(container_defaults: bool) -> Option { + if let Some(dir) = read_env_string(ENV_SECRETS_DIR) { + return Some(PathBuf::from(dir)); + } + + if container_defaults { + return Some(PathBuf::from(DEFAULT_SECRETS_DIR)); + } + + None +} + +/// Resolve the config path, honoring env and CLI overrides. +fn resolve_config_path(container_defaults: bool, overrides: &BootstrapOverrides) -> String { + if let Some(path) = read_env_string(ENV_CONFIG_PATH) { + return path; + } + if let Some(path) = &overrides.config_path { + return path.clone(); + } + + if container_defaults { + DEFAULT_CONTAINER_CONFIG_PATH.to_string() + } else { + DEFAULT_CONFIG_PATH.to_string() + } +} + +/// Resolve whether container defaults should be active. +fn resolve_container_defaults( + in_container: bool, + overrides: &BootstrapOverrides, +) -> anyhow::Result { + if let Some(env_value) = read_env_bool(ENV_CONTAINER_DEFAULTS)? { + return Ok(env_value); + } + if let Some(cli_value) = overrides.container_defaults { + return Ok(cli_value); + } + Ok(in_container) +} + +/// Apply default values and return a fully populated config. +fn finalize_config(overrides: ConfigOverrides, container_defaults: bool) -> anyhow::Result { + let base_url = overrides + .potatomesh + .base_url + .ok_or_else(|| anyhow::anyhow!("potatomesh.base_url is required"))?; + let poll_interval_secs = overrides.potatomesh.poll_interval_secs.unwrap_or({ + if container_defaults { + DEFAULT_CONTAINER_POLL_INTERVAL_SECS + } else { + DEFAULT_POLL_INTERVAL_SECS + } + }); + + let homeserver = overrides + .matrix + .homeserver + .ok_or_else(|| anyhow::anyhow!("matrix.homeserver is required"))?; + let as_token = overrides + .matrix + .as_token + .ok_or_else(|| anyhow::anyhow!("matrix.as_token is required"))?; + let server_name = overrides + .matrix + .server_name + .ok_or_else(|| anyhow::anyhow!("matrix.server_name is required"))?; + let room_id = overrides + .matrix + .room_id + .ok_or_else(|| anyhow::anyhow!("matrix.room_id is required"))?; + + let state_file = overrides.state.state_file.unwrap_or_else(|| { + if container_defaults { + DEFAULT_CONTAINER_STATE_FILE.to_string() + } else { + DEFAULT_STATE_FILE.to_string() + } + }); + + Ok(Config { + potatomesh: PotatomeshConfig { + base_url, + poll_interval_secs, + }, + matrix: MatrixConfig { + homeserver, + as_token, + server_name, + room_id, + }, + state: StateConfig { state_file }, + }) +} + impl Config { + /// Load config from a specific path. + #[allow(dead_code)] pub fn load_from_file(path: &str) -> anyhow::Result { let contents = fs::read_to_string(path)?; let cfg = toml::from_str(&contents)?; Ok(cfg) } + /// Load config from the default path in the working directory. + #[allow(dead_code)] pub fn from_default_path() -> anyhow::Result { - let path = "Config.toml"; + let path = DEFAULT_CONFIG_PATH; if !Path::new(path).exists() { anyhow::bail!("Config file {path} not found"); } Self::load_from_file(path) } + + /// Load configuration by merging TOML, CLI, env, and secret values. + pub fn load_with_overrides(overrides: BootstrapOverrides) -> anyhow::Result { + let in_container = detect_container(); + let container_defaults = resolve_container_defaults(in_container, &overrides)?; + let config_path = resolve_config_path(container_defaults, &overrides); + let secrets_dir = resolve_secrets_dir(container_defaults); + + let mut merged = ConfigOverrides::default(); + if let Some(file_overrides) = load_optional_config(&config_path)? { + merged.merge(file_overrides); + } else { + tracing::warn!( + "Config file {} not found; continuing with overrides", + config_path + ); + } + + merged.merge(overrides.values); + merged.merge(env_overrides()?); + merged.merge(secret_overrides(secrets_dir.as_deref())?); + + let config = finalize_config(merged, container_defaults)?; + let context = RuntimeContext { + in_container, + container_defaults, + config_path, + secrets_dir, + }; + + Ok(ConfigBootstrap { config, context }) + } } #[cfg(test)] @@ -62,6 +536,44 @@ mod tests { use super::*; use serial_test::serial; use std::io::Write; + use std::{env, path::PathBuf}; + + struct EnvGuard { + key: String, + value: Option, + } + + impl EnvGuard { + fn set>(key: K, value: &str) -> Self { + let key = key.into(); + let previous = env::var(&key).ok(); + env::set_var(&key, value); + Self { + key, + value: previous, + } + } + + fn unset>(key: K) -> Self { + let key = key.into(); + let previous = env::var(&key).ok(); + env::remove_var(&key); + Self { + key, + value: previous, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.value { + env::set_var(&self.key, value); + } else { + env::remove_var(&self.key); + } + } + } #[test] fn parse_minimal_config_from_toml_str() { @@ -154,4 +666,190 @@ mod tests { let result = Config::from_default_path(); assert!(result.is_ok()); } + + #[test] + fn detect_container_from_env_values() { + assert!(detect_container_from(Some("1"), None)); + assert!(detect_container_from(Some("true"), None)); + assert!(!detect_container_from(Some("0"), None)); + assert!(!detect_container_from(Some("false"), None)); + assert!(!detect_container_from(Some("FALSE"), None)); + } + + #[test] + fn detect_container_from_cgroup_markers() { + let cgroup = "12:memory:/docker/abcd\n11:pids:/kubepods.slice"; + assert!(detect_container_from(None, Some(cgroup))); + + let host_cgroup = "0::/user.slice/user-1000.slice"; + assert!(!detect_container_from(None, Some(host_cgroup))); + } + + #[test] + #[serial] + fn env_overrides_cli_and_toml() { + let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/"); + let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token"); + let _guard_poll = EnvGuard::set(ENV_POTATOMESH_POLL_INTERVAL, "25"); + let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0"); + + let toml_str = r#" + [potatomesh] + base_url = "https://toml.example/" + poll_interval_secs = 10 + + [matrix] + homeserver = "https://matrix.example.org" + as_token = "toml-token" + server_name = "example.org" + room_id = "!roomid:example.org" + + [state] + state_file = "toml_state.json" + "#; + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!(file, "{}", toml_str).unwrap(); + + let overrides = BootstrapOverrides { + config_path: Some(file.path().to_str().unwrap().to_string()), + container_defaults: Some(false), + values: ConfigOverrides { + potatomesh: PotatomeshOverrides { + base_url: Some("https://cli.example/".to_string()), + poll_interval_secs: Some(15), + }, + matrix: MatrixOverrides { + as_token: Some("cli-token".to_string()), + ..Default::default() + }, + state: StateOverrides { + state_file: Some("cli_state.json".to_string()), + }, + }, + }; + + let result = Config::load_with_overrides(overrides).unwrap(); + assert_eq!(result.config.potatomesh.base_url, "https://env.example/"); + assert_eq!(result.config.potatomesh.poll_interval_secs, 25); + assert_eq!(result.config.matrix.as_token, "env-token"); + assert_eq!(result.config.state.state_file, "cli_state.json"); + } + + #[test] + #[serial] + fn secret_file_overrides_env_values() { + let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/"); + let _guard_homeserver = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org"); + let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org"); + let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org"); + let _guard_env_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token"); + let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0"); + + let secret_file = tempfile::NamedTempFile::new().unwrap(); + fs::write(secret_file.path(), "secret-token").unwrap(); + let _guard_secret = EnvGuard::set( + format!("{}_FILE", ENV_MATRIX_AS_TOKEN), + secret_file.path().to_str().unwrap(), + ); + + let overrides = BootstrapOverrides::default(); + let result = Config::load_with_overrides(overrides).unwrap(); + assert_eq!(result.config.matrix.as_token, "secret-token"); + } + + #[test] + #[serial] + fn container_defaults_change_paths_and_intervals() { + let _guard_container = EnvGuard::set(ENV_CONTAINER, "1"); + let _guard_defaults = EnvGuard::unset(ENV_CONTAINER_DEFAULTS); + let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/"); + let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org"); + let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token"); + let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org"); + let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org"); + + let overrides = BootstrapOverrides::default(); + let result = Config::load_with_overrides(overrides).unwrap(); + + assert!(result.context.in_container); + assert!(result.context.container_defaults); + assert_eq!(result.context.config_path, DEFAULT_CONTAINER_CONFIG_PATH); + assert_eq!(result.config.state.state_file, DEFAULT_CONTAINER_STATE_FILE); + assert_eq!( + result.config.potatomesh.poll_interval_secs, + DEFAULT_CONTAINER_POLL_INTERVAL_SECS + ); + } + + #[test] + #[serial] + fn container_defaults_can_be_disabled() { + let _guard_container = EnvGuard::set(ENV_CONTAINER, "1"); + let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0"); + let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/"); + let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org"); + let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token"); + let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org"); + let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org"); + + let overrides = BootstrapOverrides::default(); + let result = Config::load_with_overrides(overrides).unwrap(); + + assert!(result.context.in_container); + assert!(!result.context.container_defaults); + assert_eq!(result.context.config_path, DEFAULT_CONFIG_PATH); + assert_eq!(result.config.state.state_file, DEFAULT_STATE_FILE); + assert_eq!( + result.config.potatomesh.poll_interval_secs, + DEFAULT_POLL_INTERVAL_SECS + ); + } + + #[test] + #[serial] + fn secrets_dir_defaults_are_used_when_present() { + let _guard_container = EnvGuard::set(ENV_CONTAINER, "1"); + let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "1"); + let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/"); + let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org"); + let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org"); + let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org"); + + let temp_dir = tempfile::tempdir().unwrap(); + let secret_path = temp_dir.path().join(ENV_MATRIX_AS_TOKEN); + fs::write(&secret_path, "dir-token").unwrap(); + let _guard_dir = EnvGuard::set(ENV_SECRETS_DIR, temp_dir.path().to_str().unwrap()); + + let overrides = BootstrapOverrides::default(); + let result = Config::load_with_overrides(overrides).unwrap(); + assert_eq!(result.config.matrix.as_token, "dir-token"); + assert_eq!( + result.context.secrets_dir, + Some(PathBuf::from(temp_dir.path())) + ); + } + + #[test] + #[serial] + fn read_env_bool_rejects_invalid_values() { + let _guard = EnvGuard::set("POTATOMESH_TEST_BOOL", "maybe"); + let result = read_env_bool("POTATOMESH_TEST_BOOL"); + assert!(result.is_err()); + } + + #[test] + #[serial] + fn read_env_u64_rejects_invalid_values() { + let _guard = EnvGuard::set("POTATOMESH_TEST_U64", "not-a-number"); + let result = read_env_u64("POTATOMESH_TEST_U64"); + assert!(result.is_err()); + } + + #[test] + fn read_secret_file_rejects_empty_contents() { + let file = tempfile::NamedTempFile::new().unwrap(); + fs::write(file.path(), " ").unwrap(); + let result = read_secret_file(file.path()); + assert!(result.is_err()); + } } diff --git a/matrix/src/main.rs b/matrix/src/main.rs index b45423fb..4360602f 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod cli; mod config; mod matrix; mod potatomesh; @@ -19,13 +20,22 @@ mod potatomesh; use std::{fs, path::Path}; use anyhow::Result; +use clap::Parser; use tokio::time::{sleep, Duration}; use tracing::{error, info}; +use crate::cli::Cli; use crate::config::Config; use crate::matrix::MatrixAppserviceClient; use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage, PotatoNode}; +fn format_runtime_context(context: &config::RuntimeContext) -> String { + format!( + "Runtime context: in_container={} container_defaults={} config_path={} secrets_dir={:?}", + context.in_container, context.container_defaults, context.config_path, context.secrets_dir + ) +} + #[derive(Debug, serde::Serialize, serde::Deserialize, Default)] pub struct BridgeState { /// Highest message id processed by the bridge. @@ -172,8 +182,12 @@ async fn main() -> Result<()> { ) .init(); - let cfg = Config::from_default_path()?; - info!("Loaded config: {:?}", cfg); + let cli = Cli::parse(); + let bootstrap = Config::load_with_overrides(cli.into_overrides())?; + info!("Loaded config: {:?}", bootstrap.config); + info!("{}", format_runtime_context(&bootstrap.context)); + + let cfg = bootstrap.config; let http = reqwest::Client::builder().build()?; let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone()); @@ -723,4 +737,20 @@ mod tests { assert_eq!(state.last_message_id, Some(100)); } + + #[test] + fn format_runtime_context_includes_flags() { + let context = config::RuntimeContext { + in_container: true, + container_defaults: false, + config_path: "/app/Config.toml".to_string(), + secrets_dir: Some(std::path::PathBuf::from("/run/secrets")), + }; + + let rendered = format_runtime_context(&context); + assert!(rendered.contains("in_container=true")); + assert!(rendered.contains("container_defaults=false")); + assert!(rendered.contains("/app/Config.toml")); + assert!(rendered.contains("/run/secrets")); + } }