The only way to give a gateway a printer was to edit printers.toml and restart it. A printer added from the console now arrives as a provision_printer message, is checked by actually connecting to it (so a mistyped access code is reported to the operator, not discovered later), and only then is saved to the gateway's own SQLite file and joined to the running fleet - no restart. remove_printer undoes it. Both are answered with a command_result carrying the request id. The access code exists only on the gateway: the cloud relays it once and keeps nothing, which is the platform's rule for printer credentials. The store file is created owner-only; the message type's Debug and the fleet entry's Debug redact the credential; and an unparseable message from the cloud is no longer logged with its payload, because the message that fails to parse is exactly the one carrying an access code. The cloud may not choose a certificate path or serial port on the gateway's disk, so provision_printer cannot carry them. Startup now reads printers.toml (strict, as before; a missing file is fine) and the store (a bad stored entry is skipped, not fatal), so the gateway comes up with its fleet with the API unreachable. Discovery treats stored printers as already configured. rusqlite (MIT, bundled SQLite) passes cargo deny.
200 lines
7.3 KiB
Rust
200 lines
7.3 KiB
Rust
//! The gateway's own database: printers an operator added from the console.
|
|
//!
|
|
//! Why the gateway keeps these itself and not the cloud: a printer's access
|
|
//! code is a credential for a machine on someone's LAN, and the platform's
|
|
//! rule is that those never rest in the cloud. The console sends the code
|
|
//! once, over the uplink, and this is where it lands. It also means the
|
|
//! fleet survives the API being unreachable - the gateway starts, connects
|
|
//! its printers and keeps reporting locally from this alone.
|
|
//!
|
|
//! SQLite, one file. The file holds credentials, so on Unix it is created
|
|
//! owner-only (0600) before SQLite touches it; SQLite gives its journal files
|
|
//! the same mode as the main one.
|
|
//!
|
|
//! `printers.toml` still works and is read alongside this; see `main.rs`.
|
|
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
|
|
use anyhow::Context;
|
|
use rusqlite::{Connection, params};
|
|
|
|
use crate::fleet::PrinterEntry;
|
|
|
|
pub struct Store {
|
|
// rusqlite's `Connection` is not `Sync`, so it cannot be shared between
|
|
// tasks directly. Wrapping it in a `Mutex` is the plain fix: one caller
|
|
// at a time, which is plenty for a few writes a day.
|
|
conn: Mutex<Connection>,
|
|
}
|
|
|
|
impl Store {
|
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
|
if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
|
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {dir:?} for the local store"))?;
|
|
}
|
|
create_private(path)?;
|
|
let conn = Connection::open(path).with_context(|| format!("opening the local store at {path:?}"))?;
|
|
// Each printer is a JSON document keyed by its id. The id is the
|
|
// gateway's own printer id - the one telemetry is reported under -
|
|
// so "which printer" has a single answer everywhere.
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS printers (
|
|
id TEXT PRIMARY KEY,
|
|
json TEXT NOT NULL
|
|
);",
|
|
)?;
|
|
Ok(Self { conn: Mutex::new(conn) })
|
|
}
|
|
|
|
/// Adds a printer, or replaces the one with the same id.
|
|
pub fn save_printer(&self, entry: &PrinterEntry) -> anyhow::Result<()> {
|
|
let json = serde_json::to_string(entry)?;
|
|
let conn = self.conn.lock().expect("store lock poisoned");
|
|
conn.execute(
|
|
"INSERT INTO printers (id, json) VALUES (?1, ?2)
|
|
ON CONFLICT(id) DO UPDATE SET json = excluded.json",
|
|
params![entry.id, json],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Forgets a printer. True if there was one.
|
|
pub fn remove_printer(&self, id: &str) -> anyhow::Result<bool> {
|
|
let conn = self.conn.lock().expect("store lock poisoned");
|
|
Ok(conn.execute("DELETE FROM printers WHERE id = ?1", params![id])? > 0)
|
|
}
|
|
|
|
/// Every stored printer, oldest first.
|
|
pub fn printers(&self) -> anyhow::Result<Vec<PrinterEntry>> {
|
|
let conn = self.conn.lock().expect("store lock poisoned");
|
|
let mut statement = conn.prepare("SELECT json FROM printers ORDER BY rowid")?;
|
|
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
|
|
let mut out = Vec::new();
|
|
for json in rows {
|
|
out.push(serde_json::from_str(&json?).context("a stored printer no longer parses")?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// Creates the file empty and owner-only if it does not exist yet.
|
|
#[cfg(unix)]
|
|
fn create_private(path: &Path) -> anyhow::Result<()> {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.mode(0o600)
|
|
.open(path)
|
|
.with_context(|| format!("creating the local store {path:?}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Windows: the file inherits its folder's permissions, which for the
|
|
/// per-user data directory is already that user's. Nothing to set here.
|
|
#[cfg(not(unix))]
|
|
fn create_private(_path: &Path) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn entry(id: &str, access_code: &str) -> PrinterEntry {
|
|
PrinterEntry {
|
|
id: id.into(),
|
|
name: format!("Printer {id}"),
|
|
vendor: "bambu".into(),
|
|
host: Some("192.168.0.50".into()),
|
|
access_code: Some(access_code.into()),
|
|
sn: Some("01P00A000000000".into()),
|
|
api_key: None,
|
|
port: None,
|
|
baud_rate: 115_200,
|
|
ca_cert_path: None,
|
|
require_valid_cert: true,
|
|
}
|
|
}
|
|
|
|
/// A throwaway directory, removed when dropped. Hand-rolled rather than
|
|
/// pulling in a crate for four tests.
|
|
struct TempDir(std::path::PathBuf);
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn temp_db() -> (TempDir, std::path::PathBuf) {
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"continuum-store-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
let path = dir.join("nested").join("continuum.db");
|
|
(TempDir(dir), path)
|
|
}
|
|
|
|
#[test]
|
|
fn what_was_saved_comes_back_after_reopening() {
|
|
let (_dir, path) = temp_db();
|
|
Store::open(&path).unwrap().save_printer(&entry("a", "11111111")).unwrap();
|
|
|
|
let reopened = Store::open(&path).unwrap();
|
|
let printers = reopened.printers().unwrap();
|
|
assert_eq!(printers.len(), 1);
|
|
assert_eq!(printers[0].id, "a");
|
|
assert_eq!(printers[0].access_code.as_deref(), Some("11111111"));
|
|
assert_eq!(printers[0].sn.as_deref(), Some("01P00A000000000"));
|
|
}
|
|
|
|
#[test]
|
|
fn saving_the_same_id_replaces_it_and_order_is_oldest_first() {
|
|
let (_dir, path) = temp_db();
|
|
let store = Store::open(&path).unwrap();
|
|
store.save_printer(&entry("a", "old")).unwrap();
|
|
store.save_printer(&entry("b", "bbb")).unwrap();
|
|
store.save_printer(&entry("a", "new")).unwrap();
|
|
|
|
let printers = store.printers().unwrap();
|
|
let ids: Vec<_> = printers.iter().map(|p| p.id.as_str()).collect();
|
|
assert_eq!(ids, ["a", "b"]);
|
|
assert_eq!(printers[0].access_code.as_deref(), Some("new"));
|
|
}
|
|
|
|
#[test]
|
|
fn removing_reports_whether_anything_was_there() {
|
|
let (_dir, path) = temp_db();
|
|
let store = Store::open(&path).unwrap();
|
|
store.save_printer(&entry("a", "x")).unwrap();
|
|
assert!(store.remove_printer("a").unwrap());
|
|
assert!(!store.remove_printer("a").unwrap());
|
|
assert!(store.printers().unwrap().is_empty());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn the_file_holding_credentials_is_owner_only() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let (_dir, path) = temp_db();
|
|
let store = Store::open(&path).unwrap();
|
|
store.save_printer(&entry("a", "secret")).unwrap();
|
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_file_is_an_error_not_an_empty_fleet() {
|
|
let (_dir, path) = temp_db();
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap();
|
|
assert!(Store::open(&path).is_err());
|
|
}
|
|
}
|