mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
backup: seal the Proton session under a key kept off the volume
WORKSPACE_SESSION_KEY_FILE names a file holding a 32-byte key, meant to be a Docker secret. When it is set, proton-session.json is written as AES-256-GCM ciphertext, so a copy of the data volume alone no longer carries a usable session. That is what the OS keyring gives a native host, for a container that has no Secret Service. A plain session found while a key is set is sealed the first time it is read. A sealed file without its key, or a malformed key file, is an error at startup rather than a quiet fall back to plain JSON.
This commit is contained in:
1 parent
cd18f80c04
commit
cc7d05aaf2
10 files changed
+218
-17
No files matched your search
@@ -59,6 +59,12 @@ WORKSPACE_BACKUP_DEBOUNCE_SEC=300
|
|||||||
# default: a backup that forgets on command is one accident from being none.
|
# default: a backup that forgets on command is one accident from being none.
|
||||||
WORKSPACE_BACKUP_PRUNE=0
|
WORKSPACE_BACKUP_PRUNE=0
|
||||||
|
|
||||||
|
# Seal the stored Proton session under a key kept off the data volume, so a
|
||||||
|
# copy of the volume alone carries no usable session. Point it at a Docker
|
||||||
|
# secret (see compose.override.yml.example); `openssl rand -base64 32` makes
|
||||||
|
# the key. Losing the key costs a new `proton-login`, nothing more.
|
||||||
|
# WORKSPACE_SESSION_KEY_FILE=/run/secrets/session_key
|
||||||
|
|
||||||
# --- Notification mail -------------------------------------------------------
|
# --- Notification mail -------------------------------------------------------
|
||||||
# Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset.
|
# Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
# secrets
|
# secrets
|
||||||
.env
|
.env
|
||||||
|
/secrets
|
||||||
|
|
||||||
# host-specific compose overlay
|
# host-specific compose overlay
|
||||||
compose.override.yml
|
compose.override.yml
|
||||||
@@ -269,6 +269,22 @@ without it on the next start; no new login is needed. Built with
|
|||||||
the OS keyring instead and the file is only a fallback; the container has no
|
the OS keyring instead and the file is only a fallback; the container has no
|
||||||
session bus, so there it is always the file.
|
session bus, so there it is always the file.
|
||||||
|
|
||||||
|
In a container, `WORKSPACE_SESSION_KEY_FILE` does what the keyring would: the
|
||||||
|
file is sealed with AES-256-GCM under a key read from somewhere other than the
|
||||||
|
volume, so a copy of the volume alone is no longer a session. A Docker secret is
|
||||||
|
the place for it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p secrets
|
||||||
|
openssl rand -base64 32 > secrets/session.key
|
||||||
|
sudo chown 10001 secrets/session.key && sudo chmod 400 secrets/session.key
|
||||||
|
```
|
||||||
|
|
||||||
|
then the `secrets:` lines from `compose.override.yml.example`, and
|
||||||
|
`WORKSPACE_SESSION_KEY_FILE=/run/secrets/session_key` in `.env`. A plain session
|
||||||
|
already on the volume is sealed the next time it is read. Lose the key and the
|
||||||
|
session goes with it; `proton-login` makes a new one.
|
||||||
|
|
||||||
Afterwards the server resumes on its own and nothing prompts again. Refresh
|
Afterwards the server resumes on its own and nothing prompts again. Refresh
|
||||||
tokens are single-use, so every rotation is written back immediately — which is
|
tokens are single-use, so every rotation is written back immediately — which is
|
||||||
also why two servers must not share one session file.
|
also why two servers must not share one session file.
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ services:
|
|||||||
- "traefik.http.routers.workspace.entrypoints=websecure"
|
- "traefik.http.routers.workspace.entrypoints=websecure"
|
||||||
- "traefik.http.routers.workspace.tls.certresolver=https_resolver"
|
- "traefik.http.routers.workspace.tls.certresolver=https_resolver"
|
||||||
- "traefik.http.services.workspace.loadbalancer.server.port=4242"
|
- "traefik.http.services.workspace.loadbalancer.server.port=4242"
|
||||||
|
# The key the Proton session is sealed under, mounted from the host at
|
||||||
|
# /run/secrets/session_key. Needs WORKSPACE_SESSION_KEY_FILE in .env.
|
||||||
|
secrets:
|
||||||
|
- session_key
|
||||||
|
|
||||||
# Mail, locally. Mailpit accepts everything on 1025 and shows it on 8025, so
|
# Mail, locally. Mailpit accepts everything on 1025 and shows it on 8025, so
|
||||||
# the reminders can be read before a real relay is ever involved:
|
# the reminders can be read before a real relay is ever involved:
|
||||||
@@ -33,6 +37,12 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8025:8025"
|
- "127.0.0.1:8025:8025"
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# Outside compose is a plain bind mount, so the file must be readable by
|
||||||
|
# the container's user: `chown 10001 secrets/session.key`.
|
||||||
|
session_key:
|
||||||
|
file: ./secrets/session.key
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
narl:
|
narl:
|
||||||
external: true
|
external: true
|
||||||
@@ -118,6 +118,7 @@ mod tests {
|
|||||||
interval: None,
|
interval: None,
|
||||||
debounce: std::time::Duration::from_secs(0),
|
debounce: std::time::Duration::from_secs(0),
|
||||||
prune: false,
|
prune: false,
|
||||||
|
session_key_file: None,
|
||||||
},
|
},
|
||||||
mail: MailConfig {
|
mail: MailConfig {
|
||||||
url: None,
|
url: None,
|
||||||
|
|||||||
+5
-5
@@ -65,10 +65,10 @@ pub struct Backup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Backup {
|
impl Backup {
|
||||||
pub fn new(db: SqlitePool, config: Arc<Config>) -> Arc<Self> {
|
pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> {
|
||||||
let store = SessionStore::new(config.proton_session_path());
|
let store = SessionStore::open(&config)?;
|
||||||
let configured = matches!(store.load(), Ok(Some(_)));
|
let configured = matches!(store.load(), Ok(Some(_)));
|
||||||
Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -81,7 +81,7 @@ 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 {
|
||||||
@@ -333,7 +333,7 @@ pub fn spawn(backup: Arc<Backup>) {
|
|||||||
|
|
||||||
/// One-shot mirror for `narl-workspace backup-now`, without starting a server.
|
/// One-shot mirror for `narl-workspace backup-now`, without starting a server.
|
||||||
pub async fn run_once(db: SqlitePool, config: Arc<Config>) -> Result<Summary> {
|
pub async fn run_once(db: SqlitePool, config: Arc<Config>) -> Result<Summary> {
|
||||||
let backup = Backup::new(db, config);
|
let backup = Backup::new(db, config)?;
|
||||||
backup
|
backup
|
||||||
.run_now()
|
.run_now()
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
+170
-8
@@ -23,12 +23,22 @@
|
|||||||
//! [`super::proton`], which persists on every rotation.
|
//! [`super::proton`], which persists on every rotation.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use aes_gcm::{
|
||||||
|
aead::{Aead, KeyInit, Payload},
|
||||||
|
Aes256Gcm, Nonce,
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use rand::RngCore;
|
||||||
use proton_drive_rs::KeySalt;
|
use proton_drive_rs::KeySalt;
|
||||||
use proton_sdk::account::KeyPassphrases;
|
use proton_sdk::account::KeyPassphrases;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct StoredSession {
|
pub struct StoredSession {
|
||||||
@@ -127,14 +137,39 @@ mod passphrases {
|
|||||||
/// protecting, and no weaker than the thing it sits beside. Build with
|
/// protecting, and no weaker than the thing it sits beside. Build with
|
||||||
/// `--features keyring` on a host that has a session bus to use the keyring
|
/// `--features keyring` on a host that has a session bus to use the keyring
|
||||||
/// instead, with the file as the fallback when it cannot be reached.
|
/// instead, with the file as the fallback when it cannot be reached.
|
||||||
|
///
|
||||||
|
/// What a keyring adds is that a copy of the volume does not carry the
|
||||||
|
/// session. `WORKSPACE_SESSION_KEY_FILE` gives a container the same: the file
|
||||||
|
/// is sealed with AES-256-GCM under a key that lives somewhere else, a Docker
|
||||||
|
/// secret being the obvious place. A plain file found while a key is set is
|
||||||
|
/// sealed the first time it is read.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SessionStore {
|
pub struct SessionStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
key: Option<Arc<Zeroizing<[u8; 32]>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts a sealed session file. Nothing that is JSON can start with it.
|
||||||
|
const MAGIC: &[u8; 8] = b"NARLSES1";
|
||||||
|
const NONCE: usize = 12;
|
||||||
|
|
||||||
impl SessionStore {
|
impl SessionStore {
|
||||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||||
Self { path: path.into() }
|
Self {
|
||||||
|
path: path.into(),
|
||||||
|
key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store the configuration describes: the usual path, sealed when a
|
||||||
|
/// key file is set. A key file that is missing or malformed is an error
|
||||||
|
/// rather than a quiet fall back to plain JSON.
|
||||||
|
pub fn open(config: &Config) -> Result<Self> {
|
||||||
|
let mut store = Self::new(config.proton_session_path());
|
||||||
|
if let Some(file) = &config.backup.session_key_file {
|
||||||
|
store.key = Some(Arc::new(read_key(file)?));
|
||||||
|
}
|
||||||
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(&self) -> &Path {
|
pub fn path(&self) -> &Path {
|
||||||
@@ -147,27 +182,50 @@ impl SessionStore {
|
|||||||
return Ok(Some(stored));
|
return Ok(Some(stored));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = match std::fs::read_to_string(&self.path) {
|
let bytes = match std::fs::read(&self.path) {
|
||||||
Ok(json) => json,
|
Ok(bytes) => Zeroizing::new(bytes),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(e).with_context(|| format!("could not read {}", self.path.display()))
|
return Err(e).with_context(|| format!("could not read {}", self.path.display()))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let stored = serde_json::from_str(&json)
|
|
||||||
|
let sealed = bytes.starts_with(MAGIC);
|
||||||
|
let json = match (sealed, &self.key) {
|
||||||
|
(true, Some(key)) => open(key, &bytes).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"{} does not open with WORKSPACE_SESSION_KEY_FILE — was the key replaced?",
|
||||||
|
self.path.display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
(true, None) => bail!(
|
||||||
|
"{} is sealed — set WORKSPACE_SESSION_KEY_FILE to the key it was sealed with",
|
||||||
|
self.path.display()
|
||||||
|
),
|
||||||
|
(false, _) => bytes,
|
||||||
|
};
|
||||||
|
let stored = serde_json::from_slice(&json)
|
||||||
.with_context(|| format!("{} is not a session this build understands", self.path.display()))?;
|
.with_context(|| format!("{} is not a session this build understands", self.path.display()))?;
|
||||||
|
|
||||||
|
if !sealed && self.key.is_some() {
|
||||||
|
self.save(&stored)?;
|
||||||
|
tracing::info!("the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE");
|
||||||
|
}
|
||||||
Ok(Some(stored))
|
Ok(Some(stored))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, stored: &StoredSession) -> Result<()> {
|
pub fn save(&self, stored: &StoredSession) -> Result<()> {
|
||||||
let json = serde_json::to_string(stored)?;
|
let json = Zeroizing::new(serde_json::to_vec(stored)?);
|
||||||
|
|
||||||
#[cfg(feature = "keyring")]
|
#[cfg(feature = "keyring")]
|
||||||
if keyring_save(&json).is_ok() {
|
if keyring_save(std::str::from_utf8(&json)?).is_ok() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
write_private(&self.path, json.as_bytes())
|
match &self.key {
|
||||||
|
Some(key) => write_private(&self.path, &seal(key, &json)),
|
||||||
|
None => write_private(&self.path, &json),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&self) -> Result<()> {
|
pub fn clear(&self) -> Result<()> {
|
||||||
@@ -182,6 +240,49 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 32 random bytes, base64 — what `openssl rand -base64 32` writes.
|
||||||
|
fn read_key(file: &Path) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let text = Zeroizing::new(
|
||||||
|
std::fs::read_to_string(file)
|
||||||
|
.with_context(|| format!("could not read the session key {}", file.display()))?,
|
||||||
|
);
|
||||||
|
let bytes = Zeroizing::new(STANDARD.decode(text.trim()).unwrap_or_default());
|
||||||
|
let key: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"{} must hold 32 random bytes in base64 — `openssl rand -base64 32` makes one",
|
||||||
|
file.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Zeroizing::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MAGIC ‖ nonce ‖ ciphertext`, with the magic bound in as associated data.
|
||||||
|
fn seal(key: &[u8; 32], json: &[u8]) -> Vec<u8> {
|
||||||
|
let mut nonce = [0u8; NONCE];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce);
|
||||||
|
let ciphertext = Aes256Gcm::new(key.into())
|
||||||
|
.encrypt(Nonce::from_slice(&nonce), Payload { msg: json, aad: MAGIC })
|
||||||
|
.expect("AES-GCM seals any input this size");
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(MAGIC.len() + NONCE + ciphertext.len());
|
||||||
|
out.extend_from_slice(MAGIC);
|
||||||
|
out.extend_from_slice(&nonce);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(key: &[u8; 32], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
let rest = &sealed[MAGIC.len()..];
|
||||||
|
if rest.len() < NONCE {
|
||||||
|
bail!("the sealed session is cut short");
|
||||||
|
}
|
||||||
|
let (nonce, ciphertext) = rest.split_at(NONCE);
|
||||||
|
Aes256Gcm::new(key.into())
|
||||||
|
.decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad: MAGIC })
|
||||||
|
.map(Zeroizing::new)
|
||||||
|
.map_err(|_| anyhow!("the key does not open it, or it was altered"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Write `bytes` to `path` as `0600`, and atomically: a crash mid-write must
|
/// Write `bytes` to `path` as `0600`, and atomically: a crash mid-write must
|
||||||
/// not leave a truncated session behind, because a half-written refresh token
|
/// not leave a truncated session behind, because a half-written refresh token
|
||||||
/// is indistinguishable from a revoked one and costs an interactive re-login.
|
/// is indistinguishable from a revoked one and costs an interactive re-login.
|
||||||
@@ -299,4 +400,65 @@ mod tests {
|
|||||||
assert_eq!(loaded.mailbox_password.as_deref(), Some("hunter2hunter2"));
|
assert_eq!(loaded.mailbox_password.as_deref(), Some("hunter2hunter2"));
|
||||||
assert_eq!(loaded.key_salts.len(), 1);
|
assert_eq!(loaded.key_salts.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn keyed(path: &Path, key: [u8; 32]) -> SessionStore {
|
||||||
|
SessionStore {
|
||||||
|
path: path.to_owned(),
|
||||||
|
key: Some(Arc::new(Zeroizing::new(key))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keyed_store_seals_the_file_and_only_its_key_opens_it() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("proton-session.json");
|
||||||
|
let passphrases = KeyPassphrases::from_iter([("key-1", b"secret".to_vec())]);
|
||||||
|
|
||||||
|
keyed(&path, [7; 32]).save(&stored(passphrases.clone())).unwrap();
|
||||||
|
let raw = std::fs::read(&path).unwrap();
|
||||||
|
assert!(raw.starts_with(MAGIC));
|
||||||
|
assert!(!raw.windows(7).any(|w| w == b"refresh"), "tokens left in the clear");
|
||||||
|
|
||||||
|
let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap();
|
||||||
|
assert!(loaded.key_passphrases == passphrases);
|
||||||
|
|
||||||
|
let wrong = keyed(&path, [8; 32]).load().err().unwrap();
|
||||||
|
assert!(format!("{wrong:#}").contains("does not open"), "{wrong:#}");
|
||||||
|
let none = SessionStore::new(&path).load().err().unwrap();
|
||||||
|
assert!(none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"), "{none}");
|
||||||
|
|
||||||
|
let mut flipped = raw.clone();
|
||||||
|
*flipped.last_mut().unwrap() ^= 1;
|
||||||
|
std::fs::write(&path, &flipped).unwrap();
|
||||||
|
assert!(keyed(&path, [7; 32]).load().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_file_is_sealed_the_first_time_a_key_reads_it() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("proton-session.json");
|
||||||
|
SessionStore::new(&path).save(&stored(KeyPassphrases::new())).unwrap();
|
||||||
|
assert!(std::fs::read(&path).unwrap().starts_with(b"{"));
|
||||||
|
|
||||||
|
let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.refresh_token, "refresh");
|
||||||
|
assert!(std::fs::read(&path).unwrap().starts_with(MAGIC));
|
||||||
|
assert!(keyed(&path, [7; 32]).load().unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_key_file_must_hold_32_bytes_of_base64() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("session.key");
|
||||||
|
|
||||||
|
std::fs::write(&file, format!("{}\n", STANDARD.encode([3u8; 32]))).unwrap();
|
||||||
|
assert_eq!(*read_key(&file).unwrap(), [3u8; 32]);
|
||||||
|
|
||||||
|
for bad in ["", "not base64 at all", &STANDARD.encode([3u8; 16])] {
|
||||||
|
std::fs::write(&file, bad).unwrap();
|
||||||
|
let error = read_key(&file).err().unwrap();
|
||||||
|
assert!(error.to_string().contains("openssl rand -base64 32"), "{error}");
|
||||||
|
}
|
||||||
|
assert!(read_key(&dir.path().join("missing")).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -31,7 +31,7 @@ narl-workspace — a terminal-themed personal workspace
|
|||||||
";
|
";
|
||||||
|
|
||||||
pub async fn proton_login(config: &Config) -> Result<()> {
|
pub async fn proton_login(config: &Config) -> Result<()> {
|
||||||
let store = backup::session::SessionStore::new(config.proton_session_path());
|
let store = backup::session::SessionStore::open(config)?;
|
||||||
|
|
||||||
// Said before anything is asked for, not after: Proton requires a
|
// Said before anything is asked for, not after: Proton requires a
|
||||||
// third-party client to be honest about what it is at the point where
|
// third-party client to be honest about what it is at the point where
|
||||||
@@ -68,7 +68,7 @@ pub async fn proton_login(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn proton_logout(config: &Config) -> Result<()> {
|
pub fn proton_logout(config: &Config) -> Result<()> {
|
||||||
let store = backup::session::SessionStore::new(config.proton_session_path());
|
let store = backup::session::SessionStore::open(config)?;
|
||||||
store.clear()?;
|
store.clear()?;
|
||||||
println!("Forgot the Proton session. Backups are off until the next login.");
|
println!("Forgot the Proton session. Backups are off until the next login.");
|
||||||
println!(
|
println!(
|
||||||
@@ -78,7 +78,7 @@ pub fn proton_logout(config: &Config) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
||||||
let store = backup::session::SessionStore::new(config.proton_session_path());
|
let store = backup::session::SessionStore::open(config)?;
|
||||||
let Some(stored) = store.load()? else {
|
let Some(stored) = store.load()? else {
|
||||||
println!("Proton Drive backups are not configured.");
|
println!("Proton Drive backups are not configured.");
|
||||||
println!("Run `narl-workspace proton-login` to enable them.");
|
println!("Run `narl-workspace proton-login` to enable them.");
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ pub struct BackupConfig {
|
|||||||
/// Whether a file deleted here is trashed there. Off by default: a backup
|
/// 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.
|
/// that forgets on command is one accident away from being no backup.
|
||||||
pub prune: bool,
|
pub prune: bool,
|
||||||
|
/// A file holding the key `proton-session.json` is sealed under, kept off
|
||||||
|
/// the data volume (a Docker secret, say) so a copy of the volume alone
|
||||||
|
/// does not carry a usable session. `None` leaves the file as plain JSON.
|
||||||
|
pub session_key_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look a setting up in the environment.
|
/// Look a setting up in the environment.
|
||||||
@@ -331,6 +335,7 @@ impl BackupConfig {
|
|||||||
interval,
|
interval,
|
||||||
debounce,
|
debounce,
|
||||||
prune,
|
prune,
|
||||||
|
session_key_file: var("SESSION_KEY_FILE").map(PathBuf::from),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ 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 = backup::Backup::new(db.clone(), Arc::clone(&config))?;
|
||||||
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))?;
|
||||||
|
|||||||
Reference in new issue
Block a user