Skip to main content

hns_browser_loopback_proxy/chromium/
config.rs

1use std::net::{Ipv4Addr, SocketAddrV4};
2use std::time::Duration;
3
4use base64::Engine as _;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use ring::rand::{SecureRandom, SystemRandom};
7use thiserror::Error;
8
9use crate::LocalCertificateAuthority;
10use crate::host::HostScope;
11
12const SESSION_ID_RANDOM_BYTES: usize = 16;
13
14#[derive(Clone, Eq, Hash, PartialEq)]
15pub struct ProxySessionId {
16    bytes: [u8; SESSION_ID_RANDOM_BYTES],
17    token: String,
18}
19
20impl ProxySessionId {
21    pub fn generate() -> Result<Self, SessionIdGenerationError> {
22        let mut bytes = [0_u8; SESSION_ID_RANDOM_BYTES];
23        SystemRandom::new()
24            .fill(&mut bytes)
25            .map_err(|_| SessionIdGenerationError)?;
26        Self::from_bytes(bytes)
27    }
28
29    /// Constructs one checked session while preserving the existing
30    /// URL-safe, unpadded platform token representation.
31    pub fn from_bytes(
32        bytes: [u8; SESSION_ID_RANDOM_BYTES],
33    ) -> Result<Self, SessionIdGenerationError> {
34        if bytes == [0; SESSION_ID_RANDOM_BYTES] {
35            return Err(SessionIdGenerationError);
36        }
37        Ok(Self {
38            token: URL_SAFE_NO_PAD.encode(bytes),
39            bytes,
40        })
41    }
42
43    /// Exact nonzero bytes shared with the canonical browser runtime.
44    pub const fn as_bytes(&self) -> &[u8; SESSION_ID_RANDOM_BYTES] {
45        &self.bytes
46    }
47
48    /// Explicitly exposes the opaque token for platform boundary serialization.
49    pub fn as_str(&self) -> &str {
50        &self.token
51    }
52}
53
54impl std::fmt::Debug for ProxySessionId {
55    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        formatter.write_str("ProxySessionId([REDACTED])")
57    }
58}
59
60#[derive(Clone, Debug, Eq, Hash, PartialEq)]
61pub struct ProxyInstanceId {
62    session: ProxySessionId,
63    generation: u64,
64}
65
66impl ProxyInstanceId {
67    pub fn new(session: ProxySessionId, generation: u64) -> Self {
68        Self {
69            session,
70            generation,
71        }
72    }
73
74    pub fn session(&self) -> &ProxySessionId {
75        &self.session
76    }
77
78    pub fn generation(&self) -> u64 {
79        self.generation
80    }
81}
82
83#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
84#[non_exhaustive]
85pub enum LoopbackBind {
86    #[default]
87    Ipv4,
88}
89
90/// Determines which browser requests one proxy generation may route.
91///
92/// `DaneBrowser` is the Chromium release mode: every canonical DNS host
93/// reaches the same Rust request boundary for dual-root resolution,
94/// DNSSEC/TLSA discovery, and origin trust selection. `ScopedHns` remains as a
95/// narrow internal substrate for host-scope unit and integration coverage.
96#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub enum ProxyRoutingMode {
98    #[default]
99    ScopedHns,
100    DaneBrowser,
101}
102
103impl LoopbackBind {
104    pub const fn socket_addr(self) -> SocketAddrV4 {
105        match self {
106            Self::Ipv4 => SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0),
107        }
108    }
109}
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub struct ProxyLimits {
113    max_header_bytes: usize,
114    max_body_bytes: u64,
115    max_active_clients: usize,
116    max_requests_per_window: usize,
117    max_requests_per_host_per_window: usize,
118    max_tracked_hosts: usize,
119    rate_window: Duration,
120}
121
122impl ProxyLimits {
123    pub const DEFAULT_MAX_HEADER_BYTES: usize = 64 * 1024;
124    pub const DEFAULT_MAX_BODY_BYTES: u64 = 1024 * 1024;
125    pub const DEFAULT_MAX_ACTIVE_CLIENTS: usize = 64;
126    pub const DEFAULT_MAX_REQUESTS_PER_WINDOW: usize = 240;
127    pub const DEFAULT_MAX_REQUESTS_PER_HOST_PER_WINDOW: usize =
128        Self::DEFAULT_MAX_REQUESTS_PER_WINDOW;
129    pub const DEFAULT_MAX_TRACKED_HOSTS: usize = 256;
130    pub const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(10);
131
132    #[allow(clippy::too_many_arguments)]
133    pub fn new(
134        max_header_bytes: usize,
135        max_body_bytes: u64,
136        max_active_clients: usize,
137        max_requests_per_window: usize,
138        max_requests_per_host_per_window: usize,
139        max_tracked_hosts: usize,
140        rate_window: Duration,
141    ) -> Result<Self, ProxyLimitsError> {
142        require_nonzero(max_header_bytes, "max_header_bytes")?;
143        require_at_most(
144            max_header_bytes,
145            Self::DEFAULT_MAX_HEADER_BYTES,
146            "max_header_bytes",
147        )?;
148        if max_body_bytes == 0 {
149            return Err(ProxyLimitsError::Zero("max_body_bytes"));
150        }
151        if max_body_bytes > Self::DEFAULT_MAX_BODY_BYTES {
152            return Err(ProxyLimitsError::ExceedsMaximum("max_body_bytes"));
153        }
154        require_nonzero(max_active_clients, "max_active_clients")?;
155        require_at_most(
156            max_active_clients,
157            Self::DEFAULT_MAX_ACTIVE_CLIENTS,
158            "max_active_clients",
159        )?;
160        require_nonzero(max_requests_per_window, "max_requests_per_window")?;
161        require_at_most(
162            max_requests_per_window,
163            Self::DEFAULT_MAX_REQUESTS_PER_WINDOW,
164            "max_requests_per_window",
165        )?;
166        require_nonzero(
167            max_requests_per_host_per_window,
168            "max_requests_per_host_per_window",
169        )?;
170        require_at_most(
171            max_requests_per_host_per_window,
172            Self::DEFAULT_MAX_REQUESTS_PER_HOST_PER_WINDOW,
173            "max_requests_per_host_per_window",
174        )?;
175        require_nonzero(max_tracked_hosts, "max_tracked_hosts")?;
176        require_at_most(
177            max_tracked_hosts,
178            Self::DEFAULT_MAX_TRACKED_HOSTS,
179            "max_tracked_hosts",
180        )?;
181        if rate_window.is_zero() {
182            return Err(ProxyLimitsError::Zero("rate_window"));
183        }
184        if rate_window > Self::DEFAULT_RATE_WINDOW {
185            return Err(ProxyLimitsError::ExceedsMaximum("rate_window"));
186        }
187        if max_requests_per_host_per_window > max_requests_per_window {
188            return Err(ProxyLimitsError::PerHostExceedsGlobal);
189        }
190
191        Ok(Self {
192            max_header_bytes,
193            max_body_bytes,
194            max_active_clients,
195            max_requests_per_window,
196            max_requests_per_host_per_window,
197            max_tracked_hosts,
198            rate_window,
199        })
200    }
201
202    pub const fn max_header_bytes(self) -> usize {
203        self.max_header_bytes
204    }
205
206    pub const fn max_body_bytes(self) -> u64 {
207        self.max_body_bytes
208    }
209
210    pub const fn max_active_clients(self) -> usize {
211        self.max_active_clients
212    }
213
214    pub const fn max_requests_per_window(self) -> usize {
215        self.max_requests_per_window
216    }
217
218    pub const fn max_requests_per_host_per_window(self) -> usize {
219        self.max_requests_per_host_per_window
220    }
221
222    pub const fn max_tracked_hosts(self) -> usize {
223        self.max_tracked_hosts
224    }
225
226    pub const fn rate_window(self) -> Duration {
227        self.rate_window
228    }
229}
230
231impl Default for ProxyLimits {
232    fn default() -> Self {
233        Self {
234            max_header_bytes: Self::DEFAULT_MAX_HEADER_BYTES,
235            max_body_bytes: Self::DEFAULT_MAX_BODY_BYTES,
236            max_active_clients: Self::DEFAULT_MAX_ACTIVE_CLIENTS,
237            max_requests_per_window: Self::DEFAULT_MAX_REQUESTS_PER_WINDOW,
238            max_requests_per_host_per_window: Self::DEFAULT_MAX_REQUESTS_PER_HOST_PER_WINDOW,
239            max_tracked_hosts: Self::DEFAULT_MAX_TRACKED_HOSTS,
240            rate_window: Self::DEFAULT_RATE_WINDOW,
241        }
242    }
243}
244
245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246pub struct ProxyTimeouts {
247    request_header_timeout: Duration,
248    socket_timeout: Duration,
249}
250
251impl ProxyTimeouts {
252    pub const DEFAULT_REQUEST_HEADER_TIMEOUT: Duration = Duration::from_secs(5);
253    pub const DEFAULT_SOCKET_TIMEOUT: Duration = Duration::from_secs(30);
254
255    pub fn new(
256        request_header_timeout: Duration,
257        socket_timeout: Duration,
258    ) -> Result<Self, ProxyTimeoutsError> {
259        if request_header_timeout.is_zero() {
260            return Err(ProxyTimeoutsError::Zero("request_header_timeout"));
261        }
262        if socket_timeout.is_zero() {
263            return Err(ProxyTimeoutsError::Zero("socket_timeout"));
264        }
265        if request_header_timeout > socket_timeout {
266            return Err(ProxyTimeoutsError::HeaderExceedsSocket);
267        }
268        if request_header_timeout > Self::DEFAULT_REQUEST_HEADER_TIMEOUT {
269            return Err(ProxyTimeoutsError::ExceedsMaximum("request_header_timeout"));
270        }
271        if socket_timeout > Self::DEFAULT_SOCKET_TIMEOUT {
272            return Err(ProxyTimeoutsError::ExceedsMaximum("socket_timeout"));
273        }
274        Ok(Self {
275            request_header_timeout,
276            socket_timeout,
277        })
278    }
279
280    pub const fn request_header_timeout(self) -> Duration {
281        self.request_header_timeout
282    }
283
284    pub const fn socket_timeout(self) -> Duration {
285        self.socket_timeout
286    }
287}
288
289impl Default for ProxyTimeouts {
290    fn default() -> Self {
291        Self {
292            request_header_timeout: Self::DEFAULT_REQUEST_HEADER_TIMEOUT,
293            socket_timeout: Self::DEFAULT_SOCKET_TIMEOUT,
294        }
295    }
296}
297
298/// Immutable configuration for one proxy generation.
299///
300/// Authentication is intentionally absent: `RunningProxy` must generate a mandatory fresh
301/// [`crate::auth::ProxyAuthorization`] when it starts.
302#[derive(Clone, Debug, Eq, PartialEq)]
303pub struct ProxyConfig {
304    instance: ProxyInstanceId,
305    hns_scope: Option<HostScope>,
306    local_certificate_authority: Option<LocalCertificateAuthority>,
307    limits: ProxyLimits,
308    timeouts: ProxyTimeouts,
309    bind: LoopbackBind,
310    routing_mode: ProxyRoutingMode,
311}
312
313impl ProxyConfig {
314    pub fn new(instance: ProxyInstanceId, scope: HostScope) -> Self {
315        Self {
316            instance,
317            hns_scope: Some(scope),
318            local_certificate_authority: None,
319            limits: ProxyLimits::default(),
320            timeouts: ProxyTimeouts::default(),
321            bind: LoopbackBind::default(),
322            routing_mode: ProxyRoutingMode::default(),
323        }
324    }
325
326    /// Creates a Chromium-style generation which admits canonical HNS and
327    /// ICANN DNS names to the native gateway while rejecting special-use,
328    /// search, and IP-literal targets.
329    pub fn dane_browser(instance: ProxyInstanceId) -> Self {
330        Self {
331            instance,
332            hns_scope: None,
333            local_certificate_authority: None,
334            limits: ProxyLimits::default(),
335            timeouts: ProxyTimeouts::default(),
336            bind: LoopbackBind::default(),
337            routing_mode: ProxyRoutingMode::DaneBrowser,
338        }
339    }
340
341    pub fn with_controls(
342        instance: ProxyInstanceId,
343        scope: HostScope,
344        limits: ProxyLimits,
345        timeouts: ProxyTimeouts,
346        bind: LoopbackBind,
347    ) -> Self {
348        Self {
349            instance,
350            hns_scope: Some(scope),
351            local_certificate_authority: None,
352            limits,
353            timeouts,
354            bind,
355            routing_mode: ProxyRoutingMode::default(),
356        }
357    }
358
359    pub fn instance(&self) -> &ProxyInstanceId {
360        &self.instance
361    }
362
363    pub fn with_local_certificate_authority(
364        mut self,
365        certificate_authority: LocalCertificateAuthority,
366    ) -> Self {
367        self.local_certificate_authority = Some(certificate_authority);
368        self
369    }
370
371    pub fn local_certificate_authority(&self) -> Option<&LocalCertificateAuthority> {
372        self.local_certificate_authority.as_ref()
373    }
374
375    pub fn hns_scope(&self) -> Option<&HostScope> {
376        self.hns_scope.as_ref()
377    }
378
379    pub const fn limits(&self) -> ProxyLimits {
380        self.limits
381    }
382
383    pub const fn timeouts(&self) -> ProxyTimeouts {
384        self.timeouts
385    }
386
387    pub const fn bind(&self) -> LoopbackBind {
388        self.bind
389    }
390
391    pub const fn routing_mode(&self) -> ProxyRoutingMode {
392        self.routing_mode
393    }
394}
395
396#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
397#[error("the operating-system random number generator failed")]
398pub struct SessionIdGenerationError;
399
400#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
401pub enum ProxyLimitsError {
402    #[error("{0} must be nonzero")]
403    Zero(&'static str),
404    #[error("{0} exceeds the hard proxy maximum")]
405    ExceedsMaximum(&'static str),
406    #[error("the per-host request limit cannot exceed the global request limit")]
407    PerHostExceedsGlobal,
408}
409
410#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
411pub enum ProxyTimeoutsError {
412    #[error("{0} must be nonzero")]
413    Zero(&'static str),
414    #[error("{0} exceeds the hard proxy maximum")]
415    ExceedsMaximum(&'static str),
416    #[error("the request-header timeout cannot exceed the socket timeout")]
417    HeaderExceedsSocket,
418}
419
420fn require_nonzero(value: usize, name: &'static str) -> Result<(), ProxyLimitsError> {
421    if value == 0 {
422        Err(ProxyLimitsError::Zero(name))
423    } else {
424        Ok(())
425    }
426}
427
428fn require_at_most(
429    value: usize,
430    maximum: usize,
431    name: &'static str,
432) -> Result<(), ProxyLimitsError> {
433    if value > maximum {
434        Err(ProxyLimitsError::ExceedsMaximum(name))
435    } else {
436        Ok(())
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn session_ids_are_fresh_and_debug_redacted() {
446        let first = ProxySessionId::generate().unwrap();
447        let second = ProxySessionId::generate().unwrap();
448
449        assert_ne!(first, second);
450        assert_ne!(first.as_bytes(), &[0; SESSION_ID_RANDOM_BYTES]);
451        assert!(!first.as_str().is_empty());
452        assert!(!format!("{first:?}").contains(first.as_str()));
453    }
454
455    #[test]
456    fn checked_session_bytes_retain_the_frozen_base64url_token() {
457        let bytes = [0xabu8; SESSION_ID_RANDOM_BYTES];
458        let session = ProxySessionId::from_bytes(bytes).unwrap();
459
460        assert_eq!(session.as_bytes(), &bytes);
461        assert_eq!(session.as_str(), "q6urq6urq6urq6urq6urqw");
462        assert_eq!(
463            ProxySessionId::from_bytes([0; SESSION_ID_RANDOM_BYTES]),
464            Err(SessionIdGenerationError)
465        );
466        assert!(!format!("{session:?}").contains(session.as_str()));
467    }
468
469    #[test]
470    fn instance_debug_preserves_generation_but_redacts_session() {
471        let instance = ProxyInstanceId::new(ProxySessionId::generate().unwrap(), 7);
472        let debug = format!("{instance:?}");
473
474        assert!(debug.contains("generation: 7"));
475        assert!(!debug.contains(instance.session().as_str()));
476    }
477
478    #[test]
479    fn bind_is_always_ipv4_loopback_and_allows_ephemeral_port() {
480        assert_eq!(
481            LoopbackBind::Ipv4.socket_addr(),
482            SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)
483        );
484    }
485
486    #[test]
487    fn default_limits_match_the_android_parity_envelope() {
488        let limits = ProxyLimits::default();
489
490        assert_eq!(limits.max_header_bytes(), 64 * 1024);
491        assert_eq!(limits.max_body_bytes(), 1024 * 1024);
492        assert_eq!(limits.max_active_clients(), 64);
493        assert_eq!(limits.max_requests_per_window(), 240);
494        assert_eq!(limits.max_requests_per_host_per_window(), 240);
495        assert_eq!(limits.max_tracked_hosts(), 256);
496        assert_eq!(limits.rate_window(), Duration::from_secs(10));
497    }
498
499    #[test]
500    fn custom_limits_reject_zero_and_incoherent_values() {
501        assert_eq!(
502            ProxyLimits::new(0, 1, 1, 1, 1, 1, Duration::from_secs(1)),
503            Err(ProxyLimitsError::Zero("max_header_bytes"))
504        );
505        assert_eq!(
506            ProxyLimits::new(1, 1, 1, 3, 4, 1, Duration::from_secs(1)),
507            Err(ProxyLimitsError::PerHostExceedsGlobal)
508        );
509        assert_eq!(
510            ProxyLimits::new(1, 1, 1, 1, 1, 1, Duration::ZERO),
511            Err(ProxyLimitsError::Zero("rate_window"))
512        );
513        assert_eq!(
514            ProxyLimits::new(
515                ProxyLimits::DEFAULT_MAX_HEADER_BYTES + 1,
516                1,
517                1,
518                1,
519                1,
520                1,
521                Duration::from_secs(1),
522            ),
523            Err(ProxyLimitsError::ExceedsMaximum("max_header_bytes"))
524        );
525    }
526
527    #[test]
528    fn timeouts_match_parity_defaults_and_reject_invalid_ordering() {
529        let timeouts = ProxyTimeouts::default();
530        assert_eq!(timeouts.request_header_timeout(), Duration::from_secs(5));
531        assert_eq!(timeouts.socket_timeout(), Duration::from_secs(30));
532        assert_eq!(
533            ProxyTimeouts::new(Duration::from_secs(31), Duration::from_secs(30)),
534            Err(ProxyTimeoutsError::HeaderExceedsSocket)
535        );
536        assert_eq!(
537            ProxyTimeouts::new(Duration::from_secs(5), Duration::from_secs(31)),
538            Err(ProxyTimeoutsError::ExceedsMaximum("socket_timeout"))
539        );
540    }
541
542    #[test]
543    fn proxy_config_is_immutable_and_contains_no_authentication_bypass() {
544        let instance = ProxyInstanceId::new(ProxySessionId::generate().unwrap(), 3);
545        let scope = HostScope::new("welcome").unwrap();
546        let config = ProxyConfig::new(instance.clone(), scope.clone());
547
548        assert_eq!(config.instance(), &instance);
549        assert_eq!(config.hns_scope(), Some(&scope));
550        assert!(config.local_certificate_authority().is_none());
551        assert_eq!(config.routing_mode(), ProxyRoutingMode::ScopedHns);
552        assert_eq!(config.bind(), LoopbackBind::Ipv4);
553        assert_eq!(config.limits(), ProxyLimits::default());
554        assert_eq!(config.timeouts(), ProxyTimeouts::default());
555        assert!(!format!("{config:?}").contains(scope.root().as_str()));
556
557        let dane_browser = ProxyConfig::dane_browser(instance);
558        assert_eq!(dane_browser.hns_scope(), None);
559        assert_eq!(dane_browser.routing_mode(), ProxyRoutingMode::DaneBrowser);
560    }
561}