runtime: ranges, health, logs, supervised loops, strict config

Media answers Range requests with 206 and Content-Range, so videos seek
and Safari plays them. Sealed files too: the vault's opener now decrypts
by record position (STREAM BE32 through StreamPrimitive, the same
construction and the same files), so a range starts at the record that
holds its first byte. Compression is limited to text, because a gzipped
body no longer matches the byte ranges a player asks for.

GET /api/health answers without a session once the database does, and
the compose healthcheck asks it instead of only opening the port.
Requests are logged at info with share tokens and keys left out of the
path and no query strings. A request that is not answered in 30 seconds
gets a 503, except uploads, imports, the export, re-sealing and backups.

The backup, mail, sweep and SFTP loops run under a supervisor that logs
a panic and starts the loop again. The backup loop starts without a
Proton session and picks up a later proton-login without a restart. A
lock file keeps backup-now and the server from mirroring at once, and an
unreadable session key file turns backups off instead of failing boot.

Numbers in the configuration are checked against bounds at startup, so
a session length or an upload limit can no longer overflow later, and a
switch that is neither on nor off is an error rather than off. Config
and MailConfig print with the password, secret and SMTP URL redacted.

A session epoch is signed into each cookie (migration 0014). :logout!
advances it, which ends every session, and a changed WORKSPACE_PASSWORD
advances it on the next start. Cookies issued before this keep working
in the first epoch.
This commit is contained in:
nvrl committed 2026-10-20 02:01:50 +02:00
1 parent d1f27ac18d
commit ee13ae44fa
20 files changed
+733 -134

No files matched your search

