From cc3b8742bcdfed3051413ee436c909c72032b873 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 13:55:34 +0000 Subject: [PATCH] The first admin gets a random password, not admin / ipodderx (#321) A database with no accounts created admin with the hard-coded password ipodderx, published in the README, the docs and the sample compose file, and the log line announcing it printed the password. /api/login is open to anyone who reaches the port, and nothing made anyone change it, so a fresh install was an admin account anyone could sign into. Now the password is IPX_ADMIN_PASSWORD when set, for scripted installs, or 24 random hex characters written to admin-password beside config.toml, created 0600 so it is never readable by others even for a moment. The log says where it is, never what it is, as it does for the web token (#40). Existing databases are untouched: seeding runs only with no accounts at all. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- crates/ipx/CHANGELOG.md | 1 + crates/ipx/src/main.rs | 64 +++++++++++++++++++++++++++++++++++++---- docker-compose.yml | 3 +- docs/cli.md | 3 +- docs/configuration.md | 1 + docs/users.md | 5 ++-- 7 files changed, 70 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 79decc5..92d6e1f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ cargo build --release ./target/release/ipx daemon ``` -The first start creates **admin / ipodderx**. Sign in at `/login`, then change it: +The first start creates **admin** with a random password, written to `admin-password` beside +config.toml (readable by ipx's user alone; the log says where, never what it is). Set +`IPX_ADMIN_PASSWORD` to choose it instead. Sign in at `/login`, then change it: ```sh echo -n 'a good password' | ipx user passwd admin diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index d3fa5a8..74d3139 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A `.torrent` file larger than 10 MB, or slower than the torrent stall limit, is refused. iPX read one into memory however large it was (#330). - Feeds, episodes, artwork and `.torrent` files are no longer fetched from internal addresses (loopback, the LAN, Docker's networks), redirects included, unless config.toml's `fetch_internal` lists them. Adding a feed could read what a service inside the network answered (#320). - HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351). +- A new install's `admin` gets a random password, written to `admin-password` beside config.toml and readable by iPX's user alone, or the one in `IPX_ADMIN_PASSWORD`. Every install began as admin / ipodderx, a password published in the README, and the log printed it (#321). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 3491336..e460d35 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -118,9 +118,35 @@ enum UserCmd { Rename { name: String, new_name: String }, } -/// What a brand new database starts with, so there is always a way in. Announced loudly -/// in the log, and the first thing the settings page nags about. -const DEFAULT_PASSWORD: &str = "ipodderx"; +/// The first admin's password: `IPX_ADMIN_PASSWORD` when set, or a random one written to +/// `admin-password` beside config.toml, readable by the daemon's user alone. Every install began +/// as admin / ipodderx, published in the README, and nothing made anyone change it (#321). +/// Returns the password and where it can be found, for the log, which never shows it. +fn first_password(config_dir: &std::path::Path, from_env: Option) -> Result<(String, String)> { + if let Some(p) = from_env.filter(|p| !p.is_empty()) { + return Ok((p, "with the password in IPX_ADMIN_PASSWORD".into())); + } + let password = crate::auth::new_session_token()[..24].to_owned(); + let file = config_dir.join("admin-password"); + write_private(&file, &password)?; + Ok((password, format!("with the password in {}", file.display()))) +} + +/// Writes a secret only its owner may read: created 0600, never readable for a moment between. +fn write_private(file: &std::path::Path, text: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + std::fs::create_dir_all(file.parent().unwrap_or(std::path::Path::new(".")))?; + let _ = std::fs::remove_file(file); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(file) + .with_context(|| format!("writing {}", file.display()))?; + f.write_all(text.as_bytes())?; + Ok(()) +} /// Everything a command needs. One per process. pub struct Ctx { @@ -611,10 +637,12 @@ async fn daemon( // A database with nobody in it cannot be signed into. if ctx.db.users().await?.is_empty() { - ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true).await?; + let dir = ctx.config_path.parent().unwrap_or(std::path::Path::new(".")); + let (password, told) = first_password(dir, std::env::var("IPX_ADMIN_PASSWORD").ok())?; + ctx.db.create_user("admin", Some(&crate::auth::hash_password(&password)?), true).await?; tracing::warn!( - "no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ - Change it with `echo -n | ipx user passwd admin`" + "no accounts yet: created 'admin' {told}. Sign in and change it, or with \ + `echo -n | ipx user passwd admin`" ); } @@ -2448,6 +2476,30 @@ fn duration(secs: u64) -> String { mod tests { use super::*; + #[test] + fn the_first_admin_gets_a_password_nobody_else_knows() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("ipx-first-password-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let (a, told) = first_password(&dir, None).unwrap(); + let file = dir.join("admin-password"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), a); + assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); + assert!(told.contains("admin-password") && !told.contains(&a), "the log says where, not what: {told}"); + assert_eq!(a.len(), 24); + assert_ne!(a, "ipodderx"); + assert_ne!(first_password(&dir, None).unwrap().0, a, "each new database gets its own"); + + std::fs::remove_dir_all(&dir).unwrap(); + let (b, told) = first_password(&dir, Some("chosen".into())).unwrap(); + assert_eq!(b, "chosen"); + assert!(!told.contains("chosen")); + assert!(!file.exists(), "a password set in the environment is not written anywhere"); + assert_eq!(first_password(&dir, Some(String::new())).unwrap().0.len(), 24, "empty means unset"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() { let cfg = config::Config::default(); diff --git a/docker-compose.yml b/docker-compose.yml index b4229ad..b351466 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ # A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both # images first (`docker build -t ipx .` and `docker build -f Dockerfile.web -t ipx-web .`), then -# `docker compose up -d`, and sign in at http://:8099 as admin / ipodderx. +# `docker compose up -d`, and sign in at http://:8099 as admin, with the password the first +# start writes to the config volume: `docker exec ipx cat /config/admin-password`. # docs/configuration.md has the rest, Postgres included. services: ipx: diff --git a/docs/cli.md b/docs/cli.md index 7e99215..05339d4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -37,7 +37,8 @@ ipx user rm sam # account, subscriptions a ``` The first account created is an admin; later ones are ordinary users. A database with no accounts -at all gets **admin / ipodderx** on the next daemon start, announced in the log — change it. +at all gets **admin** on the next daemon start, with a random password in `admin-password` beside +config.toml (or `IPX_ADMIN_PASSWORD`), announced in the log — change it. To avoid even the command line, read it interactively: diff --git a/docs/configuration.md b/docs/configuration.md index 2178cc6..3a2f2e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -183,6 +183,7 @@ had it only through the OPML loses it as before. Leaving the OPML yourself keeps | Variable | Effect | |---|---| | `IPX_CONFIG` | Config file path | +| `IPX_ADMIN_PASSWORD` | The first admin's password, for a database with no accounts; random, in `admin-password` beside config.toml, without it | | `IPX_DATA_DIR` | Directory holding `state.db` | | `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` | | `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own | diff --git a/docs/users.md b/docs/users.md index c593aa4..4977e05 100644 --- a/docs/users.md +++ b/docs/users.md @@ -51,8 +51,9 @@ Three ways, tried in order of how specific the claim is: 3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck and any scripts use. -A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in -the log. Change it: +A database with no accounts creates **admin** on the next daemon start, with the password in +`IPX_ADMIN_PASSWORD` if it is set, or a random one written to `admin-password` beside config.toml, +readable by ipx's user alone. The log says where the password is, never what it is. Change it: ```sh echo -n 'a good password' | ipx user passwd admin