//! `nomifun-web` — the standalone Web host for the browser deployment. //! //! In the unified architecture there is ONE Rust backend (`nomifun-app`, the //! former `nomicore`). It runs in two host modes: //! * embedded in the Tauri desktop shell (`apps/desktop`), started in-process //! on a localhost port in `--local` (no-auth) mode — the shell IS the trust //! boundary, so no login is required; //! * here, as a standalone server that ALSO serves the built SPA (`ui/dist`) //! so browsers hit the same HTTP API. This replaces the old Node `web-host`. //! //! Unlike the desktop shell, this host is reachable over the network, so it //! boots the backend in AUTHENTICATED mode by default (login required). On a //! fresh data dir it provisions the first admin out-of-band (see //! `ensure_admin_credentials`), because the in-band setup endpoints are //! local-only. `--insecure-no-auth` opts back into desktop-style no-auth for a //! host that is only reachable over loopback / a trusted private network. //! //! This host boots the backend **in-process** (same binary), composes its `/api` //! router with a static `ServeDir` fallback for the SPA, and serves both on one //! port. Env mutation + runtime init happen before the tokio runtime starts, //! mirroring the `nomicore` bin's ordering. use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::process::ExitCode; use anyhow::{Context, Result}; use clap::Parser; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; /// Env var that, when truthy, opts into `--insecure-no-auth` without the flag. const ENV_INSECURE_NO_AUTH: &str = "NOMIFUN_WEB_INSECURE_NO_AUTH"; #[derive(Parser, Debug)] #[command( name = "nomifun-web", about = "NomiFun unified Web host (SPA + backend API)" )] struct Args { /// Host/IP address to bind on. Defaults to loopback; use `0.0.0.0` to accept /// connections from other machines (do so behind a trusted gateway / private /// network — see `--insecure-no-auth`). #[arg(long, env = "NOMIFUN_WEB_HOST", default_value = "127.0.0.1")] host: String, /// Port to listen on (serves both the API and the SPA). #[arg(long, env = "NOMIFUN_WEB_PORT", default_value_t = 8787)] port: u16, /// Data directory for the backend (db + storage). Defaults to the same /// per-user dir as the desktop shell (`%LOCALAPPDATA%\NomiFun\Nomi` on /// Windows, see `nomifun_app::cli::default_data_dir`) so every host and /// dev loop shares one state by default. The env value is taken literally /// (no `/Nomi` suffix) — production deployments (Docker `/data`, systemd /// `/var/lib/nomifun`) rely on that. #[arg( long, env = "NOMIFUN_DATA_DIR", default_value_os_t = nomifun_app::cli::default_data_dir(), value_parser = nomifun_app::cli::parse_non_empty_path )] data_dir: PathBuf, /// Directory containing the built SPA (ui/dist). #[arg(long, env = "NOMIFUN_WEB_DIST", default_value = "../../ui/dist")] dist: PathBuf, /// DANGER: run the backend in local mode — authentication is fully DISABLED /// and every client acts as a privileged user with shell/file/agent access. /// Only for a host reachable solely over loopback or a trusted private /// network. Without this flag the web host requires a login (safe default). /// Can also be enabled via `NOMIFUN_WEB_INSECURE_NO_AUTH=true`. #[arg(long)] insecure_no_auth: bool, /// Initial admin username provisioned on first run (authenticated mode only). /// Ignored once an admin exists. #[arg(long, env = "NOMIFUN_ADMIN_USERNAME", default_value = "admin")] admin_user: String, /// Initial admin password provisioned on first run (authenticated mode only). /// If omitted, no admin is pre-seeded: the install is left uninitialised and /// the first WebUI visitor creates the admin interactively via first-run /// setup (`POST /api/auth/setup`). Ignored once an admin exists. #[arg(long, env = "NOMIFUN_ADMIN_PASSWORD")] admin_password: Option, } /// Parse a truthy env value (`1`/`true`/`yes`/`on`, case-insensitive). fn env_flag(name: &str) -> bool { std::env::var(name) .map(|v| { matches!( v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) }) .unwrap_or(false) } fn main() -> Result { // If an ACP agent CLI spawned this binary as an MCP stdio bridge // (`current_exe() mcp-requirement-stdio` etc.), run that helper and exit // BEFORE clap parses our own Args (which would reject the subcommand) and // before any backend/server init. Every host binary must honor these or the // injected declaration tools (requirement_complete / team / guide) never // appear in the agent's session. if let Some(code) = nomifun_app::commands::run_mcp_stdio_subcommand_if_present() { return Ok(code); } let args = Args::parse(); // Authentication is ON by default; `--insecure-no-auth` (or the env var) // opts into the desktop-style no-auth local mode. The env is read manually // to avoid clap's bool+env flag ambiguity. let insecure_no_auth = args.insecure_no_auth || env_flag(ENV_INSECURE_NO_AUTH); // Build a fully-defaulted backend CLI without touching this process's argv, // then override the bits this host owns. `parse_from` gives a defaulted Cli. let mut cli = nomifun_app::cli::Cli::parse_from(["nomifun-web"]); cli.host = args.host.clone(); cli.port = args.port; cli.data_dir = args.data_dir.clone(); cli.local = insecure_no_auth; // Same ordering as the nomicore bin: runtime init + PATH enhancement BEFORE // any worker thread / tokio runtime exists. nomifun_runtime::init(&cli.data_dir); // SAFETY: called before the tokio runtime (and its threads) is built. let merged_path = unsafe { nomifun_runtime::enhance_process_path() }; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; runtime.block_on(serve(cli, merged_path, args)) } async fn serve(cli: nomifun_app::cli::Cli, merged_path: String, args: Args) -> Result { // Resolve the bind address up front so a bad --host fails fast with a clear // message instead of a cryptic socket error. let ip: IpAddr = args.host.parse().with_context(|| { format!( "invalid --host '{}': expected an IP like 127.0.0.1 or 0.0.0.0", args.host ) })?; if !ip.is_loopback() && cli.local { tracing::warn!( %ip, "binding a non-loopback address with --insecure-no-auth: authentication is DISABLED — \ anyone who can reach this port gets full host access (shell, files, agents). \ Put a trusted gateway in front, use a private network, or drop --insecure-no-auth." ); } // Boot the backend in-process (env → data layer → services), then mount the // real API router with the SPA as the fallback for non-/api routes. let env = nomifun_app::bootstrap::init_environment(&cli, &merged_path)?; let database = nomifun_app::bootstrap::init_data_layer(&env.config).await?; let services = nomifun_app::AppServices::from_config(database, &env.config).await?; // First-run admin provisioning. No-op in local mode and once an admin // exists; otherwise a fresh authenticated install would have no way to set // the first password (the in-band setup routes are local-only). Returns // whether the install still awaits interactive first-run setup. let needs_first_run_setup = nomifun_app::bootstrap::ensure_admin_credentials( &services, nomifun_app::bootstrap::AdminBootstrap { username: Some(args.admin_user.clone()), password: args.admin_password.clone(), }, ) .await?; if needs_first_run_setup && !ip.is_loopback() { tracing::warn!( %ip, "first-run setup is OPEN on a non-loopback address: the NEXT client to reach this \ port will create the admin account. Complete setup over a trusted network/tunnel \ first, or pre-seed with NOMIFUN_ADMIN_PASSWORD." ); } let api = nomifun_app::create_router(&services).await; let app = api .fallback_service( ServeDir::new(&args.dist) .append_index_html_on_directories(true) .fallback(ServeFile::new(args.dist.join("index.html"))), ) .layer(TraceLayer::new_for_http()); let addr = SocketAddr::new(ip, args.port); tracing::info!( requested = %addr, auth = if cli.local { "disabled (insecure-no-auth)" } else { "required" }, dist = ?args.dist, "nomifun-web: embedded backend + SPA on one port" ); // Port failover: if `args.port` is taken, bind a bounded-scan neighbour (or // an ephemeral port) instead of hard-failing, then announce the actually // bound port via `{data_dir}/port.json` + stdout so the operator/launcher // can re-point clients — a browser cannot self-discover a moved port. let (actual_port, listener) = nomifun_app::bootstrap::bind_with_fallback(ip, args.port).await?; if actual_port != args.port { tracing::warn!( requested = args.port, actual = actual_port, "preferred port was busy; bound a fallback port" ); } nomifun_app::bootstrap::announce_bound_port(&cli.data_dir, &args.host, actual_port); axum::serve(listener, app).await?; services.database.close().await; drop(env); Ok(ExitCode::SUCCESS) }