use std::{ net::SocketAddr, process::{Output, Stdio}, sync::LazyLock, }; use axum::{ Json, Router, extract::State, http::{self, StatusCode, header::HeaderMap}, routing::post, }; use tokio::{net::TcpListener, process::Command}; //use utoipa::{OpenApi as _, ToSchema, openapi::OpenApi}; //use utoipa_axum::router::{OpenApiRouter, UtoipaMethodRouterExt}; use serde::{Deserialize, Serialize}; use crate::{SourceFile, prelude::*}; pub mod api; use api::{ConvenientAttrPath, NixLiteral}; //pub static UID: LazyLock = LazyLock::new(rustix::process::getuid); //pub static USER_SOCKET_DIR: LazyLock<&'static Path> = LazyLock::new(|| { // let dir: Box = env::var_os("XDG_RUNTIME_DIR") // .map(PathBuf::from) // .unwrap_or_else(|| ["/", "run", "user", &UID.to_string()].into_iter().collect()) // .into_boxed_path(); // // Box::leak(dir) //}); //pub static TMPDIR: LazyLock<&'static Path> = LazyLock::new(|| { // let dir: Box = env::temp_dir().into_boxed_path(); // // Box::leak(dir) //}); pub static NIX: LazyLock<&'static Path> = LazyLock::new(|| { which::which("nix") .inspect_err(|e| error!("couldn't find `nix` in PATH: {e}")) .map(PathBuf::into_boxed_path) .map(|boxed| &*Box::leak(boxed)) .unwrap_or(Path::new("/run/current-system/sw/bin/nix")) }); pub async fn run(config: Config) { let addr = config.addr.clone(); let router = Router::new() .route("/set", post(ep_set_post)) // `.with_state()` has to be last for the type inference to work. .with_state(config); //let (router, api): (Router, OpenApi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) // .routes(utoipa_axum::routes!(ep_set_post)) // // `.with_state()` has to be last for the type inference works. // .with_state(config) // .split_for_parts(); let listener = TcpListener::bind(addr).await.unwrap(); axum::serve(listener, router).await.unwrap(); } #[derive(Debug, Clone)] pub struct Config { pub config_file: SourceFile, pub addr: SocketAddr, pub token: Option, } #[derive(Debug, Clone, PartialEq, PartialOrd)] #[derive(Deserialize, Serialize)] //#[derive(ToSchema)] pub struct SetParams { pub name: ConvenientAttrPath, pub value: NixLiteral, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Deserialize, Serialize)] //#[derive(ToSchema)] pub struct SetResponse { /// Will be 0 if everything is okay. /// /// Will be -1 for an error with no code. pub status: i64, pub msg: Option, } #[axum::debug_handler] //#[utoipa::path( // post, // path = "/set", // responses( // (status = 200, description = "Request was valid", body = SetResponse) // ), //)] async fn ep_set_post( State(config): State, headers: HeaderMap, Json(SetParams { name, value }): Json, ) -> Result, StatusCode> { debug!("POST /set with name={name:?}, value={value:?}"); if let Some(token) = &config.token { let Some(auth) = headers.get(http::header::AUTHORIZATION) else { // FIXME: technically RFC9110 requires us to respond with a // `WWW-Authenticate` header. error!("token specified in config but not provided in request"); return Err(StatusCode::UNAUTHORIZED); }; // No need to go through UTF-8 decoding here. if auth.as_bytes() != token.as_bytes() { error!("token provided in request does not match configured token"); return Err(StatusCode::UNAUTHORIZED); } } let file = config.config_file.clone(); let prio = crate::get_where(file.clone()); let new_prio = prio - 1; let opt_name = name.to_nix_decl(); let opt_val = value.to_nix_source(); let new_line = crate::get_next_prio_line(file.clone(), &opt_name, new_prio, &opt_val); match crate::write_next_prio(file.clone(), new_line) { Ok(()) => (), Err(e) => { error!("Couldn't write next generation to {}: {e}", file.display()); let status = e.raw_os_error().map(i64::from).unwrap_or(-1); return Ok(Json(SetResponse { status, msg: Some(format!("{e}")), })); }, }; let child_status = match nix_run_apply(&config).await { Ok(v) => v, Err(e) => { let status = e.raw_os_error().map(i64::from).unwrap_or(-1); return Ok(Json(SetResponse { status, msg: Some(format!("{e}")), })); }, }; let Output { status, stdout, stderr, } = child_status; if status.code() != Some(0) { error!( "Child `nix run` process returned non-zero code {:?}", status.code(), ); error!("Child stdout: {}", stdout.as_bstr()); error!("Child stderr: {}", stderr.as_bstr()); } let status = status.code().map(i64::from).unwrap_or(-1); let msg = format!( "Stdout: {}\nStderr: {}\n", stdout.as_bstr(), stderr.as_bstr() ); Ok(Json(SetResponse { status, msg: Some(msg), })) } async fn nix_run_apply(config: &Config) -> Result { let configuration_nix = config .config_file .path() .parent() .unwrap() .join("configuration.nix"); let configuration_nix = configuration_nix .to_str() .expect("specified NixOS config file is not a UTF-8 path"); let expr = format!( "(import {{ configuration = {}; }})\ .config.dynamicism.applyDynamicConfiguration {{ baseConfiguration = {}; }}", configuration_nix, configuration_nix, ); let child = Command::new(*NIX) .arg("run") .arg("--show-trace") .arg("--log-format") .arg("raw-with-logs") .arg("--impure") .arg("-E") .arg(expr) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .tap(|cmd| { if tracing::enabled!(Level::DEBUG) { let args = cmd .as_std() .get_args() .map(OsStr::to_string_lossy) .join(" "); debug!("Spawning command: `nix {args}`"); } }) .spawn() .inspect_err(|e| error!("error spawning command: {e}"))?; let output = child.wait_with_output().await.inspect_err(|e| { error!("couldn't wait for spawned child process: {e}"); })?; Ok(output) } //#[derive(Copy)] //#[derive(Debug, Clone, PartialEq)] //#[derive(utoipa::OpenApi)] //#[openapi(paths(ep_set_post))] //pub struct ApiDoc;