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
This commit is contained in:
co-authored by
Claude Opus 5
parent
b58cb75cfe
commit
f04d63dd7b
@@ -2,6 +2,7 @@ mod appview_push;
|
||||
mod car;
|
||||
mod dag_cbor;
|
||||
mod firehose;
|
||||
mod invite;
|
||||
mod jwt_issuer;
|
||||
mod keys;
|
||||
mod password;
|
||||
@@ -17,6 +18,16 @@ 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.
|
||||
@@ -25,11 +36,57 @@ async fn main() -> anyhow::Result<()> {
|
||||
// PDS_HOST`. Real environment variables always win over the file.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.init();
|
||||
// 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)
|
||||
@@ -71,6 +128,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
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))
|
||||
@@ -241,7 +313,12 @@ async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerRe
|
||||
.pds_handle_dns_zone
|
||||
.trim_start_matches('.')
|
||||
.to_string()],
|
||||
invite_code_required: false,
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user