Files
narl-workspace/src/config.rs
T
nvrl a641d9b6ba vault: files sealed under a password that is never stored
Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.

:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.

Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
2026-10-19 16:03:59 +02:00

378 lines
13 KiB
Rust

use std::{net::SocketAddr, path::PathBuf, time::Duration};
use anyhow::{Context, Result};
use chrono::Weekday;
use chrono_tz::Tz;
/// Runtime configuration. Everything comes from the environment (`.env` is
/// loaded on startup), because this is a deliberately single-user application.
#[derive(Debug, Clone)]
pub struct Config {
pub username: String,
pub password: String,
pub secret: Vec<u8>,
pub bind: SocketAddr,
pub data_dir: PathBuf,
pub session_days: i64,
pub max_upload_bytes: usize,
pub secure_cookie: bool,
/// How long the vault stays open with nothing using it.
pub vault_idle: std::time::Duration,
pub backup: BackupConfig,
pub mail: MailConfig,
pub sftp: SftpConfig,
}
/// 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
/// refuse to start over a feature nobody has turned on.
#[derive(Debug, Clone)]
pub struct SftpConfig {
pub bind: Option<SocketAddr>,
}
impl SftpConfig {
fn from_env() -> Result<Self> {
let bind = var("SFTP_BIND")
.map(|v| v.parse())
.transpose()
.context("WORKSPACE_SFTP_BIND must look like 127.0.0.1:2222")?;
Ok(Self { bind })
}
}
/// Time of day in the configured timezone, as `HH:MM`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeOfDay {
pub hour: u32,
pub minute: u32,
}
/// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same
/// 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.
#[derive(Debug, Clone)]
pub struct MailConfig {
/// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every
/// notification off, including the queue that would otherwise pile up.
pub url: Option<String>,
pub from: String,
pub to: String,
/// The timezone every "today" in the notifier is asked in.
pub timezone: Tz,
/// When to ask about the diary, if no document was written that day.
pub reminder_at: Option<TimeOfDay>,
/// When to mail about cards that are due or overdue.
pub card_due_at: Option<TimeOfDay>,
/// Weekday and time of the digest.
pub digest_at: Option<(Weekday, TimeOfDay)>,
/// The kinds that are allowed to be sent: `reminder`, `card_due`, `digest`,
/// `login`. Listing them explicitly is how a single kind is switched off.
pub kinds: Vec<String>,
}
impl MailConfig {
/// Whether mail is configured at all.
pub fn enabled(&self) -> bool {
self.url.is_some()
}
pub fn sends(&self, kind: &str) -> bool {
self.enabled() && self.kinds.iter().any(|k| k == kind)
}
}
/// `20:00`, or `off` for nothing at all.
fn parse_time(key: &str, raw: &str) -> Result<Option<TimeOfDay>> {
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
return Ok(None);
}
let (hour, minute) = raw
.split_once(':')
.with_context(|| format!("{key} must look like 20:00, or be `off`"))?;
let time = TimeOfDay {
hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?,
minute: minute
.trim()
.parse()
.with_context(|| format!("{key}: {minute:?} is not a minute"))?,
};
if time.hour > 23 || time.minute > 59 {
anyhow::bail!("{key} must be between 00:00 and 23:59");
}
Ok(Some(time))
}
fn parse_weekday(raw: &str) -> Option<Weekday> {
match raw.to_ascii_lowercase().as_str() {
"mon" | "monday" => Some(Weekday::Mon),
"tue" | "tuesday" => Some(Weekday::Tue),
"wed" | "wednesday" => Some(Weekday::Wed),
"thu" | "thursday" => Some(Weekday::Thu),
"fri" | "friday" => Some(Weekday::Fri),
"sat" | "saturday" => Some(Weekday::Sat),
"sun" | "sunday" => Some(Weekday::Sun),
_ => None,
}
}
/// `sun 18:00`, or `off`.
fn parse_digest(raw: &str) -> Result<Option<(Weekday, TimeOfDay)>> {
let key = "WORKSPACE_DIGEST_AT";
let Some((day, time)) = raw.split_once(char::is_whitespace) else {
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
return Ok(None);
}
anyhow::bail!("{key} must look like `sun 18:00`, or be `off`");
};
let day = parse_weekday(day.trim())
.with_context(|| format!("{key}: {day:?} is not a weekday like `sun`"))?;
Ok(parse_time(key, time.trim())?.map(|time| (day, time)))
}
/// The Proton Drive mirror. Dormant until a session has been stored by
/// `narl-workspace proton-login`; these values only shape how often it runs.
#[derive(Debug, Clone)]
pub struct BackupConfig {
/// Name of the Drive device (its sync-root folder) this server owns.
pub device_name: String,
/// Backstop between runs. `None` disables the timer, leaving the mirror to
/// whatever `POST /api/backup` asks for.
pub interval: Option<Duration>,
/// How long the workspace must be quiet after a change before it is mirrored,
/// so a writing session is uploaded once rather than after every keystroke.
pub debounce: Duration,
/// Whether a file deleted here is trashed there. Off by default: a backup
/// that forgets on command is one accident away from being no backup.
pub prune: bool,
}
/// Look a setting up in the environment.
///
/// `WORKSPACE_*` is the name to use. `DIARY_*` is still read behind it, because
/// this application was called narl-diary until it grew spaces and the running
/// deployment's `.env` still says so; a rename that logs the owner out of their
/// own server would be a poor trade for a tidier prefix.
pub fn var(key: &str) -> Option<String> {
std::env::var(format!("WORKSPACE_{key}"))
.or_else(|_| std::env::var(format!("DIARY_{key}")))
.ok()
.filter(|v| !v.trim().is_empty())
}
impl Config {
pub fn from_env() -> Result<Self> {
let username = var("USER").context("WORKSPACE_USER must be set (see .env.example)")?;
let password =
var("PASSWORD").context("WORKSPACE_PASSWORD must be set (see .env.example)")?;
let secret = var("SECRET")
.context("WORKSPACE_SECRET must be set (see .env.example)")?
.into_bytes();
if secret.len() < 16 {
anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters");
}
if password == "change-me" {
tracing::warn!(
"WORKSPACE_PASSWORD is still the example value from .env.example — \
anyone who can reach this server can read the workspace"
);
}
let bind = var("BIND")
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
.parse()
.context("WORKSPACE_BIND must look like 127.0.0.1:4242")?;
let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into()));
let session_days = var("SESSION_DAYS")
.map(|v| v.parse())
.transpose()
.context("WORKSPACE_SESSION_DAYS must be a number")?
.unwrap_or(30);
let max_upload_bytes = var("MAX_UPLOAD_MB")
.map(|v| v.parse::<usize>())
.transpose()
.context("WORKSPACE_MAX_UPLOAD_MB must be a number")?
.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 {
username,
password,
secret,
bind,
data_dir,
session_days,
max_upload_bytes,
secure_cookie,
vault_idle,
backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?,
sftp: SftpConfig::from_env()?,
})
}
pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads")
}
/// Where the Proton session blob lives. Next to the database, because the
/// two are equally sensitive and equally worth putting on the same volume.
pub fn proton_session_path(&self) -> PathBuf {
self.data_dir.join("proton-session.json")
}
/// The SFTP server's own identity, generated once on first boot. Losing it
/// only costs a host-key warning on every client, never data.
pub fn sftp_host_key_path(&self) -> PathBuf {
self.data_dir.join("sftp_host_key")
}
}
impl MailConfig {
fn from_env() -> Result<Self> {
let url = var("SMTP_URL");
// Both addresses are the owner's: this mails the workspace to its writer.
let to = var("MAIL_TO").unwrap_or_default();
let from = var("MAIL_FROM").unwrap_or_else(|| to.clone());
if url.is_some() && (from.is_empty() || to.is_empty()) {
anyhow::bail!("WORKSPACE_SMTP_URL is set, so WORKSPACE_MAIL_TO must be set too");
}
let timezone = var("TIMEZONE")
.unwrap_or_else(|| "Europe/Berlin".to_string())
.parse::<Tz>()
.map_err(|e| anyhow::anyhow!("WORKSPACE_TIMEZONE: {e}"))?;
let reminder_at = parse_time(
"WORKSPACE_REMINDER_AT",
&var("REMINDER_AT").unwrap_or_else(|| "20:00".into()),
)?;
let card_due_at = parse_time(
"WORKSPACE_CARD_DUE_AT",
&var("CARD_DUE_AT").unwrap_or_else(|| "08:00".into()),
)?;
let digest_at = parse_digest(&var("DIGEST_AT").unwrap_or_else(|| "sun 18:00".into()))?;
let kinds = var("MAIL_KINDS")
.unwrap_or_else(|| "reminder,card_due,digest,login".into())
.split(',')
.map(|k| k.trim().to_ascii_lowercase())
.filter(|k| !k.is_empty() && k != "off" && k != "none")
.collect::<Vec<_>>();
if let Some(unknown) = kinds
.iter()
.find(|k| !["reminder", "card_due", "digest", "login"].contains(&k.as_str()))
{
anyhow::bail!(
"WORKSPACE_MAIL_KINDS: {unknown:?} is not one of reminder, card_due, digest, login"
);
}
Ok(Self {
url,
from,
to,
timezone,
reminder_at,
card_due_at,
digest_at,
kinds,
})
}
}
impl BackupConfig {
fn from_env() -> Result<Self> {
// The default keeps the old name on purpose: it names a device that
// already exists in Proton Drive, and a new name there would mean a
// second device and a fresh upload of everything in it.
let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
let minutes = var("BACKUP_INTERVAL_MIN")
.map(|v| v.parse::<u64>())
.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 debounce = Duration::from_secs(
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 {
device_name,
interval,
debounce,
prune,
})
}
}
#[cfg(test)]
mod tests {
use super::{parse_digest, parse_time, TimeOfDay};
use chrono::Weekday;
#[test]
fn a_time_of_day_is_read_as_written() {
assert_eq!(
parse_time("T", "20:00").unwrap(),
Some(TimeOfDay { hour: 20, minute: 0 })
);
assert_eq!(
parse_time("T", "07:45").unwrap(),
Some(TimeOfDay { hour: 7, minute: 45 })
);
assert_eq!(parse_time("T", "off").unwrap(), None);
}
#[test]
fn a_time_that_does_not_exist_is_refused_rather_than_rounded() {
for raw in ["24:00", "20:60", "8", "eight", "20:00:00"] {
assert!(parse_time("T", raw).is_err(), "{raw:?}");
}
}
#[test]
fn a_digest_is_a_weekday_and_a_time() {
assert_eq!(
parse_digest("sun 18:00").unwrap(),
Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 }))
);
assert_eq!(
parse_digest("Monday 09:30").unwrap(),
Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 }))
);
assert_eq!(parse_digest("off").unwrap(), None);
assert!(parse_digest("someday 18:00").is_err());
assert!(parse_digest("sun").is_err());
}
}