mirror of
https://github.com/atuinsh/atuin.git
synced 2026-10-16 04:26:53 +02:00
feat(server): read the database password from db_password_file (atuinsh/atuin#4425)
Fixes atuinsh/atuin#2452.
This commit is contained in:
1 parent
756cd80455
commit
3842c76b3e
14 files changed
+296
-11
No files matched your search
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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))?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user