Files
MyAiDesk/nomifun-tauri/apps/web/src/main.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

214 lines
9.6 KiB
Rust

//! `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<String>,
}
/// 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<ExitCode> {
// 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<ExitCode> {
// 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)
}