Skip to main content

tsoracle_standalone/
config.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::time::Duration;
27
28/// openraft's three-address membership entry: the raft peer RPC address, the
29/// scheme-less service endpoint clients are redirected to, and the scheme-less
30/// admin endpoint the `tsoracle admin` CLI is redirected to.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MemberAddr {
33    pub raft_addr: String,
34    pub service_endpoint: String,
35    pub admin_endpoint: String,
36}
37
38/// openraft timing knobs (milliseconds), defaulted to the values the example used.
39#[derive(Debug, Clone)]
40pub struct RaftTuning {
41    pub heartbeat_ms: u64,
42    pub election_min_ms: u64,
43    pub election_max_ms: u64,
44}
45
46impl Default for RaftTuning {
47    fn default() -> Self {
48        Self {
49            heartbeat_ms: 250,
50            election_min_ms: 1_000,
51            election_max_ms: 2_000,
52        }
53    }
54}
55
56#[derive(Debug, Clone)]
57pub struct FileConfig {
58    pub state_dir: PathBuf,
59}
60
61/// mTLS material for the peer transport (PEM file paths, read at build()).
62/// Presence on a driver config turns the peer transport into mutual TLS:
63/// `cert`/`key` is this node's identity (used as both the peer-server identity
64/// and the peer-client identity when dialing); `ca` verifies connecting peers.
65/// The CA MUST be cluster-dedicated — anyone holding a cert it signed can join
66/// replication.
67#[derive(Debug, Clone)]
68pub struct PeerTlsConfig {
69    pub cert: PathBuf,
70    pub key: PathBuf,
71    pub ca: PathBuf,
72}
73
74/// mTLS material for the membership-admin gRPC server (PEM file paths,
75/// read at build()). Presence on an `OpenraftConfig` turns the admin port
76/// into mutual TLS: `cert`/`key` is this node's admin-server identity; `ca`
77/// verifies connecting admin clients (operators dialing via `tsoracle admin`).
78///
79/// Security contract: the admin CA MUST be issued from a CA chain independent
80/// of the peer CA. Anyone holding a cert signed by the configured admin CA can
81/// change cluster membership (AddLearner / Promote / RemoveNode). `build`
82/// enforces the trivial form of this — it rejects sharing the same CA file
83/// (by path or by byte content) — but it CANNOT detect two distinct CA files
84/// where one is in the trust chain of the other, or both chained under a
85/// shared root. Issuing the two CAs from independent roots is the operator's
86/// responsibility.
87#[derive(Debug, Clone)]
88pub struct AdminTlsConfig {
89    pub cert: PathBuf,
90    pub key: PathBuf,
91    pub ca: PathBuf,
92}
93
94#[derive(Debug, Clone)]
95pub struct OpenraftConfig {
96    pub id: u64,
97    pub raft_addr: std::net::SocketAddr,
98    pub raft_dir: PathBuf,
99    pub bootstrap: bool,
100    /// Required ONLY with `bootstrap`; `None` on a non-bootstrap restart
101    /// (membership recovers from raft state, #408).
102    pub initial_membership: Option<BTreeMap<u64, MemberAddr>>,
103    pub tuning: RaftTuning,
104    pub peer_tls: Option<PeerTlsConfig>,
105    /// Bind address for the membership-admin gRPC server. `None` serves no
106    /// admin surface.
107    pub admin_listen: Option<std::net::SocketAddr>,
108    /// mTLS material for the admin gRPC server. `None` means plaintext on
109    /// loopback only; routable `admin_listen` without TLS is rejected at
110    /// build() (see `StandaloneError::AdminInsecureRoutable`).
111    pub admin_tls: Option<AdminTlsConfig>,
112    /// When `false`, a non-loopback `raft_addr` without `peer_tls` is
113    /// rejected at build() (see `StandaloneError::PeerInsecureRoutable`).
114    /// Set `true` to opt out — intended only for single-host dev or
115    /// service-mesh-terminated mTLS, and matches the helm chart's
116    /// `tls.allowInsecurePeer`.
117    pub allow_insecure_peer: bool,
118}
119
120#[derive(Debug, Clone)]
121pub struct PaxosConfig {
122    pub node_id: u64,
123    pub peer_listen: std::net::SocketAddr,
124    /// id -> paxos peer addr. Required at EVERY start (OmniPaxos has no
125    /// membership-driven addressing).
126    pub peers: BTreeMap<u64, String>,
127    /// id -> tsoracle service endpoint for LeaderHint follower-redirect.
128    pub tso_peers: BTreeMap<u64, String>,
129    pub data_dir: PathBuf,
130    pub tick_interval: Duration,
131    pub peer_tls: Option<PeerTlsConfig>,
132    /// When `false`, a non-loopback `peer_listen` without `peer_tls` is
133    /// rejected at build() (see `StandaloneError::PeerInsecureRoutable`).
134    /// Set `true` to opt out — intended only for single-host dev or
135    /// service-mesh-terminated mTLS, and matches the helm chart's
136    /// `tls.allowInsecurePeer`.
137    pub allow_insecure_peer: bool,
138}
139
140pub enum DriverConfig {
141    #[cfg(feature = "file")]
142    File(FileConfig),
143    #[cfg(feature = "openraft")]
144    Openraft(OpenraftConfig),
145    #[cfg(feature = "paxos")]
146    Paxos(PaxosConfig),
147}
148
149/// Parse a comma-separated `id=host:port` peer map. Public so the bin and examples can reuse it instead of duplicating the parser.
150pub fn parse_peer_map(input: &str) -> Result<BTreeMap<u64, String>, String> {
151    let mut out = BTreeMap::new();
152    for pair in input.split(',') {
153        let pair = pair.trim();
154        if pair.is_empty() {
155            continue;
156        }
157        let (id, addr) = pair
158            .split_once('=')
159            .ok_or_else(|| format!("bad peer entry {pair:?}, expected id=host:port"))?;
160        let id: u64 = id
161            .trim()
162            .parse()
163            .map_err(|_| format!("bad peer id in {pair:?}"))?;
164        out.insert(id, addr.trim().to_string());
165    }
166    Ok(out)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn parse_peer_map_reads_id_host_port_pairs() {
175        let map = parse_peer_map("1=127.0.0.1:5001, 2=127.0.0.1:5002").unwrap();
176        assert_eq!(map.get(&1).map(String::as_str), Some("127.0.0.1:5001"));
177        assert_eq!(map.get(&2).map(String::as_str), Some("127.0.0.1:5002"));
178    }
179
180    #[test]
181    fn parse_peer_map_rejects_entry_without_equals() {
182        let err = parse_peer_map("1=127.0.0.1:5001,garbage").unwrap_err();
183        assert!(err.contains("expected id=host:port"), "got: {err}");
184    }
185
186    #[test]
187    fn parse_peer_map_skips_blank_entries() {
188        let map = parse_peer_map("1=a:1,,2=b:2,").unwrap();
189        assert_eq!(map.len(), 2);
190    }
191
192    #[test]
193    fn raft_tuning_defaults_match_the_example_values() {
194        let tuning = RaftTuning::default();
195        assert_eq!(tuning.heartbeat_ms, 250);
196        assert_eq!(tuning.election_min_ms, 1_000);
197        assert_eq!(tuning.election_max_ms, 2_000);
198    }
199}