From 62da635682ad476993a08f4f62a9457abd8fa7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 15 May 2026 14:24:57 +0200 Subject: [PATCH 1/3] fix(ws-bridged): apply --topic/--auth-token/--tls/--metrics CLI-Flags to DaemonConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run()` mergte bisher nur `--listen`, `--domain` und `--log-level` aus den geparsten `CliArgs` in die `DaemonConfig`. Die anderen Flags (`--topic`, `--auth-token`, `--tls-cert`, `--tls-key`, `--metrics`) wurden in `cli::parse()` korrekt extrahiert, aber nie auf `cfg` übertragen. Resultat: ein Start wie zerodds-ws-bridged --listen 0.0.0.0:8080 --domain 42 \ --topic Card --topic Thread --auth-token shared-secret loggte `topics=0 auth-mode=none tls=off metrics=off` trotz CLI-Flags. Spec §2 sagt "CLI überschreibt File-Werte" — galt nur partiell. Changes: - apply_cli_overrides(cfg, args) als testbare Funktion extrahiert. - --topic ist additiv (an cfg.topics angehängt) mit default_ws_path-Mapping und type_name == name als Default; direction = "bidir" (Spec-Default §5). - --auth-token impliziert auth_mode = "bearer". - --tls-cert / --tls-key impliziert tls_enabled = true. - --metrics impliziert metrics_enabled = true. - 8 Unit-Tests im bin-Crate decken jede Flag-Familie ab plus empty-args-leaves-config-unchanged als Regression-Wächter. Spec-Bezug: docs/specs/zerodds-ws-bridge-1.0.md §2 (CLI-Surface), §5 (Topic-Mapping), §7.1 (TLS), §7.2 (Auth), §8.2 (Metrics). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/bin/zerodds-ws-bridged.rs | 154 ++++++++++++++++-- 1 file changed, 144 insertions(+), 10 deletions(-) diff --git a/crates/websocket-bridge/src/bin/zerodds-ws-bridged.rs b/crates/websocket-bridge/src/bin/zerodds-ws-bridged.rs index a35ce05b..5df8a300 100644 --- a/crates/websocket-bridge/src/bin/zerodds-ws-bridged.rs +++ b/crates/websocket-bridge/src/bin/zerodds-ws-bridged.rs @@ -30,7 +30,7 @@ use std::path::Path; use std::process::ExitCode; use zerodds_websocket_bridge::daemon::cli::{self, CliArgs, HELP_TEXT, VERSION_TEXT}; -use zerodds_websocket_bridge::daemon::config::DaemonConfig; +use zerodds_websocket_bridge::daemon::config::{self, DaemonConfig, TopicConfig}; use zerodds_websocket_bridge::daemon::server::{self, ServerError}; fn main() -> ExitCode { @@ -76,15 +76,7 @@ fn run(args: CliArgs) -> Result<(), ServerError> { } else { DaemonConfig::default_for_dev() }; - if let Some(l) = args.listen { - cfg.listen = l; - } - if let Some(d) = args.domain { - cfg.domain = d; - } - if let Some(lvl) = args.log_level { - cfg.log_level = lvl; - } + apply_cli_overrides(&mut cfg, args); let handle = server::start(cfg)?; eprintln!("[zerodds-ws-bridged] running on {}", handle.local_addr); @@ -102,3 +94,145 @@ fn run(args: CliArgs) -> Result<(), ServerError> { Ok(()) } } + +/// Wendet CLI-Overrides auf die geladene Config an. Public-in-Crate fuer Tests. +/// +/// Spec §2: CLI ueberschreibt File-Werte. `--topic` ist additiv (kann mehrfach +/// vorkommen), alle anderen Flags sind Replace-Operationen. +fn apply_cli_overrides(cfg: &mut DaemonConfig, args: CliArgs) { + if let Some(l) = args.listen { + cfg.listen = l; + } + if let Some(d) = args.domain { + cfg.domain = d; + } + if let Some(lvl) = args.log_level { + cfg.log_level = lvl; + } + if let Some(token) = args.auth_token { + cfg.auth_mode = "bearer".to_string(); + cfg.auth_bearer_token = Some(token); + } + if let Some(cert) = args.tls_cert { + cfg.tls_cert_file = cert; + cfg.tls_enabled = true; + } + if let Some(key) = args.tls_key { + cfg.tls_key_file = key; + cfg.tls_enabled = true; + } + if let Some(addr) = args.metrics { + cfg.metrics_addr = addr; + cfg.metrics_enabled = true; + } + for name in args.topics { + let topic = TopicConfig { + name: name.clone(), + type_name: name.clone(), + direction: "bidir".to_string(), + ws_path: config::default_ws_path(&name), + ..Default::default() + }; + cfg.topics.push(topic); + } +} + +#[cfg(test)] +mod tests { + use super::apply_cli_overrides; + use zerodds_websocket_bridge::daemon::cli::CliArgs; + use zerodds_websocket_bridge::daemon::config::DaemonConfig; + + fn cfg() -> DaemonConfig { + DaemonConfig::default_for_dev() + } + + fn args() -> CliArgs { + CliArgs::default() + } + + #[test] + fn listen_override_replaces_default() { + let mut c = cfg(); + let mut a = args(); + a.listen = Some("0.0.0.0:9000".into()); + apply_cli_overrides(&mut c, a); + assert_eq!(c.listen, "0.0.0.0:9000"); + } + + #[test] + fn domain_override_replaces_default() { + let mut c = cfg(); + let mut a = args(); + a.domain = Some(42); + apply_cli_overrides(&mut c, a); + assert_eq!(c.domain, 42); + } + + #[test] + fn log_level_override_replaces_default() { + let mut c = cfg(); + let mut a = args(); + a.log_level = Some("debug".into()); + apply_cli_overrides(&mut c, a); + assert_eq!(c.log_level, "debug"); + } + + #[test] + fn auth_token_sets_bearer_mode_and_token() { + let mut c = cfg(); + let mut a = args(); + a.auth_token = Some("secret-32-bytes".into()); + apply_cli_overrides(&mut c, a); + assert_eq!(c.auth_mode, "bearer"); + assert_eq!(c.auth_bearer_token.as_deref(), Some("secret-32-bytes")); + } + + #[test] + fn tls_cert_and_key_enable_tls() { + let mut c = cfg(); + let mut a = args(); + a.tls_cert = Some("/etc/tls/cert.pem".into()); + a.tls_key = Some("/etc/tls/key.pem".into()); + apply_cli_overrides(&mut c, a); + assert!(c.tls_enabled); + assert_eq!(c.tls_cert_file, "/etc/tls/cert.pem"); + assert_eq!(c.tls_key_file, "/etc/tls/key.pem"); + } + + #[test] + fn metrics_addr_enables_metrics() { + let mut c = cfg(); + let mut a = args(); + a.metrics = Some("0.0.0.0:9091".into()); + apply_cli_overrides(&mut c, a); + assert!(c.metrics_enabled); + assert_eq!(c.metrics_addr, "0.0.0.0:9091"); + } + + #[test] + fn topics_cli_appends_to_existing() { + let mut c = cfg(); + let mut a = args(); + a.topics = vec!["Card".into(), "Thread".into()]; + apply_cli_overrides(&mut c, a); + assert_eq!(c.topics.len(), 2); + assert_eq!(c.topics[0].name, "Card"); + assert_eq!(c.topics[0].direction, "bidir"); + assert_eq!(c.topics[0].ws_path, "/topics/card"); + assert_eq!(c.topics[1].name, "Thread"); + } + + #[test] + fn empty_args_leaves_config_unchanged() { + let mut c = cfg(); + let baseline = c.clone(); + apply_cli_overrides(&mut c, args()); + assert_eq!(c.listen, baseline.listen); + assert_eq!(c.domain, baseline.domain); + assert!(!c.tls_enabled); + assert!(!c.metrics_enabled); + assert_eq!(c.auth_mode, "none"); + assert!(c.topics.is_empty()); + } +} From 86898c684101cafc81454261bcf7473b5d8dfb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 15 May 2026 14:25:11 +0200 Subject: [PATCH 2/3] fix(ws-bridged): align ws-bridged.yaml.example with parser schema + loadback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die ausgelieferte packaging/linux/configs/ws-bridged.yaml.example hatte historisch verschachtelte Top-Level-Keys (participant.domain_id, websocket.bind, routes:, observability.metrics_bind). Der echte Parser in daemon/config.rs::load_from_str erwartet aber flache Keys: listen, domain, log_level, tls, auth, acl, metrics, topics. Weil unbekannte Keys mit `_ => {}` stillschweigend ignoriert wurden, fiel die Drift nie auf — die Bridge bootete einfach mit Defaults (127.0.0.1:8080, domain=0, topics=0) trotz user-konfigurierter Werte. Changes: - example-yaml umgeschrieben gegen Parser-Schema + Spec §3. - Spec-§-Ref von §4 (Wire-Protocol) auf §3 (Config-File-Format) korrigiert. - Kommentar dokumentiert auth-Modi, ACL-Pflicht bei non-none-Auth, und WARN-Verhalten bei unbekannten Keys als Drift-Watchpoint. - Loadback-Integration-Test (tests/example_yaml_loadback.rs): include_str!() bindet die ausgelieferte yaml.example zur Compile-Zeit ein und fährt sie durch DaemonConfig::load_from_str. Schlägt künftige Doku-Drift sofort als Test-Fail auf, nicht erst im Feld. Spec-Bezug: docs/specs/zerodds-ws-bridge-1.0.md §3 (Config-File-Format). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/example_yaml_loadback.rs | 75 +++++++++++++++++++ .../linux/configs/ws-bridged.yaml.example | 70 ++++++++++------- 2 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 crates/websocket-bridge/tests/example_yaml_loadback.rs diff --git a/crates/websocket-bridge/tests/example_yaml_loadback.rs b/crates/websocket-bridge/tests/example_yaml_loadback.rs new file mode 100644 index 00000000..be27188d --- /dev/null +++ b/crates/websocket-bridge/tests/example_yaml_loadback.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ZeroDDS Contributors + +//! Loadback-Test fuer `packaging/linux/configs/ws-bridged.yaml.example`. +//! +//! Hintergrund: das ausgelieferte example-yaml hatte historisch ein +//! Schema (`participant:` / `websocket:` / `routes:` / `observability:`), +//! das der Parser stillschweigend mit `_ => {}` ignorierte; die Bridge +//! bootete mit Defaults statt der user-konfigurierten Werte (Issue #3 +//! auf github.com/zero-objects/zero-dds). +//! +//! Dieser Test verhindert eine Wiederholung: die ausgelieferte example +//! YAML wird via `include_str!` zur Build-Zeit eingebettet und durch +//! `DaemonConfig::load_from_str` gefahren. Schlaegt Doku-Drift in +//! Zukunft wieder zu, faellt sie hier auf — nicht erst im Feld. +//! +//! Spec-Bezug: `docs/specs/zerodds-ws-bridge-1.0.md` §3 (Config-File-Format). + +#![cfg(feature = "daemon")] +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use zerodds_websocket_bridge::daemon::config::DaemonConfig; + +/// Das ausgelieferte example-YAML — wird zur Compile-Zeit gegen den +/// realen File eingebettet, damit Drift sofort als Test-Fail sichtbar wird. +const EXAMPLE_YAML: &str = include_str!("../../../packaging/linux/configs/ws-bridged.yaml.example"); + +#[test] +fn example_yaml_parses_without_error() { + let res = DaemonConfig::load_from_str(EXAMPLE_YAML); + assert!( + res.is_ok(), + "ws-bridged.yaml.example parst nicht: {:?}", + res.err() + ); +} + +#[test] +fn example_yaml_key_fields_match_intent() { + let cfg = DaemonConfig::load_from_str(EXAMPLE_YAML) + .expect("example yaml must parse — siehe example_yaml_parses_without_error"); + + // listen/domain/log_level — die Top-Level-Skalare aus dem File. + assert_eq!(cfg.listen, "0.0.0.0:8080"); + assert_eq!(cfg.domain, 0); + assert_eq!(cfg.log_level, "info"); + + // tls — disabled by default, aber Pfade gesetzt, damit User sie sehen. + assert!(!cfg.tls_enabled); + assert_eq!(cfg.tls_cert_file, "/etc/zerodds/certs/ws-bridged.crt"); + assert_eq!(cfg.tls_key_file, "/etc/zerodds/certs/ws-bridged.key"); + + // auth — none als Default; bearer_token ist auskommentiert. + assert_eq!(cfg.auth_mode, "none"); + assert!(cfg.auth_bearer_token.is_none()); + + // metrics — disabled by default. + assert!(!cfg.metrics_enabled); + assert_eq!(cfg.metrics_addr, "127.0.0.1:9091"); + + // topics — genau eine Demo-Route, die der User als Vorlage anpassen kann. + assert_eq!( + cfg.topics.len(), + 1, + "example soll genau einen Demo-Topic zeigen" + ); + let t = &cfg.topics[0]; + assert_eq!(t.name, "Chat::Message"); + assert_eq!(t.type_name, "Chat::Message"); + assert_eq!(t.direction, "bidir"); + assert_eq!(t.ws_path, "/chat"); + assert_eq!(t.reliability, "reliable"); + assert_eq!(t.durability, "volatile"); + assert_eq!(t.history_depth, 10); +} diff --git a/packaging/linux/configs/ws-bridged.yaml.example b/packaging/linux/configs/ws-bridged.yaml.example index 16e13252..7beaec08 100644 --- a/packaging/linux/configs/ws-bridged.yaml.example +++ b/packaging/linux/configs/ws-bridged.yaml.example @@ -1,35 +1,49 @@ # /etc/zerodds/ws-bridged.yaml.example # Default configuration for zerodds-ws-bridged. -# Spec: zerodds-ws-bridge-1.0 §4 (config schema), §11 (deployment). +# Spec: zerodds-ws-bridge-1.0 §3 (config schema), §11 (deployment). +# +# Parser-Source-of-Truth: crates/websocket-bridge/src/daemon/config.rs::load_from_str. +# Unbekannte top-level Keys werden mit einer WARN-Zeile auf stderr geloggt, +# der Daemon startet trotzdem. Tippfehler in Keys fallen also nicht durch +# einen Validation-Error auf — auf das WARN-Log achten. -participant: - domain_id: 0 - qos_profile: builtin/reliable_keep_last_10 +listen: "0.0.0.0:8080" +domain: 0 +log_level: info -websocket: - bind: "0.0.0.0:8080" - tls: - enabled: false - cert: /etc/zerodds/certs/ws-bridged.crt - key: /etc/zerodds/certs/ws-bridged.key - origin_allowlist: - - "https://zerodds.local" - max_message_bytes: 1048576 # 1 MiB DoS-Cap - ping_interval_secs: 30 - pong_timeout_secs: 10 +tls: + enabled: false + cert_file: "/etc/zerodds/certs/ws-bridged.crt" + key_file: "/etc/zerodds/certs/ws-bridged.key" + # client_ca_file: "/etc/zerodds/certs/clients-ca.pem" # für mTLS -routes: - # DDS-Topic <-> WS-URL Mapping. Cross-Ref: zerodds-ws-bridge-1.0 §5. - - dds_topic: "ChatMessage" - type: "demo::Chat::Message" - ws_path: "/chat" - direction: "bidirectional" +# Auth-Modi: none | bearer | jwt | mtls | sasl (Spec §7.2). +# `bearer` ist single-subject — alle Connections mit korrektem Token +# laufen als `bearer_subject`. Für Multi-User → `jwt`. +auth: + mode: "none" + # bearer_token: "${WS_BRIDGE_TOKEN}" # ENV-Substitution wird vor Parse expandiert + # bearer_subject: "service-account" -logging: - level: info - format: json - destination: journal # journal | file:/var/log/zerodds/ws-bridged.log +# Topic-ACL (Spec §7.3). Bei auth_mode != none Pflicht für alle Topics, +# sonst werden Connections mit "acl reject" abgelehnt. +# acl: +# Chat::Message: +# read: "service-account, alice" # CSV-Liste der erlaubten Subjects +# write: "service-account" -observability: - metrics_bind: "127.0.0.1:9091" - otlp_endpoint: "" # leer = OTLP disabled, sonst http://otel:4318 +metrics: + enabled: false + address: "127.0.0.1:9091" # /metrics + /catalog + /healthz + +topics: + # DDS-Topic ↔ WS-URL Mapping. Cross-Ref: zerodds-ws-bridge-1.0 §5. + - name: "Chat::Message" + type: "Chat::Message" # Default = name wenn nicht gesetzt + direction: "bidir" # in | out | bidir + ws_path: "/chat" # Default = /topics/ + qos: + reliability: "reliable" + durability: "volatile" + history: + depth: 10 From 770b2f80433e69321a073ba6d5266b95d185b746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 15 May 2026 14:25:22 +0200 Subject: [PATCH 3/3] feat(ws-bridged): WARN-log unknown top-level config keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DaemonConfig::load_from_str() ignorierte bisher unbekannte Top-Level-Keys stillschweigend (`_ => {}`). Das war die Wurzel-Ursache dafür, dass die veraltete ws-bridged.yaml.example (mit participant:/websocket:/routes:/ observability: statt listen:/domain:/tls:/auth:/metrics:/topics:) nie als Fehlkonfig auffiel — Bridge bootete einfach mit Defaults. Changes: - Unbekannte Top-Level-Keys werden weiterhin nicht als ConfigError abgelehnt (Forward-Compatibility), aber per stderr eine WARN-Zeile geloggt mit Hinweis auf erwartete Keys und Spec-Ref §3. - Unit-Test (unknown_top_level_keys_do_not_fail_parse) friert die Forward-Compat-Semantik ein: falls jemand künftig auf strict-reject umschwenkt, bricht der Test absichtlich und zwingt zu einer Migration-Entscheidung. Beispiel-Output bei Tippfehler oder altem Schema: [zerodds-ws-bridged config] WARN: unknown top-level key "participant" ignored (typo or schema drift? expected one of: listen, domain, log_level, tls, auth, acl, metrics, topics — see docs/specs/zerodds-ws-bridge-1.0.md §3) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/websocket-bridge/src/daemon/config.rs | 37 +++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/websocket-bridge/src/daemon/config.rs b/crates/websocket-bridge/src/daemon/config.rs index 708f261b..61f344fc 100644 --- a/crates/websocket-bridge/src/daemon/config.rs +++ b/crates/websocket-bridge/src/daemon/config.rs @@ -278,7 +278,19 @@ impl DaemonConfig { } } } - _ => {} // unbekannte top-level-keys werden ignoriert + _ => { + // Unbekannte top-level-keys werden NICHT als Fehler behandelt + // (Forward-Compatibility), aber via stderr-WARN sichtbar gemacht, + // damit Tippfehler oder Doku-Drift (z.B. veraltete `participant:` / + // `websocket:` / `routes:` / `observability:` Sektionen) nicht + // unbemerkt zu Default-Werten fuehren. + eprintln!( + "[zerodds-ws-bridged config] WARN: unknown top-level key {:?} ignored \ + (typo or schema drift? expected one of: listen, domain, log_level, \ + tls, auth, acl, metrics, topics — see docs/specs/zerodds-ws-bridge-1.0.md §3)", + k + ); + } } } Ok(out) @@ -610,6 +622,29 @@ mod tests { assert_eq!(default_ws_path("Chat::Message"), "/topics/chat/message"); } + #[test] + fn unknown_top_level_keys_do_not_fail_parse() { + // Forward-Compatibility: unbekannte Top-Level-Keys (Tippfehler, + // veraltete Schema-Drift wie `participant:` / `websocket:` / `routes:` + // / `observability:` aus der alten yaml-example) duerfen den Parse + // NICHT als ConfigError abbrechen — sie werden via stderr-WARN + // sichtbar gemacht und der Daemon bootet mit Defaults fuer die + // fehlenden bekannten Keys. Geaenderte Semantik (z.B. zukuenftig + // strikt ablehnen) bricht diesen Test absichtlich. + let yaml = "\ +participant: + domain_id: 7 +websocket: + bind: \"0.0.0.0:8080\" +typo_listen: \"1.2.3.4:9999\" +listen: \"0.0.0.0:1234\" +"; + let cfg = DaemonConfig::load_from_str(yaml).expect("must not error on unknown keys"); + // Der bekannte Key wirkt, alles unter unknown wird ignoriert. + assert_eq!(cfg.listen, "0.0.0.0:1234"); + assert_eq!(cfg.domain, 0); // participant.domain_id wurde NICHT gemapped + } + #[test] fn slug_replaces_unsafe_chars() { assert_eq!(default_ws_path("My Topic!"), "/topics/my_topic_");