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    /// Server key used to look up the credential in the credential store
18    /// (`~/.heddle/credentials.toml`).  Matches the key used by `heddle auth login`.
19    pub server_key: Option<String>,
20    /// Enable TLS.
21    pub tls_enabled: bool,
22    /// Override the expected TLS server name.
23    pub tls_domain_name: Option<String>,
24    /// Optional PEM CA certificate bundle for server verification.
25    pub tls_ca_certificate_pem: Option<String>,
26    /// Skip TLS certificate verification (insecure).
27    pub tls_skip_verify: bool,
28    /// Explicitly allow cleartext (non-TLS) connections to non-loopback hosts.
29    ///
30    /// Loopback cleartext is always permitted for local development. Non-loopback
31    /// cleartext requires this flag (CLI `--insecure`, remote `insecure = true`,
32    /// user config, or `HEDDLE_REMOTE_INSECURE`).
33    pub allow_insecure: bool,
34    /// Connection timeout in seconds.
35    pub timeout_secs: u64,
36    /// Enable compression.
37    pub compression: bool,
38    /// Preferred chunk size.
39    pub chunk_size: usize,
40    /// Enable chunked transfer negotiation.
41    pub chunked_transfer: bool,
42    /// Enable resumable transfer negotiation.
43    pub resumable_transfer: bool,
44    /// Enable pack transfer negotiation.
45    pub pack_transfer: bool,
46    /// Enable partial fetch negotiation.
47    pub partial_fetch: bool,
48}
49
50impl ClientConfig {
51    /// Create a new client configuration.
52    pub fn new(client_id: impl Into<String>) -> Self {
53        Self {
54            client_id: client_id.into(),
55            token: None,
56            auth_proof_key_pem: None,
57            server_key: None,
58            tls_enabled: false,
59            tls_domain_name: None,
60            tls_ca_certificate_pem: None,
61            tls_skip_verify: false,
62            allow_insecure: false,
63            timeout_secs: 30,
64            compression: true,
65            chunk_size: 64 * 1024,
66            chunked_transfer: true,
67            resumable_transfer: true,
68            pack_transfer: true,
69            partial_fetch: true,
70        }
71    }
72
73    /// Set authentication token.
74    pub fn with_token(mut self, token: AuthToken) -> Self {
75        self.token = Some(token);
76        self
77    }
78
79    /// Set a private key used for proof-of-possession metadata.
80    pub fn with_auth_proof_key_pem(mut self, pem: impl Into<String>) -> Self {
81        self.auth_proof_key_pem = Some(pem.into());
82        self
83    }
84
85    /// Set the server key used to look up credentials in the credential store.
86    pub fn with_server_key(mut self, key: impl Into<String>) -> Self {
87        self.server_key = Some(key.into());
88        self
89    }
90
91    /// Enable TLS.
92    pub fn with_tls(mut self, skip_verify: bool) -> Self {
93        self.tls_enabled = true;
94        self.tls_skip_verify = skip_verify;
95        self
96    }
97
98    pub fn with_tls_domain_name(mut self, domain_name: impl Into<String>) -> Self {
99        self.tls_domain_name = Some(domain_name.into());
100        self
101    }
102
103    pub fn with_tls_ca_certificate_pem(mut self, pem: impl Into<String>) -> Self {
104        self.tls_enabled = true;
105        self.tls_ca_certificate_pem = Some(pem.into());
106        self
107    }
108
109    /// Set timeout.
110    pub fn with_timeout(mut self, secs: u64) -> Self {
111        self.timeout_secs = secs;
112        self
113    }
114
115    /// Disable compression.
116    pub fn without_compression(mut self) -> Self {
117        self.compression = false;
118        self
119    }
120
121    /// Set chunk size.
122    pub fn with_chunk_size(mut self, size: usize) -> Self {
123        self.chunk_size = size;
124        self
125    }
126
127    /// Enable or disable chunked transfer negotiation.
128    pub fn with_chunked_transfer(mut self, enabled: bool) -> Self {
129        self.chunked_transfer = enabled;
130        self
131    }
132
133    /// Enable or disable resumable transfer negotiation.
134    pub fn with_resumable_transfer(mut self, enabled: bool) -> Self {
135        self.resumable_transfer = enabled;
136        self
137    }
138
139    /// Enable or disable pack transfer negotiation.
140    pub fn with_pack_transfer(mut self, enabled: bool) -> Self {
141        self.pack_transfer = enabled;
142        self
143    }
144
145    /// Enable or disable partial fetch negotiation.
146    pub fn with_partial_fetch(mut self, enabled: bool) -> Self {
147        self.partial_fetch = enabled;
148        self
149    }
150
151    /// Explicitly allow cleartext to non-loopback addresses.
152    pub fn with_allow_insecure(mut self, allow: bool) -> Self {
153        self.allow_insecure = allow;
154        self
155    }
156}
157
158impl Default for ClientConfig {
159    fn default() -> Self {
160        Self::new("heddle-client")
161    }
162}
163
164/// True for loopback IPs (`127.0.0.0/8`, `::1`).
165pub fn is_loopback_ip(ip: IpAddr) -> bool {
166    ip.is_loopback()
167}
168
169/// Whether a cleartext client connection to `addr` is permitted.
170///
171/// - TLS enabled → always allowed
172/// - Cleartext to loopback → allowed (local dev)
173/// - Cleartext to non-loopback → only when `allow_insecure` is set
174pub fn cleartext_connect_allowed(
175    addr: SocketAddr,
176    tls_enabled: bool,
177    allow_insecure: bool,
178) -> bool {
179    tls_enabled || is_loopback_ip(addr.ip()) || allow_insecure
180}
181
182/// Error message when refusing non-loopback cleartext without an explicit opt-in.
183pub fn cleartext_refused_message(addr: SocketAddr) -> String {
184    format!(
185        "refusing cleartext connection to non-loopback address {addr}; \
186enable TLS (remote.tls_enabled / HEDDLE_REMOTE_TLS) or pass --insecure \
187(or set remote.insecure=true / HEDDLE_REMOTE_INSECURE=1) for intentional cleartext \
188(e.g. VPN → VPS testing)"
189    )
190}
191
192#[cfg(test)]
193mod tests {
194    use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
195
196    use super::*;
197
198    #[test]
199    fn loopback_cleartext_allowed_without_insecure() {
200        let v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 8421));
201        let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 8421));
202        assert!(cleartext_connect_allowed(v4, false, false));
203        assert!(cleartext_connect_allowed(v6, false, false));
204    }
205
206    #[test]
207    fn non_loopback_cleartext_requires_insecure() {
208        let addr = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 10), 8421));
209        assert!(!cleartext_connect_allowed(addr, false, false));
210        assert!(cleartext_connect_allowed(addr, false, true));
211        assert!(cleartext_connect_allowed(addr, true, false));
212    }
213
214    #[test]
215    fn is_loopback_classifies_hosts() {
216        assert!(is_loopback_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
217        assert!(is_loopback_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
218        assert!(!is_loopback_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
219    }
220}