#![allow(dead_code)]
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use garde::Validate;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE_NAME: &str = "config.json";
pub const SCHEMA_PATH: &str = "docs/mobux.schema.json";
pub const SCHEMA_JSON: &str = include_str!("../docs/mobux.schema.json");
const DEFAULT_PORT: u16 = 8080;
const DEFAULT_ACME_DIRECTORY: &str = "https://acme-v02.api.letsencrypt.org/directory";
const DEFAULT_ACME_HTTP_PORT: u16 = 80;
const DEFAULT_SERVICE_NAME: &str = "mobux";
const DEFAULT_VAPID_CONTACT: &str = "mailto:admin@example.com";
const DEFAULT_UPDATE_CHECK_URL: &str = "https://index.crates.io/mo/bu/mobux";
pub const DEFAULT_AUTH_USER: &str = "mobux";
pub const NO_AUTH_WARNING: &str = "warning: authentication is OFF — mobux serves anyone who \
reaches this port. Access control is delegated to the reverse proxy in front of it.";
pub const PORT_DEPRECATION: &str = "PORT is deprecated; rename it to MOBUX_PORT";
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
#[garde(dive)]
pub server: ServerConfig,
#[serde(default)]
#[garde(dive)]
pub auth: AuthConfig,
#[serde(default)]
#[garde(dive)]
pub tls: TlsConfig,
#[serde(default)]
#[garde(dive)]
pub paths: PathsConfig,
#[serde(default)]
#[garde(dive)]
pub session: SessionConfig,
#[serde(default)]
#[garde(dive)]
pub app: AppConfig,
#[serde(default)]
#[garde(dive)]
pub push: PushConfig,
#[serde(default)]
#[garde(dive)]
pub update: UpdateConfig,
#[serde(default)]
#[garde(dive)]
pub access: AccessConfig,
#[serde(default)]
#[garde(dive)]
pub files: FilesConfig,
#[serde(default)]
#[garde(dive)]
pub proxy: ProxyConfig,
#[serde(default)]
#[garde(dive)]
pub mcp: McpConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default = "default_port")]
#[garde(range(min = 1))]
pub port: u16,
#[serde(default)]
#[garde(custom(base_path_value))]
pub base_path: String,
#[serde(default)]
#[garde(skip)]
pub behind_tls_proxy: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
#[serde(default)]
#[garde(custom(plain_value))]
pub user: String,
#[serde(default)]
#[garde(custom(plain_value))]
pub pass: String,
#[serde(default)]
#[garde(custom(pin_value))]
pub pin: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
#[serde(default = "default_tls_enabled")]
#[garde(skip)]
pub enabled: bool,
#[serde(default)]
#[garde(inner(custom(host_value)))]
pub hosts: Vec<String>,
#[serde(default)]
#[garde(skip)]
pub cert_file: String,
#[serde(default)]
#[garde(skip)]
pub key_file: String,
#[serde(default)]
#[garde(inner(custom(host_value)))]
pub acme_domains: Vec<String>,
#[serde(default)]
#[garde(custom(email_value))]
pub acme_email: String,
#[serde(default = "default_acme_directory")]
#[garde(custom(https_url_value))]
pub acme_directory: String,
#[serde(default = "default_acme_http_port")]
#[garde(range(min = 1))]
pub acme_http_port: u16,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct PathsConfig {
#[serde(default)]
#[garde(skip)]
pub data_dir: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct SessionConfig {
#[serde(default)]
#[garde(skip)]
pub shell: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
#[serde(default)]
#[garde(custom(authority_value))]
pub domain: String,
#[serde(default)]
#[garde(skip)]
pub dev: bool,
#[serde(default = "default_service_name")]
#[garde(custom(service_name_value))]
pub service_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct PushConfig {
#[serde(default = "default_vapid_contact")]
#[garde(custom(vapid_contact_value))]
pub vapid_contact: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
#[serde(default = "default_update_check_url")]
#[garde(custom(https_url_value))]
pub check_url: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct AccessConfig {
#[serde(default)]
#[garde(skip)]
pub port: u16,
#[serde(default)]
#[garde(custom(team_domain_value))]
pub team_domain: String,
#[serde(default)]
#[garde(custom(plain_value))]
pub aud: String,
#[serde(default)]
#[garde(custom(bare_host_value))]
pub hostname: String,
#[serde(default)]
#[garde(inner(custom(required_email_value)))]
pub allowed_emails: Vec<String>,
#[serde(default)]
#[garde(inner(custom(required_plain_value)))]
pub service_tokens: Vec<String>,
}
const ACCESS_CERTS_PATH: &str = "/cdn-cgi/access/certs";
impl AccessConfig {
pub fn team_origin(&self) -> String {
let domain = self.team_domain.trim().trim_end_matches('/').to_lowercase();
if domain.contains("://") {
return domain;
}
format!("https://{domain}")
}
pub fn issuer(&self) -> String {
self.team_origin()
}
pub fn jwks_url(&self) -> String {
format!("{}{ACCESS_CERTS_PATH}", self.team_origin())
}
pub fn is_configured(&self) -> bool {
self != &AccessConfig::default()
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct FilesConfig {
#[serde(default)]
#[garde(custom(file_roots_value))]
pub roots: BTreeMap<String, String>,
#[serde(default)]
#[garde(skip)]
pub listing: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct ProxyConfig {
#[serde(default)]
#[garde(custom(proxy_targets_value))]
pub targets: BTreeMap<String, u16>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
#[serde(default)]
#[garde(skip)]
pub port: u16,
}
fn default_port() -> u16 {
DEFAULT_PORT
}
fn default_tls_enabled() -> bool {
false
}
fn default_acme_directory() -> String {
DEFAULT_ACME_DIRECTORY.to_string()
}
fn default_acme_http_port() -> u16 {
DEFAULT_ACME_HTTP_PORT
}
fn default_service_name() -> String {
DEFAULT_SERVICE_NAME.to_string()
}
fn default_vapid_contact() -> String {
DEFAULT_VAPID_CONTACT.to_string()
}
fn default_update_check_url() -> String {
DEFAULT_UPDATE_CHECK_URL.to_string()
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
port: default_port(),
base_path: String::new(),
behind_tls_proxy: false,
}
}
}
impl Default for TlsConfig {
fn default() -> Self {
TlsConfig {
enabled: default_tls_enabled(),
hosts: Vec::new(),
cert_file: String::new(),
key_file: String::new(),
acme_domains: Vec::new(),
acme_email: String::new(),
acme_directory: default_acme_directory(),
acme_http_port: default_acme_http_port(),
}
}
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
domain: String::new(),
dev: false,
service_name: default_service_name(),
}
}
}
impl Default for PushConfig {
fn default() -> Self {
PushConfig {
vapid_contact: default_vapid_contact(),
}
}
}
impl Default for UpdateConfig {
fn default() -> Self {
UpdateConfig {
check_url: default_update_check_url(),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialConfig {
#[serde(default)]
pub server: Option<PartialServerConfig>,
#[serde(default)]
pub auth: Option<PartialAuthConfig>,
#[serde(default)]
pub tls: Option<PartialTlsConfig>,
#[serde(default)]
pub paths: Option<PartialPathsConfig>,
#[serde(default)]
pub session: Option<PartialSessionConfig>,
#[serde(default)]
pub app: Option<PartialAppConfig>,
#[serde(default)]
pub push: Option<PartialPushConfig>,
#[serde(default)]
pub update: Option<PartialUpdateConfig>,
#[serde(default)]
pub access: Option<PartialAccessConfig>,
#[serde(default)]
pub files: Option<PartialFilesConfig>,
#[serde(default)]
pub proxy: Option<PartialProxyConfig>,
#[serde(default)]
pub mcp: Option<PartialMcpConfig>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialServerConfig {
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub base_path: Option<String>,
#[serde(default)]
pub behind_tls_proxy: Option<bool>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialAuthConfig {
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub pass: Option<String>,
#[serde(default)]
pub pin: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialTlsConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub hosts: Option<Vec<String>>,
#[serde(default)]
pub cert_file: Option<String>,
#[serde(default)]
pub key_file: Option<String>,
#[serde(default)]
pub acme_domains: Option<Vec<String>>,
#[serde(default)]
pub acme_email: Option<String>,
#[serde(default)]
pub acme_directory: Option<String>,
#[serde(default)]
pub acme_http_port: Option<u16>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialPathsConfig {
#[serde(default)]
pub data_dir: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialSessionConfig {
#[serde(default)]
pub shell: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialAppConfig {
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub dev: Option<bool>,
#[serde(default)]
pub service_name: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialPushConfig {
#[serde(default)]
pub vapid_contact: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialUpdateConfig {
#[serde(default)]
pub check_url: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialAccessConfig {
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub team_domain: Option<String>,
#[serde(default)]
pub aud: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub allowed_emails: Option<Vec<String>>,
#[serde(default)]
pub service_tokens: Option<Vec<String>>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialFilesConfig {
#[serde(default)]
pub roots: Option<BTreeMap<String, String>>,
#[serde(default)]
pub listing: Option<bool>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialProxyConfig {
#[serde(default)]
pub targets: Option<BTreeMap<String, u16>>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialMcpConfig {
#[serde(default)]
pub port: Option<u16>,
}
impl PartialConfig {
pub fn server_port(&self) -> Option<u16> {
self.server.as_ref().and_then(|server| server.port)
}
pub fn auth_user(&self) -> Option<String> {
self.auth.as_ref().and_then(|auth| auth.user.clone())
}
pub fn auth_pin(&self) -> Option<String> {
self.auth.as_ref().and_then(|auth| auth.pin.clone())
}
}
impl Config {
pub fn merged(mut self, partial: PartialConfig) -> Config {
if let Some(server) = partial.server {
overlay(&mut self.server.port, server.port);
overlay(&mut self.server.base_path, server.base_path);
overlay(&mut self.server.behind_tls_proxy, server.behind_tls_proxy);
}
if let Some(auth) = partial.auth {
overlay(&mut self.auth.user, auth.user);
overlay(&mut self.auth.pass, auth.pass);
overlay(&mut self.auth.pin, auth.pin);
}
if let Some(tls) = partial.tls {
overlay(&mut self.tls.enabled, tls.enabled);
overlay(&mut self.tls.hosts, tls.hosts);
overlay(&mut self.tls.cert_file, tls.cert_file);
overlay(&mut self.tls.key_file, tls.key_file);
overlay(&mut self.tls.acme_domains, tls.acme_domains);
overlay(&mut self.tls.acme_email, tls.acme_email);
overlay(&mut self.tls.acme_directory, tls.acme_directory);
overlay(&mut self.tls.acme_http_port, tls.acme_http_port);
}
if let Some(paths) = partial.paths {
overlay(&mut self.paths.data_dir, paths.data_dir);
}
if let Some(session) = partial.session {
overlay(&mut self.session.shell, session.shell);
}
if let Some(app) = partial.app {
overlay(&mut self.app.domain, app.domain);
overlay(&mut self.app.dev, app.dev);
overlay(&mut self.app.service_name, app.service_name);
}
if let Some(push) = partial.push {
overlay(&mut self.push.vapid_contact, push.vapid_contact);
}
if let Some(update) = partial.update {
overlay(&mut self.update.check_url, update.check_url);
}
if let Some(access) = partial.access {
overlay(&mut self.access.port, access.port);
overlay(&mut self.access.team_domain, access.team_domain);
overlay(&mut self.access.aud, access.aud);
overlay(&mut self.access.hostname, access.hostname);
overlay(&mut self.access.allowed_emails, access.allowed_emails);
overlay(&mut self.access.service_tokens, access.service_tokens);
}
if let Some(files) = partial.files {
overlay(&mut self.files.roots, files.roots);
overlay(&mut self.files.listing, files.listing);
}
if let Some(proxy) = partial.proxy {
overlay(&mut self.proxy.targets, proxy.targets);
}
if let Some(mcp) = partial.mcp {
overlay(&mut self.mcp.port, mcp.port);
}
self
}
}
fn overlay<T>(target: &mut T, value: Option<T>) {
if let Some(value) = value {
*target = value;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Number,
Text,
Toggle,
List,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldValue {
Number(u16),
Text(String),
Toggle(bool),
List(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FieldSpec {
pub key: &'static str,
pub env: &'static str,
pub flag: Option<&'static str>,
pub kind: FieldKind,
pub help: &'static str,
}
pub const FIELDS: &[FieldSpec] = &[
FieldSpec {
key: "server.port",
env: "MOBUX_PORT",
flag: Some("--port"),
kind: FieldKind::Number,
help: "Port to listen on (default 8080)",
},
FieldSpec {
key: "server.base_path",
env: "MOBUX_BASE_PATH",
flag: Some("--base-path"),
kind: FieldKind::Text,
help: "Path prefix a reverse proxy publishes mobux under, e.g. /mobux",
},
FieldSpec {
key: "server.behind_tls_proxy",
env: "MOBUX_BEHIND_TLS_PROXY",
flag: Some("--behind-tls-proxy"),
kind: FieldKind::Toggle,
help: "Trust a reverse proxy to terminate TLS (default off)",
},
FieldSpec {
key: "auth.user",
env: "MOBUX_AUTH_USER",
flag: Some("--user"),
kind: FieldKind::Text,
help: "Username to unlock the web UI (default mobux)",
},
FieldSpec {
key: "auth.pass",
env: "MOBUX_AUTH_PASS",
flag: Some("--pass"),
kind: FieldKind::Text,
help: "Password to unlock the web UI",
},
FieldSpec {
key: "auth.pin",
env: "MOBUX_PIN",
flag: Some("--pin"),
kind: FieldKind::Text,
help: "PIN to unlock the web UI",
},
FieldSpec {
key: "tls.enabled",
env: "MOBUX_TLS",
flag: Some("--tls"),
kind: FieldKind::Toggle,
help: "Serve HTTPS with a generated certificate (default off)",
},
FieldSpec {
key: "tls.hosts",
env: "MOBUX_TLS_HOSTS",
flag: Some("--tls-host"),
kind: FieldKind::List,
help: "Extra hostname on the generated certificate",
},
FieldSpec {
key: "tls.cert_file",
env: "MOBUX_CERT_FILE",
flag: Some("--cert-file"),
kind: FieldKind::Text,
help: "Certificate PEM to serve instead of a generated one",
},
FieldSpec {
key: "tls.key_file",
env: "MOBUX_KEY_FILE",
flag: Some("--key-file"),
kind: FieldKind::Text,
help: "Private key PEM matching the certificate",
},
FieldSpec {
key: "tls.acme_domains",
env: "MOBUX_ACME_DOMAINS",
flag: Some("--acme-domain"),
kind: FieldKind::List,
help: "Domain to obtain an ACME certificate for",
},
FieldSpec {
key: "tls.acme_email",
env: "MOBUX_ACME_EMAIL",
flag: Some("--acme-email"),
kind: FieldKind::Text,
help: "Account contact for the ACME directory",
},
FieldSpec {
key: "tls.acme_directory",
env: "MOBUX_ACME_DIRECTORY",
flag: Some("--acme-directory"),
kind: FieldKind::Text,
help: "ACME directory URL",
},
FieldSpec {
key: "tls.acme_http_port",
env: "MOBUX_ACME_HTTP_PORT",
flag: Some("--acme-http-port"),
kind: FieldKind::Number,
help: "Port the HTTP-01 challenge responder binds (default 80)",
},
FieldSpec {
key: "paths.data_dir",
env: "MOBUX_DATA_DIR",
flag: Some("--data-dir"),
kind: FieldKind::Text,
help: "Directory for the database and other state",
},
FieldSpec {
key: "session.shell",
env: "MOBUX_SESSION_SHELL",
flag: Some("--shell"),
kind: FieldKind::Text,
help: "Shell to launch inside tmux (default $SHELL)",
},
FieldSpec {
key: "app.domain",
env: "MOBUX_DOMAIN",
flag: Some("--domain"),
kind: FieldKind::Text,
help: "Public host:port the Android app is pinned to",
},
FieldSpec {
key: "app.dev",
env: "MOBUX_DEV",
flag: Some("--dev"),
kind: FieldKind::Toggle,
help: "Dev mode, reported through /api/build-info",
},
FieldSpec {
key: "app.service_name",
env: "MOBUX_SERVICE_NAME",
flag: Some("--service-name"),
kind: FieldKind::Text,
help: "systemd unit the self-updater restarts (default mobux)",
},
FieldSpec {
key: "push.vapid_contact",
env: "MOBUX_VAPID_CONTACT",
flag: Some("--vapid-contact"),
kind: FieldKind::Text,
help: "VAPID contact, a mailto: address or an https:// URL",
},
FieldSpec {
key: "update.check_url",
env: "MOBUX_UPDATE_CHECK_URL",
flag: Some("--update-check-url"),
kind: FieldKind::Text,
help: "Where the version list is fetched from",
},
FieldSpec {
key: "access.port",
env: "MOBUX_ACCESS_PORT",
flag: Some("--access-port"),
kind: FieldKind::Number,
help: "Loopback port for the Cloudflare Access listener; 0 turns it off",
},
FieldSpec {
key: "access.team_domain",
env: "MOBUX_ACCESS_TEAM_DOMAIN",
flag: Some("--access-team-domain"),
kind: FieldKind::Text,
help: "Cloudflare Access team domain or https:// origin, e.g. example.cloudflareaccess.com",
},
FieldSpec {
key: "access.aud",
env: "MOBUX_ACCESS_AUD",
flag: Some("--access-aud"),
kind: FieldKind::Text,
help: "AUD tag of the Cloudflare Access application",
},
FieldSpec {
key: "access.hostname",
env: "MOBUX_ACCESS_HOSTNAME",
flag: Some("--access-hostname"),
kind: FieldKind::Text,
help: "Public hostname the Cloudflare Tunnel serves mobux on",
},
FieldSpec {
key: "access.allowed_emails",
env: "MOBUX_ACCESS_ALLOWED_EMAILS",
flag: Some("--access-allowed-email"),
kind: FieldKind::List,
help: "Email address the Access listener admits",
},
FieldSpec {
key: "access.service_tokens",
env: "MOBUX_ACCESS_SERVICE_TOKENS",
flag: Some("--access-service-token"),
kind: FieldKind::List,
help: "Client id of a service token the Access listener admits",
},
FieldSpec {
key: "mcp.port",
env: "MOBUX_MCP_PORT",
flag: Some("--mcp-port"),
kind: FieldKind::Number,
help: "Loopback port for the MCP server at /mcp; 0 turns it off",
},
];
pub fn partial_from_fields(fields: &[(&str, FieldValue)]) -> PartialConfig {
let mut root = serde_json::Map::new();
for (key, value) in fields {
insert_field(&mut root, key, value);
}
serde_json::from_value(serde_json::Value::Object(root))
.expect("every field key names a leaf of the partial tree")
}
fn insert_field(
root: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: &FieldValue,
) {
let (section, leaf) = key.split_once('.').expect("a dotted field key");
let section = root
.entry(section)
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.expect("a section object");
match value {
FieldValue::Number(number) => {
section.insert(leaf.to_string(), serde_json::Value::from(*number));
}
FieldValue::Text(text) => {
section.insert(leaf.to_string(), serde_json::Value::from(text.clone()));
}
FieldValue::Toggle(on) => {
section.insert(leaf.to_string(), serde_json::Value::from(*on));
}
FieldValue::List(items) => {
let list = section
.entry(leaf)
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
.as_array_mut()
.expect("a list");
list.extend(items.iter().cloned().map(serde_json::Value::from));
}
}
}
pub const FILES_ENV: &str = "MOBUX_FILES";
pub const PROXY_ENV: &str = "MOBUX_PROXY";
pub const ENV_ONLY: &[&str] = &[
"PORT",
"MOBUX_CONFIG_DIR",
"MOBUX_TMUX_SOCKET",
"MOBUX_UPDATE_TEST_INDEX",
"MOBUX_UPDATE_DISABLE_RUN",
"MOBUX_DEV_CERT",
"MOBUX_DEV_KEY",
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DirEnv {
pub mobux_config_dir: Option<String>,
pub xdg_config_home: Option<String>,
pub home: Option<String>,
}
impl DirEnv {
pub fn from_env() -> Self {
let read = |key: &str| std::env::var(key).ok();
DirEnv {
mobux_config_dir: read("MOBUX_CONFIG_DIR"),
xdg_config_home: read("XDG_CONFIG_HOME"),
home: read("HOME"),
}
}
}
pub fn resolve_config_dir(env: &DirEnv) -> PathBuf {
if let Some(dir) = non_empty(&env.mobux_config_dir) {
return PathBuf::from(dir);
}
if let Some(dir) = non_empty(&env.xdg_config_home) {
return PathBuf::from(dir).join("mobux");
}
let home = non_empty(&env.home).unwrap_or(".");
PathBuf::from(home).join(".config").join("mobux")
}
pub fn config_dir() -> PathBuf {
resolve_config_dir(&DirEnv::from_env())
}
pub fn config_file_path() -> PathBuf {
config_dir().join(CONFIG_FILE_NAME)
}
fn non_empty(value: &Option<String>) -> Option<&str> {
value.as_deref().map(str::trim).filter(|v| !v.is_empty())
}
#[derive(Debug)]
pub enum LoadError {
Read { path: PathBuf, message: String },
Syntax { path: PathBuf, message: String },
Shape {
path: PathBuf,
key: String,
message: String,
suggestion: Option<String>,
},
Invalid { path: PathBuf, message: String },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::Read { path, message } => {
write!(f, "{}: {message}", path.display())
}
LoadError::Syntax { path, message } => {
write!(f, "{}: not valid JSON: {message}", path.display())
}
LoadError::Shape {
path,
key,
message,
suggestion,
} => {
write!(f, "{}: at `{key}`: {message}", path.display())?;
match suggestion {
Some(word) => write!(f, " — did you mean `{word}`?"),
None => Ok(()),
}
}
LoadError::Invalid { path, message } => {
write!(f, "{}: {message}", path.display())
}
}
}
}
impl std::error::Error for LoadError {}
pub fn load_partial_from(path: &Path) -> Result<Option<PartialConfig>, LoadError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(LoadError::Read {
path: path.to_path_buf(),
message: err.to_string(),
})
}
};
let partial = parse_partial(path, &raw)?;
validate(path, &Config::default().merged(partial.clone()))?;
Ok(Some(partial))
}
pub fn load_from(path: &Path) -> Result<Config, LoadError> {
Ok(Config::default().merged(load_partial_from(path)?.unwrap_or_default()))
}
pub fn load() -> Result<Config, LoadError> {
load_from(&config_file_path())
}
pub fn parse(path: &Path, raw: &str) -> Result<Config, LoadError> {
let partial = parse_partial(path, raw)?;
let config = Config::default().merged(partial);
validate(path, &config)?;
Ok(config)
}
fn parse_partial(path: &Path, raw: &str) -> Result<PartialConfig, LoadError> {
let mut deserializer = serde_json::Deserializer::from_str(raw);
serde_path_to_error::deserialize(&mut deserializer).map_err(|err| shape_error(path, err))
}
fn shape_error(path: &Path, err: serde_path_to_error::Error<serde_json::Error>) -> LoadError {
let key = pointer_of(&err);
let inner = err.into_inner();
if matches!(
inner.classify(),
serde_json::error::Category::Syntax | serde_json::error::Category::Eof
) {
return LoadError::Syntax {
path: path.to_path_buf(),
message: inner.to_string(),
};
}
let message = inner.to_string();
let suggestion = unknown_field_suggestion(&message);
LoadError::Shape {
path: path.to_path_buf(),
key,
message,
suggestion,
}
}
fn pointer_of(err: &serde_path_to_error::Error<serde_json::Error>) -> String {
let path = err.path().to_string();
if path.is_empty() || path == "." {
return "(root)".to_string();
}
path
}
fn unknown_field_suggestion(message: &str) -> Option<String> {
let rest = message.strip_prefix("unknown field `")?;
let (unknown, rest) = rest.split_once('`')?;
let candidates = backticked(rest);
let best = candidates
.into_iter()
.map(|candidate| (edit_distance(unknown, candidate), candidate))
.min_by_key(|(distance, _)| *distance)?;
let (distance, candidate) = best;
if distance == 0 || distance > 3 || distance >= candidate.chars().count() {
return None;
}
Some(candidate.to_string())
}
fn backticked(text: &str) -> Vec<&str> {
text.split('`').skip(1).step_by(2).collect()
}
fn edit_distance(left: &str, right: &str) -> usize {
let right_chars: Vec<char> = right.chars().collect();
let mut previous: Vec<usize> = (0..=right_chars.len()).collect();
let mut current = vec![0usize; right_chars.len() + 1];
for (row, left_char) in left.chars().enumerate() {
current[0] = row + 1;
for (column, right_char) in right_chars.iter().enumerate() {
let substitution = previous[column] + usize::from(left_char != *right_char);
current[column + 1] = substitution
.min(previous[column + 1] + 1)
.min(current[column] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
pub fn check_fields(config: &Config) -> Result<(), String> {
config
.validate()
.map_err(|report| report.to_string().trim().to_string())
}
pub fn check(config: &Config) -> Result<(), String> {
check_fields(config)?;
if !config.tls.acme_domains.is_empty() && config.tls.acme_email.trim().is_empty() {
return Err("tls.acme_email: required when tls.acme_domains is set".to_string());
}
check_access(config)?;
check_mcp(config)
}
pub fn check_mcp(config: &Config) -> Result<(), String> {
let port = config.mcp.port;
if port == 0 {
return Ok(());
}
if port == config.server.port {
return Err(format!(
"mcp.port: must differ from server.port ({})",
config.server.port
));
}
if port == config.access.port {
return Err(format!(
"mcp.port: must differ from access.port ({})",
config.access.port
));
}
Ok(())
}
pub fn check_access(config: &Config) -> Result<(), String> {
let access = &config.access;
if !access.is_configured() {
return Ok(());
}
access.validate().map_err(|report| {
report
.iter()
.map(|(path, error)| format!("access.{path}: {error}"))
.collect::<Vec<_>>()
.join("\n")
})?;
if access.port == 0 {
return Err("access.port: required when the access block is set".to_string());
}
if access.port == config.server.port {
return Err(format!(
"access.port: must differ from server.port ({})",
config.server.port
));
}
if access.team_domain.trim().is_empty() {
return Err("access.team_domain: required when the access block is set".to_string());
}
if access.aud.trim().is_empty() {
return Err("access.aud: required when the access block is set".to_string());
}
if access.allowed_emails.is_empty() && access.service_tokens.is_empty() {
return Err(
"access.allowed_emails: list at least one email, or a client id in \
access.service_tokens; an empty allowlist admits no one"
.to_string(),
);
}
Ok(())
}
fn validate(path: &Path, config: &Config) -> Result<(), LoadError> {
check(config).map_err(|message| LoadError::Invalid {
path: path.to_path_buf(),
message,
})
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct EnvSnapshot {
values: BTreeMap<String, String>,
}
impl EnvSnapshot {
pub fn from_env() -> Self {
EnvSnapshot::new(std::env::vars())
}
pub fn new<K, V, I>(pairs: I) -> Self
where
K: AsRef<str>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
let values = pairs
.into_iter()
.filter(|(key, _)| is_known_env(key.as_ref()))
.map(|(key, value)| (key.as_ref().to_string(), value.into()))
.collect();
EnvSnapshot { values }
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
}
}
fn is_known_env(key: &str) -> bool {
FIELDS.iter().any(|field| field.env == key)
|| ENV_ONLY.contains(&key)
|| key == FILES_ENV
|| key == PROXY_ENV
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credentials {
pub user: String,
pub pass: String,
}
pub fn resolve(
defaults: Config,
file: PartialConfig,
env: &EnvSnapshot,
flags: PartialConfig,
) -> Config {
let flags = without_blank_credentials(flags);
let mut file = file;
let mut environment = env_partial(env);
if stated_pin(&flags).is_some() {
clear_pass(&mut file);
clear_pass(&mut environment);
}
defaults.merged(file).merged(environment).merged(flags)
}
pub fn env_partial(env: &EnvSnapshot) -> PartialConfig {
let text = |key: &str| env.get(key).map(str::to_string);
let list = |key: &str| env.get(key).map(split_list);
PartialConfig {
server: Some(PartialServerConfig {
port: env_port(env),
base_path: text("MOBUX_BASE_PATH"),
behind_tls_proxy: env.get("MOBUX_BEHIND_TLS_PROXY").map(truthy_toggle),
}),
auth: Some(PartialAuthConfig {
user: text("MOBUX_AUTH_USER"),
pass: text("MOBUX_AUTH_PASS"),
pin: text("MOBUX_PIN"),
}),
tls: Some(PartialTlsConfig {
enabled: env.get("MOBUX_TLS").map(truthy_tls),
hosts: list("MOBUX_TLS_HOSTS"),
cert_file: text("MOBUX_CERT_FILE"),
key_file: text("MOBUX_KEY_FILE"),
acme_domains: list("MOBUX_ACME_DOMAINS"),
acme_email: text("MOBUX_ACME_EMAIL"),
acme_directory: text("MOBUX_ACME_DIRECTORY"),
acme_http_port: env.get("MOBUX_ACME_HTTP_PORT").and_then(parse_u16),
}),
paths: Some(PartialPathsConfig {
data_dir: text("MOBUX_DATA_DIR"),
}),
session: Some(PartialSessionConfig {
shell: text("MOBUX_SESSION_SHELL"),
}),
app: Some(PartialAppConfig {
domain: text("MOBUX_DOMAIN"),
dev: env.get("MOBUX_DEV").map(truthy_toggle),
service_name: text("MOBUX_SERVICE_NAME"),
}),
push: Some(PartialPushConfig {
vapid_contact: text("MOBUX_VAPID_CONTACT"),
}),
update: Some(PartialUpdateConfig {
check_url: text("MOBUX_UPDATE_CHECK_URL"),
}),
access: Some(PartialAccessConfig {
port: env.get("MOBUX_ACCESS_PORT").and_then(parse_u16),
team_domain: text("MOBUX_ACCESS_TEAM_DOMAIN"),
aud: text("MOBUX_ACCESS_AUD"),
hostname: text("MOBUX_ACCESS_HOSTNAME"),
allowed_emails: list("MOBUX_ACCESS_ALLOWED_EMAILS"),
service_tokens: list("MOBUX_ACCESS_SERVICE_TOKENS"),
}),
files: Some(PartialFilesConfig {
roots: env.get(FILES_ENV).map(split_map),
listing: None,
}),
proxy: Some(PartialProxyConfig {
targets: env.get(PROXY_ENV).map(split_port_map),
}),
mcp: Some(PartialMcpConfig {
port: env.get("MOBUX_MCP_PORT").and_then(parse_u16),
}),
}
}
pub fn flags_partial(
port: Option<u16>,
user: Option<String>,
pin: Option<String>,
) -> PartialConfig {
PartialConfig {
server: Some(PartialServerConfig {
port,
..Default::default()
}),
auth: Some(PartialAuthConfig {
user,
pass: None,
pin,
}),
..PartialConfig::default()
}
}
pub fn port_deprecation(env: &EnvSnapshot, flags: &PartialConfig) -> Option<&'static str> {
if flags.server_port().is_some() {
return None;
}
if env.get("MOBUX_PORT").and_then(parse_u16).is_some() {
return None;
}
env.get("PORT")
.and_then(parse_u16)
.map(|_| PORT_DEPRECATION)
}
impl Config {
pub fn credentials(&self) -> Option<Credentials> {
let user = non_blank(&self.auth.user);
let pass = non_blank(&self.auth.pass);
let pin = non_blank(&self.auth.pin);
if let (Some(user), Some(pass)) = (user, pass) {
return Some(Credentials {
user: user.to_string(),
pass: pass.to_string(),
});
}
pin.map(|pin| Credentials {
user: user.unwrap_or(DEFAULT_AUTH_USER).to_string(),
pass: pin.to_string(),
})
}
}
fn env_port(env: &EnvSnapshot) -> Option<u16> {
env.get("MOBUX_PORT")
.and_then(parse_u16)
.or_else(|| env.get("PORT").and_then(parse_u16))
}
fn parse_u16(value: &str) -> Option<u16> {
value.trim().parse::<u16>().ok()
}
pub fn split_list(value: &str) -> Vec<String> {
value
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}
pub fn split_map(value: &str) -> BTreeMap<String, String> {
split_list(value)
.into_iter()
.map(|item| match item.split_once('=') {
Some((name, path)) => (name.trim().to_string(), path.trim().to_string()),
None => (item, String::new()),
})
.collect()
}
pub fn split_port_map(value: &str) -> BTreeMap<String, u16> {
split_map(value)
.into_iter()
.map(|(name, port)| (name, port.parse().unwrap_or(0)))
.collect()
}
fn truthy_tls(value: &str) -> bool {
value != "0" && !value.eq_ignore_ascii_case("false")
}
fn truthy_toggle(value: &str) -> bool {
value == "1" || value.eq_ignore_ascii_case("true")
}
fn non_blank(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
fn stated_pin(partial: &PartialConfig) -> Option<&str> {
partial
.auth
.as_ref()
.and_then(|auth| auth.pin.as_deref())
.and_then(non_blank)
}
fn without_blank_credentials(mut partial: PartialConfig) -> PartialConfig {
if let Some(auth) = partial.auth.as_mut() {
drop_blank(&mut auth.user);
drop_blank(&mut auth.pass);
drop_blank(&mut auth.pin);
}
partial
}
fn drop_blank(value: &mut Option<String>) {
if value.as_deref().is_some_and(|v| v.trim().is_empty()) {
*value = None;
}
}
fn clear_pass(partial: &mut PartialConfig) {
if let Some(auth) = partial.auth.as_mut() {
auth.pass = Some(String::new());
}
}
fn plain_value(value: &str, _: &()) -> garde::Result {
let offender = value
.chars()
.find(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '\\' | '$'));
match offender {
None => Ok(()),
Some(c) => Err(garde::Error::new(format!(
"contains {c:?}; whitespace, quotes, backslashes and $ cannot survive a systemd unit"
))),
}
}
fn pin_value(value: &str, ctx: &()) -> garde::Result {
plain_value(value, ctx)?;
if value.is_empty() {
return Ok(());
}
let length = value.chars().count();
if !(4..=64).contains(&length) {
return Err(garde::Error::new(
"must be between 4 and 64 characters, or empty to leave the PIN unset",
));
}
Ok(())
}
fn base_path_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
plain_value(value, ctx)?;
if !value.starts_with('/') {
return Err(garde::Error::new("must start with `/`, e.g. `/mobux`"));
}
if value.contains("..") {
return Err(garde::Error::new("must not contain `..`"));
}
if value.contains(';') {
return Err(garde::Error::new(
"must not contain `;`; it would split the Set-Cookie header",
));
}
Ok(())
}
pub fn file_roots_value(roots: &BTreeMap<String, String>, _: &()) -> garde::Result {
for (name, path) in roots {
file_root_name_value(name)?;
if !Path::new(path).is_absolute() {
return Err(garde::Error::new(format!(
"`{name}` must be an absolute path, got {path:?}"
)));
}
}
Ok(())
}
pub fn proxy_targets_value(targets: &BTreeMap<String, u16>, _: &()) -> garde::Result {
for (name, port) in targets {
file_root_name_value(name)?;
if *port == 0 {
return Err(garde::Error::new(format!(
"`{name}` must name a port from 1 to 65535"
)));
}
}
Ok(())
}
fn file_root_name_value(name: &str) -> garde::Result {
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'));
if ok {
return Ok(());
}
Err(garde::Error::new(format!(
"{name:?} must be letters, digits, `-` or `_`"
)))
}
fn host_value(value: &str, _: &()) -> garde::Result {
if value.trim().is_empty() {
return Err(garde::Error::new("must not be blank"));
}
let ok = value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '*' | '_'));
if !ok {
return Err(garde::Error::new(
"must be a hostname: letters, digits, dots, dashes",
));
}
Ok(())
}
fn bare_host_value(value: &str, _: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let ok = !value.starts_with(['.', '-'])
&& !value.ends_with(['.', '-'])
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'));
if !ok {
return Err(garde::Error::new(
"must be a bare hostname such as example.cloudflareaccess.com, without a scheme, port or path",
));
}
Ok(())
}
const TEAM_DOMAIN_RULE: &str = "must be an https:// origin or a bare hostname such as \
example.cloudflareaccess.com, without a path or query; http:// is allowed only for \
127.0.0.1 and localhost";
fn team_domain_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let rule = || garde::Error::new(TEAM_DOMAIN_RULE);
let (scheme, rest) = match value.split_once("://") {
Some((scheme, rest)) => (scheme, rest),
None => ("https", value),
};
let authority = rest.strip_suffix('/').unwrap_or(rest);
if authority.contains(['/', '?', '#', '@']) {
return Err(rule());
}
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
};
bare_host_value(host, ctx).map_err(|_| rule())?;
if host.is_empty() {
return Err(rule());
}
if let Some(port) = port {
if !port.parse::<u16>().is_ok_and(|p| p > 0) {
return Err(rule());
}
}
match scheme {
"https" => Ok(()),
"http" if matches!(host, "127.0.0.1" | "localhost") => Ok(()),
_ => Err(rule()),
}
}
fn required_email_value(value: &str, ctx: &()) -> garde::Result {
if value.trim().is_empty() {
return Err(garde::Error::new("must not be blank"));
}
email_value(value, ctx)
}
fn required_plain_value(value: &str, ctx: &()) -> garde::Result {
if value.trim().is_empty() {
return Err(garde::Error::new("must not be blank"));
}
plain_value(value, ctx)
}
fn authority_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let (host, port) = match value.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (value, None),
};
host_value(host, ctx)?;
match port {
None => Ok(()),
Some(port) if port.parse::<u16>().is_ok_and(|p| p > 0) => Ok(()),
Some(_) => Err(garde::Error::new(
"port must be a number between 1 and 65535",
)),
}
}
fn email_value(value: &str, _: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let Some((local, domain)) = value.split_once('@') else {
return Err(garde::Error::new("must be an email address"));
};
if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
return Err(garde::Error::new("must be an email address"));
}
Ok(())
}
fn https_url_value(value: &str, _: &()) -> garde::Result {
if value.starts_with("https://") && value.len() > "https://".len() {
return Ok(());
}
Err(garde::Error::new("must be an https:// URL"))
}
fn service_name_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Err(garde::Error::new("must name a systemd unit"));
}
plain_value(value, ctx)
}
fn vapid_contact_value(value: &str, _: &()) -> garde::Result {
let mailto = value
.strip_prefix("mailto:")
.is_some_and(|rest| rest.contains('@'));
if mailto || value.starts_with("https://") {
return Ok(());
}
Err(garde::Error::new(
"must be a mailto: address or an https:// URL",
))
}
pub fn schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes")
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = "config.json";
fn parse_str(raw: &str) -> Result<Config, LoadError> {
parse(Path::new(SOURCE), raw)
}
fn message(raw: &str) -> String {
parse_str(raw)
.expect_err("expected a load error")
.to_string()
}
#[test]
fn schema_file_matches_the_types() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(SCHEMA_PATH);
let generated = format!("{}\n", serde_json::to_string_pretty(&schema()).unwrap());
if std::env::var_os("MOBUX_WRITE_SCHEMA").is_some() {
std::fs::write(&path, &generated).expect("writing the schema");
return;
}
let committed = std::fs::read_to_string(&path).expect("docs/mobux.schema.json exists");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&committed).expect("valid JSON"),
schema(),
"docs/mobux.schema.json is stale — rerun this test with MOBUX_WRITE_SCHEMA=1"
);
}
fn schema_node(schema: &serde_json::Value, key: &str) -> serde_json::Value {
let (section, leaf) = key.split_once('.').expect("dotted key");
let reference = schema["properties"][section]["$ref"]
.as_str()
.unwrap_or_else(|| panic!("no section `{section}` in the schema"))
.rsplit('/')
.next()
.expect("a $defs pointer")
.to_string();
schema["$defs"][reference]["properties"][leaf].clone()
}
#[test]
fn schema_marks_every_field_optional_and_rejects_unknown_keys() {
let schema = schema();
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert!(schema.get("required").is_none());
assert_eq!(
schema["$defs"]["ServerConfig"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
schema_node(&schema, "server.port")["default"],
serde_json::json!(8080)
);
}
#[test]
fn an_empty_document_is_the_default_config() {
assert_eq!(parse_str("{}").unwrap(), Config::default());
}
#[test]
fn defaults_mirror_the_values_the_server_uses_today() {
let config = Config::default();
assert_eq!(config.server.port, 8080);
assert!(!config.tls.enabled);
assert_eq!(config.tls.acme_http_port, 80);
assert_eq!(config.app.service_name, "mobux");
assert_eq!(config.push.vapid_contact, "mailto:admin@example.com");
assert!(!config.app.dev);
assert_eq!(config.server.base_path, "");
assert!(!config.server.behind_tls_proxy);
}
#[test]
fn a_base_path_is_absolute_or_empty() {
assert_eq!(
parse_str(r#"{"server": {"base_path": "/mobux"}}"#)
.unwrap()
.server
.base_path,
"/mobux"
);
assert!(
message(r#"{"server": {"base_path": "mobux"}}"#).contains("must start with `/`"),
"a relative prefix is not a mount point"
);
assert!(
message(r#"{"server": {"base_path": "/a/../b"}}"#).contains("`..`"),
"a traversal is not a mount point"
);
assert!(
message(r#"{"server": {"base_path": "/a;Secure"}}"#).contains("`;`"),
"a semicolon would split the Set-Cookie header"
);
}
#[test]
fn a_stated_leaf_wins_and_the_rest_keeps_its_default() {
let config = parse_str(r#"{"server": {"port": 5151}}"#).unwrap();
assert_eq!(config.server.port, 5151);
assert!(!config.tls.enabled);
assert_eq!(config.update.check_url, Config::default().update.check_url);
}
#[test]
fn every_section_round_trips_through_the_partial_mirror() {
let raw = serde_json::to_string(&Config::default()).unwrap();
assert_eq!(parse_str(&raw).unwrap(), Config::default());
}
#[test]
fn malformed_json_names_the_position() {
let error = message(r#"{"server": }"#);
assert!(error.starts_with("config.json: not valid JSON:"), "{error}");
assert!(error.contains("line 1"), "{error}");
}
#[test]
fn an_unknown_key_suggests_the_nearest_spelling() {
let error = message(r#"{"server": {"prot": 8080}}"#);
assert!(error.contains("at `server.prot`"), "{error}");
assert!(error.contains("did you mean `port`?"), "{error}");
}
#[test]
fn an_unknown_section_suggests_the_nearest_section() {
let error = message(r#"{"serve": {}}"#);
assert!(error.contains("did you mean `server`?"), "{error}");
}
#[test]
fn an_unrecognisable_key_is_reported_without_a_guess() {
let error = message(r#"{"telemetry": {}}"#);
assert!(error.contains("unknown field `telemetry`"), "{error}");
assert!(!error.contains("did you mean"), "{error}");
}
#[test]
fn a_wrong_type_names_the_key_and_the_expected_type() {
let error = message(r#"{"server": {"port": "8080"}}"#);
assert!(error.contains("at `server.port`"), "{error}");
assert!(error.contains("invalid type: string"), "{error}");
}
#[test]
fn a_number_outside_the_port_range_is_rejected_by_serde() {
let error = message(r#"{"server": {"port": 70000}}"#);
assert!(error.contains("at `server.port`"), "{error}");
}
#[test]
fn port_zero_is_rejected_by_the_range_rule() {
let error = message(r#"{"server": {"port": 0}}"#);
assert!(error.contains("server.port"), "{error}");
}
#[test]
fn a_short_pin_is_rejected() {
let error = message(r#"{"auth": {"pin": "12"}}"#);
assert!(error.contains("auth.pin"), "{error}");
assert!(error.contains("between 4 and 64"), "{error}");
}
#[test]
fn an_empty_pin_leaves_the_pin_unset() {
assert_eq!(parse_str(r#"{"auth": {"pin": ""}}"#).unwrap().auth.pin, "");
}
#[test]
fn a_credential_systemd_would_mangle_is_rejected() {
let error = message(r#"{"auth": {"pass": "hunter 2"}}"#);
assert!(error.contains("auth.pass"), "{error}");
}
#[test]
fn a_blank_tls_host_is_rejected() {
let error = message(r#"{"tls": {"hosts": ["ok.example", " "]}}"#);
assert!(error.contains("tls.hosts[1]"), "{error}");
}
#[test]
fn acme_domains_without_an_email_are_rejected() {
let error = message(r#"{"tls": {"acme_domains": ["mobux.example"]}}"#);
assert!(error.contains("tls.acme_email"), "{error}");
assert!(error.contains("required"), "{error}");
}
#[test]
fn acme_domains_with_an_email_are_accepted() {
let config = parse_str(
r#"{"tls": {"acme_domains": ["a.example"], "acme_email": "me@example.com"}}"#,
)
.unwrap();
assert_eq!(config.tls.acme_domains, vec!["a.example".to_string()]);
}
#[test]
fn a_plain_http_acme_directory_is_rejected() {
let error = message(r#"{"tls": {"acme_directory": "http://acme.example/dir"}}"#);
assert!(error.contains("tls.acme_directory"), "{error}");
assert!(error.contains("https://"), "{error}");
}
#[test]
fn a_domain_may_carry_a_port() {
let config = parse_str(r#"{"app": {"domain": "mobux.example:5151"}}"#).unwrap();
assert_eq!(config.app.domain, "mobux.example:5151");
}
#[test]
fn a_domain_with_a_junk_port_is_rejected() {
let error = message(r#"{"app": {"domain": "mobux.example:https"}}"#);
assert!(error.contains("app.domain"), "{error}");
}
#[test]
fn an_empty_service_name_is_rejected() {
let error = message(r#"{"app": {"service_name": ""}}"#);
assert!(error.contains("app.service_name"), "{error}");
}
#[test]
fn a_vapid_contact_must_be_mailto_or_https() {
let error = message(r#"{"push": {"vapid_contact": "admin@example.com"}}"#);
assert!(error.contains("push.vapid_contact"), "{error}");
}
#[test]
fn a_missing_file_is_the_default_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
assert_eq!(load_from(&path).unwrap(), Config::default());
}
#[test]
fn a_file_on_disk_is_read_and_merged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, r#"{"session": {"shell": "/bin/zsh"}}"#).unwrap();
assert_eq!(load_from(&path).unwrap().session.shell, "/bin/zsh");
}
#[test]
fn a_missing_file_states_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
assert_eq!(load_partial_from(&path).unwrap(), None);
}
#[test]
fn a_file_that_cannot_be_read_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let error = load_partial_from(dir.path()).unwrap_err();
assert!(matches!(error, LoadError::Read { .. }), "{error}");
}
#[test]
fn startup_reads_the_file_and_lets_the_environment_override_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(
&path,
r#"{"server": {"port": 5151}, "session": {"shell": "/bin/zsh"}}"#,
)
.unwrap();
let file = load_partial_from(&path).unwrap().expect("a file on disk");
let resolved = resolve(
Config::default(),
file,
&env(&[("MOBUX_PORT", "5152")]),
PartialConfig::default(),
);
assert_eq!(resolved.server.port, 5152);
assert_eq!(resolved.session.shell, "/bin/zsh");
assert!(!resolved.tls.enabled);
}
#[test]
fn config_dir_prefers_the_explicit_override() {
let env = DirEnv {
mobux_config_dir: Some("/srv/mobux".to_string()),
xdg_config_home: Some("/home/me/.xdg".to_string()),
home: Some("/home/me".to_string()),
};
assert_eq!(resolve_config_dir(&env), PathBuf::from("/srv/mobux"));
}
#[test]
fn config_dir_falls_back_to_xdg_config_home() {
let env = DirEnv {
mobux_config_dir: None,
xdg_config_home: Some("/home/me/.xdg".to_string()),
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.xdg/mobux")
);
}
#[test]
fn config_dir_falls_back_to_home() {
let env = DirEnv {
mobux_config_dir: None,
xdg_config_home: None,
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.config/mobux")
);
}
#[test]
fn an_empty_override_is_ignored() {
let env = DirEnv {
mobux_config_dir: Some(" ".to_string()),
xdg_config_home: Some(String::new()),
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.config/mobux")
);
}
#[test]
fn config_dir_without_a_home_stays_relative() {
assert_eq!(
resolve_config_dir(&DirEnv::default()),
PathBuf::from("./.config/mobux")
);
}
#[test]
fn every_field_spec_names_a_key_in_the_schema() {
let schema = schema();
for field in FIELDS {
let node = schema_node(&schema, field.key);
assert!(!node.is_null(), "{} is missing from the schema", field.key);
let description = node["description"].as_str().unwrap_or_default();
assert!(
description.contains(field.env),
"{} does not document {}",
field.key,
field.env
);
}
}
fn env(pairs: &[(&str, &str)]) -> EnvSnapshot {
EnvSnapshot::new(pairs.iter().copied())
}
fn flags(port: Option<u16>, user: Option<&str>, pin: Option<&str>) -> PartialConfig {
flags_partial(port, user.map(str::to_string), pin.map(str::to_string))
}
fn file(raw: &str) -> PartialConfig {
parse_partial(Path::new(SOURCE), raw).expect("a valid partial document")
}
fn resolved(
file_layer: PartialConfig,
env_layer: &EnvSnapshot,
flag_layer: PartialConfig,
) -> Config {
resolve(Config::default(), file_layer, env_layer, flag_layer)
}
fn credentials(env_layer: &EnvSnapshot, flag_layer: PartialConfig) -> Option<Credentials> {
resolved(PartialConfig::default(), env_layer, flag_layer).credentials()
}
fn creds(user: &str, pass: &str) -> Option<Credentials> {
Some(Credentials {
user: user.to_string(),
pass: pass.to_string(),
})
}
#[test]
fn nothing_stated_anywhere_is_the_default_config() {
assert_eq!(
resolved(
PartialConfig::default(),
&env(&[]),
PartialConfig::default()
),
Config::default()
);
}
#[test]
fn tls_is_off_until_a_layer_asks_for_it() {
assert!(
!resolved(
PartialConfig::default(),
&env(&[]),
PartialConfig::default()
)
.tls
.enabled
);
assert!(
resolved(
file(r#"{"tls": {"enabled": true}}"#),
&env(&[]),
PartialConfig::default()
)
.tls
.enabled
);
assert!(
resolved(
PartialConfig::default(),
&env(&[("MOBUX_TLS", "1")]),
PartialConfig::default()
)
.tls
.enabled
);
let flag_layer = PartialConfig {
tls: Some(PartialTlsConfig {
enabled: Some(true),
..PartialTlsConfig::default()
}),
..PartialConfig::default()
};
assert!(
resolved(PartialConfig::default(), &env(&[]), flag_layer)
.tls
.enabled
);
}
#[test]
fn a_flag_beats_the_env_which_beats_the_file_which_beats_the_default() {
let file_layer = file(r#"{"server": {"port": 5150}}"#);
let env_layer = env(&[("MOBUX_PORT", "5152")]);
assert_eq!(
resolved(
file_layer.clone(),
&env_layer,
flags(Some(5151), None, None)
)
.server
.port,
5151
);
assert_eq!(
resolved(file_layer.clone(), &env_layer, PartialConfig::default())
.server
.port,
5152
);
assert_eq!(
resolved(file_layer, &env(&[]), PartialConfig::default())
.server
.port,
5150
);
assert_eq!(
resolved(
PartialConfig::default(),
&env(&[]),
PartialConfig::default()
)
.server
.port,
8080
);
}
#[test]
fn the_user_and_pin_flags_beat_the_env_which_beats_the_file() {
let file_layer = file(r#"{"auth": {"user": "filed", "pin": "1111"}}"#);
let env_layer = env(&[("MOBUX_AUTH_USER", "enved"), ("MOBUX_PIN", "2222")]);
let flagged = resolved(
file_layer.clone(),
&env_layer,
flags(None, Some("flagged"), Some("3333")),
);
assert_eq!(flagged.auth.user, "flagged");
assert_eq!(flagged.auth.pin, "3333");
let enved = resolved(file_layer.clone(), &env_layer, PartialConfig::default());
assert_eq!(enved.auth.user, "enved");
assert_eq!(enved.auth.pin, "2222");
let filed = resolved(file_layer, &env(&[]), PartialConfig::default());
assert_eq!(filed.auth.user, "filed");
assert_eq!(filed.auth.pin, "1111");
}
#[test]
fn every_flagless_field_takes_the_env_over_the_file() {
let file_layer = file(
r#"{
"server": {"base_path": "/filed", "behind_tls_proxy": false},
"auth": {"pass": "filed"},
"tls": {
"enabled": true,
"hosts": ["filed.example"],
"cert_file": "/filed/cert.pem",
"key_file": "/filed/key.pem",
"acme_domains": ["filed.example"],
"acme_email": "filed@example.com",
"acme_directory": "https://filed.example/dir",
"acme_http_port": 8081
},
"paths": {"data_dir": "/filed/data"},
"session": {"shell": "/filed/sh"},
"app": {"domain": "filed.example", "dev": false, "service_name": "filed"},
"push": {"vapid_contact": "mailto:filed@example.com"},
"update": {"check_url": "https://filed.example/index"}
}"#,
);
let env_layer = env(&[
("MOBUX_BASE_PATH", "/enved"),
("MOBUX_BEHIND_TLS_PROXY", "1"),
("MOBUX_AUTH_PASS", "enved"),
("MOBUX_TLS", "0"),
("MOBUX_TLS_HOSTS", "a.example, b.example"),
("MOBUX_CERT_FILE", "/enved/cert.pem"),
("MOBUX_KEY_FILE", "/enved/key.pem"),
("MOBUX_ACME_DOMAINS", "acme.example"),
("MOBUX_ACME_EMAIL", "enved@example.com"),
("MOBUX_ACME_DIRECTORY", "https://enved.example/dir"),
("MOBUX_ACME_HTTP_PORT", "8082"),
("MOBUX_DATA_DIR", "/enved/data"),
("MOBUX_SESSION_SHELL", "/enved/sh"),
("MOBUX_DOMAIN", "enved.example"),
("MOBUX_DEV", "1"),
("MOBUX_SERVICE_NAME", "enved"),
("MOBUX_VAPID_CONTACT", "mailto:enved@example.com"),
("MOBUX_UPDATE_CHECK_URL", "https://enved.example/index"),
]);
let config = resolved(file_layer.clone(), &env_layer, PartialConfig::default());
assert_eq!(config.server.base_path, "/enved");
assert!(config.server.behind_tls_proxy);
assert_eq!(config.auth.pass, "enved");
assert!(!config.tls.enabled);
assert_eq!(config.tls.hosts, ["a.example", "b.example"]);
assert_eq!(config.tls.cert_file, "/enved/cert.pem");
assert_eq!(config.tls.key_file, "/enved/key.pem");
assert_eq!(config.tls.acme_domains, ["acme.example"]);
assert_eq!(config.tls.acme_email, "enved@example.com");
assert_eq!(config.tls.acme_directory, "https://enved.example/dir");
assert_eq!(config.tls.acme_http_port, 8082);
assert_eq!(config.paths.data_dir, "/enved/data");
assert_eq!(config.session.shell, "/enved/sh");
assert_eq!(config.app.domain, "enved.example");
assert!(config.app.dev);
assert_eq!(config.app.service_name, "enved");
assert_eq!(config.push.vapid_contact, "mailto:enved@example.com");
assert_eq!(config.update.check_url, "https://enved.example/index");
let filed = resolved(file_layer, &env(&[]), PartialConfig::default());
assert_eq!(filed.server.base_path, "/filed");
assert!(!filed.server.behind_tls_proxy);
assert_eq!(filed.auth.pass, "filed");
assert!(filed.tls.enabled);
assert_eq!(filed.tls.acme_http_port, 8081);
assert_eq!(filed.app.service_name, "filed");
}
#[test]
fn a_blank_env_var_states_nothing() {
let file_layer = file(r#"{"session": {"shell": "/filed/sh"}}"#);
let env_layer = env(&[("MOBUX_SESSION_SHELL", " "), ("MOBUX_PORT", "")]);
let config = resolved(file_layer, &env_layer, PartialConfig::default());
assert_eq!(config.session.shell, "/filed/sh");
assert_eq!(config.server.port, 8080);
}
#[test]
fn an_unknown_variable_is_not_kept_in_the_snapshot() {
assert_eq!(env(&[("PATH", "/usr/bin")]), env(&[]));
assert_eq!(env(&[("PORT", "5153")]).get("PORT"), Some("5153"));
}
#[test]
fn port_falls_back_to_the_default() {
assert_eq!(
resolved(
PartialConfig::default(),
&env(&[]),
PartialConfig::default()
)
.server
.port,
8080
);
}
#[test]
fn port_flag_beats_both_env_vars() {
let env_layer = env(&[("MOBUX_PORT", "5152"), ("PORT", "5153")]);
let flag_layer = flags(Some(5151), None, None);
assert_eq!(
resolved(PartialConfig::default(), &env_layer, flag_layer.clone())
.server
.port,
5151
);
assert_eq!(port_deprecation(&env_layer, &flag_layer), None);
}
#[test]
fn mobux_port_beats_the_deprecated_port() {
let env_layer = env(&[("MOBUX_PORT", "5152"), ("PORT", "5153")]);
assert_eq!(
resolved(
PartialConfig::default(),
&env_layer,
PartialConfig::default()
)
.server
.port,
5152
);
assert_eq!(
port_deprecation(&env_layer, &PartialConfig::default()),
None
);
}
#[test]
fn deprecated_port_alone_still_works_and_is_flagged() {
let env_layer = env(&[("PORT", "5153")]);
assert_eq!(
resolved(
PartialConfig::default(),
&env_layer,
PartialConfig::default()
)
.server
.port,
5153
);
assert_eq!(
port_deprecation(&env_layer, &PartialConfig::default()),
Some(PORT_DEPRECATION)
);
}
#[test]
fn unparseable_port_values_fall_through() {
assert_eq!(
resolved(
PartialConfig::default(),
&env(&[("MOBUX_PORT", "http"), ("PORT", "5153")]),
PartialConfig::default()
)
.server
.port,
5153
);
assert_eq!(
resolved(
PartialConfig::default(),
&env(&[("MOBUX_PORT", "http")]),
PartialConfig::default()
)
.server
.port,
8080
);
assert_eq!(
resolved(
file(r#"{"server": {"port": 5150}}"#),
&env(&[("MOBUX_PORT", "http"), ("PORT", "nope")]),
PartialConfig::default()
)
.server
.port,
5150
);
assert_eq!(
port_deprecation(&env(&[("PORT", "5153")]), &flags(Some(5151), None, None)),
None
);
}
#[test]
fn an_unparseable_acme_http_port_falls_through() {
assert_eq!(
resolved(
file(r#"{"tls": {"acme_http_port": 8081}}"#),
&env(&[("MOBUX_ACME_HTTP_PORT", "http")]),
PartialConfig::default()
)
.tls
.acme_http_port,
8081
);
}
#[test]
fn credentials_are_absent_without_a_pin_or_password() {
assert_eq!(
credentials(&env(&[("MOBUX_AUTH_USER", "me")]), PartialConfig::default()),
None
);
assert_eq!(credentials(&env(&[]), PartialConfig::default()), None);
}
#[test]
fn env_pin_still_works_on_its_own() {
assert_eq!(
credentials(&env(&[("MOBUX_PIN", "12345")]), PartialConfig::default()),
creds("mobux", "12345")
);
assert_eq!(
credentials(
&env(&[("MOBUX_AUTH_USER", "me"), ("MOBUX_PIN", "12345")]),
PartialConfig::default()
),
creds("me", "12345")
);
}
#[test]
fn env_user_and_password_still_work() {
assert_eq!(
credentials(
&env(&[("MOBUX_AUTH_USER", "me"), ("MOBUX_AUTH_PASS", "secret")]),
PartialConfig::default()
),
creds("me", "secret")
);
}
#[test]
fn a_user_and_password_beat_a_pin() {
assert_eq!(
credentials(
&env(&[
("MOBUX_AUTH_USER", "me"),
("MOBUX_AUTH_PASS", "secret"),
("MOBUX_PIN", "12345"),
]),
PartialConfig::default()
),
creds("me", "secret")
);
}
#[test]
fn pin_and_user_flags_beat_their_env_vars() {
assert_eq!(
credentials(
&env(&[("MOBUX_AUTH_USER", "me"), ("MOBUX_PIN", "12345")]),
flags(None, Some("walker"), Some("99999"))
),
creds("walker", "99999")
);
}
#[test]
fn pin_flag_beats_an_env_password() {
assert_eq!(
credentials(
&env(&[("MOBUX_AUTH_USER", "me"), ("MOBUX_AUTH_PASS", "secret")]),
flags(None, None, Some("99999"))
),
creds("me", "99999")
);
}
#[test]
fn a_pin_flag_also_beats_a_password_from_the_file() {
let config = resolved(
file(r#"{"auth": {"user": "me", "pass": "secret"}}"#),
&env(&[]),
flags(None, None, Some("99999")),
);
assert_eq!(config.credentials(), creds("me", "99999"));
}
#[test]
fn empty_values_are_ignored() {
assert_eq!(
credentials(
&env(&[
("MOBUX_AUTH_USER", "me"),
("MOBUX_AUTH_PASS", ""),
("MOBUX_PIN", "12345"),
]),
flags(None, Some(""), None)
),
creds("me", "12345")
);
}
#[test]
fn a_file_password_still_beats_an_env_pin() {
let config = resolved(
file(r#"{"auth": {"user": "me", "pass": "secret"}}"#),
&env(&[("MOBUX_PIN", "12345")]),
PartialConfig::default(),
);
assert_eq!(config.credentials(), creds("me", "secret"));
}
fn sample_value(kind: FieldKind) -> FieldValue {
match kind {
FieldKind::Number => FieldValue::Number(8443),
FieldKind::Text => FieldValue::Text("sample".to_string()),
FieldKind::Toggle => FieldValue::Toggle(false),
FieldKind::List => FieldValue::List(vec!["sample.example".to_string()]),
}
}
#[test]
fn every_field_kind_matches_the_type_in_the_schema() {
let schema = schema();
for field in FIELDS {
let node = schema_node(&schema, field.key);
let expected = match field.kind {
FieldKind::Number => "integer",
FieldKind::Text => "string",
FieldKind::Toggle => "boolean",
FieldKind::List => "array",
};
assert_eq!(
node["type"],
serde_json::json!(expected),
"{} is a {:?} in the table",
field.key,
field.kind
);
}
}
#[test]
fn every_field_key_reaches_its_leaf_of_the_partial() {
for field in FIELDS {
let partial = partial_from_fields(&[(field.key, sample_value(field.kind))]);
assert_ne!(
partial,
PartialConfig::default(),
"{} set nothing",
field.key
);
}
}
#[test]
fn a_repeated_list_field_accumulates() {
let partial = partial_from_fields(&[
("tls.hosts", FieldValue::List(vec!["a.example".to_string()])),
("tls.hosts", FieldValue::List(vec!["b.example".to_string()])),
]);
assert_eq!(
partial.tls.and_then(|tls| tls.hosts),
Some(vec!["a.example".to_string(), "b.example".to_string()])
);
}
const ACCESS_BLOCK: &str = r#"{"access": {
"port": 5153,
"team_domain": "example.cloudflareaccess.com",
"aud": "aud-tag-sample",
"hostname": "mobux.example.com",
"allowed_emails": ["user@example.com"],
"service_tokens": ["client-id-sample.access"]
}}"#;
fn access_message(fields: &str) -> String {
message(&format!(r#"{{"access": {{{fields}}}}}"#))
}
const VALID_ACCESS: &str = r#""port": 5153, "team_domain": "example.cloudflareaccess.com", "aud": "aud-tag-sample", "allowed_emails": ["user@example.com"]"#;
#[test]
fn an_absent_access_block_leaves_the_listener_off() {
let config = parse_str("{}").unwrap();
assert_eq!(config.access, AccessConfig::default());
assert!(!config.access.is_configured());
assert_eq!(check_access(&config), Ok(()));
}
#[test]
fn a_complete_access_block_loads_from_the_file() {
let access = parse_str(ACCESS_BLOCK).unwrap().access;
assert!(access.is_configured());
assert_eq!(access.port, 5153);
assert_eq!(access.team_domain, "example.cloudflareaccess.com");
assert_eq!(access.aud, "aud-tag-sample");
assert_eq!(access.hostname, "mobux.example.com");
assert_eq!(access.allowed_emails, ["user@example.com"]);
assert_eq!(access.service_tokens, ["client-id-sample.access"]);
}
#[test]
fn a_complete_access_block_loads_from_the_environment() {
let config = resolved(
PartialConfig::default(),
&env(&[
("MOBUX_ACCESS_PORT", "5153"),
("MOBUX_ACCESS_TEAM_DOMAIN", "example.cloudflareaccess.com"),
("MOBUX_ACCESS_AUD", "aud-tag-sample"),
("MOBUX_ACCESS_HOSTNAME", "mobux.example.com"),
(
"MOBUX_ACCESS_ALLOWED_EMAILS",
"a@example.com, b@example.com",
),
("MOBUX_ACCESS_SERVICE_TOKENS", "client-id-sample.access"),
]),
PartialConfig::default(),
);
assert_eq!(check(&config), Ok(()));
assert_eq!(config.access.port, 5153);
assert_eq!(config.access.team_domain, "example.cloudflareaccess.com");
assert_eq!(config.access.aud, "aud-tag-sample");
assert_eq!(config.access.hostname, "mobux.example.com");
assert_eq!(
config.access.allowed_emails,
["a@example.com", "b@example.com"]
);
assert_eq!(config.access.service_tokens, ["client-id-sample.access"]);
}
#[test]
fn the_environment_overrides_the_access_block_in_the_file() {
let config = resolved(
file(ACCESS_BLOCK),
&env(&[
("MOBUX_ACCESS_PORT", "5154"),
("MOBUX_ACCESS_AUD", "aud-tag-enved"),
("MOBUX_ACCESS_ALLOWED_EMAILS", "enved@example.com"),
]),
PartialConfig::default(),
);
assert_eq!(check(&config), Ok(()));
assert_eq!(config.access.port, 5154);
assert_eq!(config.access.aud, "aud-tag-enved");
assert_eq!(config.access.allowed_emails, ["enved@example.com"]);
assert_eq!(config.access.team_domain, "example.cloudflareaccess.com");
assert_eq!(config.access.service_tokens, ["client-id-sample.access"]);
}
#[test]
fn a_service_token_alone_is_an_allowlist() {
let access = parse_str(
r#"{"access": {"port": 5153, "team_domain": "example.cloudflareaccess.com", "aud": "aud-tag-sample", "service_tokens": ["client-id-sample.access"]}}"#,
)
.unwrap()
.access;
assert!(access.allowed_emails.is_empty());
}
#[test]
fn an_access_block_without_an_aud_is_rejected() {
let error = access_message(
r#""port": 5153, "team_domain": "example.cloudflareaccess.com", "allowed_emails": ["user@example.com"]"#,
);
assert_eq!(
error,
"config.json: access.aud: required when the access block is set"
);
}
#[test]
fn an_access_block_without_an_allowlist_is_rejected() {
let error = access_message(
r#""port": 5153, "team_domain": "example.cloudflareaccess.com", "aud": "aud-tag-sample""#,
);
assert_eq!(
error,
"config.json: access.allowed_emails: list at least one email, or a client id in \
access.service_tokens; an empty allowlist admits no one"
);
}
#[test]
fn an_access_block_without_a_port_is_rejected() {
let error = access_message(
r#""team_domain": "example.cloudflareaccess.com", "aud": "aud-tag-sample", "allowed_emails": ["user@example.com"]"#,
);
assert_eq!(
error,
"config.json: access.port: required when the access block is set"
);
}
#[test]
fn an_access_port_equal_to_the_main_port_is_rejected() {
let error = message(&format!(
r#"{{"server": {{"port": 5153}}, "access": {{{VALID_ACCESS}}}}}"#
));
assert_eq!(
error,
"config.json: access.port: must differ from server.port (5153)"
);
}
#[test]
fn an_access_block_without_a_team_domain_is_rejected() {
let error = access_message(
r#""port": 5153, "aud": "aud-tag-sample", "allowed_emails": ["user@example.com"]"#,
);
assert_eq!(
error,
"config.json: access.team_domain: required when the access block is set"
);
}
fn with_team_domain(team_domain: &str) -> Result<Config, LoadError> {
parse_str(&format!(
r#"{{"access": {{"port": 5153, "team_domain": "{team_domain}", "aud": "aud-tag-sample", "allowed_emails": ["user@example.com"]}}}}"#
))
}
#[test]
fn a_bare_team_domain_means_https() {
let access = with_team_domain("example.cloudflareaccess.com")
.unwrap()
.access;
assert_eq!(access.issuer(), "https://example.cloudflareaccess.com");
assert_eq!(
access.jwks_url(),
"https://example.cloudflareaccess.com/cdn-cgi/access/certs"
);
}
#[test]
fn a_mixed_case_team_domain_yields_a_lower_case_issuer() {
let access = with_team_domain("https://Example.CloudflareAccess.com")
.unwrap()
.access;
assert_eq!(access.issuer(), "https://example.cloudflareaccess.com");
}
#[test]
fn an_https_team_origin_is_accepted() {
let access = with_team_domain("https://example.cloudflareaccess.com/")
.unwrap()
.access;
assert_eq!(access.issuer(), "https://example.cloudflareaccess.com");
assert_eq!(
access.jwks_url(),
"https://example.cloudflareaccess.com/cdn-cgi/access/certs"
);
}
#[test]
fn a_plain_http_team_origin_is_accepted_on_loopback() {
for (team_domain, issuer) in [
("http://127.0.0.1:9123", "http://127.0.0.1:9123"),
("http://localhost:9123", "http://localhost:9123"),
] {
let access = with_team_domain(team_domain).unwrap().access;
assert_eq!(access.issuer(), issuer);
assert_eq!(access.jwks_url(), format!("{issuer}/cdn-cgi/access/certs"));
}
}
#[test]
fn a_team_domain_with_a_path_query_or_plain_http_is_rejected() {
for team_domain in [
"http://example.cloudflareaccess.com",
"ftp://example.cloudflareaccess.com",
"https://example.cloudflareaccess.com/cdn-cgi",
"example.cloudflareaccess.com/cdn-cgi",
"https://example.cloudflareaccess.com?team=x",
"https://example.cloudflareaccess.com:https",
"https://",
] {
let error = with_team_domain(team_domain)
.expect_err(team_domain)
.to_string();
assert!(error.contains("access.team_domain"), "{error}");
assert!(error.contains("must be an https:// origin"), "{error}");
}
}
#[test]
fn a_bad_access_value_from_the_environment_fails_the_final_config() {
let config = resolved(
PartialConfig::default(),
&env(&[
("MOBUX_ACCESS_PORT", "5153"),
("MOBUX_ACCESS_TEAM_DOMAIN", "https://evil.example/x"),
("MOBUX_ACCESS_AUD", "aud-tag-sample"),
("MOBUX_ACCESS_ALLOWED_EMAILS", "nobody"),
]),
PartialConfig::default(),
);
let error = check_access(&config).expect_err("a bad env value is rejected");
assert!(error.contains("access.team_domain"), "{error}");
assert!(error.contains("access.allowed_emails[0]"), "{error}");
assert!(error.contains("must be an email address"), "{error}");
}
#[test]
fn a_hostname_with_a_scheme_or_path_is_rejected() {
for hostname in ["https://mobux.example.com", "mobux.example.com/app"] {
let error = access_message(&format!(r#"{VALID_ACCESS}, "hostname": "{hostname}""#));
assert!(error.contains("access.hostname"), "{error}");
assert!(error.contains("must be a bare hostname"), "{error}");
}
}
#[test]
fn a_malformed_allowed_email_is_rejected() {
let error = access_message(
r#""port": 5153, "team_domain": "example.cloudflareaccess.com", "aud": "aud-tag-sample", "allowed_emails": ["user@example.com", "nobody"]"#,
);
assert!(error.contains("access.allowed_emails[1]"), "{error}");
assert!(error.contains("must be an email address"), "{error}");
}
#[test]
fn a_stray_access_field_alone_fails_closed() {
let error = access_message(r#""hostname": "mobux.example.com""#);
assert!(error.contains("access.port"), "{error}");
}
#[test]
fn an_access_block_from_the_environment_fails_the_same_rules() {
let config = resolved(
PartialConfig::default(),
&env(&[("MOBUX_ACCESS_PORT", "5153")]),
PartialConfig::default(),
);
assert_eq!(
check_access(&config),
Err("access.team_domain: required when the access block is set".to_string())
);
}
#[test]
fn file_roots_read_from_the_file_and_the_environment() {
let config =
parse_str(r#"{"files": {"roots": {"site": "/srv/site"}, "listing": true}}"#).unwrap();
assert_eq!(config.files.roots["site"], "/srv/site");
assert!(config.files.listing);
let config = resolved(
PartialConfig::default(),
&env(&[("MOBUX_FILES", "site=/srv/site, docs=/srv/docs")]),
PartialConfig::default(),
);
assert_eq!(config.files.roots.len(), 2);
assert_eq!(config.files.roots["docs"], "/srv/docs");
assert!(!config.files.listing);
}
#[test]
fn a_file_root_needs_a_segment_name_and_an_absolute_path() {
assert!(message(r#"{"files": {"roots": {"site": "srv"}}}"#).contains("absolute"));
assert!(message(r#"{"files": {"roots": {"a/b": "/srv"}}}"#).contains("letters"));
assert!(message(r#"{"files": {"roots": {"..": "/srv"}}}"#).contains("letters"));
}
#[test]
fn proxy_targets_read_from_the_file_and_the_environment() {
let config = parse_str(r#"{"proxy": {"targets": {"vite": 5173}}}"#).unwrap();
assert_eq!(config.proxy.targets["vite"], 5173);
let config = resolved(
PartialConfig::default(),
&env(&[("MOBUX_PROXY", "vite=5173, docs=8000,bad=x")]),
PartialConfig::default(),
);
assert_eq!(config.proxy.targets.len(), 3);
assert_eq!(config.proxy.targets["docs"], 8000);
assert_eq!(config.proxy.targets["bad"], 0);
}
#[test]
fn a_proxy_target_needs_a_segment_name_and_a_port() {
assert!(message(r#"{"proxy": {"targets": {"vite": 0}}}"#).contains("port"));
assert!(message(r#"{"proxy": {"targets": {"a/b": 80}}}"#).contains("letters"));
assert!(message(r#"{"proxy": {"targets": {"vite": 70000}}}"#).contains("proxy"));
}
#[test]
fn the_mcp_server_is_off_by_default_and_env_turns_it_on() {
assert_eq!(Config::default().mcp.port, 0);
let config = resolve(
Config::default(),
PartialConfig::default(),
&env(&[("MOBUX_MCP_PORT", "8415")]),
PartialConfig::default(),
);
assert_eq!(config.mcp.port, 8415);
}
#[test]
fn an_mcp_port_equal_to_the_main_port_is_rejected() {
assert_eq!(
message(r#"{"server": {"port": 5151}, "mcp": {"port": 5151}}"#),
"config.json: mcp.port: must differ from server.port (5151)"
);
}
#[test]
fn the_env_namespaces_do_not_overlap() {
for field in FIELDS {
assert!(
!ENV_ONLY.contains(&field.env),
"{} is listed both in the schema and as env-only",
field.env
);
}
}
}