diff --git a/.gitignore b/.gitignore index a49e43a..34fe899 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ target/ -mods.db \ No newline at end of file + +# State +database.db* +secrets.json diff --git a/Cargo.lock b/Cargo.lock index 9e75ab8..267978c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -406,6 +412,44 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -684,27 +728,6 @@ dependencies = [ "crypto-common 0.2.2", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.7" @@ -1812,12 +1835,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "p256" version = "0.13.2" @@ -2179,17 +2196,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.19", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -2796,8 +2802,8 @@ dependencies = [ "axum", "base64", "chrono", + "clap", "cookie", - "dirs", "maud", "rand 0.9.5", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 0b6fc46..293921f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,4 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus reqwest-middleware = "0.4" reqwest-chain = "1.0" urlencoding = "2.1" -dirs = "6.0.0" +clap = { version = "4.6.6", default-features = false, features = ["std", "derive", "help", "usage", "error-context"] } diff --git a/flake.nix b/flake.nix index 49c8a53..040c26b 100644 --- a/flake.nix +++ b/flake.nix @@ -100,6 +100,7 @@ }: let cfg = config.services.starhaven; + stateDirectory = "starhaven"; in { options.services.starhaven = { @@ -116,6 +117,12 @@ example = "https://starhaven.dev"; description = "External base URL (with scheme)."; }; + + listen = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1:3000"; + description = "Address:port to listen on."; + }; }; config = lib.mkIf cfg.enable { @@ -125,15 +132,18 @@ after = [ "network-online.target" ]; wants = [ "network-online.target" ]; - environment = { - HTTP_EXTERNAL_BASE = cfg.externalBase; - XDG_DATA_HOME = "/var/lib"; - }; - serviceConfig = { - ExecStart = lib.getExe cfg.package; + ExecStart = lib.escapeShellArgs [ + (lib.getExe cfg.package) + "--external-base" + cfg.externalBase + "--listen" + cfg.listen + "--data-dir" + "/var/lib/${stateDirectory}" + ]; DynamicUser = true; - StateDirectory = "starhaven"; + StateDirectory = stateDirectory; Restart = "on-failure"; }; }; diff --git a/src/config.rs b/src/config.rs index 18b27e8..770a97d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,38 +1,58 @@ -//! Configuration and on-disk paths. +//! Command-line configuration and on-disk paths. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use clap::Parser; /// The OAuth scope requested for every session. pub const OAUTH_SCOPE: &str = "atproto transition:generic"; -/// Application configuration. +/// The starhaven.dev server. +#[derive(Parser)] +#[command(name = "server", version)] pub struct Config { - /// External base URL (with scheme), e.g. `https://starhaven.dev`. - pub external_base: String, + /// External base URL (with scheme). Defaults to the listen address. + #[arg(long)] + external_base: Option, + + /// Address:port to listen on. + #[arg(long, default_value = "127.0.0.1:3000")] + pub listen: SocketAddr, + + /// State directory. + #[arg(long, default_value = ".")] + pub data_dir: PathBuf, } impl Config { - /// Load configuration from environment variables. - pub fn from_env() -> Self { - let external_base = std::env::var("HTTP_EXTERNAL_BASE") - .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()); + /// Parse arguments, then fill in defaults that depend on other arguments. + pub fn parse() -> Self { + ::parse().resolve() + } - Self { external_base } + /// Derive `external_base` from `listen` when it wasn't given. Every + /// constructor must run this; the accessor assumes it has. + fn resolve(mut self) -> Self { + if self.external_base.is_none() { + self.external_base = Some(format!("http://{}", self.listen)); + } + self } - /// The app's data directory (e.g. `~/.local/share/starhaven`). Relocated - /// by setting `XDG_DATA_HOME`, not an app-specific env var. - pub fn state_dir() -> std::path::PathBuf { - let base = dirs::data_local_dir().expect("no local data directory for this platform"); - base.join("starhaven") + /// External base URL. + pub fn external_base(&self) -> &str { + self.external_base.as_deref().expect("filled in by resolve") } /// Path to the SQLite database file. Created on startup if missing. - pub fn database_path() -> std::path::PathBuf { - Self::state_dir().join("database.db") + pub fn database_path(&self) -> PathBuf { + self.data_dir.join("database.db") } /// The OAuth client id (the `client-metadata.json` URL). pub fn oauth_client_id(&self) -> String { - format!("{}/client-metadata.json", self.external_base) + format!("{}/client-metadata.json", self.external_base()) } /// Whether `external_base` is `localhost`/a loopback IP. @@ -41,7 +61,7 @@ impl Config { /// `oauth::session`'s host parsing. pub fn is_loopback(&self) -> bool { let host = self - .external_base + .external_base() .trim_start_matches("https://") .trim_start_matches("http://") .split(['/', ':']) @@ -71,12 +91,12 @@ impl Config { /// The OAuth redirect URI. pub fn oauth_redirect_uri(&self) -> String { - format!("{}/callback", self.external_base) + format!("{}/callback", self.external_base()) } /// The JWKS URI. pub fn jwks_uri(&self) -> String { - format!("{}/jwks.json", self.external_base) + format!("{}/jwks.json", self.external_base()) } } @@ -85,9 +105,13 @@ mod tests { use super::*; fn config_with_base(external_base: &str) -> Config { - Config { - external_base: external_base.to_string(), - } + Config::parse_from(["server", "--external-base", external_base]).resolve() + } + + #[test] + fn external_base_defaults_to_listen_address() { + let config = Config::parse_from(["server", "--listen", "127.0.0.1:9999"]).resolve(); + assert_eq!(config.external_base(), "http://127.0.0.1:9999"); } #[test] diff --git a/src/main.rs b/src/main.rs index 4174bfb..e4eb528 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,9 +18,10 @@ use state::AppState; #[tokio::main] async fn main() { - let state = AppState::new(Config::from_env()) + let state = AppState::new(Config::parse()) .await .expect("failed to initialize application state"); + let listen = state.config.listen; let app = Router::new() .merge(oauth::router()) @@ -28,7 +29,7 @@ async fn main() { .route("/hello", post(hello)) .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") + let listener = tokio::net::TcpListener::bind(listen) .await .unwrap(); println!("listening on http://{}", listener.local_addr().unwrap()); diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index c240e78..daf21f2 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -215,12 +215,13 @@ pub async fn callback( let max_age = Duration::days(30).num_seconds(); let mut headers = HeaderMap::new(); - if let Ok(h) = build_session_cookie_header(&state.config.external_base, &session_value, max_age) + if let Ok(h) = + build_session_cookie_header(state.config.external_base(), &session_value, max_age) { headers.append(header::SET_COOKIE, h); } if let Ok(h) = - build_identity_cookie_header(&state.config.external_base, &identity_value, max_age) + build_identity_cookie_header(state.config.external_base(), &identity_value, max_age) { headers.append(header::SET_COOKIE, h); } diff --git a/src/oauth/logout.rs b/src/oauth/logout.rs index 83acc07..5f95ebf 100644 --- a/src/oauth/logout.rs +++ b/src/oauth/logout.rs @@ -15,7 +15,7 @@ use crate::state::AppState; /// Local only: `atproto_oauth` 0.14.5 has no token-revocation call, so /// tokens stay valid server-side until they expire. pub async fn logout(State(state): State) -> Result { - let external_base = &state.config.external_base; + let external_base = state.config.external_base(); let mut headers = HeaderMap::new(); // Surface (not swallow) a failure to clear cookies, or logout could // silently not log the user out. diff --git a/src/oauth/metadata.rs b/src/oauth/metadata.rs index caaa953..aa94c9d 100644 --- a/src/oauth/metadata.rs +++ b/src/oauth/metadata.rs @@ -19,7 +19,7 @@ pub async fn client_metadata(State(state): State) -> Response { let doc = json!({ "client_id": config.oauth_client_id(), "client_name": "starhaven", - "client_uri": config.external_base, + "client_uri": config.external_base(), "redirect_uris": [config.oauth_redirect_uri()], "jwks_uri": config.jwks_uri(), "scope": crate::config::OAUTH_SCOPE, diff --git a/src/oauth/refresh.rs b/src/oauth/refresh.rs index 2464650..3c95d9e 100644 --- a/src/oauth/refresh.rs +++ b/src/oauth/refresh.rs @@ -129,7 +129,7 @@ pub async fn try_refresh_session( .map_err(|e| AppError::Internal(anyhow::anyhow!("session encode failed: {e}")))?; let max_age = Duration::days(30).num_seconds(); - let set_cookie = build_session_cookie_header(&state.config.external_base, &encoded, max_age) + let set_cookie = build_session_cookie_header(state.config.external_base(), &encoded, max_age) .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie build failed: {e}")))? .to_str() .map_err(|e| AppError::Internal(anyhow::anyhow!("cookie to_str failed: {e}")))? diff --git a/src/secrets.rs b/src/secrets.rs index 0357ae9..cef65fe 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -7,8 +7,6 @@ use base64::Engine as _; use rand::RngCore as _; use serde::{Deserialize, Serialize}; -use crate::config::Config; - /// Persistent secrets, loaded once at startup. pub struct Secrets { /// OAuth private signing key (P-256). @@ -26,10 +24,10 @@ struct SecretsFile { } impl Secrets { - /// Load secrets from the state directory, generating and persisting them - /// on first run. - pub fn load_or_generate() -> anyhow::Result { - let path = Config::state_dir().join("secrets.json"); + /// Load secrets from `data_dir`, generating and persisting them on first + /// run. + pub fn load_or_generate(data_dir: &std::path::Path) -> anyhow::Result { + let path = data_dir.join("secrets.json"); if let Ok(contents) = std::fs::read_to_string(&path) { let file: SecretsFile = serde_json::from_str(&contents)?; diff --git a/src/state.rs b/src/state.rs index cb0f0ce..3da3e65 100644 --- a/src/state.rs +++ b/src/state.rs @@ -41,9 +41,9 @@ impl AppState { /// Build application state from configuration. pub async fn new(config: Config) -> anyhow::Result { // Holds both the secrets file and the database. - std::fs::create_dir_all(Config::state_dir())?; + std::fs::create_dir_all(&config.data_dir)?; - let secrets = Secrets::load_or_generate()?; + let secrets = Secrets::load_or_generate(&config.data_dir)?; let http_client = reqwest::Client::new(); let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[])); @@ -55,7 +55,7 @@ impl AppState { // Create and migrate db if necessary. let connect_options = SqliteConnectOptions::new() - .filename(Config::database_path()) + .filename(config.database_path()) .create_if_missing(true); let db = SqlitePoolOptions::new() .connect_with(connect_options)