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
+771 -172

No files matched your search

+118 -7
View File
@@ -11,6 +11,7 @@ mod sftp;
mod state;
mod static_files;
mod sweep;
mod tasks;
mod throttle;
mod vault;
@@ -23,7 +24,9 @@ use sqlx::sqlite::{
};
use axum::http::{header, HeaderValue};
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};
@@ -106,7 +109,14 @@ async fn main() -> Result<()> {
let bind = config.bind;
let max_upload = config.max_upload_bytes;
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));
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());
let epoch = auth::Epoch::load(&db, &config).await?;
let vault = vault::Vault::new(db.clone(), config.vault_idle);
vault.spawn_idle_lock();
let state = AppState {
@@ -124,13 +135,20 @@ async fn main() -> Result<()> {
vault_throttle: Arc::default(),
backup,
notify,
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
};
if let Some(sftp_bind) = config.sftp.bind {
let sftp_state = state.clone();
tokio::spawn(async move {
if let Err(e) = sftp::serve(sftp_state, sftp_bind).await {
tracing::error!("sftp server stopped: {e:#}");
tasks::supervise("the sftp server", move || {
let state = sftp_state.clone();
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))
.fallback(static_files::handler)
.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(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);
let listener = tokio::net::TcpListener::bind(bind)
@@ -250,9 +280,90 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -
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.
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
/// styles only, because CodeMirror injects its theme as a <style> element at
/// runtime; scripts stay confined to the bundled files.