feat(server): read the database password from db_password_file (atuinsh/atuin#4425)

Fixes atuinsh/atuin#2452.
This commit is contained in:
Marko Vejnovic authored and Marko Vejnovic committed 2026-10-15 22:38:51 +00:00
1 parent 756cd80455
commit 3842c76b3e
14 files changed
+296 -11

No files matched your search

Generated
+4
View File
@@ -797,6 +797,8 @@ dependencies = [
"futures-util",
"metrics",
"metrics-exporter-prometheus",
"percent-encoding",
"proptest",
"rand 0.8.7",
"regex",
"reqwest",
@@ -804,6 +806,8 @@ dependencies = [
"secrecy",
"serde",
"sqlx",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"tower",
+1
View File
@@ -104,6 +104,7 @@ opentelemetry-otlp = "0.32"
opentelemetry_sdk = "0.32"
palette = { version = "0.7", features = ["serializing"] }
parking_lot = "0.12"
percent-encoding = "2"
portable-pty = "0.9"
pretty_assertions = "1"
proptest = "1"
+4
View File
@@ -44,6 +44,7 @@ reqwest = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true, features = ["postgres", "sqlite", "mysql", "regexp", "tls-native-tls"] }
thiserror = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
@@ -60,8 +61,11 @@ uuid = { workspace = true }
# TODO(taylordotfish): in Rust 1.99 we can change this to `workspace = true`
atuin-client = { path = "../atuin-client", version = "18.24.0-beta.2", default-features = false, features = ["sync"] }
futures-util = { workspace = true }
percent-encoding = { workspace = true }
proptest = { workspace = true }
regex = { workspace = true }
rstest = { workspace = true }
tempfile = { workspace = true }
tower = { workspace = true, features = ["util"] }
tracing-tree = { workspace = true }
+3
View File
@@ -12,6 +12,9 @@
# db_uri="sqlite:///config/atuin-server.db"
# db_uri = "mysql://root:foobar@127.0.0.1/atuin"
## file holding the database password, which is then left out of db_uri
# db_password_file = "/run/secrets/atuin_db_password"
## Maximum size for one record entry
## 1024 * 1024 * 1024
# max_record_size = 1073741824
+154 -1
View File
@@ -58,8 +58,11 @@ pub mod models;
mod postgres;
pub use postgres::Postgres;
mod sqlite;
use std::io;
use std::path::PathBuf;
use async_trait::async_trait;
use atuin_common::db::OwnedDbUrl;
use atuin_common::db::{DbUrl, MysqlDbUrl, OwnedDbUrl, PostgresDbUrl};
use atuin_domain::record::{EncryptedData, Record, RecordIdx, RecordSeriesKey, RecordStatus};
use easy_cast::Conv;
use secrecy::{ExposeSecret, SecretString};
@@ -98,6 +101,61 @@ pub type DbResult<T> = Result<T, DbError>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DbSettings {
pub db_uri: OwnedDbUrl,
/// A file holding the password for `db_uri`.
pub db_password_file: Option<PathBuf>,
}
impl DbSettings {
/// Get the URL to connect to: `db_uri`, with the password from `db_password_file` if one is set.
///
/// # Errors
/// [`DbPasswordError::AlsoInUri`] if `db_uri` holds a password too, since one would silently
/// win over the other.
pub fn connect_url(&self) -> Result<OwnedDbUrl, DbPasswordError> {
let Some(path) = &self.db_password_file else {
return Ok(self.db_uri.clone());
};
let mut db_uri = self.db_uri.clone();
let url = match &mut db_uri {
DbUrl::Postgres(PostgresDbUrl(url)) | DbUrl::Mysql(MysqlDbUrl(url)) => url,
DbUrl::Sqlite(_) => return Err(DbPasswordError::Sqlite),
};
if url.password().is_some() {
return Err(DbPasswordError::AlsoInUri);
}
let contents = fs_err::read_to_string(path).map_err(DbPasswordError::Read)?;
let password = contents.trim_end_matches(['\r', '\n']);
if password.is_empty() {
return Err(DbPasswordError::Empty);
}
// `set_password` escapes everything sqlx would decode except `%` itself, so without this
// `x%41y` would reach the database as `xAy`.
url.set_password(Some(&password.replace('%', "%25")))
.map_err(|()| DbPasswordError::NoHost)?;
Ok(db_uri)
}
}
/// Errors returned by [`DbSettings::connect_url`].
#[derive(Debug, thiserror::Error)]
pub enum DbPasswordError {
#[error("failed to read db_password_file")]
Read(#[source] io::Error),
#[error("db_uri already holds a password; remove it from db_uri or unset db_password_file")]
AlsoInUri,
#[error(
"db_uri names no host to attach db_password_file's password to; add the host to db_uri"
)]
NoHost,
#[error("SQLite databases take no password; unset db_password_file")]
Sqlite,
#[error("db_password_file is empty")]
Empty,
}
/// The SQL a backend runs, written in its placeholder dialect.
@@ -484,3 +542,98 @@ where
Database::status(self, user).await
}
}
#[cfg(test)]
mod tests {
use std::mem::discriminant;
use percent_encoding::percent_decode_str;
use proptest::prelude::*;
use rstest::rstest;
use tempfile::NamedTempFile;
use super::*;
/// A password file holding `contents`, removed on drop.
fn password_file(contents: &str) -> NamedTempFile {
let file = NamedTempFile::new().unwrap();
fs_err::write(file.path(), contents).unwrap();
file
}
fn settings(db_uri: &str, password_file: &NamedTempFile) -> DbSettings {
DbSettings {
db_uri: db_uri.parse().unwrap(),
db_password_file: Some(password_file.path().to_owned()),
}
}
/// Decode the password in `db_uri` the way sqlx does.
fn sqlx_password(db_uri: &OwnedDbUrl) -> Option<String> {
let (DbUrl::Postgres(PostgresDbUrl(url)) | DbUrl::Mysql(MysqlDbUrl(url))) = db_uri else {
return None;
};
url.password().map(|password| percent_decode_str(password).decode_utf8().unwrap().into())
}
#[rstest]
#[case::bare("hunter2", "hunter2")]
#[case::trailing_newline("hunter2\n", "hunter2")]
#[case::trailing_crlf("hunter2\r\n", "hunter2")]
#[case::spaces_kept(" hunter 2 \n", " hunter 2 ")]
#[case::percent_escape_kept("x%41y\n", "x%41y")]
fn password_is_the_file_without_trailing_newlines(
#[values("postgres://atuin@db/atuin", "mysql://atuin@db/atuin")] db_uri: &str,
#[case] contents: &str,
#[case] expected: &str,
) {
let file = password_file(contents);
let url = settings(db_uri, &file).connect_url().unwrap();
assert_eq!(sqlx_password(&url).as_deref(), Some(expected));
}
proptest! {
#[rstest]
fn any_password_survives_the_url(password in "(?s)(%[0-9A-Fa-f]{2}|.)*[^\r\n]") {
let file = password_file(&password);
let url = settings("postgres://atuin@db/atuin", &file).connect_url().unwrap();
prop_assert_eq!(sqlx_password(&url), Some(password));
}
}
#[rstest]
#[case::password_in_postgres_uri_too(
"postgres://atuin:pw@db/atuin",
"pw",
DbPasswordError::AlsoInUri
)]
#[case::password_in_mysql_uri_too(
"mysql://atuin:pw@db/atuin",
"pw",
DbPasswordError::AlsoInUri
)]
#[case::no_host("postgres://", "pw", DbPasswordError::NoHost)]
#[case::no_host_with_database("postgres:///atuin", "pw", DbPasswordError::NoHost)]
#[case::sqlite("sqlite:///config/atuin.db", "pw", DbPasswordError::Sqlite)]
#[case::empty("postgres://atuin@db/atuin", "", DbPasswordError::Empty)]
#[case::only_a_newline("postgres://atuin@db/atuin", "\n", DbPasswordError::Empty)]
fn unusable_settings_are_rejected(
#[case] db_uri: &str,
#[case] contents: &str,
#[case] expected: DbPasswordError,
) {
let file = password_file(contents);
let err = settings(db_uri, &file).connect_url().unwrap_err();
assert_eq!(discriminant(&err), discriminant(&expected), "{err}");
}
#[rstest]
fn missing_password_file_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let settings = DbSettings {
db_uri: "postgres://atuin@db/atuin".parse().unwrap(),
db_password_file: Some(dir.path().join("missing")),
};
assert!(matches!(settings.connect_url(), Err(DbPasswordError::Read(_))));
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ pub async fn connect(db_uri: OwnedDbUrl) -> DbResult<Arc<dyn DynDatabase>> {
}
async fn connect_and_build_router(settings: Settings) -> Result<Router> {
let database = connect(settings.db_settings.db_uri.clone())
let database = connect(settings.db_settings.connect_url()?)
.await
.wrap_err_with(|| format!("failed to connect to db: {:?}", settings.db_settings))?;
+1
View File
@@ -38,6 +38,7 @@ pub async fn start_server(path: &str) -> (url::Url, oneshot::Sender<()>, JoinHan
register_webhook_username: String::new(),
db_settings: DbSettings {
db_uri: db_uri.parse().expect("invalid ATUIN_DB_URI"),
db_password_file: None,
},
metrics: atuin_server::settings::Metrics::default(),
fake_version: None,
@@ -0,0 +1,33 @@
use atuin_server::db::DbSettings;
use rstest::rstest;
use url::Url;
/// The password reaches Postgres through `db_password_file` alone: the same `db_uri` without the
/// file is refused, so a server that ignored the file could not pass.
#[rstest]
#[tokio::test]
async fn postgres_authenticates_with_the_password_file() {
let db_uri = atuin_common::env::var("ATUIN_DB_URI")
.unwrap_or_else(|_| "postgres://atuin:pass@localhost:5432/atuin".to_owned());
let mut url = Url::parse(&db_uri).unwrap();
let password = url.password().expect("ATUIN_DB_URI holds a password").to_owned();
url.set_password(None).unwrap();
let file = tempfile::NamedTempFile::new().unwrap();
fs_err::write(file.path(), format!("{password}\n")).unwrap();
let without_file = DbSettings {
db_uri: url.as_str().parse().unwrap(),
db_password_file: None,
};
assert!(
atuin_server::connect(without_file.connect_url().unwrap()).await.is_err(),
"Postgres accepted {url} with no password, so this test cannot tell the file was used"
);
let with_file = DbSettings {
db_password_file: Some(file.path().to_owned()),
..without_file
};
atuin_server::connect(with_file.connect_url().unwrap()).await.unwrap();
}
+1
View File
@@ -30,6 +30,7 @@ fn get_settings(env_uri: Option<String>) -> eyre::Result<DbSettings> {
Ok(DbSettings {
db_uri: db_uri.parse()?,
db_password_file: None,
})
}
+1
View File
@@ -86,6 +86,7 @@ async fn server() -> TestServer {
register_webhook_username: String::new(),
db_settings: DbSettings {
db_uri: format!("sqlite://{}", db.to_str().unwrap()).parse().unwrap(),
db_password_file: None,
},
metrics: atuin_server::settings::Metrics::default(),
fake_version: None,
+1
View File
@@ -45,6 +45,7 @@ impl SyncServer {
db_uri: format!("sqlite://{}", dir.path().join("server.db").display())
.parse()
.unwrap(),
db_password_file: None,
},
metrics: atuin_server::settings::Metrics::default(),
fake_version: None,
+33
View File
@@ -61,6 +61,39 @@ ATUIN_DB_USERNAME=atuin
ATUIN_DB_PASSWORD=really-insecure
```
!!! tip "Secure passwords"
For extra safety, we recommend using [Docker
secrets](https://docs.docker.com/compose/how-tos/use-secrets/). Atuin can
read passwords from a file. Pass the path of the password file via the
`ATUIN_DB_PASSWORD_FILE` environment variable, and remove the password
from `ATUIN_DB_URI`. Postgres reads the same file from
`POSTGRES_PASSWORD_FILE`, which replaces `POSTGRES_PASSWORD`; it won't
start with both set:
```yaml
services:
atuin:
# ...
environment:
# ...
ATUIN_DB_URI: postgres://${ATUIN_DB_USERNAME}@db/${ATUIN_DB_NAME}
ATUIN_DB_PASSWORD_FILE: /run/secrets/atuin_db_password
secrets:
- atuin_db_password
db:
# ...
environment:
# ...
POSTGRES_PASSWORD_FILE: /run/secrets/atuin_db_password
secrets:
- atuin_db_password
secrets:
atuin_db_password:
file: ./db_password.txt
```
Start the services using `docker compose`:
+27 -2
View File
@@ -225,9 +225,34 @@ metadata:
Note that this configuration will store the database folder _outside_ the Kubernetes cluster, in the folder `/Users/firstname.lastname/.kube/database` of the host system by configuring the `storageClassName` to be `manual`. In a real enterprise setup, you would probably want to store the database content permanently in the cluster, and not in the host system.
You should also change the password string in `ATUIN_DB_PASSWORD` and `ATUIN_DB_URI` in the`secrets.yaml` file to a more secure one.
You should also change the password string in `ATUIN_DB_PASSWORD` and
`ATUIN_DB_URI` in the `secrets.yaml` file to a more secure one.
The Atuin service is exposed on port `30530` of the host system. That's configured by the `nodePort` property. Kubernetes has a strict rule that you aren't allowed to expose a port numbered lower than 30000. To make the clients work, set the port in your `config.toml` file, for example `sync_address = "http://192.168.1.10:30530"`.
!!! tip "Secure passwords"
For extra safety, we recommend using [Kubernetes
secrets](https://kubernetes.io/docs/concepts/configuration/secret/). Atuin
can read passwords from a file. Pass the path of the password file via the
`ATUIN_DB_PASSWORD_FILE` environment variable, and remove the password
from `ATUIN_DB_URI` in `secrets.yaml`
(`postgres://atuin@postgres/atuin`):
```yaml
env:
- name: ATUIN_DB_PASSWORD_FILE
value: /run/secrets/atuin/ATUIN_DB_PASSWORD
volumeMounts:
- mountPath: /run/secrets/atuin
name: atuin-secrets
readOnly: true
volumes:
- name: atuin-secrets
secret:
secretName: atuin-secrets
items:
- key: ATUIN_DB_PASSWORD
path: ATUIN_DB_PASSWORD
```
Deploy the Atuin server using `kubectl`:
+32 -7
View File
@@ -48,13 +48,14 @@ ATUIN_OPEN_REGISTRATION=true
ATUIN_DB_URI="postgres://user:password@hostname/database"
```
| Parameter | Description |
| ------------------- | --------------------------------------------------------------- |
| `db_uri` | A valid database URI, for saving history (required, no default) |
| `host` | The host to listen on (default: 127.0.0.1) |
| `port` | The TCP port to listen on (default: 8888) |
| `open_registration` | If `true`, accept new user registrations (default: false) |
| `path` | A path to prepend to all routes of the server (default: empty) |
| Parameter | Description |
| ------------------- | -------------------------------------------------------------------------- |
| `db_uri` | A valid database URI, for saving history (required, no default) |
| `db_password_file` | A file holding the database password, kept out of `db_uri` (default: none) |
| `host` | The host to listen on (default: 127.0.0.1) |
| `port` | The TCP port to listen on (default: 8888) |
| `open_registration` | If `true`, accept new user registrations (default: false) |
| `path` | A path to prepend to all routes of the server (default: empty) |
## Database
@@ -111,6 +112,30 @@ can do so either in `server.toml` or via the environment variable:
although we support it, issues are deprioritized in favor of tier-one
databases -- PostgreSQL and SQLite.
### Keeping the password in a file
Instead of writing the password into `db_uri`, you can keep it in a file, such
as a [Docker](docker.md#keeping-the-password-in-a-secret) or
[Kubernetes](kubernetes.md#keeping-the-password-in-a-secret) secret, and point
`db_password_file` at it:
```toml
db_uri="postgres://user@hostname/database"
db_password_file="/run/secrets/atuin_db_password"
```
Or with environment variables:
```bash
ATUIN_DB_URI="postgres://user@hostname/database"
ATUIN_DB_PASSWORD_FILE="/run/secrets/atuin_db_password"
```
Trailing newlines in the file are ignored, and the rest is used as-is, so
unlike a password in `db_uri` it doesn't need escaping. `db_uri` must name the
host, and must not contain a password as well. This works with PostgreSQL and
MySQL; SQLite has no password.
## TLS
We strongly urge you enable TLS with Atuin. Without TLS, passwords are sent