Files
maarcadetweet/crates/pds-server/src/main.rs
T
tomdeboneandClaude Opus 5 f04d63dd7b feat(pds): Einladungscodes für createAccount
Die Instanz soll öffentlich erreichbar werden. createAccount hatte bis
jetzt keinerlei Schranke: kein Code, kein Rate-Limit, describeServer
meldete invite_code_required hartkodiert false. Jeder hätte beliebig viele
Konten anlegen können, jedes mit eigenem Repo, Blöcken und
Firehose-Events.

Zwei Tabellen: invite_codes trägt Zähler und Sperre, invite_code_uses
protokolliert, welcher DID welchen Code eingelöst hat — ein Code kann
mehrere Konten wert sein, also ist "wer hat ihn benutzt" eine Menge. Die
DID hat bewusst keinen Fremdschlüssel: das Protokoll soll das Konto
überleben.

Der Kern ist das Einlösen ohne Rennen. Geprüft wird nicht vorher, sondern
im Schreiben selbst:

  UPDATE invite_codes SET used_count = used_count + 1
   WHERE code = $1 AND NOT disabled AND used_count < max_uses
  RETURNING used_count, max_uses

Der Verlierer zweier gleichzeitiger Registrierungen blockiert auf der
Zeilensperre, liest danach die committete Zeile neu, wertet die
WHERE-Klausel erneut aus, trifft nichts und bekommt 400. Ein
Lesen-dann-Schreiben hätte beide durchgelassen — ich habe genau das
probeweise eingebaut, woraufhin die Nebenläufigkeitstests umfielen
("a one-use code let 5 concurrent registrations through").

Das Einlösen ist die erste Anweisung in der bestehenden Transaktion von
create_account: die Zeilensperre hält über die Konto-Inserts, und ein
Rollback gibt den Code wieder frei. Wer ein Handle-Rennen verliert,
verliert nicht auch noch seine Einladung.

Alle Fehlerfälle — fehlend, leer, unbekannt, gesperrt, aufgebraucht —
liefern dieselbe Meldung, damit der unauthentifizierte Endpoint kein
Orakel zum Abtasten des Code-Raums wird.

Codes erzeugt das Binary selbst, statt dafür einen Endpoint zu öffnen:
`pds-server invite create --count 5 --uses 1`, dazu list und disable.

PDS_INVITE_REQUIRED steht auf false per Default — sonst brechen die
Integrationstests und jede Dev-Instanz. Der Server warnt beim Start
deutlich, solange es aus ist, und describeServer meldet jetzt den
tatsächlichen Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 21:06:45 +02:00

328 lines
12 KiB
Rust

