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
@@ -0,0 +1,684 @@
|
||||
//! Invite codes: minting, listing, and the single redeem operation that
|
||||
//! `com.atproto.server.createAccount` calls.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! `createAccount` had no gate at all. That was fine while the PDS only
|
||||
//! answered on `127.0.0.1:2583`; it is not fine on a public name, because
|
||||
//! each accepted account allocates a repo head, a server-held key pair, an
|
||||
//! MST that grows with every write, and a stream of firehose events every
|
||||
//! subscribed AppView is obliged to index. Invite codes are the smallest
|
||||
//! gate that turns "anyone with curl" into "anyone the operator handed a
|
||||
//! string to".
|
||||
//!
|
||||
//! The gate is off by default (`PDS_INVITE_REQUIRED`, see
|
||||
//! [`at_shared::config`]) so the existing test suites and dev instances
|
||||
//! keep creating throwaway accounts; `main` warns loudly on every boot
|
||||
//! where it is off.
|
||||
//!
|
||||
//! # Where codes come from
|
||||
//!
|
||||
//! Nowhere over HTTP. Minting lives in the `pds-server invite` subcommand
|
||||
//! ([`run_cli`]), which the operator runs on the box. Adding a
|
||||
//! `createInviteCode` endpoint would mean the thing that guards
|
||||
//! registration is itself reachable by whoever can reach registration —
|
||||
//! at which point it guards nothing, and the only question left is
|
||||
//! whether *its* auth has a hole. A subcommand has no attack surface to
|
||||
//! get wrong.
|
||||
//!
|
||||
//! # The one interesting piece of code in here
|
||||
//!
|
||||
//! [`redeem`] is a single conditional `UPDATE … RETURNING`, not a
|
||||
//! `SELECT`-then-`UPDATE`. See its docs and
|
||||
//! `migrations/pds/0004_invite_codes.sql` for why that distinction is the
|
||||
//! whole feature.
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
/// Alphabet the codes are drawn from: Crockford base32, lowercased.
|
||||
///
|
||||
/// Exactly 32 symbols, which is the property that matters — it lets each
|
||||
/// character consume exactly 5 bits of entropy with no modulo bias, so
|
||||
/// every code in the space is equally likely. A 31- or 36-character
|
||||
/// "human friendly" alphabet would need rejection sampling to say the
|
||||
/// same thing, and the usual `byte % len` shortcut would quietly make
|
||||
/// some characters more probable than others.
|
||||
///
|
||||
/// The excluded letters are Crockford's: `i`, `l`, `o` and `u`. The
|
||||
/// first three are the ones people mistype as `1`, `1` and `0` when
|
||||
/// copying a code out of a chat message; `u` is dropped so a random draw
|
||||
/// cannot spell something the operator has to apologise for.
|
||||
const CODE_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// Characters per group, and groups per code. Two groups of five is
|
||||
/// 50 bits of entropy — far past anything an online guesser can reach
|
||||
/// against a database round-trip per attempt, and short enough to read
|
||||
/// aloud.
|
||||
const GROUP_LEN: usize = 5;
|
||||
const GROUPS: usize = 2;
|
||||
|
||||
/// Fixed prefix so a code is recognisable as one when it turns up out of
|
||||
/// context (a support ticket, a pasted log line) and so it cannot be
|
||||
/// confused with a handle or a DID.
|
||||
const CODE_PREFIX: &str = "mt";
|
||||
|
||||
/// Why a redemption was refused.
|
||||
///
|
||||
/// Deliberately coarse. The route maps [`RedeemError::Invalid`] to a
|
||||
/// single `400 InvalidInviteCode` with one fixed message, so an
|
||||
/// unauthenticated caller cannot use the error text to distinguish
|
||||
/// "no such code" from "that code exists but is used up" — which would
|
||||
/// turn the endpoint into an oracle for probing the code space.
|
||||
#[derive(Debug)]
|
||||
pub enum RedeemError {
|
||||
/// Unknown, disabled, or already at its use limit. One variant on
|
||||
/// purpose: see the type docs.
|
||||
Invalid,
|
||||
/// The database itself failed. Distinct from [`RedeemError::Invalid`]
|
||||
/// because this is a `500`, not a `400` — refusing a legitimate code
|
||||
/// because Postgres hiccuped would be a lie to the user.
|
||||
Db(sqlx::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RedeemError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RedeemError::Invalid => write!(f, "invite code is not valid"),
|
||||
RedeemError::Db(e) => write!(f, "invite lookup failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a client-supplied code into the form stored in the table.
|
||||
///
|
||||
/// Trims surrounding whitespace (people paste codes with a trailing
|
||||
/// newline out of a terminal) and lowercases. Every generated code is
|
||||
/// already lowercase ASCII, so this is an exact normalisation — which is
|
||||
/// what lets [`redeem`] look the code up with a plain `code = $1` and
|
||||
/// hit the primary-key index, instead of `lower(code) = $1`, which
|
||||
/// would force a sequential scan on the one query that runs per
|
||||
/// registration attempt.
|
||||
///
|
||||
/// Returns `None` for a code that is empty after trimming, so "field
|
||||
/// present but blank" and "field absent" reach the route as the same
|
||||
/// case.
|
||||
pub fn normalize(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Generate one cryptographically random invite code, e.g.
|
||||
/// `mt-7k3qw-z9d2m`.
|
||||
///
|
||||
/// Randomness comes from [`OsRng`] — the same source
|
||||
/// `keys::generate_user_keys` and `password::hash_password` already use
|
||||
/// in this crate, i.e. the OS CSPRNG, never a seeded or thread-local
|
||||
/// generator. A code is a bearer credential for creating an account on
|
||||
/// this server; a predictable one is the same bug as a predictable
|
||||
/// password-reset token.
|
||||
///
|
||||
/// Entropy: [`GROUPS`] × [`GROUP_LEN`] characters × 5 bits = 50 bits.
|
||||
pub fn generate_code() -> String {
|
||||
let total = GROUPS * GROUP_LEN;
|
||||
let mut bytes = vec![0u8; total];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
|
||||
let mut out = String::with_capacity(CODE_PREFIX.len() + total + GROUPS);
|
||||
out.push_str(CODE_PREFIX);
|
||||
for chunk in bytes.chunks(GROUP_LEN) {
|
||||
out.push('-');
|
||||
for b in chunk {
|
||||
// Take the low 5 bits of a uniformly random byte. The
|
||||
// alphabet is exactly 32 symbols, so this is a bijection
|
||||
// from 5 bits onto it — no bias, no rejection loop.
|
||||
out.push(CODE_ALPHABET[(*b & 0b0001_1111) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Consume one use of `code` on behalf of the account `did` / `handle`.
|
||||
///
|
||||
/// **This must be called with the same transaction that creates the
|
||||
/// account.** The whole point is that a code is spent if and only if an
|
||||
/// account was actually created: if the caller's transaction rolls back
|
||||
/// — handle taken, key generation failed, anything — the counter goes
|
||||
/// back with it and the code is still redeemable. `create_account`
|
||||
/// therefore begins its transaction, redeems here, inserts `users` and
|
||||
/// `repos`, and only then commits.
|
||||
///
|
||||
/// # The race, and why there isn't one
|
||||
///
|
||||
/// The tempting implementation is: `SELECT used_count, max_uses …`,
|
||||
/// compare in Rust, then `UPDATE`. That is a check-then-act. Two
|
||||
/// registrations arriving together on a code with one use left both read
|
||||
/// `used_count = 0`, both conclude they may proceed, and both write
|
||||
/// `used_count = 1`. Two accounts, one use — and the row afterwards
|
||||
/// claims it was redeemed once, so nothing even shows up as wrong.
|
||||
///
|
||||
/// Instead the check *is* the write:
|
||||
///
|
||||
/// ```sql
|
||||
/// 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
|
||||
/// ```
|
||||
///
|
||||
/// Postgres serialises the two statements on the row lock. The loser
|
||||
/// blocks until the winner commits, and then — this is the part that
|
||||
/// makes it work — does *not* continue with its old snapshot: it
|
||||
/// re-fetches the committed row and re-evaluates the `WHERE` clause
|
||||
/// against it (EvalPlanQual). `used_count` is now `1`, the predicate is
|
||||
/// false, the row is dropped from the update set, and the statement
|
||||
/// affects zero rows. Zero rows is the rejection. This function never
|
||||
/// forms an opinion about validity that could be stale by the time it
|
||||
/// acts on it, because it never looks before it writes.
|
||||
///
|
||||
/// The `invite_code_uses` insert that follows is inside the same
|
||||
/// transaction and the same row lock, so the counter and the audit rows
|
||||
/// cannot drift apart.
|
||||
pub async fn redeem(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
code: &str,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
) -> Result<(), RedeemError> {
|
||||
let normalized = match normalize(code) {
|
||||
Some(c) => c,
|
||||
None => return Err(RedeemError::Invalid),
|
||||
};
|
||||
|
||||
// One statement, and its row count is the verdict.
|
||||
let claimed: Option<(i32, i32)> = sqlx::query_as(
|
||||
r#"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"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(RedeemError::Db)?;
|
||||
|
||||
if claimed.is_none() {
|
||||
return Err(RedeemError::Invalid);
|
||||
}
|
||||
|
||||
// Audit trail: which account this code produced. Same transaction,
|
||||
// so it lands exactly when the counter increment does.
|
||||
//
|
||||
// The `(code, did)` primary key makes a duplicate impossible; a
|
||||
// conflict here would mean the same DID redeemed the same code
|
||||
// twice in one registration, which cannot happen but would corrupt
|
||||
// the counter/uses agreement if it did — so let it be an error
|
||||
// rather than silently ignoring it.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO invite_code_uses (code, did, handle)
|
||||
VALUES ($1, $2, $3)"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.bind(did)
|
||||
.bind(handle)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(RedeemError::Db)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CLI: `pds-server invite …`
|
||||
// =====================================================
|
||||
|
||||
/// One row of `invite list`, and the shape `create` hands back.
|
||||
#[derive(Debug)]
|
||||
pub struct InviteRow {
|
||||
pub code: String,
|
||||
pub max_uses: i32,
|
||||
pub used_count: i32,
|
||||
pub disabled: bool,
|
||||
pub note: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// What a parsed `invite` command line asks for.
|
||||
///
|
||||
/// Parsed out of `std::env::args` by hand. The workspace has no
|
||||
/// argument-parsing dependency and this subcommand is not worth adding
|
||||
/// one for: three verbs, three flags, and a hand-rolled parser that is
|
||||
/// small enough to unit-test exhaustively (which it is, below) beats a
|
||||
/// derive macro plus a new crate in the dependency tree of a server
|
||||
/// binary.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InviteCommand {
|
||||
/// `invite create [--count N] [--uses N] [--note TEXT]`
|
||||
Create {
|
||||
count: u32,
|
||||
uses: i32,
|
||||
note: Option<String>,
|
||||
},
|
||||
/// `invite list [--all]` — without `--all`, spent and disabled codes
|
||||
/// are hidden, because the question the operator almost always has
|
||||
/// is "what can I still hand out".
|
||||
List { all: bool },
|
||||
/// `invite disable <code>` — stop honouring a code without losing
|
||||
/// the record of which accounts it already created.
|
||||
Disable { code: String },
|
||||
}
|
||||
|
||||
/// Usage text. Printed for `invite help`, and for anything that fails to
|
||||
/// parse.
|
||||
pub const INVITE_USAGE: &str = "\
|
||||
usage: pds-server invite <command>
|
||||
|
||||
create [--count N] [--uses N] [--note TEXT]
|
||||
Mint N codes (default 1), each good for `--uses` accounts
|
||||
(default 1). Prints one code per line and nothing else, so the
|
||||
output can be piped or pasted directly.
|
||||
|
||||
list [--all]
|
||||
Show codes that can still be redeemed. --all includes spent and
|
||||
disabled ones.
|
||||
|
||||
disable <code>
|
||||
Stop honouring a code. The record of accounts it already created
|
||||
is kept.
|
||||
|
||||
The database is the one named by DATABASE_URL_PDS (read from .env like
|
||||
the server does). Codes are only meaningful while PDS_INVITE_REQUIRED
|
||||
is true.";
|
||||
|
||||
/// Parse the arguments after the `invite` verb.
|
||||
///
|
||||
/// Returns `Err(message)` for anything malformed; the caller prints the
|
||||
/// message plus [`INVITE_USAGE`] and exits non-zero. Unknown flags are
|
||||
/// an error rather than being ignored — a typo'd `--uses` that silently
|
||||
/// became `1` would hand out the wrong codes and the operator would only
|
||||
/// find out when the second person to use one got a `400`.
|
||||
pub fn parse_invite_args(args: &[String]) -> Result<InviteCommand, String> {
|
||||
let verb = args
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.ok_or_else(|| "missing invite command".to_string())?;
|
||||
let rest = &args[1..];
|
||||
|
||||
match verb {
|
||||
"create" => {
|
||||
let mut count: u32 = 1;
|
||||
let mut uses: i32 = 1;
|
||||
let mut note: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
"--count" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--count needs a value".to_string())?;
|
||||
count = v
|
||||
.parse()
|
||||
.map_err(|_| format!("--count: not a number: {v}"))?;
|
||||
if count == 0 {
|
||||
return Err("--count must be at least 1".to_string());
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
"--uses" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--uses needs a value".to_string())?;
|
||||
uses = v
|
||||
.parse()
|
||||
.map_err(|_| format!("--uses: not a number: {v}"))?;
|
||||
// Mirrors the table's CHECK (max_uses > 0). Caught
|
||||
// here so the operator gets a sentence instead of a
|
||||
// constraint-violation dump.
|
||||
if uses < 1 {
|
||||
return Err("--uses must be at least 1".to_string());
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
"--note" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--note needs a value".to_string())?;
|
||||
note = Some(v.clone());
|
||||
i += 2;
|
||||
}
|
||||
other => return Err(format!("unknown option for `create`: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(InviteCommand::Create { count, uses, note })
|
||||
}
|
||||
"list" => {
|
||||
let mut all = false;
|
||||
for a in rest {
|
||||
match a.as_str() {
|
||||
"--all" => all = true,
|
||||
other => return Err(format!("unknown option for `list`: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(InviteCommand::List { all })
|
||||
}
|
||||
"disable" => {
|
||||
let code = rest
|
||||
.first()
|
||||
.ok_or_else(|| "disable needs a code".to_string())?;
|
||||
if rest.len() > 1 {
|
||||
return Err("disable takes exactly one code".to_string());
|
||||
}
|
||||
let code = normalize(code).ok_or_else(|| "disable needs a code".to_string())?;
|
||||
Ok(InviteCommand::Disable { code })
|
||||
}
|
||||
other => Err(format!("unknown invite command: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `count` freshly generated codes, each good for `uses`
|
||||
/// accounts.
|
||||
///
|
||||
/// Retries on a primary-key collision. With 50 bits per code a
|
||||
/// collision is not something that will happen, but "not something that
|
||||
/// will happen" is exactly the class of event that turns into a
|
||||
/// confusing `duplicate key` traceback at 2am, and the retry costs three
|
||||
/// lines.
|
||||
pub async fn create_codes(
|
||||
db: &PgPool,
|
||||
count: u32,
|
||||
uses: i32,
|
||||
note: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let code = generate_code();
|
||||
let inserted = sqlx::query(
|
||||
r#"INSERT INTO invite_codes (code, max_uses, note)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (code) DO NOTHING"#,
|
||||
)
|
||||
.bind(&code)
|
||||
.bind(uses)
|
||||
.bind(note)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if inserted == 1 {
|
||||
out.push(code);
|
||||
break;
|
||||
}
|
||||
attempt += 1;
|
||||
if attempt >= 5 {
|
||||
anyhow::bail!("could not find a free invite code after 5 attempts");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read back codes for `invite list`.
|
||||
pub async fn list_codes(db: &PgPool, all: bool) -> anyhow::Result<Vec<InviteRow>> {
|
||||
// Two statements rather than one with a `$1`-toggled predicate:
|
||||
// the redeemable filter is exactly the redeem query's `WHERE`
|
||||
// clause, and keeping it spelled the same way makes it obvious that
|
||||
// `list` shows what `redeem` would accept.
|
||||
let sql = if all {
|
||||
r#"SELECT code, max_uses, used_count, disabled, note, created_at
|
||||
FROM invite_codes
|
||||
ORDER BY created_at DESC"#
|
||||
} else {
|
||||
r#"SELECT code, max_uses, used_count, disabled, note, created_at
|
||||
FROM invite_codes
|
||||
WHERE NOT disabled AND used_count < max_uses
|
||||
ORDER BY created_at DESC"#
|
||||
};
|
||||
let rows: Vec<(String, i32, i32, bool, Option<String>, chrono::DateTime<chrono::Utc>)> =
|
||||
sqlx::query_as(sql).fetch_all(db).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(code, max_uses, used_count, disabled, note, created_at)| InviteRow {
|
||||
code,
|
||||
max_uses,
|
||||
used_count,
|
||||
disabled,
|
||||
note,
|
||||
created_at,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Flip `disabled` on one code. Returns `false` if there is no such
|
||||
/// code, so the CLI can say so instead of reporting a successful no-op.
|
||||
pub async fn disable_code(db: &PgPool, code: &str) -> anyhow::Result<bool> {
|
||||
let n = sqlx::query("UPDATE invite_codes SET disabled = TRUE WHERE code = $1")
|
||||
.bind(code)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
/// Run the `invite` subcommand end to end: parse, connect, act, print.
|
||||
///
|
||||
/// Connects with the same `DATABASE_URL_PDS` and runs the same
|
||||
/// migrations as [`crate::main`], so `invite create` works on a fresh
|
||||
/// checkout before the server has ever been started — otherwise the
|
||||
/// first thing an operator does after deploying would fail with
|
||||
/// "relation invite_codes does not exist".
|
||||
pub async fn run_cli(args: &[String]) -> anyhow::Result<()> {
|
||||
if matches!(args.first().map(|s| s.as_str()), None | Some("help") | Some("-h") | Some("--help"))
|
||||
{
|
||||
println!("{INVITE_USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
let cmd = match parse_invite_args(args) {
|
||||
Ok(c) => c,
|
||||
Err(msg) => {
|
||||
eprintln!("pds-server invite: {msg}\n\n{INVITE_USAGE}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let cfg = at_shared::config::AppConfig::from_env()?;
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&cfg.database_url_pds)
|
||||
.await?;
|
||||
sqlx::migrate!("../../migrations/pds").run(&db).await?;
|
||||
|
||||
match cmd {
|
||||
InviteCommand::Create { count, uses, note } => {
|
||||
let codes = create_codes(&db, count, uses, note.as_deref()).await?;
|
||||
// Bare codes, one per line, nothing else on stdout — the
|
||||
// operator pipes this into a message or a file. Anything
|
||||
// decorative here would have to be stripped by hand.
|
||||
for c in &codes {
|
||||
println!("{c}");
|
||||
}
|
||||
if !cfg.pds_invite_required {
|
||||
// Not an error: minting codes before flipping the switch
|
||||
// is the correct order of operations. But an operator
|
||||
// who thinks they have just closed registration should
|
||||
// find out now.
|
||||
eprintln!(
|
||||
"note: PDS_INVITE_REQUIRED is not true — createAccount currently \
|
||||
accepts requests without any code."
|
||||
);
|
||||
}
|
||||
}
|
||||
InviteCommand::List { all } => {
|
||||
let rows = list_codes(&db, all).await?;
|
||||
if rows.is_empty() {
|
||||
eprintln!("no invite codes");
|
||||
}
|
||||
for r in rows {
|
||||
let state = if r.disabled {
|
||||
"disabled"
|
||||
} else if r.used_count >= r.max_uses {
|
||||
"spent"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
println!(
|
||||
"{} {}/{} {} {} {}",
|
||||
r.code,
|
||||
r.used_count,
|
||||
r.max_uses,
|
||||
state,
|
||||
r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
|
||||
r.note.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
InviteCommand::Disable { code } => {
|
||||
if disable_code(&db, &code).await? {
|
||||
println!("disabled {code}");
|
||||
} else {
|
||||
eprintln!("no such invite code: {code}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- tests -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn generated_codes_use_only_the_safe_alphabet() {
|
||||
let code = generate_code();
|
||||
// `mt-xxxxx-xxxxx`
|
||||
assert!(code.starts_with("mt-"), "code = {code}");
|
||||
let groups: Vec<&str> = code.split('-').collect();
|
||||
assert_eq!(groups.len(), GROUPS + 1, "code = {code}");
|
||||
assert_eq!(groups[0], CODE_PREFIX);
|
||||
for g in &groups[1..] {
|
||||
assert_eq!(g.len(), GROUP_LEN, "group {g} in {code}");
|
||||
for ch in g.chars() {
|
||||
assert!(
|
||||
CODE_ALPHABET.contains(&(ch as u8)),
|
||||
"character {ch:?} in {code} is outside the alphabet"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The letters people mistype must never appear.
|
||||
for bad in ['i', 'l', 'o', 'u'] {
|
||||
assert!(!code[3..].contains(bad), "{code} contains {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_codes_do_not_repeat() {
|
||||
// Not a randomness test — a 1000-draw collision would mean the
|
||||
// generator is returning a constant or reusing a seeded RNG,
|
||||
// which is the failure mode that actually happens when someone
|
||||
// swaps `OsRng` for `thread_rng` with a fixed seed in a test
|
||||
// helper.
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
assert!(seen.insert(generate_code()), "duplicate code in 1000 draws");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_trims_and_lowercases() {
|
||||
assert_eq!(normalize(" MT-ABCDE-FGHJK \n").as_deref(), Some("mt-abcde-fghjk"));
|
||||
assert_eq!(normalize("mt-abcde-fghjk").as_deref(), Some("mt-abcde-fghjk"));
|
||||
// Absent and blank must be indistinguishable to the route.
|
||||
assert_eq!(normalize(""), None);
|
||||
assert_eq!(normalize(" "), None);
|
||||
assert_eq!(normalize("\t\n"), None);
|
||||
}
|
||||
|
||||
fn args(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_create_with_defaults_and_flags() {
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 1,
|
||||
uses: 1,
|
||||
note: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create", "--count", "5", "--uses", "1"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 5,
|
||||
uses: 1,
|
||||
note: None
|
||||
}
|
||||
);
|
||||
// Order must not matter, and --note takes the next argument
|
||||
// verbatim (spaces included).
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create", "--note", "meetup 2026", "--uses", "3"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 1,
|
||||
uses: 3,
|
||||
note: Some("meetup 2026".to_string())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_create_flags() {
|
||||
// A typo'd flag must not be silently ignored — that would hand
|
||||
// out codes with the default limits.
|
||||
assert!(parse_invite_args(&args(&["create", "--use", "3"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count", "x"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count", "0"])).is_err());
|
||||
// max_uses > 0 is a table constraint; catch it before Postgres does.
|
||||
assert!(parse_invite_args(&args(&["create", "--uses", "0"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--uses", "-2"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_list_and_disable() {
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["list"])).unwrap(),
|
||||
InviteCommand::List { all: false }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["list", "--all"])).unwrap(),
|
||||
InviteCommand::List { all: true }
|
||||
);
|
||||
assert!(parse_invite_args(&args(&["list", "--everything"])).is_err());
|
||||
// `disable` normalises the code the same way redeem does, so an
|
||||
// operator pasting a shouted code still disables the right row.
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["disable", " MT-ABCDE-FGHJK "])).unwrap(),
|
||||
InviteCommand::Disable {
|
||||
code: "mt-abcde-fghjk".to_string()
|
||||
}
|
||||
);
|
||||
assert!(parse_invite_args(&args(&["disable"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["disable", "a", "b"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_verb_and_empty_args() {
|
||||
assert!(parse_invite_args(&args(&[])).is_err());
|
||||
assert!(parse_invite_args(&args(&["destroy"])).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -93,6 +93,55 @@ pub async fn create_account(
|
||||
|
||||
let mut tx = state.db.begin().await.map_err(|e| internal(e))?;
|
||||
|
||||
// Invite gate.
|
||||
//
|
||||
// Inside the transaction, and *first* inside it, for two reasons.
|
||||
//
|
||||
// Inside, because the code must be spent if and only if an account
|
||||
// was really created. Redeeming before `begin()` (or in a
|
||||
// transaction of its own) would burn a code every time the INSERT
|
||||
// below hit the `users.handle` unique index — a user who lost a
|
||||
// handle race would also lose their invite, with nothing to show
|
||||
// for it. Everything from here to `tx.commit()` rolls back together.
|
||||
//
|
||||
// First, because `invite::redeem` takes the code row's lock, and
|
||||
// holding it across the account INSERTs is what serialises two
|
||||
// registrations that present the same last remaining use. See
|
||||
// `invite::redeem` for how the conditional UPDATE turns that lock
|
||||
// into a correct decision rather than a stale one.
|
||||
//
|
||||
// Note this runs after the handle/password validation above, so a
|
||||
// malformed request is rejected without touching a code at all.
|
||||
if state.cfg.pds_invite_required {
|
||||
let supplied = req
|
||||
.invite_code
|
||||
.as_deref()
|
||||
.and_then(crate::invite::normalize);
|
||||
match supplied {
|
||||
None => return Err(invalid_invite_code()),
|
||||
Some(code) => {
|
||||
if let Err(e) =
|
||||
crate::invite::redeem(&mut tx, &code, &did, &req.handle).await
|
||||
{
|
||||
return match e {
|
||||
crate::invite::RedeemError::Invalid => {
|
||||
// Deliberately not logged with the code at
|
||||
// info level: a public endpoint that echoes
|
||||
// every guessed code into the log is a way
|
||||
// to fill the disk from outside.
|
||||
warn!(
|
||||
handle = %req.handle,
|
||||
"createAccount rejected: invite code invalid, disabled or spent"
|
||||
);
|
||||
Err(invalid_invite_code())
|
||||
}
|
||||
crate::invite::RedeemError::Db(db_err) => Err(internal(db_err)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (did, handle, email, password_hash, signing_key, rotation_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"#,
|
||||
@@ -306,6 +355,28 @@ pub async fn refresh_session(
|
||||
}))
|
||||
}
|
||||
|
||||
/// The one error a failed invite check produces.
|
||||
///
|
||||
/// Same `(StatusCode, Json<ErrorBody>)` shape every other route in this
|
||||
/// module returns, so a client parses it with the code it already has:
|
||||
/// `{"error": "InvalidInviteCode", "message": "..."}` under a `400`.
|
||||
///
|
||||
/// One message for every failure mode — missing, unknown, disabled,
|
||||
/// spent — on purpose. A distinct "that code exists but is used up"
|
||||
/// would let an unauthenticated caller walk the code space and learn
|
||||
/// which strings are real, which is most of the work of stealing one.
|
||||
/// The operator can tell the cases apart from `pds-server invite list`;
|
||||
/// the internet cannot.
|
||||
fn invalid_invite_code() -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InvalidInviteCode",
|
||||
Some("a valid invite code is required to create an account on this server".into()),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn internal(e: impl std::fmt::Display) -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -6,6 +6,18 @@ pub struct CreateAccountReq {
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub did: Option<String>,
|
||||
/// The invite code, when `PDS_INVITE_REQUIRED` is on.
|
||||
///
|
||||
/// The alias is not cosmetic. This struct — like every other type in
|
||||
/// this module — is snake_case on the wire, which is what our own
|
||||
/// clients send. The AT Protocol lexicon for
|
||||
/// `com.atproto.server.createAccount` spells the field `inviteCode`,
|
||||
/// so every off-the-shelf atproto client sends *that*, and without
|
||||
/// the alias serde would drop it into `None` silently — the account
|
||||
/// would be refused with "an invite code is required" while the user
|
||||
/// is looking at the code they just pasted. Accepting both spellings
|
||||
/// costs one attribute; debugging that report costs an afternoon.
|
||||
#[serde(alias = "inviteCode")]
|
||||
pub invite_code: Option<String>,
|
||||
pub recovery_key: Option<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user