Skip to main content

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