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}