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
|
./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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user