Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem *öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS jetzt als verificationMethod (Multikey) in ihrem DID-Dokument. Damit fällt auch die hartkodierte Service-DID: describeServer gab stur did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL. Beide Endpoints leiten sie jetzt aus einer Quelle ab (AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss` des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen — also in einer Form, der kein did:web-Resolver folgen könnte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
297 lines
12 KiB
Rust
297 lines
12 KiB
Rust
use serde::Deserialize;
|
|
|
|
/// Default polling interval for the handle-sync worker, in seconds.
|
|
/// Bumped to 5 minutes — handle changes are infrequent and a missing
|
|
/// `@handle` is purely cosmetic, so we don't need to hammer the PLC.
|
|
fn default_handle_sync_interval() -> u64 {
|
|
300
|
|
}
|
|
|
|
/// Default for `APPVIEW_AUTH_REQUIRED`.
|
|
///
|
|
/// `true` — the AppView's private endpoints (notifications, home
|
|
/// timeline) reject unauthenticated requests. Fail closed: an operator
|
|
/// who forgets the variable gets the safe behaviour, and the only way
|
|
/// to serve another user's notifications to an anonymous caller is to
|
|
/// opt out explicitly.
|
|
fn default_auth_required() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AppConfig {
|
|
pub pds_host: String,
|
|
pub pds_port: u16,
|
|
pub pds_public_url: String,
|
|
pub pds_handle_dns_zone: String,
|
|
pub pds_jwt_secret: String,
|
|
pub appview_host: String,
|
|
pub appview_port: u16,
|
|
pub appview_public_url: String,
|
|
pub jetstream_url: String,
|
|
pub jetstream_collections: Vec<String>,
|
|
pub database_url_pds: String,
|
|
pub database_url_appview: String,
|
|
pub s3_endpoint: String,
|
|
pub s3_region: String,
|
|
pub s3_access_key: String,
|
|
pub s3_secret_key: String,
|
|
pub s3_bucket_pds: String,
|
|
pub s3_bucket_appview: String,
|
|
pub plc_directory_url: String,
|
|
/// Cluster-internal URL the AppView uses to reach the PDS (e.g.
|
|
/// `http://pds-server:3000`). Falls back to `pds_public_url` when
|
|
/// unset. Splitting this from `pds_public_url` lets a single
|
|
/// deployment point the AppView at the in-cluster PDS hostname
|
|
/// (which may not be reachable from outside) while clients
|
|
/// still see the public URL.
|
|
#[serde(default)]
|
|
pub pds_internal_url: Option<String>,
|
|
/// Optional shared secret for `POST /internal/ingest-commit`. If unset,
|
|
/// the endpoint accepts anonymous requests (dev mode). If set, callers
|
|
/// must send `X-Ingest-Secret: <value>`.
|
|
#[serde(default)]
|
|
pub appview_ingest_secret: Option<String>,
|
|
/// How often the handle-sync worker scans the `posts` table for rows
|
|
/// with an empty `handle` column and resolves them via the PLC
|
|
/// directory. Default: 300s (5 minutes).
|
|
#[serde(default = "default_handle_sync_interval")]
|
|
pub appview_handle_sync_interval_secs: u64,
|
|
/// Whether the AppView enforces bearer-token auth on the endpoints
|
|
/// that serve a single user's private data (`/api/notifications*`,
|
|
/// `/api/timeline/home`). Default `true`.
|
|
///
|
|
/// Set `APPVIEW_AUTH_REQUIRED=false` to get the pre-auth behaviour
|
|
/// (every endpoint public). That mode exists for two callers:
|
|
/// the fail-open integration suites, which seed synthetic DIDs the
|
|
/// PDS has never issued a token for, and an instance that is
|
|
/// already isolated at the network layer (VPN / private subnet).
|
|
/// The AppView warns loudly at startup when it is off.
|
|
#[serde(default = "default_auth_required")]
|
|
pub appview_auth_required: bool,
|
|
/// Browser origins allowed to call the AppView's `/api/*` routes,
|
|
/// from the comma-separated `APPVIEW_CORS_ORIGINS`. Empty means
|
|
/// "no allowlist configured" — the AppView then keeps the historic
|
|
/// `Access-Control-Allow-Origin: *` behaviour and warns at startup.
|
|
///
|
|
/// Example (Tauri webview origins differ per platform):
|
|
/// `APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430`
|
|
#[serde(default)]
|
|
pub appview_cors_origins: Vec<String>,
|
|
}
|
|
|
|
impl AppConfig {
|
|
pub fn from_env() -> anyhow::Result<Self> {
|
|
let env = |k: &str| std::env::var(k).map_err(|_| anyhow::anyhow!("missing env: {k}"));
|
|
let collections: Vec<String> = env("JETSTREAM_COLLECTIONS")?
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
Ok(Self {
|
|
pds_host: env("PDS_HOST")?,
|
|
pds_port: env("PDS_PORT")?.parse()?,
|
|
pds_public_url: env("PDS_PUBLIC_URL")?,
|
|
pds_handle_dns_zone: env("PDS_HANDLE_DNS_ZONE")?,
|
|
pds_jwt_secret: env("PDS_JWT_SECRET")?,
|
|
appview_host: env("APPVIEW_HOST")?,
|
|
appview_port: env("APPVIEW_PORT")?.parse()?,
|
|
appview_public_url: env("APPVIEW_PUBLIC_URL")?,
|
|
jetstream_url: env("JETSTREAM_URL")?,
|
|
jetstream_collections: collections,
|
|
database_url_pds: env("DATABASE_URL_PDS")?,
|
|
database_url_appview: env("DATABASE_URL_APPVIEW")?,
|
|
s3_endpoint: env("S3_ENDPOINT")?,
|
|
s3_region: env("S3_REGION")?,
|
|
s3_access_key: env("S3_ACCESS_KEY")?,
|
|
s3_secret_key: env("S3_SECRET_KEY")?,
|
|
s3_bucket_pds: env("S3_BUCKET_PDS")?,
|
|
s3_bucket_appview: env("S3_BUCKET_APPVIEW")?,
|
|
plc_directory_url: env("PLC_DIRECTORY_URL")?,
|
|
pds_internal_url: std::env::var("PDS_INTERNAL_URL").ok(),
|
|
appview_ingest_secret: std::env::var("APPVIEW_INGEST_SECRET").ok(),
|
|
appview_handle_sync_interval_secs: std::env::var("APPVIEW_HANDLE_SYNC_INTERVAL_SECS")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or_else(default_handle_sync_interval),
|
|
appview_auth_required: std::env::var("APPVIEW_AUTH_REQUIRED")
|
|
.ok()
|
|
.map(|s| parse_bool_env(&s))
|
|
.unwrap_or_else(default_auth_required),
|
|
appview_cors_origins: std::env::var("APPVIEW_CORS_ORIGINS")
|
|
.ok()
|
|
.map(|s| parse_csv_env(&s))
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
/// The `did:web:` DID of *this* PDS, derived from `PDS_PUBLIC_URL`.
|
|
///
|
|
/// One derivation, two consumers: `com.atproto.server.describeServer`
|
|
/// (which used to return a hardcoded `did:web:pds.maarcadetweet.local`
|
|
/// no matter what the operator configured) and
|
|
/// `GET /.well-known/did.json`, which publishes the server's signing
|
|
/// key under exactly this id. If those two ever disagreed, a client
|
|
/// that trusts `describeServer` would fetch the key document of a
|
|
/// different identity.
|
|
pub fn pds_did(&self) -> String {
|
|
did_web_from_url(&self.pds_public_url)
|
|
}
|
|
|
|
/// Base URL the AppView uses to reach the PDS.
|
|
///
|
|
/// `PDS_INTERNAL_URL` when set (the cluster-internal hostname),
|
|
/// otherwise `PDS_PUBLIC_URL`. Both the handle-sync resolver and the
|
|
/// signing-key fetch go through here, so the two can't end up
|
|
/// talking to different PDS instances.
|
|
pub fn pds_base_url(&self) -> String {
|
|
self.pds_internal_url
|
|
.clone()
|
|
.unwrap_or_else(|| self.pds_public_url.clone())
|
|
}
|
|
}
|
|
|
|
/// Interpret an environment variable as a boolean.
|
|
///
|
|
/// Accepts the spellings people actually type in a `.env` file. Anything
|
|
/// unrecognised counts as `false` for an explicitly-set variable — the
|
|
/// caller decides what an *absent* variable means (see
|
|
/// [`default_auth_required`]), and a typo like `APPVIEW_AUTH_REQUIRED=ture`
|
|
/// must never silently read as "on" when the operator's intent was to
|
|
/// switch something off... nor as "off" for a security switch. Since
|
|
/// this is only reached when the variable *is* set, and the only
|
|
/// security-relevant user of it defaults to `true` when unset, we treat
|
|
/// unknown values as `false` and rely on the startup warning to make a
|
|
/// disabled auth switch impossible to miss in the logs.
|
|
fn parse_bool_env(raw: &str) -> bool {
|
|
matches!(
|
|
raw.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes" | "on"
|
|
)
|
|
}
|
|
|
|
/// Split a comma-separated environment variable into trimmed,
|
|
/// non-empty entries. `"a, b,,c "` → `["a", "b", "c"]`.
|
|
fn parse_csv_env(raw: &str) -> Vec<String> {
|
|
raw.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// Turn an `http(s)://host[:port][/path]` URL into a `did:web:` DID.
|
|
///
|
|
/// The did:web method spec maps the authority to the method-specific
|
|
/// id, with two wrinkles that matter here:
|
|
///
|
|
/// - a port is **percent-encoded** (`:` → `%3A`), because a bare colon
|
|
/// already separates the DID's own segments. `http://127.0.0.1:2583`
|
|
/// is therefore `did:web:127.0.0.1%3A2583`, *not*
|
|
/// `did:web:127.0.0.1:2583` (which would parse as host `127.0.0.1`
|
|
/// plus a path segment `2583`).
|
|
/// - path segments, if any, are appended separated by `:`.
|
|
///
|
|
/// The default ports (80/443) are kept rather than stripped: the
|
|
/// resolution rule is a textual one, and a client that reverses this
|
|
/// mapping has to end up at the same URL we serve the document from.
|
|
pub fn did_web_from_url(url: &str) -> String {
|
|
// Strip the scheme. We accept a bare `host:port` too, which is what
|
|
// a misconfigured `PDS_PUBLIC_URL` often contains.
|
|
let rest = url
|
|
.trim()
|
|
.trim_start_matches("https://")
|
|
.trim_start_matches("http://")
|
|
.trim_end_matches('/');
|
|
// Drop any userinfo (`user@host`) and query/fragment — neither has
|
|
// a place in a did:web identifier.
|
|
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
|
|
let rest = rest.rsplit('@').next().unwrap_or(rest);
|
|
|
|
let mut parts = rest.split('/');
|
|
let authority = parts.next().unwrap_or("");
|
|
let host = authority.replacen(':', "%3A", 1);
|
|
let mut did = format!("did:web:{host}");
|
|
for segment in parts.filter(|s| !s.is_empty()) {
|
|
did.push(':');
|
|
did.push_str(segment);
|
|
}
|
|
did
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn did_web_encodes_port_as_percent_3a() {
|
|
// The dev default. A literal colon here would be read as a
|
|
// did:web path segment, so it has to be percent-encoded.
|
|
assert_eq!(
|
|
did_web_from_url("http://127.0.0.1:2583"),
|
|
"did:web:127.0.0.1%3A2583"
|
|
);
|
|
assert_eq!(
|
|
did_web_from_url("https://pds.example.com:8443"),
|
|
"did:web:pds.example.com%3A8443"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn did_web_without_port_is_plain_host() {
|
|
assert_eq!(
|
|
did_web_from_url("https://pds.maarcadetweet.local"),
|
|
"did:web:pds.maarcadetweet.local"
|
|
);
|
|
// Trailing slash must not produce an empty path segment.
|
|
assert_eq!(
|
|
did_web_from_url("https://pds.example.com/"),
|
|
"did:web:pds.example.com"
|
|
);
|
|
// Scheme-less input is tolerated.
|
|
assert_eq!(did_web_from_url("pds.example.com"), "did:web:pds.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn did_web_appends_path_segments_with_colons() {
|
|
assert_eq!(
|
|
did_web_from_url("https://example.com/user/alice"),
|
|
"did:web:example.com:user:alice"
|
|
);
|
|
// Port + path together: only the port gets percent-encoded.
|
|
assert_eq!(
|
|
did_web_from_url("http://example.com:2583/pds"),
|
|
"did:web:example.com%3A2583:pds"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn did_web_ignores_userinfo_query_and_fragment() {
|
|
assert_eq!(
|
|
did_web_from_url("https://user@example.com?x=1#frag"),
|
|
"did:web:example.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bool_env_accepts_common_spellings() {
|
|
for on in ["1", "true", "TRUE", " yes ", "on"] {
|
|
assert!(parse_bool_env(on), "{on} should parse as true");
|
|
}
|
|
for off in ["0", "false", "no", "off", "", "nonsense"] {
|
|
assert!(!parse_bool_env(off), "{off} should parse as false");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn csv_env_trims_and_drops_empties() {
|
|
assert_eq!(
|
|
parse_csv_env("tauri://localhost, http://127.0.0.1:1430 ,,"),
|
|
vec![
|
|
"tauri://localhost".to_string(),
|
|
"http://127.0.0.1:1430".to_string()
|
|
]
|
|
);
|
|
assert!(parse_csv_env(" ").is_empty());
|
|
}
|
|
}
|