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