Files
mtgc/crates/mtgc-server/src/main.rs
Garrett Mills cec61b886e Run the sync server image as a numeric UID/GID; explain unwritable /data
The image now runs as USER 10001:10001 with a matching group, so
Kubernetes can verify runAsNonRoot and fsGroup: 10001 grants it a mounted
volume. When the database can't be opened because its directory isn't
writable, the server says so and how to fix it, instead of SQLite's bare
"unable to open database file". The README covers the pod securityContext.
2026-10-13 21:21:36 -05:00

205 lines
8.0 KiB
Rust

//! `mtgc-server` — run the sync server, or manage its local accounts.
//!
//! ```text
//! mtgc-server serve (the default)
//! mtgc-server user add <name> [--display-name <text>]
//! mtgc-server user passwd <name>
//! mtgc-server user remove <name> --yes
//! mtgc-server user list
//! ```
//!
//! The `user` commands read the same `MTGC_DB` as the server, so in a container
//! they run with `docker exec -it <container> mtgc-server user add me`.
use std::io::{BufRead, IsTerminal};
use anyhow::{bail, Context};
use mtgc_server::auth::local::{hash_password, normalize_username};
use mtgc_server::{db, router, AppState, Config};
const USAGE: &str = "usage:
mtgc-server run the server
mtgc-server user add <name> [--display-name <text>]
mtgc-server user passwd <name>
mtgc-server user remove <name> --yes
mtgc-server user list";
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
None | Some("serve") => serve(),
Some("user") => user(&args[1..]),
Some("-h" | "--help" | "help") => {
println!("{USAGE}");
Ok(())
}
Some(other) => bail!("unknown command {other:?}\n{USAGE}"),
}
}
fn serve() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mtgc_server=info".into()),
)
.init();
let config = Config::from_env()?;
let conn = open_at(&config.db_path)?;
let bind = config.bind;
tracing::info!(
public_url = %config.public_url,
local_auth = config.local_auth,
oidc = config.oidc.as_ref().map(|o| o.issuer.as_str()).unwrap_or("off"),
db = %config.db_path.display(),
"starting"
);
let state = AppState::new(config, conn)?;
tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(async move {
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
tracing::info!("listening on {bind}");
axum::serve(listener, router(state)).with_graceful_shutdown(shutdown_signal()).await?;
Ok(())
})
}
/// Resolves on Ctrl-C, or on SIGTERM — what `docker stop` and Kubernetes send.
/// As PID 1 in a container the process gets no default signal handling, so
/// without catching SIGTERM it would sit out the grace period and be killed.
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
/// Account management needs only the database, not the rest of the server's
/// configuration, so it works before OIDC (say) is set up.
fn open_db() -> anyhow::Result<rusqlite::Connection> {
let path = std::env::var("MTGC_DB").unwrap_or_else(|_| "mtgc-server.db".into());
open_at(std::path::Path::new(&path))
}
/// Open the database, explaining the usual reason it can't be: a mounted
/// volume the server's user can't write to. SQLite's own message ("unable to
/// open database file") doesn't say so.
fn open_at(path: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
db::open(path).map_err(|e| {
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let probe = dir.join(".mtgc-write-test");
let writable = std::fs::write(&probe, b"").and_then(|_| std::fs::remove_file(&probe));
let err = anyhow::Error::new(e).context(format!("opening {}", path.display()));
match writable {
Ok(()) => err,
Err(w) => err.context(format!(
"{} isn't writable by this process ({w}). In the container image the server \
runs as UID/GID 10001: on Kubernetes, set the pod's securityContext.fsGroup \
to 10001; for a bind mount, `chown 10001:10001` the host directory",
dir.display()
)),
}
})
}
fn user(args: &[String]) -> anyhow::Result<()> {
let name = |i: usize| -> anyhow::Result<String> {
let n = normalize_username(args.get(i).map(String::as_str).unwrap_or_default());
if n.is_empty() {
bail!("a username is required\n{USAGE}");
}
if n.len() > 64 || n.chars().any(|c| c.is_whitespace() || c.is_control()) {
bail!("usernames are up to 64 characters, without spaces");
}
Ok(n)
};
match args.first().map(String::as_str) {
Some("add") => {
let username = name(1)?;
let display_name = flag(args, "--display-name").unwrap_or_else(|| username.clone());
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_some() {
bail!("{username} already exists — use `user passwd` to change the password");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::create_local_user(&conn, &username, &display_name, &hash)?;
println!("Created {username}.");
}
Some("passwd") => {
let username = name(1)?;
let conn = open_db()?;
if db::local_user(&conn, &username)?.is_none() {
bail!("no local account named {username}");
}
let hash = hash_password(&read_new_password()?).map_err(anyhow::Error::msg)?;
db::set_local_password(&conn, &username, &hash)?;
println!("Password changed for {username}. Devices already signed in stay signed in.");
}
Some("remove") => {
let username = name(1)?;
if !args.iter().any(|a| a == "--yes") {
bail!(
"this deletes {username}'s account, signs out their devices, and deletes \
the library stored on this server (devices keep their own copies). \
Re-run with --yes to confirm."
);
}
if db::delete_local_user(&open_db()?, &username)? {
println!("Removed {username}.");
} else {
bail!("no local account named {username}");
}
}
Some("list") => {
let users = db::list_users(&open_db()?)?;
if users.is_empty() {
println!("No accounts yet. Add one with `mtgc-server user add <name>`.");
}
for (u, devices) in users {
let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) };
let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) };
println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who);
}
}
_ => bail!("{USAGE}"),
}
Ok(())
}
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
/// Prompt twice without echo at a terminal; otherwise read one line from
/// stdin, so it can be scripted (`echo "$PW" | mtgc-server user add me`).
fn read_new_password() -> anyhow::Result<String> {
let password = if std::io::stdin().is_terminal() {
let first = rpassword::prompt_password("New password: ")?;
let again = rpassword::prompt_password("Again: ")?;
if first != again {
bail!("the passwords didn't match");
}
first
} else {
let mut line = String::new();
std::io::stdin().lock().read_line(&mut line)?;
line.trim_end_matches(['\r', '\n']).to_string()
};
if password.chars().count() < 8 {
bail!("use at least 8 characters");
}
Ok(password)
}