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//! | `FEATHERREADER_RESOLVER_HOST` | `https://bsky.social` | atproto handle-resolver base (`com.atproto.identity.resolveHandle`) the pre-handshake beta gate uses to honor an existing seat on a cookie-less login. |
23//! | `FEATHERREADER_BOT_SECRET` | *(unset = `/bot/claims` disabled)* | Shared bearer secret (`X-Bot-Secret`) gating the headless follow→invite bot's mint endpoint `POST /bot/claims`. Unset ⇒ endpoint returns 503. MUST be set (strong) on a production-like instance if the bot is used. |
24//! | `FEATHERREADER_CLAIM_TTL_SECS` | `1209600` (14 days) | TTL for a bot-minted claim invite code — long, since the claim link is delivered asynchronously (a public skeet). |
25//!
26//! The atproto OAuth sidecar (`@atproto/oauth-client-node`) is configured with a
27//! second small block — the base URL the Rust server reaches it on and the shared
28//! secret gating its internal API (see [`SidecarConfig`]):
29//!
30//! | Variable | Default | Meaning |
31//! |--------------------------------|---------------------------|---------|
32//! | `SIDECAR_PUBLIC_URL` | `http://127.0.0.1:8081` | Public base URL of the OAuth sidecar (its browser-facing `/login`, plus the OAuth `client_id`/`redirect_uri`). |
33//! | `SIDECAR_INTERNAL_URL` | *(= `SIDECAR_PUBLIC_URL`)* | Loopback base URL the Rust server reaches the sidecar's `/internal/*` API on. Defaults to the public URL for single-URL local dev. |
34//! | `SIDECAR_INTERNAL_SECRET` | *(dev fallback)* | Shared `X-Internal-Secret` for the sidecar's `/internal/*` API. |
35//! | `FEATHERREADER_COOKIE_SECRET` | *(dev fallback)* | HMAC key used to sign the session cookie. |
36//! | `FEATHERREADER_DEV_DID` | *(unset)* | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
37//!
38//! `FEATHERREADER_BIND` also accepts the design's `FEATHERREADER_ADDR` spelling
39//! as a fallback for compatibility.
40
41use std::env;
42use std::net::SocketAddr;
43use std::path::PathBuf;
44use std::time::Duration;
45
46use anyhow::{Context, Result};
47
48/// Fully-resolved server configuration, materialized once at startup.
49#[derive(Debug, Clone)]
50pub struct Config {
51 /// The socket address the HTTP server binds to.
52 pub bind: SocketAddr,
53 /// Filesystem path to the SQLite cache/database file.
54 pub db_path: PathBuf,
55 /// The externally-reachable base URL (used to build the atproto OAuth
56 /// callback and client-metadata URLs). No trailing slash.
57 pub public_url: String,
58 /// Optional login allow-list of atproto DIDs. Empty means the instance is
59 /// open to any atproto identity that can log in.
60 pub allowed_dids: Vec<String>,
61 /// The default per-feed poll interval.
62 pub poll_interval: Duration,
63 /// Retention window: read, unstarred entries older than this are pruned.
64 pub retention_days: u32,
65 /// Whether to proxy feed images through the server (privacy vs. bandwidth).
66 pub proxy_images: bool,
67 /// Closed-beta seat cap: the maximum number of DIDs that may hold beta
68 /// access at once (redeeming an invite fails with `CapacityFull` past this).
69 /// From `FEATHERREADER_BETA_CAP`, default 100.
70 pub beta_cap: i64,
71 /// The reverse-proxy header the rate limiter TRUSTS for the real client IP,
72 /// e.g. `Fly-Client-IP` (bare Fly) or `CF-Connecting-IP` (Cloudflare). When
73 /// set, ONLY this header is consulted — never the spoofable multi-hop
74 /// `X-Forwarded-For` chain — and it falls back to the socket peer if the
75 /// header is absent/unparseable. Unset (the default) trusts the socket peer
76 /// only, which is correct for a direct bind with no proxy in front.
77 /// From `FEATHERREADER_TRUSTED_IP_HEADER`.
78 pub trusted_ip_header: Option<String>,
79 /// Per-DID subscription cap. A DID may hold at most this many subscriptions;
80 /// `add_subscription` rejects over it and `import_opml` trims to it. Bounds
81 /// the storage/poller blast radius of one account on a small box.
82 /// From `FEATHERREADER_MAX_SUBS_PER_DID`, default 500.
83 pub max_subs_per_did: i64,
84 /// Global ceiling on distinct feeds in the shared cache. A new feed is
85 /// refused once the `feeds` table holds this many rows (existing feeds still
86 /// poll). From `FEATHERREADER_MAX_FEEDS`, default 10_000.
87 pub max_feeds_global: i64,
88 /// Cap on how many entries are retained per feed on insert — the newest N by
89 /// published date; older rows are pruned in the same transaction so one
90 /// firehose feed can't fill the disk. From `FEATHERREADER_MAX_ENTRIES_PER_FEED`,
91 /// default 2_000.
92 pub max_entries_per_feed: i64,
93 /// DB-size watermark, in bytes. Above it the background poller stops fetching
94 /// new content (and logs an alert) so the `$3.50 box` can't be filled to a
95 /// crash. `0` disables the watermark. From `FEATHERREADER_DB_SIZE_WATERMARK_BYTES`,
96 /// default 2 GiB.
97 pub db_size_watermark_bytes: i64,
98 /// The atproto OAuth sidecar wiring (base URL + shared internal secret).
99 pub sidecar: SidecarConfig,
100 /// HMAC key used to sign the session cookie. In production this MUST be set
101 /// (`FEATHERREADER_COOKIE_SECRET`); a stable dev fallback is used otherwise
102 /// so local runs work without configuration.
103 pub cookie_secret: String,
104 /// Optional dev-only DID: when set, a request with no valid session cookie
105 /// is served as this DID (local runs without the OAuth sidecar). Unset in a
106 /// real deployment — no session then means "logged out".
107 pub dev_did: Option<String>,
108 /// Base URL of the atproto handle resolver (`com.atproto.identity.resolveHandle`),
109 /// no trailing slash. Used by the pre-handshake beta gate to turn a submitted
110 /// handle into a DID so an existing seat can be honored on a cookie-less first
111 /// login. Defaults to [`crate::atproto::DEFAULT_RESOLVER_HOST`]. From
112 /// `FEATHERREADER_RESOLVER_HOST`.
113 pub resolver_base: String,
114 /// Shared bearer secret gating the headless bot mint endpoint (`POST
115 /// /bot/claims`), sent by the follow→invite bot as `X-Bot-Secret`. When empty
116 /// the endpoint is DISABLED (503) — a bot can't mint. Like the cookie/sidecar
117 /// secrets it MUST be set on a production-like instance (fail-loud at boot);
118 /// on a loopback/dev instance it stays unset so `/bot/claims` is simply off
119 /// until an operator opts in. From `FEATHERREADER_BOT_SECRET`.
120 pub bot_secret: Option<String>,
121 /// TTL (seconds) for a claim invite code minted by `POST /bot/claims`. The
122 /// bot delivers the claim link asynchronously (a public skeet), so this is a
123 /// generous window — the admin-mint browser flow's 30-minute TTL would expire
124 /// before the follower ever taps the link. From `FEATHERREADER_CLAIM_TTL_SECS`,
125 /// default 14 days.
126 pub claim_ttl_secs: i64,
127}
128
129/// Configuration for the atproto OAuth sidecar (`@atproto/oauth-client-node`).
130///
131/// The Rust server drives the sidecar over two surfaces:
132/// * the **public** `${public_url}/login` URL the browser is redirected to (and
133/// which anchors the sidecar's OAuth `client_id`/`redirect_uri`), and
134/// * the **internal** `${internal_url}/internal/*` API (session lookup + the authed
135/// `com.atproto.repo.*` proxy), gated by the shared [`internal_secret`] sent as
136/// the `X-Internal-Secret` header.
137///
138/// The two URLs differ in a split deployment (public = the edge origin, internal =
139/// a loopback address the app reaches the sidecar on); they collapse to the same
140/// value in single-URL local dev.
141#[derive(Debug, Clone)]
142pub struct SidecarConfig {
143 /// Public base URL of the sidecar (no trailing slash), e.g.
144 /// `https://feather-reader.com/oauth`. Anchors the browser `/login` redirect.
145 pub public_url: String,
146 /// Loopback base URL for the sidecar's `/internal/*` API (no trailing slash),
147 /// e.g. `http://127.0.0.1:8081`. Defaults to `public_url` in single-URL dev.
148 pub internal_url: String,
149 /// Shared secret for the sidecar's internal API (`X-Internal-Secret`).
150 pub internal_secret: String,
151}
152
153/// The sidecar's own dev fallback for the shared secret (matches the sidecar's
154/// `dev-internal-secret-change-me`) so a fully-local dev stack works untouched.
155const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
156
157/// The default sidecar base URL — loopback, matching the sidecar's own default.
158const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
159
160/// A stable, clearly-marked dev cookie key. Overridden by
161/// `FEATHERREADER_COOKIE_SECRET` in any real deployment.
162const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
163
164/// Default TTL for a bot-minted claim code: 14 days. Long enough that an
165/// asynchronously-delivered claim link (a public follow-back skeet) is still
166/// live when the follower taps it.
167const DEFAULT_CLAIM_TTL_SECS: i64 = 14 * 24 * 60 * 60;
168
169impl Default for SidecarConfig {
170 fn default() -> Self {
171 Self {
172 public_url: DEFAULT_SIDECAR_URL.to_string(),
173 internal_url: DEFAULT_SIDECAR_URL.to_string(),
174 internal_secret: DEV_INTERNAL_SECRET.to_string(),
175 }
176 }
177}
178
179impl SidecarConfig {
180 /// The sidecar's public `/login` URL (the browser redirect target).
181 pub fn login_url(&self) -> String {
182 format!("{}/login", self.public_url)
183 }
184
185 /// The sidecar's `/internal/session/:id` URL (loopback internal API).
186 pub fn session_url(&self, session_id: &str) -> String {
187 format!("{}/internal/session/{}", self.internal_url, session_id)
188 }
189
190 /// The sidecar's `/internal/repo` URL (the authed `com.atproto.repo.*` proxy).
191 pub fn repo_url(&self) -> String {
192 format!("{}/internal/repo", self.internal_url)
193 }
194}
195
196impl Default for Config {
197 fn default() -> Self {
198 Self {
199 // Loopback-only by default: safe for a first run; front with a
200 // reverse proxy / tunnel to expose it.
201 bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
202 db_path: PathBuf::from("featherreader.db"),
203 public_url: "http://localhost:8080".to_string(),
204 allowed_dids: Vec::new(),
205 poll_interval: Duration::from_secs(3600),
206 retention_days: 90,
207 proxy_images: false,
208 beta_cap: 100,
209 trusted_ip_header: None,
210 max_subs_per_did: 500,
211 max_feeds_global: 10_000,
212 max_entries_per_feed: 2_000,
213 db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
214 sidecar: SidecarConfig::default(),
215 cookie_secret: DEV_COOKIE_SECRET.to_string(),
216 dev_did: None,
217 resolver_base: crate::atproto::DEFAULT_RESOLVER_HOST.to_string(),
218 bot_secret: None,
219 claim_ttl_secs: DEFAULT_CLAIM_TTL_SECS,
220 }
221 }
222}
223
224impl Config {
225 /// Build a [`Config`] from the process environment, falling back to the
226 /// defaults above for anything unset. Returns an error only when a *present*
227 /// variable fails to parse — an unset variable is never an error.
228 pub fn from_env() -> Result<Self> {
229 let defaults = Config::default();
230
231 // FEATHERREADER_BIND (preferred) or FEATHERREADER_ADDR (design alias).
232 let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
233 Some(raw) => raw
234 .parse::<SocketAddr>()
235 .with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
236 None => defaults.bind,
237 };
238
239 let db_path = env_opt("FEATHERREADER_DB")
240 .map(PathBuf::from)
241 .unwrap_or(defaults.db_path);
242
243 let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
244 // Normalize away a trailing slash so callers can join paths cleanly.
245 .map(|u| u.trim_end_matches('/').to_string())
246 .unwrap_or(defaults.public_url);
247
248 let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
249 .map(|raw| {
250 raw.split(',')
251 .map(str::trim)
252 .filter(|s| !s.is_empty())
253 .map(str::to_string)
254 .collect::<Vec<_>>()
255 })
256 .unwrap_or(defaults.allowed_dids);
257
258 let poll_interval = match env_opt("FEATHERREADER_POLL_INTERVAL") {
259 Some(raw) => {
260 let secs: u64 = raw.parse().with_context(|| {
261 format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}")
262 })?;
263 Duration::from_secs(secs)
264 }
265 None => defaults.poll_interval,
266 };
267
268 let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
269 Some(raw) => raw.parse().with_context(|| {
270 format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
271 })?,
272 None => defaults.retention_days,
273 };
274
275 let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
276 Some(raw) => parse_bool(&raw).with_context(|| {
277 format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
278 })?,
279 None => defaults.proxy_images,
280 };
281
282 let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
283 Some(raw) => raw.parse().with_context(|| {
284 format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
285 })?,
286 None => defaults.beta_cap,
287 };
288
289 // Trusted client-IP header for the rate limiter. Normalized to lowercase
290 // (header lookup is case-insensitive); unset => trust only the socket peer.
291 let trusted_ip_header =
292 env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
293
294 let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
295 Some(raw) => raw.parse().with_context(|| {
296 format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
297 })?,
298 None => defaults.max_subs_per_did,
299 };
300
301 let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
302 Some(raw) => raw.parse().with_context(|| {
303 format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
304 })?,
305 None => defaults.max_feeds_global,
306 };
307
308 let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
309 Some(raw) => raw.parse().with_context(|| {
310 format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
311 })?,
312 None => defaults.max_entries_per_feed,
313 };
314
315 let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
316 Some(raw) => raw.parse().with_context(|| {
317 format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
318 })?,
319 None => defaults.db_size_watermark_bytes,
320 };
321
322 // --- atproto OAuth sidecar --------------------------------------
323 let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
324 .map(|u| u.trim_end_matches('/').to_string())
325 .unwrap_or_else(|| defaults.sidecar.public_url.clone());
326 // The internal API is reached over loopback in a split deployment; it
327 // falls back to the resolved public URL so single-URL local dev works.
328 let internal_url = env_opt("SIDECAR_INTERNAL_URL")
329 .map(|u| u.trim_end_matches('/').to_string())
330 .unwrap_or_else(|| sidecar_url.clone());
331 let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
332 .unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
333 let sidecar = SidecarConfig {
334 public_url: sidecar_url,
335 internal_url,
336 internal_secret,
337 };
338
339 let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
340 .unwrap_or_else(|| defaults.cookie_secret.clone());
341
342 // A dev DID is opt-in: only present when explicitly configured, so a real
343 // deployment never silently falls back to a shared identity.
344 let dev_did = env_opt("FEATHERREADER_DEV_DID");
345
346 let resolver_base = env_opt("FEATHERREADER_RESOLVER_HOST")
347 .map(|u| u.trim_end_matches('/').to_string())
348 .unwrap_or(defaults.resolver_base);
349
350 // Shared bot secret gating `POST /bot/claims`. Unset => the endpoint is
351 // disabled; `validate_secrets` still fail-loud rejects the *published dev
352 // default* / a too-short value on a production-like instance.
353 let bot_secret = env_opt("FEATHERREADER_BOT_SECRET");
354
355 let claim_ttl_secs = match env_opt("FEATHERREADER_CLAIM_TTL_SECS") {
356 Some(raw) => {
357 let secs: i64 = raw.parse().with_context(|| {
358 format!("FEATHERREADER_CLAIM_TTL_SECS: expected seconds, got {raw:?}")
359 })?;
360 validate_claim_ttl(secs)?
361 }
362 None => defaults.claim_ttl_secs,
363 };
364
365 let config = Self {
366 bind,
367 db_path,
368 public_url,
369 allowed_dids,
370 poll_interval,
371 retention_days,
372 proxy_images,
373 beta_cap,
374 trusted_ip_header,
375 max_subs_per_did,
376 max_feeds_global,
377 max_entries_per_feed,
378 db_size_watermark_bytes,
379 sidecar,
380 cookie_secret,
381 dev_did,
382 resolver_base,
383 bot_secret,
384 claim_ttl_secs,
385 };
386
387 // FAIL LOUD: a non-loopback (public) instance must never fall back to the
388 // repo-published dev secrets — those are known to any attacker, who could
389 // then forge a session cookie offline. Refuse to boot instead.
390 config.validate_secrets()?;
391
392 Ok(config)
393 }
394
395 /// Whether this instance is "production-like" and therefore MUST have strong,
396 /// non-default secrets. True when `FEATHERREADER_ENV=prod`, or when either the
397 /// bind address or the public URL points at a non-loopback host — i.e. the
398 /// server is reachable by someone other than the local operator.
399 fn is_prod_like(&self) -> bool {
400 if env_opt("FEATHERREADER_ENV")
401 .map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
402 .unwrap_or(false)
403 {
404 return true;
405 }
406 // A non-loopback bind (incl. 0.0.0.0, reachable off-box) is public; so is
407 // a public_url that resolves to a non-loopback host.
408 !self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
409 }
410
411 /// Enforce the secret policy for a production-like instance. On a
412 /// loopback/dev instance the dev fallbacks are kept for convenience; on a
413 /// public one each secret must be explicitly set, not equal to its published
414 /// dev constant, and at least 32 bytes. Returns `Err` (refuse boot) otherwise.
415 fn validate_secrets(&self) -> Result<()> {
416 if !self.is_prod_like() {
417 return Ok(());
418 }
419 check_secret(
420 "FEATHERREADER_COOKIE_SECRET",
421 &self.cookie_secret,
422 DEV_COOKIE_SECRET,
423 )?;
424 check_secret(
425 "SIDECAR_INTERNAL_SECRET",
426 &self.sidecar.internal_secret,
427 DEV_INTERNAL_SECRET,
428 )?;
429 // The bot secret is OPTIONAL (unset => `/bot/claims` disabled, which is a
430 // safe default). But if it IS set on a production-like instance it must be
431 // strong — a weak/short shared bearer would let anyone mint claim codes.
432 if let Some(bot_secret) = &self.bot_secret {
433 check_secret("FEATHERREADER_BOT_SECRET", bot_secret, "")?;
434 }
435 // Split-deploy footgun: on a production-like instance, if the sidecar's
436 // INTERNAL base equals its PUBLIC base and that base is non-loopback, the
437 // Rust server would send the `X-Internal-Secret` + all session/repo
438 // traffic to the PUBLIC edge URL over the network (SIDECAR_INTERNAL_URL
439 // was left unset and fell back to SIDECAR_PUBLIC_URL). The canonical
440 // container bakes SIDECAR_INTERNAL_URL to loopback; a bare-binary deploy
441 // must set it explicitly. Refuse to boot rather than leak the secret.
442 if self.sidecar.internal_url == self.sidecar.public_url
443 && public_url_is_non_loopback(&self.sidecar.public_url)
444 {
445 anyhow::bail!(
446 "SIDECAR_INTERNAL_URL is unset (defaulting to the public \
447 SIDECAR_PUBLIC_URL '{}') on a production-like instance: the internal \
448 API secret and all session/repo traffic would traverse the public \
449 network. Set SIDECAR_INTERNAL_URL to the sidecar's loopback/private \
450 address (e.g. http://127.0.0.1:8081).",
451 self.sidecar.public_url
452 );
453 }
454 Ok(())
455 }
456
457 /// Whether the given atproto DID is permitted to log in. When no allow-list
458 /// is configured the instance is open, so every DID is allowed.
459 pub fn did_allowed(&self, did: &str) -> bool {
460 self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
461 }
462
463 /// The admin-bootstrap seed for the closed-beta gate: the DIDs that get a
464 /// `beta_access` seat automatically (via [`crate::store::ensure_seed`]) so a
465 /// fresh instance always has at least the operator(s) inside the gate and
466 /// able to mint invite codes.
467 ///
468 /// Reuses `ALLOWED_DIDS` as the seed source — the same "these are the people
469 /// I trust on this instance" concept — so operators don't configure the list
470 /// twice. Returns a borrowed slice (empty when the instance is open / no
471 /// allow-list is set, in which case there is nothing to seed).
472 pub fn admin_seed_dids(&self) -> &[String] {
473 &self.allowed_dids
474 }
475}
476
477/// Minimum length (in bytes) for a production secret. 32 bytes = 256 bits, the
478/// floor for an HMAC-SHA256 key with a full-strength security margin.
479const MIN_SECRET_BYTES: usize = 32;
480
481/// Enforce that a production secret is set, not the published dev constant, and
482/// long enough. Returns a fail-loud `Err` naming the offending variable.
483fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
484 if value.is_empty() || value == dev_constant {
485 anyhow::bail!(
486 "{var} is unset or still the published dev default on a non-loopback (production) \
487 instance; refusing to boot. Set {var} to a random secret of at least \
488 {MIN_SECRET_BYTES} bytes."
489 );
490 }
491 if value.len() < MIN_SECRET_BYTES {
492 anyhow::bail!(
493 "{var} is too short ({} bytes) for a production instance; it must be at least \
494 {MIN_SECRET_BYTES} bytes.",
495 value.len()
496 );
497 }
498 Ok(())
499}
500
501/// Whether a `public_url` points at a non-loopback host. A parse failure or a
502/// missing host is treated as non-loopback (fail closed toward "public").
503fn public_url_is_non_loopback(public_url: &str) -> bool {
504 match url::Url::parse(public_url) {
505 Ok(u) => match u.host() {
506 Some(url::Host::Domain(d)) => {
507 !(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
508 }
509 Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
510 Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
511 None => true,
512 },
513 Err(_) => true,
514 }
515}
516
517/// Validate a parsed `FEATHERREADER_CLAIM_TTL_SECS`: it MUST be positive. The
518/// bot-minted claim link is delivered ASYNCHRONOUSLY (a public skeet), so a
519/// non-positive TTL mints an instantly-expired, dead link the follower can never
520/// redeem. Fail loud at boot rather than silently hand out broken links.
521fn validate_claim_ttl(secs: i64) -> Result<i64> {
522 if secs <= 0 {
523 anyhow::bail!(
524 "FEATHERREADER_CLAIM_TTL_SECS must be > 0 (got {secs}); a non-positive TTL yields \
525 instantly-expired, dead claim links"
526 );
527 }
528 Ok(secs)
529}
530
531/// Read an env var, treating an empty value the same as unset.
532fn env_opt(key: &str) -> Option<String> {
533 match env::var(key) {
534 Ok(v) if !v.trim().is_empty() => Some(v),
535 _ => None,
536 }
537}
538
539/// Parse a permissive boolean: `1/true/yes/on` vs `0/false/no/off`
540/// (case-insensitive).
541fn parse_bool(raw: &str) -> Result<bool> {
542 match raw.trim().to_ascii_lowercase().as_str() {
543 "1" | "true" | "yes" | "on" => Ok(true),
544 "0" | "false" | "no" | "off" => Ok(false),
545 other => anyhow::bail!("not a boolean: {other:?}"),
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn defaults_are_sane() {
555 let c = Config::default();
556 assert_eq!(c.bind.port(), 8080);
557 assert_eq!(c.poll_interval, Duration::from_secs(3600));
558 assert_eq!(c.retention_days, 90);
559 assert!(!c.proxy_images);
560 assert!(c.allowed_dids.is_empty());
561 assert_eq!(c.beta_cap, 100);
562 // Hardening caps default to safe, non-zero bounds; no trusted proxy header.
563 assert!(c.trusted_ip_header.is_none());
564 assert_eq!(c.max_subs_per_did, 500);
565 assert_eq!(c.max_feeds_global, 10_000);
566 assert_eq!(c.max_entries_per_feed, 2_000);
567 assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
568 }
569
570 #[test]
571 fn admin_seed_reuses_allowed_dids() {
572 let open = Config::default();
573 assert!(open.admin_seed_dids().is_empty());
574 let gated = Config {
575 allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
576 ..Config::default()
577 };
578 assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
579 }
580
581 #[test]
582 fn open_instance_allows_any_did() {
583 let c = Config::default();
584 assert!(c.did_allowed("did:plc:anything"));
585 }
586
587 #[test]
588 fn allow_list_gates_dids() {
589 let c = Config {
590 allowed_dids: vec!["did:plc:me".to_string()],
591 ..Config::default()
592 };
593 assert!(c.did_allowed("did:plc:me"));
594 assert!(!c.did_allowed("did:plc:stranger"));
595 }
596
597 #[test]
598 fn parse_bool_accepts_common_spellings() {
599 assert!(parse_bool("Yes").unwrap());
600 assert!(!parse_bool("OFF").unwrap());
601 assert!(parse_bool("maybe").is_err());
602 }
603
604 #[test]
605 fn loopback_instance_keeps_dev_fallback_secrets() {
606 // Default config is loopback + dev secrets: must be allowed to boot.
607 let c = Config::default();
608 assert!(!c.is_prod_like());
609 assert!(c.validate_secrets().is_ok());
610 }
611
612 #[test]
613 fn public_bind_with_dev_cookie_secret_refuses_boot() {
614 let c = Config {
615 bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
616 ..Config::default()
617 };
618 assert!(c.is_prod_like());
619 // Still carries the published dev cookie secret → must fail loud.
620 let err = c.validate_secrets().unwrap_err().to_string();
621 assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
622 }
623
624 #[test]
625 fn public_bind_with_short_secret_refuses_boot() {
626 let c = Config {
627 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
628 cookie_secret: "too-short".to_string(),
629 ..Config::default()
630 };
631 assert!(c.is_prod_like());
632 assert!(c.validate_secrets().is_err());
633 }
634
635 #[test]
636 fn public_bind_with_dev_sidecar_secret_refuses_boot() {
637 let c = Config {
638 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
639 // Strong cookie secret, but sidecar secret still the dev default.
640 cookie_secret: "x".repeat(48),
641 ..Config::default()
642 };
643 let err = c.validate_secrets().unwrap_err().to_string();
644 assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
645 }
646
647 #[test]
648 fn public_bind_with_strong_secrets_boots() {
649 let c = Config {
650 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
651 cookie_secret: "a".repeat(48),
652 sidecar: SidecarConfig {
653 public_url: DEFAULT_SIDECAR_URL.to_string(),
654 internal_url: DEFAULT_SIDECAR_URL.to_string(),
655 internal_secret: "b".repeat(48),
656 },
657 ..Config::default()
658 };
659 assert!(c.is_prod_like());
660 assert!(c.validate_secrets().is_ok());
661 }
662
663 #[test]
664 fn public_sidecar_url_without_internal_url_refuses_boot() {
665 // Strong secrets, but the sidecar internal URL fell back to a
666 // non-loopback public URL → the internal secret would go over the wire.
667 let c = Config {
668 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
669 cookie_secret: "a".repeat(48),
670 sidecar: SidecarConfig {
671 public_url: "https://feather-reader.com/oauth".to_string(),
672 internal_url: "https://feather-reader.com/oauth".to_string(),
673 internal_secret: "b".repeat(48),
674 },
675 ..Config::default()
676 };
677 let err = c.validate_secrets().unwrap_err().to_string();
678 assert!(err.contains("SIDECAR_INTERNAL_URL"), "{err}");
679 }
680
681 #[test]
682 fn public_sidecar_url_with_loopback_internal_url_boots() {
683 // Same public sidecar URL, but an explicit loopback internal URL: safe.
684 let c = Config {
685 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
686 cookie_secret: "a".repeat(48),
687 sidecar: SidecarConfig {
688 public_url: "https://feather-reader.com/oauth".to_string(),
689 internal_url: "http://127.0.0.1:8081".to_string(),
690 internal_secret: "b".repeat(48),
691 },
692 ..Config::default()
693 };
694 assert!(c.validate_secrets().is_ok());
695 }
696
697 #[test]
698 fn bot_secret_defaults_unset() {
699 let c = Config::default();
700 assert!(c.bot_secret.is_none());
701 assert_eq!(c.claim_ttl_secs, DEFAULT_CLAIM_TTL_SECS);
702 }
703
704 #[test]
705 fn claim_ttl_must_be_positive() {
706 // A positive TTL passes through unchanged.
707 assert_eq!(validate_claim_ttl(3600).unwrap(), 3600);
708 assert_eq!(
709 validate_claim_ttl(DEFAULT_CLAIM_TTL_SECS).unwrap(),
710 DEFAULT_CLAIM_TTL_SECS
711 );
712 // Zero and negative are rejected loudly (they mint dead, expired links).
713 for bad in [0, -1, -1209600] {
714 let err = validate_claim_ttl(bad).unwrap_err().to_string();
715 assert!(err.contains("FEATHERREADER_CLAIM_TTL_SECS"), "{err}");
716 assert!(err.contains("must be > 0"), "{err}");
717 }
718 }
719
720 #[test]
721 fn loopback_instance_allows_weak_bot_secret() {
722 // On a dev/loopback instance the bot secret isn't validated (the whole
723 // secret policy is skipped), so even a short one is accepted.
724 let c = Config {
725 bot_secret: Some("short".to_string()),
726 ..Config::default()
727 };
728 assert!(!c.is_prod_like());
729 assert!(c.validate_secrets().is_ok());
730 }
731
732 #[test]
733 fn public_bind_with_short_bot_secret_refuses_boot() {
734 let c = Config {
735 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
736 cookie_secret: "a".repeat(48),
737 sidecar: SidecarConfig {
738 public_url: DEFAULT_SIDECAR_URL.to_string(),
739 internal_url: DEFAULT_SIDECAR_URL.to_string(),
740 internal_secret: "b".repeat(48),
741 },
742 bot_secret: Some("too-short".to_string()),
743 ..Config::default()
744 };
745 let err = c.validate_secrets().unwrap_err().to_string();
746 assert!(err.contains("FEATHERREADER_BOT_SECRET"), "{err}");
747 }
748
749 #[test]
750 fn public_bind_with_strong_bot_secret_boots() {
751 let c = Config {
752 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
753 cookie_secret: "a".repeat(48),
754 sidecar: SidecarConfig {
755 public_url: DEFAULT_SIDECAR_URL.to_string(),
756 internal_url: DEFAULT_SIDECAR_URL.to_string(),
757 internal_secret: "b".repeat(48),
758 },
759 bot_secret: Some("c".repeat(48)),
760 ..Config::default()
761 };
762 assert!(c.validate_secrets().is_ok());
763 }
764
765 #[test]
766 fn public_bind_with_unset_bot_secret_boots() {
767 // An unset bot secret is fine on prod (the endpoint is just disabled).
768 let c = Config {
769 bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
770 cookie_secret: "a".repeat(48),
771 sidecar: SidecarConfig {
772 public_url: DEFAULT_SIDECAR_URL.to_string(),
773 internal_url: DEFAULT_SIDECAR_URL.to_string(),
774 internal_secret: "b".repeat(48),
775 },
776 bot_secret: None,
777 ..Config::default()
778 };
779 assert!(c.validate_secrets().is_ok());
780 }
781
782 #[test]
783 fn public_url_non_loopback_detection() {
784 assert!(!public_url_is_non_loopback("http://localhost:8080"));
785 assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
786 assert!(!public_url_is_non_loopback("http://[::1]:8080"));
787 assert!(public_url_is_non_loopback("https://feather-reader.com"));
788 assert!(public_url_is_non_loopback("http://203.0.113.5"));
789 }
790}