mod appview_push;
mod car;
mod dag_cbor;
mod firehose;
mod invite;
mod jwt_issuer;
mod keys;
mod password;
mod routes;
mod state;
use crate::routes::types::DescribeServerResp;
use crate::state::AppState;
use axum::extract::State;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
/// Usage line for the binary itself. The subcommands are operator
/// tooling; the no-argument form is the server, which is what every
/// deploy script and systemd unit invokes.
const USAGE: &str = "\
usage: pds-server [command]
(no command) run the PDS server
invite … manage invite codes (see `pds-server invite help`)
help show this message";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
// Argument dispatch, by hand.
//
// The workspace carries no argument-parsing crate and this does not
// justify adding one: exactly one subcommand exists, and the
// overwhelmingly common invocation is the bare binary. Anything we
// do not recognise is an error rather than being ignored — a
// mistyped `pds-server invit create` that silently booted a server
// would look like it worked and mint no codes.
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(|s| s.as_str()) {
None => {}
Some("invite") => {
// CLI output is meant to be read and pasted, so keep the
// log stream quiet unless the operator asked for it. Without
// this, `sqlx::migrate` chatters over the codes.
init_tracing("warn");
return invite::run_cli(&args[1..]).await;
}
Some("help") | Some("-h") | Some("--help") => {
println!("{USAGE}");
return Ok(());
}
Some(other) => {
eprintln!("pds-server: unknown command: {other}\n\n{USAGE}");
std::process::exit(2);
}
}
init_tracing("info");
let cfg = at_shared::config::AppConfig::from_env()?;
// Announce the relaxed security posture before we bind a port.
//
// `PDS_INVITE_REQUIRED` is the only switch in `AppConfig` that
// defaults to *open* (so the integration suites and dev instances
// can keep creating throwaway accounts), which makes this warning
// the only thing standing between "we made the PDS public" and
// "anyone on the internet can mint repos on our disk". Mirrors
// `appview`'s `auth::log_startup_posture`.
if !cfg.pds_invite_required {
warn!(
"PDS_INVITE_REQUIRED is not true — com.atproto.server.createAccount accepts \
ANY caller, and every accepted account allocates a repo, a server-held key \
pair and firehose events. Fine on a private/dev instance; on a publicly \
reachable PDS set PDS_INVITE_REQUIRED=true and hand out codes with \
`pds-server invite create`."
);
} else {
info!("PDS_INVITE_REQUIRED=true — createAccount requires a valid invite code");
}
let db = sqlx::postgres::PgPoolOptions::new()
.max_connections(32)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(10))
.connect(&cfg.database_url_pds)
.await?;
sqlx::migrate!("../../migrations/pds").run(&db).await?;
let blob = at_blob::S3BlobStore::new(
cfg.s3_endpoint.clone(),
cfg.s3_region.clone(),
cfg.s3_access_key.clone(),
cfg.s3_secret_key.clone(),
cfg.s3_bucket_pds.clone(),
cfg.pds_public_url.clone(),
);
// Best-effort reachability check for the configured S3 endpoint.
// The PDS continues to operate if MinIO is unreachable — `uploadBlob`
// falls back to local-only storage and the S3 push is logged at
// warn level — but we want this surfaced loudly at startup so
// operators notice in dev. See `at_blob::s3` for the
// MinIO-only limitation.
if !blob.ping().await {
warn!(
endpoint = %cfg.s3_endpoint,
bucket = %cfg.s3_bucket_pds,
"s3 ping failed at startup; uploadBlob will serve from local blockstore only"
);
}
let state = AppState::new(cfg.clone(), db, blob).await;
let app = router(state);
let addr: std::net::SocketAddr = format!("{}:{}", cfg.pds_host, cfg.pds_port).parse()?;
info!("pds-server listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
/// Install the tracing subscriber, with `default_filter` as the level
/// when `RUST_LOG` says nothing.
///
/// Factored out because the two entry points want different defaults:
/// the server wants `info`, the `invite` subcommand wants `warn` so that
/// migration chatter does not land in the middle of a list of codes the
/// operator is about to copy.
fn init_tracing(default_filter: &str) {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)),
)
.init();
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/", get(root))
.route("/healthz", get(healthz))
.route("/.well-known/did.json", get(did_document))
.route(
"/xrpc/com.atproto.server.describeServer",
get(describe_server),
)
.route(
"/xrpc/com.atproto.server.createAccount",
post(routes::auth::create_account),
)
.route(
"/xrpc/com.atproto.server.createSession",
post(routes::auth::create_session),
)
.route(
"/xrpc/com.atproto.server.refreshSession",
post(routes::auth::refresh_session),
)
.route(
"/xrpc/com.atproto.identity.resolveHandle",
post(routes::identity::resolve_handle),
)
.route(
"/xrpc/com.atproto.repo.createRecord",
post(routes::repo::create_record),
)
.route(
"/xrpc/com.atproto.repo.deleteRecord",
post(routes::feed::delete_record),
)
.route(
"/xrpc/com.atproto.feed.like.create",
post(routes::feed::create_like),
)
.route(
"/xrpc/com.atproto.uploadBlob",
post(routes::blob::upload_blob)
.layer(routes::blob::upload_blob_body_limit())
.layer::<_, std::convert::Infallible>(axum::middleware::from_fn(
routes::blob::body_limit_fallback,
)),
)
.route(
"/xrpc/com.atproto.sync.getRepo",
get(routes::sync::get_repo),
)
.route(
"/xrpc/com.atproto.sync.getBlocks",
get(routes::sync::get_blocks),
)
.route(
"/xrpc/com.atproto.sync.getLatestCommit",
get(routes::sync::get_latest_commit),
)
.route(
"/xrpc/com.atproto.sync.getRecord",
get(routes::sync::get_record),
)
.route(
"/xrpc/app.bsky.actor.profile.get",
get(routes::profile::get_profile),
)
.route(
"/xrpc/app.bsky.actor.profile.set",
post(routes::profile::set_profile),
)
.route(
"/xrpc/com.atproto.sync.listRepos",
get(routes::sync::list_repos),
)
.route(
"/xrpc/com.atproto.sync.getBlob",
get(routes::blob::get_blob),
)
// The firehose. A WebSocket upgrade arrives as a plain GET, so this
// is a normal `get` route whose handler happens to return an
// upgrade response.
.route(
"/xrpc/com.atproto.sync.subscribeRepos",
get(routes::subscribe_repos::subscribe_repos),
)
.route(
"/blob/:cid",
get(routes::blob::get_blob_by_cid),
)
.with_state(state)
}
async fn root() -> Json<serde_json::Value> {
Json(json!({
"name": "maarcadetweet-pds",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn healthz() -> Json<serde_json::Value> {
Json(json!({ "ok": true }))
}
/// `GET /.well-known/did.json` — the PDS's own DID document.
///
/// This is how the AppView (and any other relying party) learns the
/// P-256 public key that the access JWTs in
/// `Authorization: Bearer …` are signed with. Without it the AppView
/// could not verify a token at all, and the only alternative would be
/// shipping `PDS_JWT_SECRET` to a second service — a private signing
/// key crossing a service boundary, for a check that needs nothing but
/// the public half.
///
/// Nothing in this response is secret. `publicKeyMultibase` is the
/// uncompressed P-256 point derived from `PDS_JWT_SECRET` by
/// [`jwt_issuer::server_p256_public_multibase`]; the secret itself
/// never leaves this process.
///
/// The document id is [`AppConfig::pds_did`], i.e. it follows
/// `PDS_PUBLIC_URL` — so a `did:web:` resolver that starts from the DID,
/// rebuilds the URL and fetches this path lands back here rather than at
/// some other host's document.
async fn did_document(State(state): State<AppState>) -> Result<Json<serde_json::Value>, (axum::http::StatusCode, Json<serde_json::Value>)> {
let did = state.cfg.pds_did();
let public_multibase = jwt_issuer::server_p256_public_multibase(&state.cfg).map_err(|e| {
// A malformed `PDS_JWT_SECRET` is the one way this fails, and
// it is exactly the failure that also breaks every token this
// server issues — surface it instead of publishing a document
// with a missing key.
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "InternalServerError",
"message": format!("server key unavailable: {e}"),
})),
)
})?;
Ok(Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
],
"id": did,
"verificationMethod": [{
// `#atproto` is the fragment AT Proto uses for a repo's
// signing key; we reuse it for the server key so a generic
// did:web consumer finds it in the usual place.
"id": format!("{did}#atproto"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_multibase,
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": state.cfg.pds_public_url,
}],
})))
}
async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerResp> {
Json(DescribeServerResp {
// Derived from `PDS_PUBLIC_URL`, never hardcoded — see
// `AppConfig::pds_did`. The same value ids the document at
// `/.well-known/did.json`.
did: state.cfg.pds_did(),
available_user_domains: vec![state
.cfg
.pds_handle_dns_zone
.trim_start_matches('.')
.to_string()],
// The real switch, not a hardcoded `false`. A client reads this
// to decide whether to ask the user for a code *before*
// collecting a handle and password — advertising `false` on a
// server that then answers `400 InvalidInviteCode` sends the
// user back to the start of a form they already filled in.
invite_code_required: state.cfg.pds_invite_required,
links: json!({
"termsOfService": null,
"privacyPolicy": null,
}),
})
}