Skip to main content

feather_reader/
config.rs

1//! Runtime configuration for the FeatherReader server.
2//!
3//! Everything is env-driven with a sane default for every knob, so a bare
4//! `./featherreader` boots and works — no config file required (the
5//! "trivial to self-host" promise). The environment variables
6//! all share the `FEATHERREADER_*` prefix:
7//!
8//! | Variable                     | Default                  | Meaning |
9//! |------------------------------|--------------------------|---------|
10//! | `FEATHERREADER_BIND`         | `127.0.0.1:8080`         | `host:port` the HTTP server binds. |
11//! | `FEATHERREADER_DB`           | `featherreader.db`       | Path to the SQLite cache file. |
12//! | `FEATHERREADER_PUBLIC_URL`   | `http://localhost:8080`  | Externally-reachable base URL (OAuth callback + client metadata). |
13//! | `FEATHERREADER_ALLOWED_DIDS` | *(empty = open)*         | Comma-separated login allow-list of atproto DIDs. |
14//! | `FEATHERREADER_POLL_INTERVAL`| `3600` (1h)              | Default per-feed poll interval, in seconds. |
15//! | `FEATHERREADER_RETENTION_DAYS`| `90`                    | Prune read, unstarred entries older than this. |
16//! | `FEATHERREADER_PROXY_IMAGES` | `false`                  | Proxy feed images so reader IPs aren't leaked to feed hosts. |
17//! | `FEATHERREADER_TRUSTED_IP_HEADER` | *(unset)*           | Trusted reverse-proxy header for the real client IP (e.g. `Fly-Client-IP`, `CF-Connecting-IP`). Unset trusts the socket peer only. |
18//! | `FEATHERREADER_MAX_SUBS_PER_DID` | `500`                | Per-DID subscription cap. |
19//! | `FEATHERREADER_MAX_FEEDS`    | `10000`                  | Global distinct-feed ceiling. |
20//! | `FEATHERREADER_MAX_ENTRIES_PER_FEED` | `2000`           | Per-feed retained-entry cap (newest N). |
21//! | `FEATHERREADER_DB_SIZE_WATERMARK_BYTES` | `2 GiB`       | Above this the poller stops fetching new content (0 disables). |
22//!
23//! The atproto OAuth sidecar (`@atproto/oauth-client-node`) is configured with a
24//! second small block — the base URL the Rust server reaches it on and the shared
25//! secret gating its internal API (see [`SidecarConfig`]):
26//!
27//! | Variable                       | Default                   | Meaning |
28//! |--------------------------------|---------------------------|---------|
29//! | `SIDECAR_PUBLIC_URL`           | `http://127.0.0.1:8081`   | Base URL of the OAuth sidecar (its public `/login` + internal API). |
30//! | `SIDECAR_INTERNAL_SECRET`      | *(dev fallback)*          | Shared `X-Internal-Secret` for the sidecar's `/internal/*` API. |
31//! | `FEATHERREADER_COOKIE_SECRET`  | *(dev fallback)*          | HMAC key used to sign the session cookie. |
32//! | `FEATHERREADER_DEV_DID`        | *(unset)*                 | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
33//!
34//! `FEATHERREADER_BIND` also accepts the design's `FEATHERREADER_ADDR` spelling
35//! as a fallback for compatibility.
36
37use std::env;
38use std::net::SocketAddr;
39use std::path::PathBuf;
40use std::time::Duration;
41
42use anyhow::{Context, Result};
43
44/// Fully-resolved server configuration, materialized once at startup.
45#[derive(Debug, Clone)]
46pub struct Config {
47    /// The socket address the HTTP server binds to.
48    pub bind: SocketAddr,
49    /// Filesystem path to the SQLite cache/database file.
50    pub db_path: PathBuf,
51    /// The externally-reachable base URL (used to build the atproto OAuth
52    /// callback and client-metadata URLs). No trailing slash.
53    pub public_url: String,
54    /// Optional login allow-list of atproto DIDs. Empty means the instance is
55    /// open to any atproto identity that can log in.
56    pub allowed_dids: Vec<String>,
57    /// The default per-feed poll interval.
58    pub poll_interval: Duration,
59    /// Retention window: read, unstarred entries older than this are pruned.
60    pub retention_days: u32,
61    /// Whether to proxy feed images through the server (privacy vs. bandwidth).
62    pub proxy_images: bool,
63    /// Closed-beta seat cap: the maximum number of DIDs that may hold beta
64    /// access at once (redeeming an invite fails with `CapacityFull` past this).
65    /// From `FEATHERREADER_BETA_CAP`, default 100.
66    pub beta_cap: i64,
67    /// The reverse-proxy header the rate limiter TRUSTS for the real client IP,
68    /// e.g. `Fly-Client-IP` (bare Fly) or `CF-Connecting-IP` (Cloudflare). When
69    /// set, ONLY this header is consulted — never the spoofable multi-hop
70    /// `X-Forwarded-For` chain — and it falls back to the socket peer if the
71    /// header is absent/unparseable. Unset (the default) trusts the socket peer
72    /// only, which is correct for a direct bind with no proxy in front.
73    /// From `FEATHERREADER_TRUSTED_IP_HEADER`.
74    pub trusted_ip_header: Option<String>,
75    /// Per-DID subscription cap. A DID may hold at most this many subscriptions;
76    /// `add_subscription` rejects over it and `import_opml` trims to it. Bounds
77    /// the storage/poller blast radius of one account on a small box.
78    /// From `FEATHERREADER_MAX_SUBS_PER_DID`, default 500.
79    pub max_subs_per_did: i64,
80    /// Global ceiling on distinct feeds in the shared cache. A new feed is
81    /// refused once the `feeds` table holds this many rows (existing feeds still
82    /// poll). From `FEATHERREADER_MAX_FEEDS`, default 10_000.
83    pub max_feeds_global: i64,
84    /// Cap on how many entries are retained per feed on insert — the newest N by
85    /// published date; older rows are pruned in the same transaction so one
86    /// firehose feed can't fill the disk. From `FEATHERREADER_MAX_ENTRIES_PER_FEED`,
87    /// default 2_000.
88    pub max_entries_per_feed: i64,
89    /// DB-size watermark, in bytes. Above it the background poller stops fetching
90    /// new content (and logs an alert) so the `$3.50 box` can't be filled to a
91    /// crash. `0` disables the watermark. From `FEATHERREADER_DB_SIZE_WATERMARK_BYTES`,
92    /// default 2 GiB.
93    pub db_size_watermark_bytes: i64,
94    /// The atproto OAuth sidecar wiring (base URL + shared internal secret).
95    pub sidecar: SidecarConfig,
96    /// HMAC key used to sign the session cookie. In production this MUST be set
97    /// (`FEATHERREADER_COOKIE_SECRET`); a stable dev fallback is used otherwise
98    /// so local runs work without configuration.
99    pub cookie_secret: String,
100    /// Optional dev-only DID: when set, a request with no valid session cookie
101    /// is served as this DID (local runs without the OAuth sidecar). Unset in a
102    /// real deployment — no session then means "logged out".
103    pub dev_did: Option<String>,
104}
105
106/// Configuration for the atproto OAuth sidecar (`@atproto/oauth-client-node`).
107///
108/// The Rust server drives the sidecar over two surfaces:
109/// * the **public** `${public_url}/login` URL the browser is redirected to, and
110/// * the **internal** `${public_url}/internal/*` API (session lookup + the authed
111///   `com.atproto.repo.*` proxy), gated by the shared [`internal_secret`] sent as
112///   the `X-Internal-Secret` header.
113#[derive(Debug, Clone)]
114pub struct SidecarConfig {
115    /// Base URL of the sidecar (no trailing slash), e.g. `http://127.0.0.1:8081`.
116    pub public_url: String,
117    /// Shared secret for the sidecar's internal API (`X-Internal-Secret`).
118    pub internal_secret: String,
119}
120
121/// The sidecar's own dev fallback for the shared secret (matches the sidecar's
122/// `dev-internal-secret-change-me`) so a fully-local dev stack works untouched.
123const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
124
125/// The default sidecar base URL — loopback, matching the sidecar's own default.
126const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
127
128/// A stable, clearly-marked dev cookie key. Overridden by
129/// `FEATHERREADER_COOKIE_SECRET` in any real deployment.
130const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
131
132impl Default for SidecarConfig {
133    fn default() -> Self {
134        Self {
135            public_url: DEFAULT_SIDECAR_URL.to_string(),
136            internal_secret: DEV_INTERNAL_SECRET.to_string(),
137        }
138    }
139}
140
141impl SidecarConfig {
142    /// The sidecar's public `/login` URL (the browser redirect target).
143    pub fn login_url(&self) -> String {
144        format!("{}/login", self.public_url)
145    }
146
147    /// The sidecar's `/internal/session/:id` URL.
148    pub fn session_url(&self, session_id: &str) -> String {
149        format!("{}/internal/session/{}", self.public_url, session_id)
150    }
151
152    /// The sidecar's `/internal/repo` URL (the authed `com.atproto.repo.*` proxy).
153    pub fn repo_url(&self) -> String {
154        format!("{}/internal/repo", self.public_url)
155    }
156}
157
158impl Default for Config {
159    fn default() -> Self {
160        Self {
161            // Loopback-only by default: safe for a first run; front with a
162            // reverse proxy / tunnel to expose it.
163            bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
164            db_path: PathBuf::from("featherreader.db"),
165            public_url: "http://localhost:8080".to_string(),
166            allowed_dids: Vec::new(),
167            poll_interval: Duration::from_secs(3600),
168            retention_days: 90,
169            proxy_images: false,
170            beta_cap: 100,
171            trusted_ip_header: None,
172            max_subs_per_did: 500,
173            max_feeds_global: 10_000,
174            max_entries_per_feed: 2_000,
175            db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
176            sidecar: SidecarConfig::default(),
177            cookie_secret: DEV_COOKIE_SECRET.to_string(),
178            dev_did: None,
179        }
180    }
181}
182
183impl Config {
184    /// Build a [`Config`] from the process environment, falling back to the
185    /// defaults above for anything unset. Returns an error only when a *present*
186    /// variable fails to parse — an unset variable is never an error.
187    pub fn from_env() -> Result<Self> {
188        let defaults = Config::default();
189
190        // FEATHERREADER_BIND (preferred) or FEATHERREADER_ADDR (design alias).
191        let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
192            Some(raw) => raw
193                .parse::<SocketAddr>()
194                .with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
195            None => defaults.bind,
196        };
197
198        let db_path = env_opt("FEATHERREADER_DB")
199            .map(PathBuf::from)
200            .unwrap_or(defaults.db_path);
201
202        let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
203            // Normalize away a trailing slash so callers can join paths cleanly.
204            .map(|u| u.trim_end_matches('/').to_string())
205            .unwrap_or(defaults.public_url);
206
207        let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
208            .map(|raw| {
209                raw.split(',')
210                    .map(str::trim)
211                    .filter(|s| !s.is_empty())
212                    .map(str::to_string)
213                    .collect::<Vec<_>>()
214            })
215            .unwrap_or(defaults.allowed_dids);
216
217        let poll_interval = match env_opt("FEATHERREADER_POLL_INTERVAL") {
218            Some(raw) => {
219                let secs: u64 = raw.parse().with_context(|| {
220                    format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}")
221                })?;
222                Duration::from_secs(secs)
223            }
224            None => defaults.poll_interval,
225        };
226
227        let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
228            Some(raw) => raw.parse().with_context(|| {
229                format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
230            })?,
231            None => defaults.retention_days,
232        };
233
234        let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
235            Some(raw) => parse_bool(&raw).with_context(|| {
236                format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
237            })?,
238            None => defaults.proxy_images,
239        };
240
241        let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
242            Some(raw) => raw.parse().with_context(|| {
243                format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
244            })?,
245            None => defaults.beta_cap,
246        };
247
248        // Trusted client-IP header for the rate limiter. Normalized to lowercase
249        // (header lookup is case-insensitive); unset => trust only the socket peer.
250        let trusted_ip_header =
251            env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
252
253        let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
254            Some(raw) => raw.parse().with_context(|| {
255                format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
256            })?,
257            None => defaults.max_subs_per_did,
258        };
259
260        let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
261            Some(raw) => raw.parse().with_context(|| {
262                format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
263            })?,
264            None => defaults.max_feeds_global,
265        };
266
267        let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
268            Some(raw) => raw.parse().with_context(|| {
269                format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
270            })?,
271            None => defaults.max_entries_per_feed,
272        };
273
274        let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
275            Some(raw) => raw.parse().with_context(|| {
276                format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
277            })?,
278            None => defaults.db_size_watermark_bytes,
279        };
280
281        // --- atproto OAuth sidecar --------------------------------------
282        let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
283            .map(|u| u.trim_end_matches('/').to_string())
284            .unwrap_or_else(|| defaults.sidecar.public_url.clone());
285        let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
286            .unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
287        let sidecar = SidecarConfig {
288            public_url: sidecar_url,
289            internal_secret,
290        };
291
292        let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
293            .unwrap_or_else(|| defaults.cookie_secret.clone());
294
295        // A dev DID is opt-in: only present when explicitly configured, so a real
296        // deployment never silently falls back to a shared identity.
297        let dev_did = env_opt("FEATHERREADER_DEV_DID");
298
299        let config = Self {
300            bind,
301            db_path,
302            public_url,
303            allowed_dids,
304            poll_interval,
305            retention_days,
306            proxy_images,
307            beta_cap,
308            trusted_ip_header,
309            max_subs_per_did,
310            max_feeds_global,
311            max_entries_per_feed,
312            db_size_watermark_bytes,
313            sidecar,
314            cookie_secret,
315            dev_did,
316        };
317
318        // FAIL LOUD: a non-loopback (public) instance must never fall back to the
319        // repo-published dev secrets — those are known to any attacker, who could
320        // then forge a session cookie offline. Refuse to boot instead.
321        config.validate_secrets()?;
322
323        Ok(config)
324    }
325
326    /// Whether this instance is "production-like" and therefore MUST have strong,
327    /// non-default secrets. True when `FEATHERREADER_ENV=prod`, or when either the
328    /// bind address or the public URL points at a non-loopback host — i.e. the
329    /// server is reachable by someone other than the local operator.
330    fn is_prod_like(&self) -> bool {
331        if env_opt("FEATHERREADER_ENV")
332            .map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
333            .unwrap_or(false)
334        {
335            return true;
336        }
337        // A non-loopback bind (incl. 0.0.0.0, reachable off-box) is public; so is
338        // a public_url that resolves to a non-loopback host.
339        !self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
340    }
341
342    /// Enforce the secret policy for a production-like instance. On a
343    /// loopback/dev instance the dev fallbacks are kept for convenience; on a
344    /// public one each secret must be explicitly set, not equal to its published
345    /// dev constant, and at least 32 bytes. Returns `Err` (refuse boot) otherwise.
346    fn validate_secrets(&self) -> Result<()> {
347        if !self.is_prod_like() {
348            return Ok(());
349        }
350        check_secret(
351            "FEATHERREADER_COOKIE_SECRET",
352            &self.cookie_secret,
353            DEV_COOKIE_SECRET,
354        )?;
355        check_secret(
356            "SIDECAR_INTERNAL_SECRET",
357            &self.sidecar.internal_secret,
358            DEV_INTERNAL_SECRET,
359        )?;
360        Ok(())
361    }
362
363    /// Whether the given atproto DID is permitted to log in. When no allow-list
364    /// is configured the instance is open, so every DID is allowed.
365    pub fn did_allowed(&self, did: &str) -> bool {
366        self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
367    }
368
369    /// The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
370    /// `beta_access` seat automatically (via [`crate::store::ensure_seed`]) so a
371    /// fresh instance always has at least the operator(s) inside the gate and
372    /// able to mint invite codes.
373    ///
374    /// Reuses `ALLOWED_DIDS` as the seed source — the same "these are the people
375    /// I trust on this instance" concept — so operators don't configure the list
376    /// twice. Returns a borrowed slice (empty when the instance is open / no
377    /// allow-list is set, in which case there is nothing to seed).
378    pub fn admin_seed_dids(&self) -> &[String] {
379        &self.allowed_dids
380    }
381}
382
383/// Minimum length (in bytes) for a production secret. 32 bytes = 256 bits, the
384/// floor for an HMAC-SHA256 key with a full-strength security margin.
385const MIN_SECRET_BYTES: usize = 32;
386
387/// Enforce that a production secret is set, not the published dev constant, and
388/// long enough. Returns a fail-loud `Err` naming the offending variable.
389fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
390    if value.is_empty() || value == dev_constant {
391        anyhow::bail!(
392            "{var} is unset or still the published dev default on a non-loopback (production) \
393             instance; refusing to boot. Set {var} to a random secret of at least \
394             {MIN_SECRET_BYTES} bytes."
395        );
396    }
397    if value.len() < MIN_SECRET_BYTES {
398        anyhow::bail!(
399            "{var} is too short ({} bytes) for a production instance; it must be at least \
400             {MIN_SECRET_BYTES} bytes.",
401            value.len()
402        );
403    }
404    Ok(())
405}
406
407/// Whether a `public_url` points at a non-loopback host. A parse failure or a
408/// missing host is treated as non-loopback (fail closed toward "public").
409fn public_url_is_non_loopback(public_url: &str) -> bool {
410    match url::Url::parse(public_url) {
411        Ok(u) => match u.host() {
412            Some(url::Host::Domain(d)) => {
413                !(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
414            }
415            Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
416            Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
417            None => true,
418        },
419        Err(_) => true,
420    }
421}
422
423/// Read an env var, treating an empty value the same as unset.
424fn env_opt(key: &str) -> Option<String> {
425    match env::var(key) {
426        Ok(v) if !v.trim().is_empty() => Some(v),
427        _ => None,
428    }
429}
430
431/// Parse a permissive boolean: `1/true/yes/on` vs `0/false/no/off`
432/// (case-insensitive).
433fn parse_bool(raw: &str) -> Result<bool> {
434    match raw.trim().to_ascii_lowercase().as_str() {
435        "1" | "true" | "yes" | "on" => Ok(true),
436        "0" | "false" | "no" | "off" => Ok(false),
437        other => anyhow::bail!("not a boolean: {other:?}"),
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn defaults_are_sane() {
447        let c = Config::default();
448        assert_eq!(c.bind.port(), 8080);
449        assert_eq!(c.poll_interval, Duration::from_secs(3600));
450        assert_eq!(c.retention_days, 90);
451        assert!(!c.proxy_images);
452        assert!(c.allowed_dids.is_empty());
453        assert_eq!(c.beta_cap, 100);
454        // Hardening caps default to safe, non-zero bounds; no trusted proxy header.
455        assert!(c.trusted_ip_header.is_none());
456        assert_eq!(c.max_subs_per_did, 500);
457        assert_eq!(c.max_feeds_global, 10_000);
458        assert_eq!(c.max_entries_per_feed, 2_000);
459        assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
460    }
461
462    #[test]
463    fn admin_seed_reuses_allowed_dids() {
464        let open = Config::default();
465        assert!(open.admin_seed_dids().is_empty());
466        let gated = Config {
467            allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
468            ..Config::default()
469        };
470        assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
471    }
472
473    #[test]
474    fn open_instance_allows_any_did() {
475        let c = Config::default();
476        assert!(c.did_allowed("did:plc:anything"));
477    }
478
479    #[test]
480    fn allow_list_gates_dids() {
481        let c = Config {
482            allowed_dids: vec!["did:plc:me".to_string()],
483            ..Config::default()
484        };
485        assert!(c.did_allowed("did:plc:me"));
486        assert!(!c.did_allowed("did:plc:stranger"));
487    }
488
489    #[test]
490    fn parse_bool_accepts_common_spellings() {
491        assert!(parse_bool("Yes").unwrap());
492        assert!(!parse_bool("OFF").unwrap());
493        assert!(parse_bool("maybe").is_err());
494    }
495
496    #[test]
497    fn loopback_instance_keeps_dev_fallback_secrets() {
498        // Default config is loopback + dev secrets: must be allowed to boot.
499        let c = Config::default();
500        assert!(!c.is_prod_like());
501        assert!(c.validate_secrets().is_ok());
502    }
503
504    #[test]
505    fn public_bind_with_dev_cookie_secret_refuses_boot() {
506        let c = Config {
507            bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
508            ..Config::default()
509        };
510        assert!(c.is_prod_like());
511        // Still carries the published dev cookie secret → must fail loud.
512        let err = c.validate_secrets().unwrap_err().to_string();
513        assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
514    }
515
516    #[test]
517    fn public_bind_with_short_secret_refuses_boot() {
518        let c = Config {
519            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
520            cookie_secret: "too-short".to_string(),
521            ..Config::default()
522        };
523        assert!(c.is_prod_like());
524        assert!(c.validate_secrets().is_err());
525    }
526
527    #[test]
528    fn public_bind_with_dev_sidecar_secret_refuses_boot() {
529        let c = Config {
530            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
531            // Strong cookie secret, but sidecar secret still the dev default.
532            cookie_secret: "x".repeat(48),
533            ..Config::default()
534        };
535        let err = c.validate_secrets().unwrap_err().to_string();
536        assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
537    }
538
539    #[test]
540    fn public_bind_with_strong_secrets_boots() {
541        let c = Config {
542            bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
543            cookie_secret: "a".repeat(48),
544            sidecar: SidecarConfig {
545                public_url: DEFAULT_SIDECAR_URL.to_string(),
546                internal_secret: "b".repeat(48),
547            },
548            ..Config::default()
549        };
550        assert!(c.is_prod_like());
551        assert!(c.validate_secrets().is_ok());
552    }
553
554    #[test]
555    fn public_url_non_loopback_detection() {
556        assert!(!public_url_is_non_loopback("http://localhost:8080"));
557        assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
558        assert!(!public_url_is_non_loopback("http://[::1]:8080"));
559        assert!(public_url_is_non_loopback(
560            "https://reader.justin-stanley.com"
561        ));
562        assert!(public_url_is_non_loopback("http://203.0.113.5"));
563    }
564}