//! `mtgc-server` — run the sync server, or manage its local accounts. //! //! ```text //! mtgc-server serve (the default) //! mtgc-server user add [--display-name ] //! mtgc-server user passwd //! mtgc-server user remove --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 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 [--display-name ] mtgc-server user passwd mtgc-server user remove --yes mtgc-server user list"; fn main() -> anyhow::Result<()> { let args: Vec = 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 { 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 { 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 { 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 `."); } 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 { 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 { 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) }