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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:55:34 +00:00
parent c99f481124
commit cc3b8742bc
7 changed files with 70 additions and 11 deletions

View File

@@ -47,7 +47,9 @@ cargo build --release
./target/release/ipx daemon ./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 ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipx user passwd admin

View File

@@ -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). - 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). - 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). - 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 ## [1.4.0] - 2026-10-19

View File

@@ -118,9 +118,35 @@ enum UserCmd {
Rename { name: String, new_name: String }, Rename { name: String, new_name: String },
} }
/// What a brand new database starts with, so there is always a way in. Announced loudly /// The first admin's password: `IPX_ADMIN_PASSWORD` when set, or a random one written to
/// in the log, and the first thing the settings page nags about. /// `admin-password` beside config.toml, readable by the daemon's user alone. Every install began
const DEFAULT_PASSWORD: &str = "ipodderx"; /// 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<String>) -> 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. /// Everything a command needs. One per process.
pub struct Ctx { pub struct Ctx {
@@ -611,10 +637,12 @@ async fn daemon(
// A database with nobody in it cannot be signed into. // A database with nobody in it cannot be signed into.
if ctx.db.users().await?.is_empty() { 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!( tracing::warn!(
"no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \ "no accounts yet: created 'admin' {told}. Sign in and change it, or with \
Change it with `echo -n <password> | ipx user passwd admin`" `echo -n <password> | ipx user passwd admin`"
); );
} }
@@ -2448,6 +2476,30 @@ fn duration(secs: u64) -> String {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() { fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() {
let cfg = config::Config::default(); let cfg = config::Config::default();

View File

@@ -1,6 +1,7 @@
# A sample: ipx with its database in SQLite, and ipx-web, the page, in front of it. Build both # 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 # 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://<host>:8099 as admin / ipodderx. # `docker compose up -d`, and sign in at http://<host>: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. # docs/configuration.md has the rest, Postgres included.
services: services:
ipx: ipx:

View File

@@ -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 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: To avoid even the command line, read it interactively:

View File

@@ -183,6 +183,7 @@ had it only through the OPML loses it as before. Leaving the OPML yourself keeps
| Variable | Effect | | Variable | Effect |
|---|---| |---|---|
| `IPX_CONFIG` | Config file path | | `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_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_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 | | `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |

View File

@@ -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 3. **The shared `[web] token`**, which signs in as the admin — this is what the Docker healthcheck
and any scripts use. and any scripts use.
A database with no accounts creates **admin / ipodderx** on the next daemon start and says so in A database with no accounts creates **admin** on the next daemon start, with the password in
the log. Change it: `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 ```sh
echo -n 'a good password' | ipx user passwd admin echo -n 'a good password' | ipx user passwd admin