matrix: config loading now merges optional TOML with CLI/env/secret inputs (#617)

* matrix: config loading now merges optional TOML with CLI/env/secret inputs

* matrix: fix tests

* matrix: address review comments

* matrix: fix tests

* matrix: cover missing unit test vectors
This commit is contained in:
l5y
2026-01-10 23:39:53 +01:00
committed by GitHub
parent 60e734086f
commit fed8b9e124
8 changed files with 1157 additions and 35 deletions

143
matrix/Cargo.lock generated
View File

@@ -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"
@@ -125,9 +175,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cc"
version = "1.2.51"
version = "1.2.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -145,6 +195,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"
@@ -214,9 +310,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.6"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
[[package]]
name = "fnv"
@@ -351,6 +447,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"
@@ -606,6 +708,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.17"
@@ -630,9 +738,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.179"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
@@ -762,6 +870,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"
@@ -859,6 +973,7 @@ version = "0.5.10"
dependencies = [
"anyhow",
"axum",
"clap",
"mockito",
"reqwest",
"serde",
@@ -1364,6 +1479,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"
@@ -1551,9 +1672,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.9.10+spec-1.1.0"
version = "0.9.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [
"indexmap",
"serde_core",
@@ -1738,6 +1859,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"

View File

@@ -28,6 +28,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
urlencoding = "2"
axum = { version = "0.7", features = ["json"] }
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
tempfile = "3"

View File

@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.91-bookworm AS builder
FROM rust:1.92-bookworm AS builder
WORKDIR /app

View File

@@ -56,9 +56,17 @@ 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 a TOML file, CLI flags, environment variables, or secret files. The bridge merges inputs in this order (highest to lowest):
Example:
1. CLI flags
2. Environment variables
3. Secret files (`*_FILE` paths or container defaults)
4. TOML config file
5. Container defaults (paths + poll interval)
If no TOML file is provided, required values must be supplied via CLI/env/secret inputs.
Example TOML:
```toml
[potatomesh]
@@ -86,6 +94,58 @@ state_file = "bridge_state.json"
The `hs_token` is used to validate inbound appservice transactions. Keep it identical in `Config.toml` and your Matrix appservice registration file.
### CLI Flags
Run `potatomesh-matrix-bridge --help` for the full list. Common flags:
* `--config PATH`
* `--state-file PATH`
* `--potatomesh-base-url URL`
* `--potatomesh-poll-interval-secs SECS`
* `--matrix-homeserver URL`
* `--matrix-as-token TOKEN`
* `--matrix-as-token-file PATH`
* `--matrix-hs-token TOKEN`
* `--matrix-hs-token-file PATH`
* `--matrix-server-name NAME`
* `--matrix-room-id ROOM`
* `--container` / `--no-container`
* `--secrets-dir PATH`
### Environment Variables
* `POTATOMESH_CONFIG`
* `POTATOMESH_BASE_URL`
* `POTATOMESH_POLL_INTERVAL_SECS`
* `MATRIX_HOMESERVER`
* `MATRIX_AS_TOKEN`
* `MATRIX_AS_TOKEN_FILE`
* `MATRIX_HS_TOKEN`
* `MATRIX_HS_TOKEN_FILE`
* `MATRIX_SERVER_NAME`
* `MATRIX_ROOM_ID`
* `STATE_FILE`
* `POTATOMESH_CONTAINER`
* `POTATOMESH_SECRETS_DIR`
### Secret Files
If you supply `*_FILE` values, the bridge reads the secret contents and trims whitespace. When running inside a container, the bridge also checks the default secrets directory (default: `/run/secrets`) for:
* `matrix_as_token`
* `matrix_hs_token`
### Container Defaults
Container detection checks `POTATOMESH_CONTAINER`, `CONTAINER`, and `/proc/1/cgroup`. When detected (or forced with `--container`), defaults shift to:
* Config path: `/app/Config.toml`
* State file: `/app/bridge_state.json`
* Secrets dir: `/run/secrets`
* Poll interval: 15 seconds (if not otherwise configured)
Set `POTATOMESH_CONTAINER=0` or `--no-container` to opt out of container defaults.
### PotatoMesh API
The bridge assumes:
@@ -186,7 +246,7 @@ Build the container from the repo root with the included `matrix/Dockerfile`:
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
```
Provide your config at `/app/Config.toml` and persist the bridge state file by mounting volumes. Minimal example:
Provide your config at `/app/Config.toml` (or use CLI/env/secret overrides) and persist the bridge state file by mounting volumes. Minimal example:
```bash
docker run --rm \
@@ -206,7 +266,7 @@ docker run --rm \
potatomesh-matrix-bridge
```
The image ships `Config.example.toml` for reference, but the bridge will exit if `/app/Config.toml` is not provided.
The image ships `Config.example.toml` for reference. If `/app/Config.toml` is absent, set the required values via environment variables, CLI flags, or secrets instead.
---
@@ -244,7 +304,7 @@ Delete `bridge_state.json` if you want it to replay all currently available mess
## Development
Run tests (currently mostly compile checks, no real tests yet):
Run tests:
```bash
cargo test

View File

@@ -15,6 +15,13 @@
set -e
# Default to container-aware configuration paths unless explicitly overridden.
: "${POTATOMESH_CONTAINER:=1}"
: "${POTATOMESH_SECRETS_DIR:=/run/secrets}"
export POTATOMESH_CONTAINER
export POTATOMESH_SECRETS_DIR
# Default state file path from Config.toml unless overridden.
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
STATE_DIR="$(dirname "$STATE_FILE")"

105
matrix/src/cli.rs Normal file
View File

@@ -0,0 +1,105 @@
// 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::{ArgAction, Parser};
#[cfg(not(test))]
use crate::config::{ConfigInputs, ConfigOverrides};
/// CLI arguments for the Matrix bridge.
#[derive(Debug, Parser)]
#[command(
name = "potatomesh-matrix-bridge",
version,
about = "PotatoMesh Matrix bridge"
)]
pub struct Cli {
/// Path to the configuration TOML file.
#[arg(long, value_name = "PATH")]
pub config: Option<String>,
/// Path to the bridge state file.
#[arg(long, value_name = "PATH")]
pub state_file: Option<String>,
/// PotatoMesh base URL.
#[arg(long, value_name = "URL")]
pub potatomesh_base_url: Option<String>,
/// Poll interval in seconds.
#[arg(long, value_name = "SECS")]
pub potatomesh_poll_interval_secs: Option<u64>,
/// Matrix homeserver base URL.
#[arg(long, value_name = "URL")]
pub matrix_homeserver: Option<String>,
/// Matrix appservice access token.
#[arg(long, value_name = "TOKEN")]
pub matrix_as_token: Option<String>,
/// Path to a secret file containing the Matrix appservice access token.
#[arg(long, value_name = "PATH")]
pub matrix_as_token_file: Option<String>,
/// Matrix homeserver token for inbound appservice requests.
#[arg(long, value_name = "TOKEN")]
pub matrix_hs_token: Option<String>,
/// Path to a secret file containing the Matrix homeserver token.
#[arg(long, value_name = "PATH")]
pub matrix_hs_token_file: Option<String>,
/// Matrix server name (domain).
#[arg(long, value_name = "NAME")]
pub matrix_server_name: Option<String>,
/// Matrix room id to forward into.
#[arg(long, value_name = "ROOM")]
pub matrix_room_id: Option<String>,
/// Force container defaults (overrides detection).
#[arg(long, action = ArgAction::SetTrue)]
pub container: bool,
/// Disable container defaults (overrides detection).
#[arg(long, action = ArgAction::SetTrue)]
pub no_container: bool,
/// Directory to search for default secret files.
#[arg(long, value_name = "PATH")]
pub secrets_dir: Option<String>,
}
impl Cli {
/// Convert CLI args into configuration inputs.
#[cfg(not(test))]
pub fn to_inputs(&self) -> ConfigInputs {
ConfigInputs {
config_path: self.config.clone(),
secrets_dir: self.secrets_dir.clone(),
container_override: resolve_container_override(self.container, self.no_container),
container_hint: None,
overrides: ConfigOverrides {
potatomesh_base_url: self.potatomesh_base_url.clone(),
potatomesh_poll_interval_secs: self.potatomesh_poll_interval_secs,
matrix_homeserver: self.matrix_homeserver.clone(),
matrix_as_token: self.matrix_as_token.clone(),
matrix_as_token_file: self.matrix_as_token_file.clone(),
matrix_hs_token: self.matrix_hs_token.clone(),
matrix_hs_token_file: self.matrix_hs_token_file.clone(),
matrix_server_name: self.matrix_server_name.clone(),
matrix_room_id: self.matrix_room_id.clone(),
state_file: self.state_file.clone(),
},
}
}
}
/// Resolve container override flags into an optional boolean.
#[cfg(not(test))]
fn resolve_container_override(container: bool, no_container: bool) -> Option<bool> {
match (container, no_container) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
}

View File

@@ -15,12 +15,21 @@
use serde::Deserialize;
use std::{fs, path::Path};
const DEFAULT_CONFIG_PATH: &str = "Config.toml";
const CONTAINER_CONFIG_PATH: &str = "/app/Config.toml";
const DEFAULT_STATE_FILE: &str = "bridge_state.json";
const CONTAINER_STATE_FILE: &str = "/app/bridge_state.json";
const DEFAULT_SECRETS_DIR: &str = "/run/secrets";
const CONTAINER_POLL_INTERVAL_SECS: u64 = 15;
/// PotatoMesh API settings.
#[derive(Debug, Deserialize, Clone)]
pub struct PotatomeshConfig {
pub base_url: String,
pub poll_interval_secs: u64,
}
/// Matrix appservice settings for the bridge.
#[derive(Debug, Deserialize, Clone)]
pub struct MatrixConfig {
pub homeserver: String,
@@ -30,11 +39,13 @@ pub struct MatrixConfig {
pub room_id: String,
}
/// State file configuration for the bridge.
#[derive(Debug, Deserialize, Clone)]
pub struct StateConfig {
pub state_file: String,
}
/// Full configuration loaded for the bridge runtime.
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub potatomesh: PotatomeshConfig,
@@ -42,19 +53,447 @@ pub struct Config {
pub state: StateConfig,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PartialPotatomeshConfig {
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
poll_interval_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PartialMatrixConfig {
#[serde(default)]
homeserver: Option<String>,
#[serde(default)]
as_token: Option<String>,
#[serde(default)]
hs_token: Option<String>,
#[serde(default)]
server_name: Option<String>,
#[serde(default)]
room_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PartialStateConfig {
#[serde(default)]
state_file: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PartialConfig {
#[serde(default)]
potatomesh: PartialPotatomeshConfig,
#[serde(default)]
matrix: PartialMatrixConfig,
#[serde(default)]
state: PartialStateConfig,
}
/// Overwrite an optional value when the incoming value is present.
fn merge_option<T>(target: &mut Option<T>, incoming: Option<T>) {
if incoming.is_some() {
*target = incoming;
}
}
/// CLI or environment overrides for configuration fields.
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub potatomesh_base_url: Option<String>,
pub potatomesh_poll_interval_secs: Option<u64>,
pub matrix_homeserver: Option<String>,
pub matrix_as_token: Option<String>,
pub matrix_as_token_file: Option<String>,
pub matrix_hs_token: Option<String>,
pub matrix_hs_token_file: Option<String>,
pub matrix_server_name: Option<String>,
pub matrix_room_id: Option<String>,
pub state_file: Option<String>,
}
impl ConfigOverrides {
fn apply_non_token_overrides(&self, cfg: &mut PartialConfig) {
merge_option(
&mut cfg.potatomesh.base_url,
self.potatomesh_base_url.clone(),
);
merge_option(
&mut cfg.potatomesh.poll_interval_secs,
self.potatomesh_poll_interval_secs,
);
merge_option(&mut cfg.matrix.homeserver, self.matrix_homeserver.clone());
merge_option(&mut cfg.matrix.server_name, self.matrix_server_name.clone());
merge_option(&mut cfg.matrix.room_id, self.matrix_room_id.clone());
merge_option(&mut cfg.state.state_file, self.state_file.clone());
}
fn merge(self, higher: ConfigOverrides) -> ConfigOverrides {
let matrix_as_token = if higher.matrix_as_token_file.is_some() {
higher.matrix_as_token
} else {
higher.matrix_as_token.or(self.matrix_as_token)
};
let matrix_hs_token = if higher.matrix_hs_token_file.is_some() {
higher.matrix_hs_token
} else {
higher.matrix_hs_token.or(self.matrix_hs_token)
};
ConfigOverrides {
potatomesh_base_url: higher.potatomesh_base_url.or(self.potatomesh_base_url),
potatomesh_poll_interval_secs: higher
.potatomesh_poll_interval_secs
.or(self.potatomesh_poll_interval_secs),
matrix_homeserver: higher.matrix_homeserver.or(self.matrix_homeserver),
matrix_as_token,
matrix_as_token_file: higher.matrix_as_token_file.or(self.matrix_as_token_file),
matrix_hs_token,
matrix_hs_token_file: higher.matrix_hs_token_file.or(self.matrix_hs_token_file),
matrix_server_name: higher.matrix_server_name.or(self.matrix_server_name),
matrix_room_id: higher.matrix_room_id.or(self.matrix_room_id),
state_file: higher.state_file.or(self.state_file),
}
}
}
/// Inputs gathered from CLI flags or environment variables.
#[derive(Debug, Clone, Default)]
pub struct ConfigInputs {
pub config_path: Option<String>,
pub secrets_dir: Option<String>,
pub container_override: Option<bool>,
pub container_hint: Option<String>,
pub overrides: ConfigOverrides,
}
impl ConfigInputs {
/// Merge two input sets, preferring values from `higher`.
pub fn merge(self, higher: ConfigInputs) -> ConfigInputs {
ConfigInputs {
config_path: higher.config_path.or(self.config_path),
secrets_dir: higher.secrets_dir.or(self.secrets_dir),
container_override: higher.container_override.or(self.container_override),
container_hint: higher.container_hint.or(self.container_hint),
overrides: self.overrides.merge(higher.overrides),
}
}
/// Load configuration inputs from the process environment.
#[cfg(not(test))]
pub fn from_env() -> anyhow::Result<Self> {
let overrides = ConfigOverrides {
potatomesh_base_url: env_var("POTATOMESH_BASE_URL"),
potatomesh_poll_interval_secs: parse_u64_env("POTATOMESH_POLL_INTERVAL_SECS")?,
matrix_homeserver: env_var("MATRIX_HOMESERVER"),
matrix_as_token: env_var("MATRIX_AS_TOKEN"),
matrix_as_token_file: env_var("MATRIX_AS_TOKEN_FILE"),
matrix_hs_token: env_var("MATRIX_HS_TOKEN"),
matrix_hs_token_file: env_var("MATRIX_HS_TOKEN_FILE"),
matrix_server_name: env_var("MATRIX_SERVER_NAME"),
matrix_room_id: env_var("MATRIX_ROOM_ID"),
state_file: env_var("STATE_FILE"),
};
Ok(ConfigInputs {
config_path: env_var("POTATOMESH_CONFIG"),
secrets_dir: env_var("POTATOMESH_SECRETS_DIR"),
container_override: parse_bool_env("POTATOMESH_CONTAINER")?,
container_hint: env_var("CONTAINER"),
overrides,
})
}
}
impl Config {
/// Load a full Config from a TOML file.
#[cfg(test)]
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let contents = fs::read_to_string(path)?;
let cfg = toml::from_str(&contents)?;
Ok(cfg)
}
}
pub fn from_default_path() -> anyhow::Result<Self> {
let path = "Config.toml";
if !Path::new(path).exists() {
anyhow::bail!("Config file {path} not found");
/// Load a Config by merging CLI/env overrides with an optional TOML file.
#[cfg(not(test))]
pub fn load(cli_inputs: ConfigInputs) -> anyhow::Result<Config> {
let env_inputs = ConfigInputs::from_env()?;
let cgroup_hint = read_cgroup();
load_from_sources(cli_inputs, env_inputs, cgroup_hint.as_deref())
}
/// Load configuration by merging CLI/env inputs and an optional config file.
fn load_from_sources(
cli_inputs: ConfigInputs,
env_inputs: ConfigInputs,
cgroup_hint: Option<&str>,
) -> anyhow::Result<Config> {
let merged_inputs = env_inputs.merge(cli_inputs);
let container = detect_container(
merged_inputs.container_override,
merged_inputs.container_hint.as_deref(),
cgroup_hint,
);
let defaults = default_paths(container);
let base_cfg = resolve_base_config(&merged_inputs, &defaults)?;
let mut cfg = base_cfg.unwrap_or_default();
merged_inputs.overrides.apply_non_token_overrides(&mut cfg);
let secrets_dir = resolve_secrets_dir(&merged_inputs, container, &defaults);
let as_token = resolve_token(
cfg.matrix.as_token.clone(),
merged_inputs.overrides.matrix_as_token.clone(),
merged_inputs.overrides.matrix_as_token_file.as_deref(),
secrets_dir.as_deref(),
"matrix_as_token",
)?;
let hs_token = resolve_token(
cfg.matrix.hs_token.clone(),
merged_inputs.overrides.matrix_hs_token.clone(),
merged_inputs.overrides.matrix_hs_token_file.as_deref(),
secrets_dir.as_deref(),
"matrix_hs_token",
)?;
if cfg.potatomesh.poll_interval_secs.is_none() && container {
cfg.potatomesh.poll_interval_secs = Some(defaults.poll_interval_secs);
}
Self::load_from_file(path)
if cfg.state.state_file.is_none() {
cfg.state.state_file = Some(defaults.state_file);
}
let missing = collect_missing_fields(&cfg, &as_token, &hs_token);
if !missing.is_empty() {
anyhow::bail!(
"Missing required configuration values: {}",
missing.join(", ")
);
}
Ok(Config {
potatomesh: PotatomeshConfig {
base_url: cfg.potatomesh.base_url.unwrap(),
poll_interval_secs: cfg.potatomesh.poll_interval_secs.unwrap(),
},
matrix: MatrixConfig {
homeserver: cfg.matrix.homeserver.unwrap(),
as_token: as_token.unwrap(),
hs_token: hs_token.unwrap(),
server_name: cfg.matrix.server_name.unwrap(),
room_id: cfg.matrix.room_id.unwrap(),
},
state: StateConfig {
state_file: cfg.state.state_file.unwrap(),
},
})
}
/// Collect the missing required field identifiers for error reporting.
fn collect_missing_fields(
cfg: &PartialConfig,
as_token: &Option<String>,
hs_token: &Option<String>,
) -> Vec<&'static str> {
let mut missing = Vec::new();
if cfg.potatomesh.base_url.is_none() {
missing.push("potatomesh.base_url");
}
if cfg.potatomesh.poll_interval_secs.is_none() {
missing.push("potatomesh.poll_interval_secs");
}
if cfg.matrix.homeserver.is_none() {
missing.push("matrix.homeserver");
}
if as_token.is_none() {
missing.push("matrix.as_token");
}
if hs_token.is_none() {
missing.push("matrix.hs_token");
}
if cfg.matrix.server_name.is_none() {
missing.push("matrix.server_name");
}
if cfg.matrix.room_id.is_none() {
missing.push("matrix.room_id");
}
if cfg.state.state_file.is_none() {
missing.push("state.state_file");
}
missing
}
/// Resolve the base TOML config file, honoring explicit config paths.
fn resolve_base_config(
inputs: &ConfigInputs,
defaults: &DefaultPaths,
) -> anyhow::Result<Option<PartialConfig>> {
if let Some(path) = &inputs.config_path {
return Ok(Some(load_partial_from_file(path)?));
}
let container_path = Path::new(&defaults.config_path);
if container_path.exists() {
return Ok(Some(load_partial_from_file(&defaults.config_path)?));
}
let host_path = Path::new(DEFAULT_CONFIG_PATH);
if host_path.exists() {
return Ok(Some(load_partial_from_file(DEFAULT_CONFIG_PATH)?));
}
Ok(None)
}
/// Decide which secrets directory to use based on inputs and defaults.
fn resolve_secrets_dir(
inputs: &ConfigInputs,
container: bool,
defaults: &DefaultPaths,
) -> Option<String> {
if let Some(explicit) = inputs.secrets_dir.clone() {
return Some(explicit);
}
if container {
return Some(defaults.secrets_dir.clone());
}
None
}
/// Resolve a token value from explicit values, secret files, or config file values.
fn resolve_token(
base_value: Option<String>,
explicit_value: Option<String>,
explicit_file: Option<&str>,
secrets_dir: Option<&str>,
default_secret_name: &str,
) -> anyhow::Result<Option<String>> {
if let Some(value) = explicit_value {
return Ok(Some(value));
}
if let Some(path) = explicit_file {
return Ok(Some(read_secret_file(path)?));
}
if let Some(dir) = secrets_dir {
let default_path = Path::new(dir).join(default_secret_name);
if default_path.exists() {
return Ok(Some(read_secret_file(
default_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid secret file path"))?,
)?));
}
}
Ok(base_value)
}
/// Read and trim a secret file from disk.
fn read_secret_file(path: &str) -> anyhow::Result<String> {
let contents = fs::read_to_string(path)?;
let trimmed = contents.trim();
if trimmed.is_empty() {
anyhow::bail!("Secret file {path} is empty");
}
Ok(trimmed.to_string())
}
/// Load a partial config from a TOML file.
fn load_partial_from_file(path: &str) -> anyhow::Result<PartialConfig> {
let contents = fs::read_to_string(path)?;
let cfg = toml::from_str(&contents)?;
Ok(cfg)
}
/// Compute default paths and intervals based on container mode.
fn default_paths(container: bool) -> DefaultPaths {
if container {
DefaultPaths {
config_path: CONTAINER_CONFIG_PATH.to_string(),
state_file: CONTAINER_STATE_FILE.to_string(),
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
}
} else {
DefaultPaths {
config_path: DEFAULT_CONFIG_PATH.to_string(),
state_file: DEFAULT_STATE_FILE.to_string(),
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
}
}
}
#[derive(Debug, Clone)]
struct DefaultPaths {
config_path: String,
state_file: String,
secrets_dir: String,
poll_interval_secs: u64,
}
/// Detect whether the bridge is running inside a container.
fn detect_container(
override_value: Option<bool>,
env_hint: Option<&str>,
cgroup_hint: Option<&str>,
) -> bool {
if let Some(value) = override_value {
return value;
}
if let Some(hint) = env_hint {
if !hint.trim().is_empty() {
return true;
}
}
if let Some(cgroup) = cgroup_hint {
let haystack = cgroup.to_ascii_lowercase();
return haystack.contains("docker")
|| haystack.contains("kubepods")
|| haystack.contains("containerd")
|| haystack.contains("podman");
}
false
}
/// Read the primary cgroup file for container detection.
#[cfg(not(test))]
fn read_cgroup() -> Option<String> {
fs::read_to_string("/proc/1/cgroup").ok()
}
/// Read and trim an environment variable value.
#[cfg(not(test))]
fn env_var(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// Parse a u64 environment variable value.
#[cfg(not(test))]
fn parse_u64_env(key: &str) -> anyhow::Result<Option<u64>> {
match env_var(key) {
None => Ok(None),
Some(value) => value
.parse::<u64>()
.map(Some)
.map_err(|e| anyhow::anyhow!("Invalid {key} value: {e}")),
}
}
/// Parse a boolean environment variable value.
#[cfg(not(test))]
fn parse_bool_env(key: &str) -> anyhow::Result<Option<bool>> {
match env_var(key) {
None => Ok(None),
Some(value) => parse_bool_value(key, &value).map(Some),
}
}
/// Parse a boolean string with standard truthy/falsy values.
#[cfg(not(test))]
fn parse_bool_value(key: &str, value: &str) -> anyhow::Result<bool> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => anyhow::bail!("Invalid {key} value: {value}"),
}
}
@@ -63,6 +502,43 @@ mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
use std::path::{Path, PathBuf};
struct CwdGuard {
original: PathBuf,
}
impl CwdGuard {
/// Switch to the provided path and restore the original cwd on drop.
fn enter(path: &Path) -> Self {
let original = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
std::env::set_current_dir(path).unwrap();
Self { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
if std::env::set_current_dir(&self.original).is_err() {
let _ = std::env::set_current_dir("/");
}
}
}
fn minimal_overrides() -> ConfigOverrides {
ConfigOverrides {
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
potatomesh_poll_interval_secs: Some(10),
matrix_homeserver: Some("https://matrix.example.org".to_string()),
matrix_as_token: Some("AS_TOKEN".to_string()),
matrix_hs_token: Some("HS_TOKEN".to_string()),
matrix_server_name: Some("example.org".to_string()),
matrix_room_id: Some("!roomid:example.org".to_string()),
state_file: Some("bridge_state.json".to_string()),
matrix_as_token_file: None,
matrix_hs_token_file: None,
}
}
#[test]
fn parse_minimal_config_from_toml_str() {
@@ -125,21 +601,28 @@ mod tests {
}
#[test]
#[serial]
fn from_default_path_not_found() {
let tmp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let result = Config::from_default_path();
assert!(result.is_err());
fn detect_container_prefers_override() {
assert!(detect_container(Some(true), None, None));
assert!(!detect_container(
Some(false),
Some("docker"),
Some("docker")
));
}
#[test]
#[serial]
fn from_default_path_found() {
fn detect_container_from_hint_or_cgroup() {
assert!(detect_container(None, Some("docker"), None));
assert!(detect_container(None, None, Some("kubepods")));
assert!(!detect_container(None, None, Some("")));
}
#[test]
fn load_uses_cli_overrides_over_env() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
poll_interval_secs = 5
[matrix]
homeserver = "https://matrix.example.org"
@@ -151,12 +634,345 @@ mod tests {
[state]
state_file = "bridge_state.json"
"#;
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("Config.toml");
let mut file = std::fs::File::create(file_path).unwrap();
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", toml_str).unwrap();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let result = Config::from_default_path();
assert!(result.is_ok());
let env_inputs = ConfigInputs {
config_path: Some(file.path().to_str().unwrap().to_string()),
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://env.example/".to_string()),
..minimal_overrides()
},
..ConfigInputs::default()
};
let cli_inputs = ConfigInputs {
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://cli.example/".to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cfg = load_from_sources(cli_inputs, env_inputs, None).unwrap();
assert_eq!(cfg.potatomesh.base_url, "https://cli.example/");
}
#[test]
#[serial]
fn load_uses_container_secret_defaults() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let secrets_dir = tmp_dir.path();
fs::write(secrets_dir.join("matrix_as_token"), "FROM_SECRET").unwrap();
let cli_inputs = ConfigInputs {
secrets_dir: Some(secrets_dir.to_string_lossy().to_string()),
container_override: Some(true),
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
potatomesh_poll_interval_secs: Some(10),
matrix_homeserver: Some("https://matrix.example.org".to_string()),
matrix_hs_token: Some("HS_TOKEN".to_string()),
matrix_server_name: Some("example.org".to_string()),
matrix_room_id: Some("!roomid:example.org".to_string()),
state_file: Some("bridge_state.json".to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
assert_eq!(cfg.matrix.as_token, "FROM_SECRET");
}
#[test]
fn resolve_token_prefers_explicit_value() {
let tmp_dir = tempfile::tempdir().unwrap();
let token_file = tmp_dir.path().join("token");
fs::write(&token_file, "FROM_FILE").unwrap();
let resolved = resolve_token(
Some("FROM_BASE".to_string()),
Some("FROM_EXPLICIT".to_string()),
Some(token_file.to_str().unwrap()),
Some(tmp_dir.path().to_str().unwrap()),
"matrix_as_token",
)
.unwrap();
assert_eq!(resolved, Some("FROM_EXPLICIT".to_string()));
}
#[test]
fn resolve_token_reads_explicit_file() {
let tmp_dir = tempfile::tempdir().unwrap();
let token_file = tmp_dir.path().join("token");
fs::write(&token_file, "FROM_FILE").unwrap();
let resolved = resolve_token(
None,
None,
Some(token_file.to_str().unwrap()),
None,
"matrix_as_token",
)
.unwrap();
assert_eq!(resolved, Some("FROM_FILE".to_string()));
}
#[test]
fn resolve_token_reads_default_secret_file() {
let tmp_dir = tempfile::tempdir().unwrap();
fs::write(tmp_dir.path().join("matrix_hs_token"), "FROM_SECRET").unwrap();
let resolved = resolve_token(
None,
None,
None,
Some(tmp_dir.path().to_str().unwrap()),
"matrix_hs_token",
)
.unwrap();
assert_eq!(resolved, Some("FROM_SECRET".to_string()));
}
#[test]
fn resolve_token_errors_on_empty_secret_file() {
let tmp_dir = tempfile::tempdir().unwrap();
let token_file = tmp_dir.path().join("token");
fs::write(&token_file, " ").unwrap();
let result = resolve_token(
None,
None,
Some(token_file.to_str().unwrap()),
None,
"matrix_as_token",
);
assert!(result.is_err());
}
#[test]
fn resolve_secrets_dir_prefers_explicit() {
let defaults = DefaultPaths {
config_path: "Config.toml".to_string(),
state_file: DEFAULT_STATE_FILE.to_string(),
secrets_dir: "default".to_string(),
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
};
let inputs = ConfigInputs {
secrets_dir: Some("explicit".to_string()),
..ConfigInputs::default()
};
let resolved = resolve_secrets_dir(&inputs, true, &defaults);
assert_eq!(resolved, Some("explicit".to_string()));
}
#[test]
fn resolve_secrets_dir_container_default() {
let defaults = DefaultPaths {
config_path: "Config.toml".to_string(),
state_file: DEFAULT_STATE_FILE.to_string(),
secrets_dir: "default".to_string(),
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
};
let inputs = ConfigInputs::default();
let resolved = resolve_secrets_dir(&inputs, true, &defaults);
assert_eq!(resolved, Some("default".to_string()));
assert_eq!(resolve_secrets_dir(&inputs, false, &defaults), None);
}
#[test]
#[serial]
fn resolve_base_config_prefers_explicit_path() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let config_path = tmp_dir.path().join("explicit.toml");
fs::write(
&config_path,
r#"[potatomesh]
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#,
)
.unwrap();
let defaults = default_paths(false);
let inputs = ConfigInputs {
config_path: Some(config_path.to_string_lossy().to_string()),
..ConfigInputs::default()
};
let resolved = resolve_base_config(&inputs, &defaults).unwrap();
assert!(resolved.is_some());
}
#[test]
#[serial]
fn resolve_base_config_uses_container_path_when_present() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let config_path = tmp_dir.path().join("container.toml");
fs::write(
&config_path,
r#"[potatomesh]
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#,
)
.unwrap();
let defaults = DefaultPaths {
config_path: config_path.to_string_lossy().to_string(),
state_file: DEFAULT_STATE_FILE.to_string(),
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
};
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
assert!(resolved.is_some());
}
#[test]
#[serial]
fn resolve_base_config_uses_host_path_when_present() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
fs::write(
"Config.toml",
r#"[potatomesh]
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
hs_token = "HS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#,
)
.unwrap();
let defaults = default_paths(false);
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
assert!(resolved.is_some());
}
#[test]
#[serial]
fn resolve_base_config_returns_none_when_missing() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let defaults = default_paths(false);
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
assert!(resolved.is_none());
}
#[test]
#[serial]
fn load_prefers_cli_token_file_over_env_value() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let token_file = tmp_dir.path().join("as_token");
fs::write(&token_file, "CLI_SECRET").unwrap();
let env_inputs = ConfigInputs {
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
potatomesh_poll_interval_secs: Some(10),
matrix_homeserver: Some("https://matrix.example.org".to_string()),
matrix_as_token: Some("ENV_TOKEN".to_string()),
matrix_hs_token: Some("HS_TOKEN".to_string()),
matrix_server_name: Some("example.org".to_string()),
matrix_room_id: Some("!roomid:example.org".to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cli_inputs = ConfigInputs {
overrides: ConfigOverrides {
matrix_as_token_file: Some(token_file.to_string_lossy().to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cfg = load_from_sources(cli_inputs, env_inputs, None).unwrap();
assert_eq!(cfg.matrix.as_token, "CLI_SECRET");
}
#[test]
#[serial]
fn load_uses_container_default_poll_interval() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let cli_inputs = ConfigInputs {
container_override: Some(true),
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
matrix_homeserver: Some("https://matrix.example.org".to_string()),
matrix_as_token: Some("AS_TOKEN".to_string()),
matrix_hs_token: Some("HS_TOKEN".to_string()),
matrix_server_name: Some("example.org".to_string()),
matrix_room_id: Some("!roomid:example.org".to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
assert_eq!(
cfg.potatomesh.poll_interval_secs,
CONTAINER_POLL_INTERVAL_SECS
);
}
#[test]
#[serial]
fn load_uses_default_state_path_when_missing() {
let tmp_dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(tmp_dir.path());
let cli_inputs = ConfigInputs {
overrides: ConfigOverrides {
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
potatomesh_poll_interval_secs: Some(10),
matrix_homeserver: Some("https://matrix.example.org".to_string()),
matrix_as_token: Some("AS_TOKEN".to_string()),
matrix_hs_token: Some("HS_TOKEN".to_string()),
matrix_server_name: Some("example.org".to_string()),
matrix_room_id: Some("!roomid:example.org".to_string()),
..ConfigOverrides::default()
},
..ConfigInputs::default()
};
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
assert_eq!(cfg.state.state_file, DEFAULT_STATE_FILE);
}
}

View File

@@ -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 matrix_server;
@@ -20,9 +21,13 @@ mod potatomesh;
use std::{fs, net::SocketAddr, path::Path};
use anyhow::Result;
#[cfg(not(test))]
use clap::Parser;
use tokio::time::Duration;
use tracing::{error, info};
#[cfg(not(test))]
use crate::cli::Cli;
#[cfg(not(test))]
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
@@ -207,7 +212,8 @@ async fn main() -> Result<()> {
)
.init();
let cfg = Config::from_default_path()?;
let cli = Cli::parse();
let cfg = config::load(cli.to_inputs())?;
log_config(&cfg);
let http = reqwest::Client::builder().build()?;