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}