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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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.
|
||||
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 <password> | ipx user passwd admin`"
|
||||
"no accounts yet: created 'admin' {told}. Sign in and change it, or with \
|
||||
`echo -n <password> | 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();
|
||||
|
||||
@@ -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://<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.
|
||||
services:
|
||||
ipx:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user