Skip to main content

cli_shared/
client_config.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Client configuration.
3
4use std::net::{IpAddr, SocketAddr};
5
6use wire::AuthToken;
7
8/// Client configuration.
9#[derive(Debug, Clone)]
10pub struct ClientConfig {
11    /// Client identifier.
12    pub client_id: String,
13    /// Authentication token.
14    pub token: Option<AuthToken>,
15    /// Optional private key used to prove possession of a bound token.
16    pub auth_proof_key_pem: Option<String>,
17    /// Stable authenticated principal bound to the bearer token. Request
18    /// signatures use this value as their canonical identity; the device key
19    /// remains only the proof key.
20    pub authenticated_principal: Option<String>,
21    /// Server key used to look up the credential in the credential store
22    /// (`~/.heddle/credentials.toml`).  Matches the key used by `heddle auth login`.
23    pub server_key: Option<String>,
24    /// Trusted descriptor-signing key identifier for native Iroh bootstrap.
25    pub descriptor_key_id: Option<String>,
26    /// Raw Ed25519 public key for the trusted descriptor-signing key.
27    pub descriptor_public_key: Option<[u8; 32]>,
28    /// Enable TLS.
29    pub tls_enabled: bool,
30    /// Override the expected TLS server name.
31    pub tls_domain_name: Option<String>,
32    /// Optional PEM CA certificate bundle for server verification.
33    pub tls_ca_certificate_pem: Option<String>,
34    /// Skip TLS certificate verification (insecure).
35    pub tls_skip_verify: bool,
36    /// Explicitly allow cleartext (non-TLS) connections to non-loopback hosts.
37    ///
38    /// Loopback cleartext is always permitted for local development. Non-loopback
39    /// cleartext requires this flag (CLI `--insecure`, remote `insecure = true`,
40    /// user config, or `HEDDLE_REMOTE_INSECURE`).
41    pub allow_insecure: bool,
42    /// Connection timeout in seconds.
43    pub timeout_secs: u64,
44    /// Enable compression.
45    pub compression: bool,
46    /// Preferred chunk size.
47    pub chunk_size: usize,
48    /// Enable chunked transfer negotiation.
49    pub chunked_transfer: bool,
50    /// Enable resumable transfer negotiation.
51    pub resumable_transfer: bool,
52    /// Enable pack transfer negotiation.
53    pub pack_transfer: bool,
54    /// Enable partial fetch negotiation.
55    pub partial_fetch: bool,
56    /// Maximum provider extent streams active across all provider endpoints.
57    pub provider_global_concurrency: usize,
58    /// Maximum provider extent streams multiplexed over one endpoint connection.
59    pub provider_per_endpoint_concurrency: usize,
60    /// Maximum provider body bytes retained between transport reads and spool writes.
61    pub provider_max_inflight_bytes: usize,
62    /// Maximum seconds without provider transport progress before falling back.
63    pub provider_stall_timeout_secs: u64,
64}
65
66impl ClientConfig {
67    /// Create a new client configuration.
68    pub fn new(client_id: impl Into<String>) -> Self {
69        Self {
70            client_id: client_id.into(),
71            token: None,
72            auth_proof_key_pem: None,
73            authenticated_principal: None,
74            server_key: None,
75            descriptor_key_id: None,
76            descriptor_public_key: None,
77            tls_enabled: false,
78            tls_domain_name: None,
79            tls_ca_certificate_pem: None,
80            tls_skip_verify: false,
81            allow_insecure: false,
82            timeout_secs: 30,
83            compression: true,
84            chunk_size: 64 * 1024,
85            chunked_transfer: true,
86            resumable_transfer: true,
87            pack_transfer: true,
88            partial_fetch: true,
89            provider_global_concurrency: 4,
90            provider_per_endpoint_concurrency: 2,
91            provider_max_inflight_bytes: 8 * 1024 * 1024,
92            provider_stall_timeout_secs: 15,
93        }
94    }
95
96    /// Set authentication token.
97    pub fn with_token(mut self, token: AuthToken) -> Self {
98        self.token = Some(token);
99        self
100    }
101
102    /// Set a private key used for proof-of-possession metadata.
103    pub fn with_auth_proof_key_pem(mut self, pem: impl Into<String>) -> Self {
104        self.auth_proof_key_pem = Some(pem.into());
105        self
106    }
107
108    /// Set the stable principal authenticated by the bearer token.
109    pub fn with_authenticated_principal(mut self, principal: impl Into<String>) -> Self {
110        self.authenticated_principal = Some(principal.into());
111        self
112    }
113
114    /// Set the server key used to look up credentials in the credential store.
115    pub fn with_server_key(mut self, key: impl Into<String>) -> Self {
116        self.server_key = Some(key.into());
117        self
118    }
119
120    pub fn with_descriptor_trust(
121        mut self,
122        key_id: impl Into<String>,
123        public_key: [u8; 32],
124    ) -> Self {
125        self.descriptor_key_id = Some(key_id.into());
126        self.descriptor_public_key = Some(public_key);
127        self
128    }
129
130    /// Enable TLS.
131    pub fn with_tls(mut self, skip_verify: bool) -> Self {
132        self.tls_enabled = true;
133        self.tls_skip_verify = skip_verify;
134        self
135    }
136
137    pub fn with_tls_domain_name(mut self, domain_name: impl Into<String>) -> Self {
138        self.tls_domain_name = Some(domain_name.into());
139        self
140    }
141
142    pub fn with_tls_ca_certificate_pem(mut self, pem: impl Into<String>) -> Self {
143        self.tls_enabled = true;
144        self.tls_ca_certificate_pem = Some(pem.into());
145        self
146    }
147
148    /// Set timeout.
149    pub fn with_timeout(mut self, secs: u64) -> Self {
150        self.timeout_secs = secs;
151        self
152    }
153
154    /// Disable compression.
155    pub fn without_compression(mut self) -> Self {
156        self.compression = false;
157        self
158    }
159
160    /// Set chunk size.
161    pub fn with_chunk_size(mut self, size: usize) -> Self {
162        self.chunk_size = size;
163        self
164    }
165
166    /// Enable or disable chunked transfer negotiation.
167    pub fn with_chunked_transfer(mut self, enabled: bool) -> Self {
168        self.chunked_transfer = enabled;
169        self
170    }
171
172    /// Enable or disable resumable transfer negotiation.
173    pub fn with_resumable_transfer(mut self, enabled: bool) -> Self {
174        self.resumable_transfer = enabled;
175        self
176    }
177
178    /// Enable or disable pack transfer negotiation.
179    pub fn with_pack_transfer(mut self, enabled: bool) -> Self {
180        self.pack_transfer = enabled;
181        self
182    }
183
184    /// Enable or disable partial fetch negotiation.
185    pub fn with_partial_fetch(mut self, enabled: bool) -> Self {
186        self.partial_fetch = enabled;
187        self
188    }
189
190    /// Set the maximum number of provider extent streams active globally.
191    pub fn with_provider_global_concurrency(mut self, concurrency: usize) -> Self {
192        self.provider_global_concurrency = concurrency.max(1);
193        self
194    }
195
196    /// Set the maximum provider extent streams multiplexed over one endpoint.
197    pub fn with_provider_per_endpoint_concurrency(mut self, concurrency: usize) -> Self {
198        self.provider_per_endpoint_concurrency = concurrency.max(1);
199        self
200    }
201
202    /// Set the provider body-byte budget shared by all active extent streams.
203    pub fn with_provider_max_inflight_bytes(mut self, bytes: usize) -> Self {
204        self.provider_max_inflight_bytes = bytes.max(1);
205        self
206    }
207
208    /// Set the provider transport progress threshold before ordinary Weft fallback.
209    pub fn with_provider_stall_timeout(mut self, secs: u64) -> Self {
210        self.provider_stall_timeout_secs = secs.max(1);
211        self
212    }
213
214    /// Explicitly allow cleartext to non-loopback addresses.
215    pub fn with_allow_insecure(mut self, allow: bool) -> Self {
216        self.allow_insecure = allow;
217        self
218    }
219}
220
221impl Default for ClientConfig {
222    fn default() -> Self {
223        Self::new("heddle-cli")
224    }
225}
226
227/// True for loopback IPs (`127.0.0.0/8`, `::1`).
228pub fn is_loopback_ip(ip: IpAddr) -> bool {
229    ip.is_loopback()
230}
231
232/// Whether a cleartext client connection to `addr` is permitted.
233///
234/// - TLS enabled → always allowed
235/// - Cleartext to loopback → allowed (local dev)
236/// - Cleartext to non-loopback → only when `allow_insecure` is set
237pub fn cleartext_connect_allowed(
238    addr: SocketAddr,
239    tls_enabled: bool,
240    allow_insecure: bool,
241) -> bool {
242    tls_enabled || is_loopback_ip(addr.ip()) || allow_insecure
243}
244
245/// Error message when refusing non-loopback cleartext without an explicit opt-in.
246pub fn cleartext_refused_message(addr: SocketAddr) -> String {
247    format!(
248        "refusing cleartext connection to non-loopback address {addr}; \
249enable TLS (remote.tls_enabled / HEDDLE_REMOTE_TLS) or pass --insecure \
250(or set remote.insecure=true / HEDDLE_REMOTE_INSECURE=1) for intentional cleartext \
251(e.g. VPN → VPS testing)"
252    )
253}
254
255#[cfg(test)]
256mod tests {
257    use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
258
259    use super::*;
260
261    #[test]
262    fn loopback_cleartext_allowed_without_insecure() {
263        let v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 8421));
264        let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 8421));
265        assert!(cleartext_connect_allowed(v4, false, false));
266        assert!(cleartext_connect_allowed(v6, false, false));
267    }
268
269    #[test]
270    fn non_loopback_cleartext_requires_insecure() {
271        let addr = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 10), 8421));
272        assert!(!cleartext_connect_allowed(addr, false, false));
273        assert!(cleartext_connect_allowed(addr, false, true));
274        assert!(cleartext_connect_allowed(addr, true, false));
275    }
276
277    #[test]
278    fn is_loopback_classifies_hosts() {
279        assert!(is_loopback_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
280        assert!(is_loopback_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
281        assert!(!is_loopback_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
282    }
283
284    #[test]
285    fn provider_scheduler_defaults_and_builders_are_explicit() {
286        let defaults = ClientConfig::default();
287        assert_eq!(defaults.provider_global_concurrency, 4);
288        assert_eq!(defaults.provider_per_endpoint_concurrency, 2);
289        assert_eq!(defaults.provider_max_inflight_bytes, 8 * 1024 * 1024);
290        assert_eq!(defaults.provider_stall_timeout_secs, 15);
291
292        let configured = ClientConfig::default()
293            .with_provider_global_concurrency(8)
294            .with_provider_per_endpoint_concurrency(3)
295            .with_provider_max_inflight_bytes(4 * 1024 * 1024)
296            .with_provider_stall_timeout(20);
297        assert_eq!(configured.provider_global_concurrency, 8);
298        assert_eq!(configured.provider_per_endpoint_concurrency, 3);
299        assert_eq!(configured.provider_max_inflight_bytes, 4 * 1024 * 1024);
300        assert_eq!(configured.provider_stall_timeout_secs, 20);
301    }
302}