Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 144 additions & 10 deletions crates/websocket-bridge/src/bin/zerodds-ws-bridged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
}
37 changes: 36 additions & 1 deletion crates/websocket-bridge/src/daemon/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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_");
Expand Down
75 changes: 75 additions & 0 deletions crates/websocket-bridge/tests/example_yaml_loadback.rs
Original file line number Diff line number Diff line change
@@ -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);
}
70 changes: 42 additions & 28 deletions packaging/linux/configs/ws-bridged.yaml.example
Original file line number Diff line number Diff line change
@@ -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/<slug-of-name>
qos:
reliability: "reliable"
durability: "volatile"
history:
depth: 10
Loading