+4
View File
@@ -62,6 +62,10 @@ russh-sftp = "3"
# speaks rand_core 0.10, one major version past the `rand` used everywhere else. # speaks rand_core 0.10, one major version past the `rand` used everywhere else.
rand10 = { package = "rand", version = "0.10" } rand10 = { package = "rand", version = "0.10" }
[dev-dependencies]
# A paused clock, so tests of timed loops do not wait in real time.
tokio = { version = "1", features = ["test-util"] }
[features] [features]
default = [] default = []
# Keep the Proton session in the OS keyring instead of a 0600 file. Needs a # Keep the Proton session in the OS keyring instead of a 0600 file. Needs a
+21 -4
View File
@@ -37,7 +37,11 @@ spelling still accepted behind it, so a server configured before the rename
keeps running untouched. keeps running untouched.
`WORKSPACE_SECRET` signs the session cookie; generate one with `WORKSPACE_SECRET` signs the session cookie; generate one with
`openssl rand -base64 32`. Changing it logs every device out. The database and `openssl rand -base64 32`. Changing it logs every device out, and so does
changing `WORKSPACE_PASSWORD` or `:logout!`, which ends every session but
needs no restart. A setting that is set but cannot be read — a number out of
range, a switch that is neither on nor off — stops the start with the reason,
rather than being taken for its default. The database and
uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is
the entire backup, and it is exactly what the Proton Drive mirror copies. the entire backup, and it is exactly what the Proton Drive mirror copies.
@@ -445,11 +449,14 @@ both halves.
## API ## API
Everything except the two share routes requires the session cookie. Everything except the login, the health check and the two share routes
requires the session cookie. Media answers `Range` requests, so a video seeks,
sealed or not.
| method | path | purpose | | method | path | purpose |
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/api/login`, `/api/logout` | session | | `GET` | `/api/health` | public: up, and the database answers — for a supervisor |
| `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one |
| `GET` | `/api/me` | current user | | `GET` | `/api/me` | current user |
| `GET`/`POST` | `/api/spaces` | the spaces / create one | | `GET`/`POST` | `/api/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create |
@@ -494,7 +501,17 @@ so a proxy must pass the `Host` header through unchanged (traefik and nginx's
`proxy_set_header Host $host` both do). Only the upload routes accept a body as `proxy_set_header Host $host` both do). Only the upload routes accept a body as
large as `WORKSPACE_MAX_UPLOAD_MB`; everything else stops at 16 MiB, and the large as `WORKSPACE_MAX_UPLOAD_MB`; everything else stops at 16 MiB, and the
login at 16 KiB, before anyone has proved who they are. `SIGTERM` gets in-flight login at 16 KiB, before anyone has proved who they are. `SIGTERM` gets in-flight
requests eight seconds to finish, inside the ten `docker stop` waits. requests eight seconds to finish, inside the ten `docker stop` waits. Any
other request that has not been answered after 30 seconds is given up on with
a `503`, except the ones that move files around: uploads, imports, the export,
re-sealing and a backup run.
Every request is logged at `info`, path and status and time, never a query
string, and with a share link's token and key left out. A background loop that
panics — the mirror, the mail, the sweep, the SFTP server — is logged and
started again rather than lost until the next restart. If
`WORKSPACE_SESSION_KEY_FILE` cannot be read the server starts anyway with
backups off, and `:backup` says why.
### Docker ### Docker
+3 -2
View File
@@ -28,8 +28,9 @@ services:
# rather than relying on the reverse proxy — see compose.override.yml.example. # rather than relying on the reverse proxy — see compose.override.yml.example.
# - "127.0.0.1:2222:2222" # - "127.0.0.1:2222:2222"
healthcheck: healthcheck:
# No extra tooling in the image, so probe the port with bash directly. # The image has bash and nothing like curl: a request by hand, and a
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/4242"] # 200 from /api/health, which also asks the database.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/4242 && printf 'GET /api/health HTTP/1.0\r\n\r\n' >&3 && head -1 <&3 | grep -q ' 200 '"]
interval: 30s interval: 30s
timeout: 3s timeout: 3s
start_period: 5s start_period: 5s
+10
View File
@@ -0,0 +1,10 @@
-- Sessions are signed cookies, not rows: nothing on the server lists them, so
-- nothing can revoke one. Signing a number into every cookie that has to match
-- this one gives a single lever instead — raise it, and every cookie issued
-- before is refused. It is raised by `:logout!` and whenever the login
-- password changes, which is recognised by a keyed hash of it kept here.
CREATE TABLE auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
epoch INTEGER NOT NULL,
password_hash TEXT NOT NULL
);
+83 -14
View File
@@ -28,14 +28,16 @@ fn sign(secret: &[u8], payload: &str) -> String {
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no /// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session. /// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
pub fn issue_token(config: &Config) -> String { /// A cookie is good until it expires and for as long as the epoch it was
/// issued under is the current one.
pub fn issue_token(config: &Config, epoch: i64) -> String {
let expires = crate::now() + config.session_days * 86_400; let expires = crate::now() + config.session_days * 86_400;
let payload = B64.encode(expires.to_string()); let payload = B64.encode(format!("{expires}:{epoch}"));
let signature = sign(&config.secret, &payload); let signature = sign(&config.secret, &payload);
format!("{payload}.{signature}") format!("{payload}.{signature}")
} }
pub fn verify_token(config: &Config, token: &str) -> bool { pub fn verify_token(config: &Config, token: &str, epoch: i64) -> bool {
let Some((payload, signature)) = token.split_once('.') else { let Some((payload, signature)) = token.split_once('.') else {
return false; return false;
}; };
@@ -46,10 +48,61 @@ pub fn verify_token(config: &Config, token: &str) -> bool {
let Ok(decoded) = B64.decode(payload) else { let Ok(decoded) = B64.decode(payload) else {
return false; return false;
}; };
let Ok(expires) = String::from_utf8_lossy(&decoded).parse::<i64>() else { let decoded = String::from_utf8_lossy(&decoded);
// A cookie from before epochs existed carries only the expiry, and
// belongs to the first epoch.
let (expires, issued_in) = decoded.split_once(':').unwrap_or((&decoded, "0"));
let (Ok(expires), Ok(issued_in)) = (expires.parse::<i64>(), issued_in.parse::<i64>()) else {
return false; return false;
}; };
expires > crate::now() expires > crate::now() && issued_in == epoch
}
/// The session epoch: what every cookie must have been signed under.
pub struct Epoch;
impl Epoch {
/// A keyed hash of the login password, so a change to it can be told
/// from a restart without the password being written down anywhere.
fn password_hash(config: &Config) -> String {
sign(&config.secret, &format!("password:{}", config.password))
}
/// The epoch to start with: the stored one, raised when the password
/// is not the one it was stored with.
pub async fn load(db: &sqlx::SqlitePool, config: &Config) -> anyhow::Result<i64> {
let hash = Self::password_hash(config);
let stored: Option<(i64, String)> =
sqlx::query_as("SELECT epoch, password_hash FROM auth WHERE id = 1")
.fetch_optional(db)
.await?;
let epoch = match stored {
None => 0,
Some((epoch, previous)) if previous == hash => return Ok(epoch),
Some((epoch, _)) => {
tracing::info!("the login password changed: every device is signed out");
epoch + 1
}
};
sqlx::query(
"INSERT INTO auth (id, epoch, password_hash) VALUES (1, ?1, ?2)
ON CONFLICT (id) DO UPDATE SET epoch = excluded.epoch, password_hash = excluded.password_hash",
)
.bind(epoch)
.bind(&hash)
.execute(db)
.await?;
Ok(epoch)
}
/// Sign every device out: the epoch moves on, and no cookie has it yet.
pub async fn advance(state: &AppState) -> anyhow::Result<i64> {
let epoch: i64 = sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch")
.fetch_one(&state.db)
.await?;
state.epoch.store(epoch, std::sync::atomic::Ordering::SeqCst);
Ok(epoch)
}
} }
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool { pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
@@ -94,7 +147,8 @@ impl FromRequestParts<AppState> for Session {
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?; let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?;
if verify_token(&state.config, token) { let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
if verify_token(&state.config, token, epoch) {
Ok(Session) Ok(Session)
} else { } else {
Err(AppError::Unauthorized) Err(AppError::Unauthorized)
@@ -199,45 +253,45 @@ mod tests {
#[test] #[test]
fn a_fresh_token_verifies() { fn a_fresh_token_verifies() {
let config = config(30); let config = config(30);
assert!(verify_token(&config, &issue_token(&config))); assert!(verify_token(&config, &issue_token(&config, 0), 0));
} }
#[test] #[test]
fn an_expired_token_does_not() { fn an_expired_token_does_not() {
let config = config(-1); let config = config(-1);
assert!(!verify_token(&config, &issue_token(&config))); assert!(!verify_token(&config, &issue_token(&config, 0), 0));
} }
#[test] #[test]
fn rotating_the_secret_invalidates_every_session() { fn rotating_the_secret_invalidates_every_session() {
let token = issue_token(&config(30)); let token = issue_token(&config(30), 0);
let mut rotated = config(30); let mut rotated = config(30);
rotated.secret = b"a-different-secret-entirely".to_vec(); rotated.secret = b"a-different-secret-entirely".to_vec();
assert!(!verify_token(&rotated, &token)); assert!(!verify_token(&rotated, &token, 0));
} }
#[test] #[test]
fn a_tampered_token_does_not_verify() { fn a_tampered_token_does_not_verify() {
let config = config(30); let config = config(30);
let token = issue_token(&config); let token = issue_token(&config, 0);
let (payload, signature) = token.split_once('.').unwrap(); let (payload, signature) = token.split_once('.').unwrap();
// A far-future expiry the holder signed themselves. // A far-future expiry the holder signed themselves.
let forged = base64::Engine::encode(&super::B64, "99999999999"); let forged = base64::Engine::encode(&super::B64, "99999999999");
assert!(!verify_token(&config, &format!("{forged}.{signature}"))); assert!(!verify_token(&config, &format!("{forged}.{signature}"), 0));
// The real expiry with the last byte of the signature flipped. // The real expiry with the last byte of the signature flipped.
let mut bytes = signature.as_bytes().to_vec(); let mut bytes = signature.as_bytes().to_vec();
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' }; bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
let flipped = String::from_utf8(bytes).unwrap(); let flipped = String::from_utf8(bytes).unwrap();
assert!(!verify_token(&config, &format!("{payload}.{flipped}"))); assert!(!verify_token(&config, &format!("{payload}.{flipped}"), 0));
} }
#[test] #[test]
fn malformed_tokens_are_rejected_rather_than_panicking() { fn malformed_tokens_are_rejected_rather_than_panicking() {
let config = config(30); let config = config(30);
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] { for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
assert!(!verify_token(&config, token), "{token:?}"); assert!(!verify_token(&config, token, 0), "{token:?}");
} }
} }
@@ -273,4 +327,19 @@ mod tests {
// curl, the CLI, a script with its own credentials. // curl, the CLI, a script with its own credentials.
assert!(is_same_origin(&headers(&[host]))); assert!(is_same_origin(&headers(&[host])));
} }
#[test]
fn a_cookie_from_an_earlier_epoch_is_refused() {
let config = config(30);
let token = issue_token(&config, 3);
assert!(verify_token(&config, &token, 3));
assert!(!verify_token(&config, &token, 4));
// Issued before epochs: only the expiry, and good in the first one.
let expires = crate::now() + 3600;
let payload = base64::Engine::encode(&super::B64, expires.to_string());
let legacy = format!("{payload}.{}", super::sign(&config.secret, &payload));
assert!(verify_token(&config, &legacy, 0));
assert!(!verify_token(&config, &legacy, 1));
}
} }
+70 -9
View File
@@ -62,16 +62,34 @@ pub struct Backup {
/// Held for the duration of a run, so a manual backup and the timer cannot /// Held for the duration of a run, so a manual backup and the timer cannot
/// upload the same snapshot twice at once. /// upload the same snapshot twice at once.
running: tokio::sync::Mutex<()>, running: tokio::sync::Mutex<()>,
/// Why backups cannot run at all — a session key file that is configured
/// but unreadable — when that is so. The server still starts; the mirror
/// says why it is off, every time it is asked.
disabled: Option<String>,
} }
impl Backup { impl Backup {
pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> { pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> {
let store = SessionStore::open(&config)?; let store = SessionStore::open(&config)?;
let configured = matches!(store.load(), Ok(Some(_))); Ok(Self::with_store(db, config, store, None))
Ok(Arc::new(Self { }
/// A mirror that is off for `reason`, for a server that should start
/// anyway: backups are worth a loud error, not an outage of the workspace.
pub fn unavailable(db: SqlitePool, config: Arc<Config>, reason: String) -> Arc<Self> {
let store = SessionStore::new(config.proton_session_path());
let backup = Self::with_store(db, config, store, Some(reason.clone()));
backup.set(|s| s.last_error = Some(reason));
backup
}
fn with_store(db: SqlitePool, config: Arc<Config>, store: SessionStore, disabled: Option<String>) -> Arc<Self> {
let configured = disabled.is_none() && matches!(store.load(), Ok(Some(_)));
Arc::new(Self {
db, db,
config, config,
store, store,
disabled,
wake: Notify::new(), wake: Notify::new(),
// The workspace may have been written to while this process was down, // The workspace may have been written to while this process was down,
// and a restart is the cheapest moment to find out. // and a restart is the cheapest moment to find out.
@@ -81,11 +99,11 @@ impl Backup {
..Status::default() ..Status::default()
}), }),
running: tokio::sync::Mutex::new(()), running: tokio::sync::Mutex::new(()),
})) })
} }
pub fn status(&self) -> Status { pub fn status(&self) -> Status {
let mut status = self.status.lock().expect("backup status mutex poisoned").clone(); let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner()).clone();
status.pending = self.dirty.load(Ordering::Relaxed); status.pending = self.dirty.load(Ordering::Relaxed);
status status
} }
@@ -100,7 +118,20 @@ impl Backup {
/// session has been stored — asking an unconfigured backup to run is a /// session has been stored — asking an unconfigured backup to run is a
/// no-op, not a failure. /// no-op, not a failure.
pub async fn run_now(&self) -> Result<Option<Summary>> { pub async fn run_now(&self) -> Result<Option<Summary>> {
if let Some(reason) = &self.disabled {
anyhow::bail!("backups are off: {reason}");
}
let _guard = self.running.lock().await; let _guard = self.running.lock().await;
// The same, across processes: `backup-now` beside a running server
// would otherwise share the snapshot file and the bookkeeping with it.
let _lock = match self.lock_data_dir() {
Ok(lock) => lock,
Err(e) => {
let message = format!("{e:#}");
self.set(move |s| s.last_error = Some(message));
return Err(e);
}
};
self.dirty.store(false, Ordering::Relaxed); self.dirty.store(false, Ordering::Relaxed);
self.set(|s| { self.set(|s| {
s.running = true; s.running = true;
@@ -145,6 +176,23 @@ impl Backup {
outcome outcome
} }
fn lock_data_dir(&self) -> Result<std::fs::File> {
let path = self.config.data_dir.join(".backup.lock");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("could not open {}", path.display()))?;
match file.try_lock() {
Ok(()) => Ok(file),
Err(std::fs::TryLockError::WouldBlock) => {
anyhow::bail!("another backup of this data directory is running — `backup-now` and the server take turns")
}
Err(std::fs::TryLockError::Error(e)) => Err(e).context("could not lock the data directory for a backup"),
}
}
async fn mirror(&self) -> Result<Option<Summary>> { async fn mirror(&self) -> Result<Option<Summary>> {
let Some(client) = proton::resume(&self.store).await? else { let Some(client) = proton::resume(&self.store).await? else {
return Ok(None); return Ok(None);
@@ -285,25 +333,39 @@ impl Backup {
} }
fn set(&self, edit: impl FnOnce(&mut Status)) { fn set(&self, edit: impl FnOnce(&mut Status)) {
let mut status = self.status.lock().expect("backup status mutex poisoned"); let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner());
edit(&mut status); edit(&mut status);
} }
} }
/// Run the mirror in the background for as long as the server lives. /// Run the mirror in the background for as long as the server lives. With
/// no Proton session yet it waits for one: `proton-login` is a separate
/// process, and the server picks its session up without a restart.
pub fn spawn(backup: Arc<Backup>) { pub fn spawn(backup: Arc<Backup>) {
if backup.store.load().ok().flatten().is_none() { if backup.store.load().ok().flatten().is_none() {
tracing::info!( tracing::info!(
"Proton Drive backups are not configured — run `narl-workspace proton-login` to enable them" "Proton Drive backups are not configured — run `narl-workspace proton-login` to enable them"
); );
}
crate::tasks::supervise("the backup", move || mirror_forever(Arc::clone(&backup)));
}
async fn mirror_forever(backup: Arc<Backup>) {
if backup.disabled.is_some() {
return; return;
} }
tokio::spawn(async move {
let interval = backup.config.backup.interval; let interval = backup.config.backup.interval;
let debounce = backup.config.backup.debounce; let debounce = backup.config.backup.debounce;
loop { loop {
if backup.store.load().ok().flatten().is_none() {
tokio::select! {
_ = backup.wake.notified() => {}
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}
}
continue;
}
// Nothing to do: wait for a change, or for the backstop timer. // Nothing to do: wait for a change, or for the backstop timer.
while !backup.dirty.load(Ordering::Relaxed) { while !backup.dirty.load(Ordering::Relaxed) {
match interval { match interval {
@@ -328,7 +390,6 @@ pub fn spawn(backup: Arc<Backup>) {
let _ = backup.run_now().await; let _ = backup.run_now().await;
} }
});
} }
/// One-shot mirror for `narl-workspace backup-now`, without starting a server. /// One-shot mirror for `narl-workspace backup-now`, without starting a server.
+113 -46
View File
@@ -6,7 +6,7 @@ use chrono_tz::Tz;
/// Runtime configuration. Everything comes from the environment (`.env` is /// Runtime configuration. Everything comes from the environment (`.env` is
/// loaded on startup), because this is a deliberately single-user application. /// loaded on startup), because this is a deliberately single-user application.
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
pub username: String, pub username: String,
pub password: String, pub password: String,
@@ -23,6 +23,61 @@ pub struct Config {
pub sftp: SftpConfig, pub sftp: SftpConfig,
} }
/// Written by hand so that a `{:?}` anywhere — a log line, a panic — never
/// prints the password, the cookie secret or the relay's credentials.
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("username", &self.username)
.field("password", &"<redacted>")
.field("secret", &"<redacted>")
.field("bind", &self.bind)
.field("data_dir", &self.data_dir)
.field("session_days", &self.session_days)
.field("max_upload_bytes", &self.max_upload_bytes)
.field("secure_cookie", &self.secure_cookie)
.field("vault_idle", &self.vault_idle)
.field("backup", &self.backup)
.field("mail", &self.mail)
.field("sftp", &self.sftp)
.finish()
}
}
/// A whole number from the environment, `default` when it is not set, and an
/// error at startup — not an overflow later — when it is outside `allowed`.
fn number(key: &str, default: u64, allowed: std::ops::RangeInclusive<u64>) -> Result<u64> {
let Some(raw) = var(key) else {
return Ok(default);
};
let value: u64 = raw
.trim()
.parse()
.with_context(|| format!("WORKSPACE_{key} must be a whole number, not {raw:?}"))?;
if !allowed.contains(&value) {
anyhow::bail!(
"WORKSPACE_{key} must be between {} and {}, not {value}",
allowed.start(),
allowed.end()
);
}
Ok(value)
}
/// A switch. Anything that is not clearly on or clearly off is an error:
/// `WORKSPACE_SECURE_COOKIE=yes` quietly meaning "no" is how a cookie ends up
/// going out over plain HTTP.
fn flag(key: &str) -> Result<bool> {
let Some(raw) = var(key) else {
return Ok(false);
};
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => anyhow::bail!("WORKSPACE_{key} must be 1 or 0 (true/false, yes/no, on/off), not {raw:?}"),
}
}
/// The SFTP mount. Dormant until `WORKSPACE_SFTP_BIND` is set — same shape as /// The SFTP mount. Dormant until `WORKSPACE_SFTP_BIND` is set — same shape as
/// the mail relay, because a workspace with no key registered yet should not /// the mail relay, because a workspace with no key registered yet should not
/// refuse to start over a feature nobody has turned on. /// refuse to start over a feature nobody has turned on.
@@ -51,7 +106,7 @@ pub struct TimeOfDay {
/// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same /// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same
/// dormant-until-configured shape the Proton mirror has, because a workspace /// dormant-until-configured shape the Proton mirror has, because a workspace
/// that refuses to start over an unconfigured mail relay would be a bad trade. /// that refuses to start over an unconfigured mail relay would be a bad trade.
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct MailConfig { pub struct MailConfig {
/// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every /// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every
/// notification off, including the queue that would otherwise pile up. /// notification off, including the queue that would otherwise pile up.
@@ -71,6 +126,22 @@ pub struct MailConfig {
pub kinds: Vec<String>, pub kinds: Vec<String>,
} }
impl std::fmt::Debug for MailConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailConfig")
// The URL carries the relay's user and password.
.field("url", &self.url.as_ref().map(|_| "<redacted>"))
.field("from", &self.from)
.field("to", &self.to)
.field("timezone", &self.timezone)
.field("reminder_at", &self.reminder_at)
.field("card_due_at", &self.card_due_at)
.field("digest_at", &self.digest_at)
.field("kinds", &self.kinds)
.finish()
}
}
impl MailConfig { impl MailConfig {
/// Whether mail is configured at all. /// Whether mail is configured at all.
pub fn enabled(&self) -> bool { pub fn enabled(&self) -> bool {
@@ -190,32 +261,16 @@ impl Config {
let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into())); let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into()));
let session_days = var("SESSION_DAYS") // Ten years at most: a session is a cookie, and the expiry is
.map(|v| v.parse()) // arithmetic on it that must not overflow.
.transpose() let session_days = number("SESSION_DAYS", 30, 1..=3650)? as i64;
.context("WORKSPACE_SESSION_DAYS must be a number")? // Up to a terabyte, in bytes.
.unwrap_or(30); let max_upload_bytes = usize::try_from(number("MAX_UPLOAD_MB", 1024, 1..=1024 * 1024)? * 1024 * 1024)
.context("WORKSPACE_MAX_UPLOAD_MB is more than this machine can address")?;
let max_upload_bytes = var("MAX_UPLOAD_MB") // A vault that locks the moment it opens could not be used; one that
.map(|v| v.parse::<usize>()) // stays open for more than a day is not much of a vault.
.transpose() let vault_idle = Duration::from_secs(number("VAULT_IDLE_MIN", 15, 1..=24 * 60)? * 60);
.context("WORKSPACE_MAX_UPLOAD_MB must be a number")? let secure_cookie = flag("SECURE_COOKIE")?;
.unwrap_or(1024)
* 1024
* 1024;
let vault_idle = std::time::Duration::from_secs(
var("VAULT_IDLE_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("WORKSPACE_VAULT_IDLE_MIN must be a number")?
.unwrap_or(15)
* 60,
);
let secure_cookie = var("SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Self { Ok(Self {
username, username,
@@ -311,24 +366,11 @@ impl BackupConfig {
// second device and a fresh upload of everything in it. // second device and a fresh upload of everything in it.
let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string()); let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
let minutes = var("BACKUP_INTERVAL_MIN") // 0 turns the backstop off; a week is the longest it waits.
.map(|v| v.parse::<u64>()) let minutes = number("BACKUP_INTERVAL_MIN", 60, 0..=7 * 24 * 60)?;
.transpose()
.context("WORKSPACE_BACKUP_INTERVAL_MIN must be a number of minutes")?
.unwrap_or(60);
let interval = (minutes > 0).then(|| Duration::from_secs(minutes * 60)); let interval = (minutes > 0).then(|| Duration::from_secs(minutes * 60));
let debounce = Duration::from_secs(number("BACKUP_DEBOUNCE_SEC", 300, 0..=24 * 60 * 60)?);
let debounce = Duration::from_secs( let prune = flag("BACKUP_PRUNE")?;
var("BACKUP_DEBOUNCE_SEC")
.map(|v| v.parse::<u64>())
.transpose()
.context("WORKSPACE_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
.unwrap_or(300),
);
let prune = var("BACKUP_PRUNE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Self { Ok(Self {
device_name, device_name,
@@ -342,9 +384,34 @@ impl BackupConfig {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_digest, parse_time, TimeOfDay}; use super::{flag, number, parse_digest, parse_time, TimeOfDay};
use chrono::Weekday; use chrono::Weekday;
/// Each case under a name of its own: tests run in parallel and share
/// one environment.
fn with(key: &str, value: &str) -> String {
std::env::set_var(format!("WORKSPACE_{key}"), value);
key.to_string()
}
#[test]
fn numbers_out_of_bounds_stop_the_start() {
assert_eq!(number("TEST_UNSET_NUMBER", 7, 1..=10).unwrap(), 7);
assert_eq!(number(&with("TEST_NUMBER_OK", " 9 "), 7, 1..=10).unwrap(), 9);
assert!(number(&with("TEST_NUMBER_ZERO", "0"), 7, 1..=10).is_err());
assert!(number(&with("TEST_NUMBER_HUGE", "99999999999999999999"), 7, 1..=10).is_err());
assert!(number(&with("TEST_NUMBER_NEGATIVE", "-1"), 7, 1..=10).is_err());
}
#[test]
fn a_switch_is_on_off_or_an_error() {
assert!(!flag("TEST_UNSET_FLAG").unwrap());
assert!(flag(&with("TEST_FLAG_YES", "yes")).unwrap());
assert!(flag(&with("TEST_FLAG_ONE", "1")).unwrap());
assert!(!flag(&with("TEST_FLAG_OFF", "off")).unwrap());
assert!(flag(&with("TEST_FLAG_TYPO", "ture")).is_err());
}
#[test] #[test]
fn a_time_of_day_is_read_as_written() { fn a_time_of_day_is_read_as_written() {
assert_eq!( assert_eq!(
+118 -7
View File
@@ -11,6 +11,7 @@ mod sftp;
mod state; mod state;
mod static_files; mod static_files;
mod sweep; mod sweep;
mod tasks;
mod throttle; mod throttle;
mod vault; mod vault;
@@ -23,7 +24,9 @@ use sqlx::sqlite::{
}; };
use axum::http::{header, HeaderValue}; use axum::http::{header, HeaderValue};
use tower_http::{ use tower_http::{
compression::CompressionLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer, compression::CompressionLayer,
set_header::SetResponseHeaderLayer,
trace::{DefaultOnResponse, TraceLayer},
}; };
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@@ -106,7 +109,14 @@ async fn main() -> Result<()> {
let bind = config.bind; let bind = config.bind;
let max_upload = config.max_upload_bytes; let max_upload = config.max_upload_bytes;
let config = Arc::new(config); let config = Arc::new(config);
let backup = backup::Backup::new(db.clone(), Arc::clone(&config))?; let backup = match backup::Backup::new(db.clone(), Arc::clone(&config)) {
Ok(backup) => backup,
Err(e) => {
let reason = format!("{e:#}");
tracing::error!("Proton Drive backups are off: {reason}");
backup::Backup::unavailable(db.clone(), Arc::clone(&config), reason)
}
};
backup::spawn(Arc::clone(&backup)); backup::spawn(Arc::clone(&backup));
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?; let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
@@ -114,6 +124,7 @@ async fn main() -> Result<()> {
sweep::spawn(db.clone(), config.uploads_dir()); sweep::spawn(db.clone(), config.uploads_dir());
let epoch = auth::Epoch::load(&db, &config).await?;
let vault = vault::Vault::new(db.clone(), config.vault_idle); let vault = vault::Vault::new(db.clone(), config.vault_idle);
vault.spawn_idle_lock(); vault.spawn_idle_lock();
let state = AppState { let state = AppState {
@@ -124,13 +135,20 @@ async fn main() -> Result<()> {
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
backup, backup,
notify, notify,
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
}; };
if let Some(sftp_bind) = config.sftp.bind { if let Some(sftp_bind) = config.sftp.bind {
let sftp_state = state.clone(); let sftp_state = state.clone();
tokio::spawn(async move { tasks::supervise("the sftp server", move || {
if let Err(e) = sftp::serve(sftp_state, sftp_bind).await { let state = sftp_state.clone();
tracing::error!("sftp server stopped: {e:#}"); async move {
// A port that is taken now may be free in a minute; the web
// side carries on either way.
while let Err(e) = sftp::serve(state.clone(), sftp_bind).await {
tracing::error!("sftp server stopped: {e:#}; trying again in a minute");
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
} }
}); });
} }
@@ -222,9 +240,21 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -
.nest("/api", routes::api_router(max_upload)) .nest("/api", routes::api_router(max_upload))
.fallback(static_files::handler) .fallback(static_files::handler)
.layer(axum::middleware::from_fn(auth::same_origin)) .layer(axum::middleware::from_fn(auth::same_origin))
.layer(CompressionLayer::new()) .layer(axum::middleware::from_fn(deadline))
.layer(CompressionLayer::new().compress_when(compressible))
.layer(security_headers()) .layer(security_headers())
.layer(TraceLayer::new_for_http()) .layer(
TraceLayer::new_for_http()
.make_span_with(|request: &axum::http::Request<axum::body::Body>| {
tracing::info_span!(
"request",
method = %request.method(),
path = %loggable(request.uri().path()),
)
})
.on_request(())
.on_response(DefaultOnResponse::new().level(tracing::Level::INFO)),
)
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(bind) let listener = tokio::net::TcpListener::bind(bind)
@@ -250,9 +280,90 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -
Ok(()) Ok(())
} }
/// How long an ordinary request may take to be answered. A request that takes
/// this long is stuck — waiting on a lock it will not get — and is better
/// answered with an error than left hanging.
const REQUEST_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// Requests that take as long as the bytes do: uploads, imports, re-sealing,
/// a backup run, the export. Only the time to the first byte of a response is
/// bounded anyway, so a long download of a file is never cut off.
fn is_slow(method: &axum::http::Method, path: &str) -> bool {
use axum::http::Method;
match *method {
Method::POST => {
path == "/api/media"
|| path == "/api/import"
|| path == "/api/backup"
|| path.ends_with("/import")
|| path.ends_with("/files")
|| path.ends_with("/encrypt")
|| path.ends_with("/decrypt")
}
Method::GET => path == "/api/export",
_ => false,
}
}
async fn deadline(request: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response {
use axum::response::IntoResponse;
if is_slow(request.method(), request.uri().path()) {
return next.run(request).await;
}
let path = loggable(request.uri().path()).to_string();
match tokio::time::timeout(REQUEST_DEADLINE, next.run(request)).await {
Ok(response) => response,
Err(_) => {
tracing::error!("{path} took longer than {REQUEST_DEADLINE:?} and was given up on");
(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({ "error": "the server took too long — try again" })),
)
.into_response()
}
}
}
/// Below the ten seconds `docker stop` waits before it sends SIGKILL. /// Below the ten seconds `docker stop` waits before it sends SIGKILL.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(8); const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
/// A request path as it may go into the log: a share link's token and key are
/// the whole of its secret, and the key is kept out of every log on purpose —
/// it travels in the URL fragment so that no server ever sees it, except this
/// one's API, which must not write it down either. Query strings, which carry
/// search terms, are never logged at all.
fn loggable(path: &str) -> &str {
if path.starts_with("/api/share/") {
"/api/share/…"
} else if path.starts_with("/s/") {
"/s/…"
} else {
path
}
}
/// Text is worth compressing; uploads are not. Media is already compressed,
/// and a compressed body no longer matches the byte ranges a video player
/// asks for, so nothing that answers a range — or could — is touched.
fn compressible(
status: axum::http::StatusCode,
_: axum::http::Version,
headers: &axum::http::HeaderMap,
_: &axum::http::Extensions,
) -> bool {
if status == axum::http::StatusCode::PARTIAL_CONTENT || headers.contains_key(header::ACCEPT_RANGES) {
return false;
}
let kind = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
kind.starts_with("text/")
|| kind.starts_with("application/json")
|| kind.starts_with("application/javascript")
|| kind.starts_with("image/svg+xml")
}
/// Content-Security-Policy for the app shell. `unsafe-inline` is granted to /// Content-Security-Policy for the app shell. `unsafe-inline` is granted to
/// styles only, because CodeMirror injects its theme as a <style> element at /// styles only, because CodeMirror injects its theme as a <style> element at
/// runtime; scripts stay confined to the bundled files. /// runtime; scripts stay confined to the bundled files.
+6 -3
View File
@@ -421,9 +421,11 @@ pub fn spawn(notifier: Arc<Notifier>) {
return; return;
} }
tokio::spawn(async move { crate::tasks::supervise("the notifier", move || {
// A minute is fine-grained enough for a configuration written as HH:MM let notifier = Arc::clone(&notifier);
// and coarse enough to be free. async move {
// A minute is fine-grained enough for a configuration written as
// HH:MM and coarse enough to be free.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
@@ -432,6 +434,7 @@ pub fn spawn(notifier: Arc<Notifier>) {
tracing::error!(error = %format!("{e:#}"), "the notification tick failed"); tracing::error!(error = %format!("{e:#}"), "the notification tick failed");
} }
} }
}
}); });
} }
+123 -19
View File
@@ -3,7 +3,7 @@ use std::{collections::BTreeSet, sync::LazyLock};
use axum::{ use axum::{
body::Body, body::Body,
extract::{multipart::Field, Multipart, Path, Query, State}, extract::{multipart::Field, Multipart, Path, Query, State},
http::header, http::{header, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
@@ -385,8 +385,45 @@ pub async fn serve(
_: Session, _: Session,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
headers: axum::http::HeaderMap,
) -> AppResult<Response> { ) -> AppResult<Response> {
stream_media(&state, &id, true).await stream_media(&state, &id, true, headers.get(header::RANGE)).await
}
/// The one `bytes=` range of a `Range` header, inclusive, against a file of
/// `size` bytes. `Ok(None)` serves the whole file: no header, one that cannot
/// be read, or several ranges — a whole file is always a valid answer.
/// `Err` is a range that starts past the end.
fn byte_range(header: Option<&axum::http::HeaderValue>, size: u64) -> Result<Option<(u64, u64)>, ()> {
let Some(spec) = header
.and_then(|h| h.to_str().ok())
.and_then(|h| h.trim().strip_prefix("bytes="))
else {
return Ok(None);
};
if spec.contains(',') {
return Ok(None);
}
let Some((from, to)) = spec.split_once('-') else {
return Ok(None);
};
let (from, to) = (from.trim(), to.trim());
let range = match (from.parse::<u64>(), to.parse::<u64>()) {
// `bytes=-500`: the last 500.
(Err(_), Ok(suffix)) if from.is_empty() => {
if suffix == 0 || size == 0 {
return Err(());
}
(size.saturating_sub(suffix), size - 1)
}
(Ok(start), Err(_)) if to.is_empty() => (start, size.saturating_sub(1)),
(Ok(start), Ok(end)) if start <= end => (start, end.min(size.saturating_sub(1))),
_ => return Ok(None),
};
if range.0 >= size {
return Err(());
}
Ok(Some(range))
} }
pub async fn remove( pub async fn remove(
@@ -402,7 +439,12 @@ pub async fn remove(
/// Shared by the private and the public-share media handlers. A sealed file /// Shared by the private and the public-share media handlers. A sealed file
/// is only ever served to the owner, and only while the vault is open — a /// is only ever served to the owner, and only while the vault is open — a
/// share link is a door for strangers, and the vault is not behind it. /// share link is a door for strangers, and the vault is not behind it.
pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResult<Response> { pub async fn stream_media(
state: &AppState,
id: &str,
private: bool,
range: Option<&axum::http::HeaderValue>,
) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1") let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1")
.bind(id) .bind(id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
@@ -423,18 +465,40 @@ pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResul
(true, true) => Some(state.vault.key().ok_or(AppError::Locked)?), (true, true) => Some(state.vault.key().ok_or(AppError::Locked)?),
}; };
let file = tokio::fs::File::open(state.config.uploads_dir().join(id)) let size = size.max(0) as u64;
// A video is seeked by asking for the bytes from a point on; without an
// answer to that, seeking fails, and Safari will not play it at all.
let (status, start, end) = match byte_range(range, size) {
Ok(None) => (StatusCode::OK, 0, size.saturating_sub(1)),
Ok(Some((start, end))) => (StatusCode::PARTIAL_CONTENT, start, end),
Err(()) => {
return Ok((
StatusCode::RANGE_NOT_SATISFIABLE,
[(header::CONTENT_RANGE, format!("bytes */{size}"))],
)
.into_response())
}
};
let length = if size == 0 { 0 } else { end - start + 1 };
let mut file = tokio::fs::File::open(state.config.uploads_dir().join(id))
.await .await
.map_err(|_| AppError::NotFound)?; .map_err(|_| AppError::NotFound)?;
let body = match key { let body = match key {
None => Body::from_stream(ReaderStream::new(file)), None => {
Some(key) => Body::from_stream(open_stream(file, &key, id).await?), use tokio::io::{AsyncReadExt, AsyncSeekExt};
file.seek(std::io::SeekFrom::Start(start)).await?;
Body::from_stream(ReaderStream::new(file.take(length)))
}
Some(key) => Body::from_stream(open_stream(file, &key, id, start, length).await?),
}; };
Ok(( let mut response = (
status,
[ [
(header::CONTENT_TYPE, mime), (header::CONTENT_TYPE, mime),
(header::CONTENT_LENGTH, size.to_string()), (header::CONTENT_LENGTH, length.to_string()),
(header::ACCEPT_RANGES, "bytes".to_string()),
// An upload never changes under its id, so the browser may keep // An upload never changes under its id, so the browser may keep
// it — unless it was sealed: the plaintext would then sit in the // it — unless it was sealed: the plaintext would then sit in the
// browser's cache long after the vault had been shut. // browser's cache long after the vault had been shut.
@@ -459,34 +523,59 @@ pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResul
], ],
body, body,
) )
.into_response()) .into_response();
if status == StatusCode::PARTIAL_CONTENT {
let value = format!("bytes {start}-{end}/{size}");
if let Ok(value) = axum::http::HeaderValue::from_str(&value) {
response.headers_mut().insert(header::CONTENT_RANGE, value);
}
}
Ok(response)
} }
/// A sealed file as a stream of its plaintext, a record at a time. The /// A sealed file as a stream of its plaintext, a record at a time, from
/// header is checked before the first byte goes out, so a file that will not /// byte `start` for `length` bytes. A range starts at the record that holds
/// open is an error response rather than a download that breaks off. /// its first byte, so seeking into a sealed video costs one record, not the
/// whole file before it. The header is checked before the first byte goes
/// out, so a file that will not open is an error response rather than a
/// download that breaks off.
async fn open_stream( async fn open_stream(
mut file: tokio::fs::File, mut file: tokio::fs::File,
key: &vault::Key, key: &vault::Key,
id: &str, id: &str,
start: u64,
length: u64,
) -> AppResult<impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>>> { ) -> AppResult<impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>>> {
use tokio::io::AsyncReadExt; use tokio::io::{AsyncReadExt, AsyncSeekExt};
let len = file.metadata().await?.len(); let len = file.metadata().await?.len();
let mut header = [0u8; vault::HEADER]; let mut header = [0u8; vault::HEADER];
file.read_exact(&mut header).await?; file.read_exact(&mut header).await?;
let opener = vault::Opener::new(key, id, &header, len)?; let mut opener = vault::Opener::new(key, id, &header, len)?;
let first = start / vault::CHUNK as u64;
if first > 0 {
opener.skip_to(first)?;
file.seek(std::io::SeekFrom::Start(vault::HEADER as u64 + first * vault::RECORD as u64))
.await?;
}
let skip = (start - first * vault::CHUNK as u64) as usize;
Ok(futures_util::stream::try_unfold((file, opener), |(mut file, mut opener)| async move { Ok(futures_util::stream::try_unfold(
(file, opener, skip, length),
|(mut file, mut opener, skip, left)| async move {
let len = opener.next_len(); let len = opener.next_len();
if len == 0 { if len == 0 || left == 0 {
return Ok(None); return Ok(None);
} }
let mut record = vec![0u8; len]; let mut record = vec![0u8; len];
file.read_exact(&mut record).await?; file.read_exact(&mut record).await?;
let plain = opener.open(&record).map_err(std::io::Error::other)?; let plain = opener.open(&record).map_err(std::io::Error::other)?;
Ok(Some((axum::body::Bytes::from(plain), (file, opener)))) let piece = &plain[skip.min(plain.len())..];
})) let piece = &piece[..piece.len().min(usize::try_from(left).unwrap_or(usize::MAX))];
let left = left - piece.len() as u64;
Ok(Some((axum::body::Bytes::copy_from_slice(piece), (file, opener, 0, left))))
},
))
} }
/// The id as it is stored and as it names the file on disk, or `None` for /// The id as it is stored and as it names the file on disk, or `None` for
@@ -627,7 +716,22 @@ fn urlencode(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{canonical_id, sanitize_mime}; use super::{byte_range, canonical_id, sanitize_mime};
#[test]
fn ranges_are_read_the_way_browsers_send_them() {
let range = |raw: &'static str| byte_range(Some(&axum::http::HeaderValue::from_static(raw)), 1000);
assert_eq!(byte_range(None, 1000), Ok(None));
assert_eq!(range("bytes=0-"), Ok(Some((0, 999))));
assert_eq!(range("bytes=100-199"), Ok(Some((100, 199))));
assert_eq!(range("bytes=900-5000"), Ok(Some((900, 999))));
assert_eq!(range("bytes=-100"), Ok(Some((900, 999))));
assert_eq!(range("bytes=1000-"), Err(()));
// Not understood, or several at once: the whole file is the answer.
assert_eq!(range("bytes=0-1,5-9"), Ok(None));
assert_eq!(range("items=0-1"), Ok(None));
assert_eq!(range("bytes=9-1"), Ok(None));
}
#[test] #[test]
fn only_a_uuid_is_an_id() { fn only_a_uuid_is_an_id() {
+1
View File
@@ -33,6 +33,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
// Multipart framing on top of the files themselves. // Multipart framing on top of the files themselves.
let uploads = DefaultBodyLimit::max(max_upload.saturating_add(1024 * 1024)); let uploads = DefaultBodyLimit::max(max_upload.saturating_add(1024 * 1024));
Router::new() Router::new()
.route("/health", get(session::health))
.route("/me", get(session::me)) .route("/me", get(session::me))
.route("/login", post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT))) .route("/login", post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)))
.route("/logout", post(session::logout)) .route("/logout", post(session::logout))
+33 -5
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::State, extract::{Query, State},
http::{header, HeaderMap, StatusCode}, http::{header, HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Json,
@@ -46,7 +46,7 @@ pub async fn login(
.unwrap_or("unknown"); .unwrap_or("unknown");
state.notify.note_login(user_agent).await; state.notify.note_login(user_agent).await;
let token = auth::issue_token(&state.config); let token = auth::issue_token(&state.config, state.epoch.load(std::sync::atomic::Ordering::SeqCst));
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
[( [(
@@ -57,20 +57,48 @@ pub async fn login(
)) ))
} }
pub async fn logout(session: Option<Session>, State(state): State<AppState>) -> impl IntoResponse { #[derive(Deserialize)]
pub struct LogoutQuery {
/// Every device, not just this one: the session epoch moves on.
#[serde(default)]
pub everywhere: bool,
}
pub async fn logout(
session: Option<Session>,
State(state): State<AppState>,
Query(query): Query<LogoutQuery>,
) -> AppResult<impl IntoResponse> {
// Leaving is the moment the vault should not outlive — but only the owner // Leaving is the moment the vault should not outlive — but only the owner
// leaving: anyone may clear a cookie they do not have, and that must not // leaving: anyone may clear a cookie they do not have, and that must not
// be a way to shut the vault on the one who does. // be a way to shut the vault on the one who does.
if session.is_some() { if session.is_some() {
state.vault.lock(); state.vault.lock();
if query.everywhere {
auth::Epoch::advance(&state).await?;
tracing::info!("signed out everywhere");
} }
( }
Ok((
StatusCode::OK, StatusCode::OK,
[(header::SET_COOKIE, auth::clear_cookie(&state.config))], [(header::SET_COOKIE, auth::clear_cookie(&state.config))],
Json(json!({ "ok": true })), Json(json!({ "ok": true })),
) ))
} }
pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse { pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse {
Json(json!({ "user": state.config.username })) Json(json!({ "user": state.config.username }))
} }
/// `GET /api/health` — for whatever supervises the process. Answers without a
/// session and says nothing about the workspace, only that the server is up
/// and its database answers.
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
match sqlx::query("SELECT 1").execute(&state.db).await {
Ok(_) => (StatusCode::OK, Json(json!({ "ok": true }))),
Err(e) => {
tracing::error!("health check: the database does not answer: {e}");
(StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "ok": false })))
}
}
}
+2 -1
View File
@@ -155,6 +155,7 @@ pub async fn read(
pub async fn serve_media( pub async fn serve_media(
State(state): State<AppState>, State(state): State<AppState>,
Path((token, key, id)): Path<(String, String, String)>, Path((token, key, id)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
) -> AppResult<Response> { ) -> AppResult<Response> {
let stored: Option<String> = sqlx::query( let stored: Option<String> = sqlx::query(
"SELECT n.share_key_hash "SELECT n.share_key_hash
@@ -171,7 +172,7 @@ pub async fn serve_media(
if !key_matches(stored.as_deref(), &key) { if !key_matches(stored.as_deref(), &key) {
return Err(AppError::NotFound); return Err(AppError::NotFound);
} }
media::stream_media(&state, &id, false).await media::stream_media(&state, &id, false, headers.get(axum::http::header::RANGE)).await
} }
#[cfg(test)] #[cfg(test)]
+4 -1
View File
@@ -1,4 +1,4 @@
use std::sync::Arc; use std::sync::{atomic::AtomicI64, Arc};
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -18,4 +18,7 @@ pub struct AppState {
pub backup: Arc<Backup>, pub backup: Arc<Backup>,
/// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set. /// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set.
pub notify: Arc<Notifier>, pub notify: Arc<Notifier>,
/// Signed into every session cookie, which must carry the current value
/// to be accepted; see [`crate::auth::Epoch`].
pub epoch: Arc<AtomicI64>,
} }
+9 -2
View File
@@ -19,7 +19,14 @@ const UNREFERENCED_DAYS: i64 = 7;
const EVERY: Duration = Duration::from_secs(24 * 60 * 60); const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf) { pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf) {
tokio::spawn(async move { crate::tasks::supervise("the uploads sweep", move || {
let (db, uploads) = (db.clone(), uploads.clone());
sweep_forever(db, uploads)
});
}
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf) {
{
let mut ticker = tokio::time::interval(EVERY); let mut ticker = tokio::time::interval(EVERY);
loop { loop {
ticker.tick().await; ticker.tick().await;
@@ -34,7 +41,7 @@ pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf) {
Err(e) => tracing::warn!("could not sweep uploads: {e:#}"), Err(e) => tracing::warn!("could not sweep uploads: {e:#}"),
} }
} }
}); }
} }
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
+63
View File
@@ -0,0 +1,63 @@
//! Background loops that outlive a panic in one of their passes.
//!
//! A task spawned and forgotten dies silently when it panics, and the server
//! carries on without backups, or mail, with nothing in the log after the
//! panic itself. Every long-running loop goes through [`supervise`] instead,
//! which logs the panic and starts the loop again.
use std::{future::Future, time::Duration};
/// Long enough that a loop panicking at once does not spin.
const RESTART: Duration = Duration::from_secs(10);
/// Run `make()` for as long as the server lives, starting it again after a
/// panic. A loop that returns has finished on purpose and is not restarted.
pub fn supervise<F, Fut>(name: &'static str, make: F)
where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
tokio::spawn(async move {
loop {
match tokio::spawn(make()).await {
Ok(()) => return,
Err(e) if e.is_panic() => {
let payload = e.into_panic();
let message = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "(no message)".into());
tracing::error!("{name} panicked: {message}; starting it again in {RESTART:?}");
tokio::time::sleep(RESTART).await;
}
// Cancelled: the runtime is shutting down.
Err(_) => return,
}
}
});
}
#[cfg(test)]
mod tests {
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
#[tokio::test(start_paused = true)]
async fn a_loop_that_panics_is_started_again() {
let runs = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&runs);
super::supervise("test", move || {
let counter = Arc::clone(&counter);
async move {
if counter.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("first pass");
}
}
});
tokio::time::sleep(super::RESTART * 2).await;
assert_eq!(runs.load(Ordering::SeqCst), 2);
}
}
+61 -18
View File
@@ -42,7 +42,7 @@ use std::{
use aes_gcm::{ use aes_gcm::{
aead::{ aead::{
stream::{DecryptorBE32, EncryptorBE32}, stream::{EncryptorBE32, NewStream, StreamBE32, StreamPrimitive},
Aead, KeyInit, Payload, Aead, KeyInit, Payload,
}, },
Aes256Gcm, Nonce, Aes256Gcm, Nonce,
@@ -57,7 +57,8 @@ pub const MAGIC: &[u8; 8] = b"NARLVLT1";
/// small enough to hold one in memory per open download. /// small enough to hold one in memory per open download.
pub const CHUNK: usize = 64 * 1024; pub const CHUNK: usize = 64 * 1024;
const TAG: usize = 16; const TAG: usize = 16;
const RECORD: usize = CHUNK + TAG; /// A record on disk: a chunk of plaintext and its tag.
pub const RECORD: usize = CHUNK + TAG;
const WRAP_NONCE: usize = 12; const WRAP_NONCE: usize = 12;
const STREAM_NONCE: usize = 7; const STREAM_NONCE: usize = 7;
pub const HEADER: usize = MAGIC.len() + WRAP_NONCE + 32 + TAG + STREAM_NONCE; pub const HEADER: usize = MAGIC.len() + WRAP_NONCE + 32 + TAG + STREAM_NONCE;
@@ -185,11 +186,15 @@ fn file_aad(media_id: &str) -> Vec<u8> {
aad aad
} }
/// Decrypts one sealed file, a record at a time. /// Decrypts one sealed file, a record at a time, from the first or from any
/// record a range asks to start at.
pub struct Opener { pub struct Opener {
stream: Option<DecryptorBE32<Aes256Gcm>>, stream: StreamBE32<Aes256Gcm>,
/// Records still to come, the last of them included. /// The record `open` decrypts next. STREAM binds each record to its
remaining: u64, /// position, so one moved within the file refuses to open.
position: u32,
/// Records in the file, the last of them included.
total: u64,
/// The size of the last record, which is the only short one. /// The size of the last record, which is the only short one.
last: usize, last: usize,
} }
@@ -222,35 +227,46 @@ impl Opener {
) )
.map_err(|_| anyhow!("this file was not sealed under this vault, or not as {media_id}"))?, .map_err(|_| anyhow!("this file was not sealed under this vault, or not as {media_id}"))?,
); );
let stream = DecryptorBE32::from_aead( let stream = StreamBE32::from_aead(
Aes256Gcm::new(file_key.as_slice().into()), Aes256Gcm::new(file_key.as_slice().into()),
stream_nonce.into(), stream_nonce.into(),
); );
Ok(Self { stream: Some(stream), remaining, last }) Ok(Self { stream, position: 0, total: remaining, last })
} }
/// How many bytes the next record takes on disk; 0 once all are read. /// How many bytes the next record takes on disk; 0 once all are read.
pub fn next_len(&self) -> usize { pub fn next_len(&self) -> usize {
match self.remaining { match self.total.saturating_sub(self.position as u64) {
0 => 0, 0 => 0,
1 => self.last, 1 => self.last,
_ => RECORD, _ => RECORD,
} }
} }
/// Start at record `index` instead of the first — at byte
/// `HEADER + index * RECORD` of the file — for a read that wants only the
/// end of it.
pub fn skip_to(&mut self, index: u64) -> Result<()> {
if index >= self.total {
return Err(anyhow!("past the end of the sealed file"));
}
self.position = u32::try_from(index).map_err(|_| anyhow!("sealed file is too large"))?;
Ok(())
}
/// Decrypt the record `next_len` asked for. /// Decrypt the record `next_len` asked for.
pub fn open(&mut self, record: &[u8]) -> Result<Vec<u8>> { pub fn open(&mut self, record: &[u8]) -> Result<Vec<u8>> {
let stream = self.stream.as_mut().ok_or_else(|| anyhow!("sealed file already read"))?; let left = self.total.saturating_sub(self.position as u64);
let plain = if self.remaining == 1 { if left == 0 {
self.stream return Err(anyhow!("sealed file already read"));
.take()
.expect("present: checked above")
.decrypt_last(record)
} else {
stream.decrypt_next(record)
} }
// Only the record that really is the last may be opened as last, so
// a file cut short at a record boundary still refuses to open.
let plain = self
.stream
.decrypt(self.position, left == 1, record)
.map_err(|_| anyhow!("sealed file is damaged or was tampered with"))?; .map_err(|_| anyhow!("sealed file is damaged or was tampered with"))?;
self.remaining -= 1; self.position += 1;
Ok(plain) Ok(plain)
} }
} }
@@ -587,6 +603,33 @@ mod tests {
assert!(vault.slot().is_none()); assert!(vault.slot().is_none());
} }
/// Reading from record `k` on gives exactly the plaintext from
/// `k * CHUNK` on, and the last record still has to be the last.
#[test]
fn a_sealed_file_opens_from_any_record() {
let master = key(3);
let plain: Vec<u8> = (0..3 * CHUNK + 17).map(|i| (i % 249) as u8).collect();
let sealed = seal(&master, "id", &plain, CHUNK);
for first in 0..4u64 {
let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
opener.skip_to(first).unwrap();
let mut at = HEADER + first as usize * super::RECORD;
let mut out = Vec::new();
while opener.next_len() > 0 {
let len = opener.next_len();
out.extend(opener.open(&sealed[at..at + len]).unwrap());
at += len;
}
assert_eq!(out, plain[first as usize * CHUNK..], "from record {first}");
}
let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
assert!(opener.skip_to(4).is_err());
// A middle record presented where the last one should be does not open.
opener.skip_to(3).unwrap();
let middle = &sealed[HEADER + super::RECORD..HEADER + 2 * super::RECORD];
assert!(opener.open(&middle[..opener.next_len()]).is_err());
}
#[test] #[test]
fn files_of_every_awkward_size_round_trip() { fn files_of_every_awkward_size_round_trip() {
let master = key(7); let master = key(7);
+3 -1
View File
@@ -201,7 +201,9 @@ export const api = {
login: (username: string, password: string) => login: (username: string, password: string) =>
request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }), request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }),
logout: () => request<{ ok: true }>('/logout', { method: 'POST' }), /** `everywhere`: every device's session ends, not only this one. */
logout: (everywhere = false) =>
request<{ ok: true }>(`/logout${everywhere ? '?everywhere=true' : ''}`, { method: 'POST' }),
listSpaces: () => request<NodeSummary[]>('/spaces'), listSpaces: () => request<NodeSummary[]>('/spaces'),
+4
View File
@@ -879,6 +879,10 @@ export const commands: CommandSpec[] = [
group: 'view', group: 'view',
help: 'end the session', help: 'end the session',
run: () => workspace.guard(() => workspace.logout()), run: () => workspace.guard(() => workspace.logout()),
bang: {
help: 'end every session, on every device — for a lost phone or a shared computer',
run: () => workspace.guard(() => workspace.logout(true)),
},
}, },
] ]
+2 -2
View File
@@ -400,8 +400,8 @@ class Workspace {
await this.openAgendaIfDue() await this.openAgendaIfDue()
} }
async logout() { async logout(everywhere = false) {
await api.logout() await api.logout(everywhere)
this.user = null this.user = null
this.vault = null this.vault = null
this.spaces = [] this.spaces = []