diff --git a/Cargo.lock b/Cargo.lock index 8a68be28..bcd83320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5554,6 +5554,7 @@ dependencies = [ "anyhow", "axum", "base64", + "clap", "confique", "http", "knot-atproto", @@ -5575,7 +5576,9 @@ dependencies = [ "knot-ssh", "knot-types", "knot-xrpc", + "reqwest 0.13.1", "rustix", + "serde_json", "tempfile", "tikv-jemalloc-ctl", "tikv-jemallocator", diff --git a/knot2/crates/knot-server/Cargo.toml b/knot2/crates/knot-server/Cargo.toml index ccb46e03..b2f4101c 100644 --- a/knot2/crates/knot-server/Cargo.toml +++ b/knot2/crates/knot-server/Cargo.toml @@ -28,6 +28,9 @@ knot-maintenance = { workspace = true } knot-postreceive = { workspace = true } knot-messages = { workspace = true } axum = { workspace = true } +clap = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } tower-http = { workspace = true, features = ["fs", "cors"] } tokio = { workspace = true } tokio-util = { workspace = true } diff --git a/knot2/crates/knot-server/src/admin.rs b/knot2/crates/knot-server/src/admin.rs new file mode 100644 index 00000000..23ca58f2 --- /dev/null +++ b/knot2/crates/knot-server/src/admin.rs @@ -0,0 +1,93 @@ +use anyhow::Context; +use clap::Subcommand; +use knot_xrpc::legacy_admin; +use serde_json::json; + +#[derive(Subcommand)] +pub enum AdminCommand { + /// Mint an invite code + CreateInviteCode, + /// Manage knot members + Member { + #[command(subcommand)] + command: MemberCommand, + }, + /// Manage anonymous users on the knot + User { + #[command(subcommand)] + command: UserCommand, + }, +} + +#[derive(Subcommand)] +pub enum MemberCommand { + /// Add an account as a member. + Add { subject: String }, + /// Remove a member. + Remove { subject: String }, +} + +#[derive(Subcommand)] +pub enum UserCommand { + /// Ban an account. + Ban { subject: String }, + /// Unban an account. + Unban { subject: String }, +} + +pub async fn run(config: &knot_config::Validated, command: AdminCommand) -> anyhow::Result<()> { + let (route, body) = match command { + AdminCommand::CreateInviteCode => (legacy_admin::CREATE_INVITE_CODE_ROUTE, None), + AdminCommand::Member { command } => { + let (route, subject) = match command { + MemberCommand::Add { subject } => (legacy_admin::TEMP_ADD_MEMBER_ROUTE, subject), + MemberCommand::Remove { subject } => (legacy_admin::REMOVE_MEMBER_ROUTE, subject), + }; + (route, Some(json!({ "subject": subject }))) + } + AdminCommand::User { command } => { + let (route, subject) = match command { + UserCommand::Ban { subject } => (legacy_admin::BAN_USER_ROUTE, subject), + UserCommand::Unban { subject } => (legacy_admin::UNBAN_USER_ROUTE, subject), + }; + (route, Some(json!({ "subject": subject }))) + } + }; + let response = post(config, route, body).await?; + println!("{}", serde_json::to_string_pretty(&response)?); + Ok(()) +} + +async fn post( + config: &knot_config::Validated, + route: &str, + body: Option, +) -> anyhow::Result { + let name = config + .acl + .legacy_admin_secret_env + .as_deref() + .context("acl.legacy_admin_secret_env isn't configured")?; + let secret = zeroize::Zeroizing::new(std::env::var(name).unwrap_or_default()); + let secret = secret.trim(); + anyhow::ensure!( + !secret.is_empty(), + "the environment variable {name} is unset or empty" + ); + let url = format!("https://{}{route}", config.server.hostname); + let request = reqwest::Client::new() + .post(&url) + .basic_auth("admin", Some(secret)); + let request = match body { + Some(body) => request.json(&body), + None => request, + }; + let response = request + .send() + .await + .with_context(|| format!("request {url}"))?; + let status = response.status(); + let body = response.text().await.context("read response body")?; + anyhow::ensure!(status.is_success(), "{url} responded {status}: {body}"); + serde_json::from_str(&body).context("decode response") +} diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 41ae5e10..de49c303 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -1,3 +1,4 @@ +mod admin; mod allocator; #[global_allocator] @@ -10,7 +11,7 @@ pub static malloc_conf: &[u8] = use std::collections::BTreeSet; use std::num::{NonZeroU32, NonZeroU64}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -21,6 +22,7 @@ use axum::Json; use axum::response::Html; use axum::routing::get; use base64::Engine; +use clap::{Parser, Subcommand}; use knot_atproto::Atproto; use knot_config::HomepageSource; use knot_index::Index; @@ -76,48 +78,12 @@ fn init_tracing() { .init(); } -const VALIDATE_CONFIG_ONLY: &str = "--config-only"; - -enum ValidateScope { - ConfigOnly, - Environment, -} - -impl ValidateScope { - fn verify(self, config: &knot_config::Validated) -> anyhow::Result<()> { - match self { - Self::ConfigOnly => Ok(()), - Self::Environment => config - .verify_environment() - .context("verify runtime environment"), - } +fn validate(config: &knot_config::Validated, config_only: bool) -> anyhow::Result<()> { + if !config_only { + config + .verify_environment() + .context("verify runtime environment")?; } -} - -fn validate(args: impl Iterator) -> anyhow::Result<()> { - let (flags, paths): (Vec, Vec) = args.partition(|arg| arg.starts_with("--")); - flags - .iter() - .find(|flag| flag.as_str() != VALIDATE_CONFIG_ONLY) - .map_or(Ok(()), |unknown| { - Err(anyhow::anyhow!( - "unrecognized flag {unknown}, expected {VALIDATE_CONFIG_ONLY}" - )) - })?; - let scope = match flags.is_empty() { - true => ValidateScope::Environment, - false => ValidateScope::ConfigOnly, - }; - let path = match paths.as_slice() { - [] => None, - [path] => Some(Path::new(path)), - extra => anyhow::bail!( - "expected at most one configuration path, got {}", - extra.len() - ), - }; - let config = knot_config::load(path).context("load configuration")?; - scope.verify(&config)?; println!("configuration is valid"); Ok(()) } @@ -133,15 +99,43 @@ fn keyfill_pace(config: &knot_config::KeyfillConfig) -> knot_keyfill::Pace { } } -fn subcommand(name: &str) -> Option> { - match name { - "config-template" => { - print!("{}", knot_config::template()); - Some(Ok(())) - } - "validate" => Some(validate(std::env::args().skip(2))), - _ => None, - } +#[derive(Parser)] +#[command(name = "knot")] +struct Cli { + /// Path to a TOML config file. Environment variables override file values + /// for any `KNOT_*` setting; `/etc/knot/config.toml` is consulted as a + /// final fallback so distro packaging can drop a default in place. + #[arg(short, long, global = true, value_name = "FILE", env = "KNOT_CONFIG")] + config: Option, + + #[command(subcommand)] + command: Option, +} + +// Commands that run without a configuration. +#[derive(Subcommand)] +enum Command { + /// Print a fully-commented TOML template to stdout. Use this to seed + /// `config.toml` for a fresh deploy. + ConfigTemplate, + #[command(flatten)] + Configured(ConfiguredCommand), +} + +#[derive(Subcommand)] +enum ConfiguredCommand { + /// Load and validate the configuration without starting the server. + Validate { + /// Skip runtime environment checks (paths, permissions) and only + /// validate the configuration itself. + #[arg(long)] + config_only: bool, + }, + /// Administer a running knot. + Admin { + #[command(subcommand)] + command: admin::AdminCommand, + }, } #[tokio::main] @@ -150,12 +144,35 @@ async fn main() -> anyhow::Result<()> { rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable) .context("disable core dumps and ptrace attachment")?; - if let Some(result) = std::env::args().nth(1).as_deref().and_then(subcommand) { - return result; + let cli = Cli::parse(); + + let command = match cli.command { + Some(Command::ConfigTemplate) => { + print!("{}", knot_config::template()); + return Ok(()); + } + Some(Command::Configured(command)) => Some(command), + None => None, + }; + + let config = knot_config::load(cli.config.as_deref()).context("load configuration")?; + + match command { + None => serve(config).await, + Some(ConfiguredCommand::Validate { config_only }) => validate(&config, config_only), + Some(ConfiguredCommand::Admin { command }) => admin::run(&config, command).await, } +} +async fn serve(config: knot_config::Validated) -> anyhow::Result<()> { + config + .verify_environment() + .context("verify runtime environment")?; init_tracing(); + run(config).await +} +async fn run(config: knot_config::Validated) -> anyhow::Result<()> { tracing::info!("!"); tracing::info!("!"); tracing::info!("!"); @@ -168,12 +185,6 @@ async fn main() -> anyhow::Result<()> { tracing::info!("!"); tracing::info!("!"); - let config_path = std::env::args().nth(1).map(PathBuf::from); - let config = knot_config::load(config_path.as_deref()).context("load configuration")?; - config - .verify_environment() - .context("verify runtime environment")?; - let resources = knot_resource::init(knot_resource::Ceilings { max_threads: match config.resources.max_threads { 0 => None, diff --git a/nix/modules/knot-rs.nix b/nix/modules/knot-rs.nix index ad2d5df5..4144c737 100644 --- a/nix/modules/knot-rs.nix +++ b/nix/modules/knot-rs.nix @@ -67,7 +67,7 @@ pkgs.runCommandLocal "knot-config.toml" { nativeBuildInputs = [cfg.package]; } '' - knot-server validate --config-only ${rendered} + knot-server validate --config-only --config ${rendered} ln -s ${rendered} $out '' else rendered; @@ -422,7 +422,7 @@ in { UMask = "0077"; WorkingDirectory = cfg.stateDir; EnvironmentFile = cfg.environmentFile; - ExecStart = "${lib.getExe cfg.package} /etc/knot/config.toml"; + ExecStart = "${lib.getExe cfg.package} --config /etc/knot/config.toml"; Restart = "on-failure"; RestartSec = 5; TimeoutStopSec = 120; diff --git a/systemd/knot.service b/systemd/knot.service index c7bd3fc7..298bddc0 100644 --- a/systemd/knot.service +++ b/systemd/knot.service @@ -6,7 +6,7 @@ StartLimitIntervalSec=60 StartLimitBurst=5 [Service] -ExecStart=/usr/local/bin/knot-server /var/lib/knot/config.toml +ExecStart=/usr/local/bin/knot-server --config /var/lib/knot/config.toml User=git Group=git StateDirectory=knot