diff --git a/mise.lock b/mise.lock index 3cd025e..9cf1bee 100644 --- a/mise.lock +++ b/mise.lock @@ -8,10 +8,18 @@ backend = "aqua:ast-grep/ast-grep" checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" +[tools.ast-grep."platforms.linux-arm64-musl"] +checksum = "sha256:5c830eae8456569e2f7212434ed9c238f58dca412d76045418ed6d394a755836" +url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-unknown-linux-gnu.zip" + [tools.ast-grep."platforms.linux-x64"] checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" +[tools.ast-grep."platforms.linux-x64-musl"] +checksum = "sha256:e825a05603f0bcc4cd9076c4cc8c9abd6d008b7cd07d9aa3cc323ba4b8606651" +url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-x86_64-unknown-linux-gnu.zip" + [tools.ast-grep."platforms.macos-arm64"] checksum = "sha256:fc300d5293b1c770a5aece03a8a193b92e71e87cec726c28096990691a582620" url = "https://github.com/ast-grep/ast-grep/releases/download/0.42.0/app-aarch64-apple-darwin.zip" @@ -32,10 +40,6 @@ backend = "cargo:cargo-audit" version = "0.13.9" backend = "cargo:cargo-edit" -[[tools."cargo:cargo-features"]] -version = "1.0.0" -backend = "cargo:cargo-features" - [[tools."cargo:cargo-features-manager"]] version = "0.11.1" backend = "cargo:cargo-features-manager" @@ -49,21 +53,13 @@ version = "0.9.126" backend = "cargo:cargo-nextest" [[tools."cargo:cargo-shear"]] -version = "1.9.1" +version = "1.11.2" backend = "cargo:cargo-shear" [[tools."cargo:cargo-vet"]] version = "0.10.2" backend = "cargo:cargo-vet" -[[tools."cargo:diesel-cli"]] -version = "2.3.6" -backend = "cargo:diesel-cli" - -[tools."cargo:diesel-cli".options] -default-features = "false" -features = "sqlite,sqlite-bundled" - [[tools."cargo:diesel_cli"]] version = "2.3.6" backend = "cargo:diesel_cli" @@ -72,10 +68,6 @@ backend = "cargo:diesel_cli" default-features = "false" features = "sqlite,sqlite-bundled" -[[tools."cargo:rinf_cli"]] -version = "8.9.1" -backend = "cargo:rinf_cli" - [[tools.flutter]] version = "3.38.9-stable" backend = "asdf:flutter" @@ -88,10 +80,18 @@ backend = "aqua:protocolbuffers/protobuf/protoc" checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" +[tools.protoc."platforms.linux-arm64-musl"] +checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79" +url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip" + [tools.protoc."platforms.linux-x64"] checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" +[tools.protoc."platforms.linux-x64-musl"] +checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323" +url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip" + [tools.protoc."platforms.macos-arm64"] checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed" url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip" @@ -109,24 +109,32 @@ version = "3.14.3" backend = "core:python" [tools.python."platforms.linux-arm64"] -checksum = "sha256:be0f4dc2932f762292b27d46ea7d3e8e66ddf3969a5eb0254a229015ed402625" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.linux-arm64-musl"] +checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" [tools.python."platforms.linux-x64"] -checksum = "sha256:0a73413f89efd417871876c9accaab28a9d1e3cd6358fbfff171a38ec99302f0" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" + +[tools.python."platforms.linux-x64-musl"] +checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" [tools.python."platforms.macos-arm64"] -checksum = "sha256:4703cdf18b26798fde7b49b6b66149674c25f97127be6a10dbcf29309bdcdcdb" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-aarch64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz" [tools.python."platforms.macos-x64"] -checksum = "sha256:76f1cc26e3d262eae8ca546a93e8bded10cf0323613f7e246fea2e10a8115eb7" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz" [tools.python."platforms.windows-x64"] -checksum = "sha256:950c5f21a015c1bdd1337f233456df2470fab71e4d794407d27a84cb8b9909a0" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260303/cpython-3.14.3+20260303-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" +checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" [[tools.rust]] version = "1.93.0" diff --git a/server/Cargo.lock b/server/Cargo.lock index 32a1587..284b8ba 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -669,6 +669,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -730,6 +780,7 @@ dependencies = [ "async-trait", "chacha20poly1305", "chrono", + "clap", "dashmap", "diesel", "diesel-async", @@ -761,6 +812,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "windows-service", "x25519-dalek", "zeroize", ] @@ -1433,6 +1485,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -1442,6 +1534,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "console" version = "0.15.11" @@ -2051,7 +2149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2853,6 +2951,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -3186,7 +3290,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3320,6 +3424,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -4285,7 +4395,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4701,7 +4811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4895,7 +5005,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5469,6 +5579,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" @@ -5675,6 +5791,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5747,6 +5869,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "193cae8e647981c35bc947fdd57ba7928b1fa0d4a79305f6dd2dc55221ac35ac" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.59.0", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/server/crates/arbiter-proto/src/lib.rs b/server/crates/arbiter-proto/src/lib.rs index 732ee65..87644f6 100644 --- a/server/crates/arbiter-proto/src/lib.rs +++ b/server/crates/arbiter-proto/src/lib.rs @@ -2,6 +2,10 @@ pub mod transport; pub mod url; use base64::{Engine, prelude::BASE64_STANDARD}; +use std::{ + path::PathBuf, + sync::{LazyLock, RwLock}, +}; pub mod proto { tonic::include_proto!("arbiter"); @@ -27,8 +31,27 @@ pub struct ClientMetadata { } pub static BOOTSTRAP_PATH: &str = "bootstrap_token"; +pub const DEFAULT_SERVER_PORT: u16 = 50051; +static HOME_OVERRIDE: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +pub fn set_home_path_override(path: Option) -> Result<(), std::io::Error> { + let mut lock = HOME_OVERRIDE + .write() + .map_err(|_| std::io::Error::other("home path override lock poisoned"))?; + *lock = path; + Ok(()) +} pub fn home_path() -> Result { + if let Some(path) = HOME_OVERRIDE + .read() + .map_err(|_| std::io::Error::other("home path override lock poisoned"))? + .clone() + { + std::fs::create_dir_all(&path)?; + return Ok(path); + } + static ARBITER_HOME: &str = ".arbiter"; let home_dir = std::env::home_dir().ok_or(std::io::Error::new( std::io::ErrorKind::PermissionDenied, diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8996fce..1075dfc 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -53,7 +53,11 @@ spki.workspace = true alloy.workspace = true prost-types.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" +clap = { version = "4.6", features = ["derive"] } [dev-dependencies] insta = "1.46.3" test-log = { version = "0.2", default-features = false, features = ["trace"] } + +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" diff --git a/server/crates/arbiter-server/src/cli.rs b/server/crates/arbiter-server/src/cli.rs new file mode 100644 index 0000000..424260c --- /dev/null +++ b/server/crates/arbiter-server/src/cli.rs @@ -0,0 +1,72 @@ +use std::{ + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + path::PathBuf, +}; + +use clap::{Args, Parser, Subcommand}; + +const DEFAULT_LISTEN_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::LOCALHOST, + arbiter_proto::DEFAULT_SERVER_PORT, +)); + +#[derive(Debug, Parser)] +#[command(name = "arbiter-server")] +#[command(about = "Arbiter gRPC server")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run server in foreground mode. + Run(RunArgs), + /// Manage service lifecycle. + Service { + #[command(subcommand)] + command: ServiceCommand, + }, +} + +#[derive(Debug, Clone, Args)] +pub struct RunArgs { + #[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)] + pub listen_addr: SocketAddr, + #[arg(long)] + pub data_dir: Option, +} + +impl Default for RunArgs { + fn default() -> Self { + Self { + listen_addr: DEFAULT_LISTEN_ADDR, + data_dir: None, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum ServiceCommand { + /// Install Windows service in Service Control Manager. + Install(ServiceInstallArgs), + /// Internal service entrypoint. SCM only. + #[command(hide = true)] + Run(ServiceRunArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ServiceInstallArgs { + #[arg(long)] + pub start: bool, + #[arg(long)] + pub data_dir: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct ServiceRunArgs { + #[arg(long, default_value_t = DEFAULT_LISTEN_ADDR)] + pub listen_addr: SocketAddr, + #[arg(long)] + pub data_dir: Option, +} diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index 9b2695e..027802e 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -1,5 +1,13 @@ #![forbid(unsafe_code)] -use crate::context::ServerContext; + +use std::{net::SocketAddr, path::PathBuf}; + +use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl}; +use miette::miette; +use tonic::transport::{Identity, ServerTlsConfig}; +use tracing::info; + +use crate::{actors::bootstrap::GetToken, context::ServerContext}; pub mod actors; pub mod context; @@ -18,3 +26,64 @@ impl Server { Self { context } } } + +#[derive(Debug, Clone)] +pub struct RunConfig { + pub addr: SocketAddr, + pub data_dir: Option, + pub log_arbiter_url: bool, +} + +impl RunConfig { + pub fn new(addr: SocketAddr, data_dir: Option) -> Self { + Self { + addr, + data_dir, + log_arbiter_url: true, + } + } +} + +pub async fn run_server_until_shutdown(config: RunConfig, shutdown: F) -> miette::Result<()> +where + F: Future + Send + 'static, +{ + arbiter_proto::set_home_path_override(config.data_dir.clone()) + .map_err(|err| miette!("failed to set home path override: {err}"))?; + + let db = db::create_pool(None).await?; + info!(addr = %config.addr, "Database ready"); + + let context = ServerContext::new(db).await?; + info!(addr = %config.addr, "Server context ready"); + + if config.log_arbiter_url { + let url = ArbiterUrl { + host: config.addr.ip().to_string(), + port: config.addr.port(), + ca_cert: context.tls.ca_cert().clone().into_owned(), + bootstrap_token: context + .actors + .bootstrapper + .ask(GetToken) + .await + .map_err(|err| miette!("failed to get bootstrap token from actor: {err}"))?, + }; + info!(%url, "Server URL"); + } + + let tls = ServerTlsConfig::new().identity(Identity::from_pem( + context.tls.cert_pem(), + context.tls.key_pem(), + )); + + tonic::transport::Server::builder() + .tls_config(tls) + .map_err(|err| miette!("Failed to setup TLS: {err}"))? + .add_service(ArbiterServiceServer::new(Server::new(context))) + .serve_with_shutdown(config.addr, shutdown) + .await + .map_err(|e| miette!("gRPC server error: {e}"))?; + + Ok(()) +} diff --git a/server/crates/arbiter-server/src/main.rs b/server/crates/arbiter-server/src/main.rs index 7605523..b133296 100644 --- a/server/crates/arbiter-server/src/main.rs +++ b/server/crates/arbiter-server/src/main.rs @@ -1,56 +1,42 @@ -use std::net::SocketAddr; +mod cli; +mod service; -use arbiter_proto::{proto::arbiter_service_server::ArbiterServiceServer, url::ArbiterUrl}; -use arbiter_server::{Server, actors::bootstrap::GetToken, context::ServerContext, db}; -use miette::miette; +use clap::Parser; +use cli::{Cli, Command, RunArgs, ServiceCommand}; use rustls::crypto::aws_lc_rs; -use tonic::transport::{Identity, ServerTlsConfig}; use tracing::info; -const PORT: u16 = 50051; - #[tokio::main] async fn main() -> miette::Result<()> { aws_lc_rs::default_provider().install_default().unwrap(); + init_logging(); - tracing_subscriber::fmt() + let cli = Cli::parse(); + + match cli.command { + None => run_foreground(RunArgs::default()).await, + Some(Command::Run(args)) => run_foreground(args).await, + Some(Command::Service { command }) => match command { + ServiceCommand::Install(args) => service::install_service(args), + ServiceCommand::Run(args) => service::run_service_dispatcher(args), + }, + } +} + +async fn run_foreground(args: RunArgs) -> miette::Result<()> { + info!(addr = %args.listen_addr, "Starting arbiter server"); + arbiter_server::run_server_until_shutdown( + arbiter_server::RunConfig::new(args.listen_addr, args.data_dir), + std::future::pending::<()>(), + ) + .await +} + +fn init_logging() { + let _ = tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) - .init(); - - info!("Starting arbiter server"); - - let db = db::create_pool(None).await?; - info!("Database ready"); - - let context = ServerContext::new(db).await?; - - let addr: SocketAddr = format!("127.0.0.1:{PORT}").parse().expect("valid address"); - info!(%addr, "Starting gRPC server"); - - let url = ArbiterUrl { - host: addr.ip().to_string(), - port: addr.port(), - ca_cert: context.tls.ca_cert().clone().into_owned(), - bootstrap_token: context.actors.bootstrapper.ask(GetToken).await.unwrap(), - }; - - info!(%url, "Server URL"); - - let tls = ServerTlsConfig::new().identity(Identity::from_pem( - context.tls.cert_pem(), - context.tls.key_pem(), - )); - - tonic::transport::Server::builder() - .tls_config(tls) - .map_err(|err| miette!("Faild to setup TLS: {err}"))? - .add_service(ArbiterServiceServer::new(Server::new(context))) - .serve(addr) - .await - .map_err(|e| miette::miette!("gRPC server error: {e}"))?; - - unreachable!("gRPC server should run indefinitely"); + .try_init(); } diff --git a/server/crates/arbiter-server/src/service/mod.rs b/server/crates/arbiter-server/src/service/mod.rs new file mode 100644 index 0000000..c2adf69 --- /dev/null +++ b/server/crates/arbiter-server/src/service/mod.rs @@ -0,0 +1,19 @@ +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::{install_service, run_service_dispatcher}; + +#[cfg(not(windows))] +pub fn install_service(_: crate::cli::ServiceInstallArgs) -> miette::Result<()> { + Err(miette::miette!( + "service install is currently supported only on Windows" + )) +} + +#[cfg(not(windows))] +pub fn run_service_dispatcher(_: crate::cli::ServiceRunArgs) -> miette::Result<()> { + Err(miette::miette!( + "service run entrypoint is currently supported only on Windows" + )) +} diff --git a/server/crates/arbiter-server/src/service/windows.rs b/server/crates/arbiter-server/src/service/windows.rs new file mode 100644 index 0000000..ce8ebcc --- /dev/null +++ b/server/crates/arbiter-server/src/service/windows.rs @@ -0,0 +1,230 @@ +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Command, + sync::mpsc, + time::Duration, +}; + +use miette::{Context as _, IntoDiagnostic as _, miette}; +use windows_service::{ + define_windows_service, + service::{ + ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, + ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, + service_dispatcher, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; + +use crate::cli::{ServiceInstallArgs, ServiceRunArgs}; +use arbiter_server::{RunConfig, run_server_until_shutdown}; + +const SERVICE_NAME: &str = "ArbiterServer"; +const SERVICE_DISPLAY_NAME: &str = "Arbiter Server"; + +pub fn default_service_data_dir() -> PathBuf { + let base = std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + base.join("Arbiter") +} + +pub fn install_service(args: ServiceInstallArgs) -> miette::Result<()> { + ensure_admin_rights()?; + + let executable = std::env::current_exe().into_diagnostic()?; + let data_dir = args.data_dir.unwrap_or_else(default_service_data_dir); + + std::fs::create_dir_all(&data_dir) + .into_diagnostic() + .with_context(|| format!("failed to create service data dir: {}", data_dir.display()))?; + ensure_token_acl_contract(&data_dir)?; + + let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE; + let manager = ServiceManager::local_computer(None::<&str>, manager_access) + .into_diagnostic() + .wrap_err("failed to open Service Control Manager")?; + + let launch_arguments = vec![ + OsString::from("service"), + OsString::from("run"), + OsString::from("--data-dir"), + data_dir.as_os_str().to_os_string(), + ]; + + let service_info = ServiceInfo { + name: OsString::from(SERVICE_NAME), + display_name: OsString::from(SERVICE_DISPLAY_NAME), + service_type: ServiceType::OWN_PROCESS, + start_type: ServiceStartType::AutoStart, + error_control: ServiceErrorControl::Normal, + executable_path: executable, + launch_arguments, + dependencies: vec![], + account_name: Some(OsString::from(r"NT AUTHORITY\LocalService")), + account_password: None, + }; + + let service = manager + .create_service( + &service_info, + ServiceAccess::QUERY_STATUS | ServiceAccess::START, + ) + .into_diagnostic() + .wrap_err("failed to create Windows service in SCM")?; + + if args.start { + service + .start::<&str>(&[]) + .into_diagnostic() + .wrap_err("service created but failed to start")?; + } + + Ok(()) +} + +pub fn run_service_dispatcher(args: ServiceRunArgs) -> miette::Result<()> { + SERVICE_RUN_ARGS + .set(args) + .map_err(|_| miette!("service runtime args are already initialized"))?; + + service_dispatcher::start(SERVICE_NAME, ffi_service_main) + .into_diagnostic() + .wrap_err("failed to start service dispatcher")?; + + Ok(()) +} + +define_windows_service!(ffi_service_main, service_main); + +static SERVICE_RUN_ARGS: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn service_main(_arguments: Vec) { + if let Err(error) = run_service_main() { + tracing::error!(error = ?error, "Windows service main failed"); + } +} + +fn run_service_main() -> miette::Result<()> { + let args = SERVICE_RUN_ARGS + .get() + .cloned() + .ok_or_else(|| miette!("service run args are missing"))?; + + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let status_handle = + service_control_handler::register(SERVICE_NAME, move |control| match control { + ServiceControl::Stop => { + let _ = shutdown_tx.send(()); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + }) + .into_diagnostic() + .wrap_err("failed to register service control handler")?; + + set_status( + &status_handle, + ServiceState::StartPending, + ServiceControlAccept::empty(), + )?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .into_diagnostic() + .wrap_err("failed to build tokio runtime for service")?; + + set_status( + &status_handle, + ServiceState::Running, + ServiceControlAccept::STOP, + )?; + + let data_dir = args.data_dir.unwrap_or_else(default_service_data_dir); + let config = RunConfig { + addr: args.listen_addr, + data_dir: Some(data_dir), + log_arbiter_url: true, + }; + + let result = runtime.block_on(run_server_until_shutdown(config, async move { + let _ = tokio::task::spawn_blocking(move || shutdown_rx.recv()).await; + })); + + set_status( + &status_handle, + ServiceState::Stopped, + ServiceControlAccept::empty(), + )?; + + result +} + +fn set_status( + status_handle: &service_control_handler::ServiceStatusHandle, + current_state: ServiceState, + controls_accepted: ServiceControlAccept, +) -> miette::Result<()> { + status_handle + .set_service_status(ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state, + controls_accepted, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(10), + process_id: None, + }) + .into_diagnostic() + .wrap_err("failed to update service state")?; + + Ok(()) +} + +fn ensure_admin_rights() -> miette::Result<()> { + let status = Command::new("net") + .arg("session") + .status() + .into_diagnostic() + .wrap_err("failed to check administrator rights")?; + + if status.success() { + Ok(()) + } else { + Err(miette!( + "administrator privileges are required to install Windows service" + )) + } +} + +fn ensure_token_acl_contract(data_dir: &Path) -> miette::Result<()> { + // IMPORTANT: Keep this ACL setup explicit. + // The service account needs write access, while the interactive user only needs read access + // to the bootstrap token and service data directory. + let target = data_dir.as_os_str(); + + let status = Command::new("icacls") + .arg(target) + .arg("/grant") + .arg("*S-1-5-19:(OI)(CI)M") + .arg("/grant") + .arg("*S-1-5-32-545:(OI)(CI)RX") + .arg("/T") + .arg("/C") + .status() + .into_diagnostic() + .wrap_err("failed to apply ACLs for service data directory")?; + + if status.success() { + Ok(()) + } else { + Err(miette!( + "failed to ensure ACL contract for service data directory" + )) + } +}