From 7f56473b1b3fcb8e5f2940eff257aefe75b1bc17 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 29 Aug 2026 12:51:38 +0800 Subject: [PATCH 1/2] feat(ai,remote): add AI assistant and Tailscale remote access (P0-1, P0-3) - AiAssistantService + /api/ai/chat: OpenAI-compatible chat proxy (default local Ollama, configurable endpoint/model/key), SSE streaming + non-streaming - RemoteAccessService + /api/remote: Tailscale-based zero-config remote access (status/enable/disable), no public IP needed - Agent Catalog: opencode/hermes 24h AI host templates (P0-2), jellyfin media center with /dev/dri GPU passthrough (P1-5), kodi vertical template (P2-7) - ComposeGenerator: devices whitelist restricted to /dev/dri - ConfigMetaRegistry: ai/remote/docker categories with whitelisted entries - AlertEngine.Dispose: tolerate repeated CTS disposal (crash fix) - eng/install: one-click Debian/Ubuntu install + deb packaging (P1-4) - NAbilityConstants: AiChat, RemoteAccess capabilities --- eng/install/build-deb.sh | 89 +++++++ eng/install/install.sh | 177 ++++++++++++++ src/FortOS.Agent/Catalog/AgentCatalog.cs | 221 ++++++++++++++++++ src/FortOS.Agent/Compose/ComposeGenerator.cs | 33 ++- .../Configuration/ConfigMetaRegistry.cs | 29 ++- .../Controllers/AgentsController.cs | 8 + src/FortOS.Api/Controllers/AiController.cs | 69 ++++++ .../Controllers/RemoteController.cs | 28 +++ src/FortOS.Api/Program.cs | 2 + src/FortOS.Api/Services/AiAssistantService.cs | 180 ++++++++++++++ .../Services/RemoteAccessService.cs | 141 +++++++++++ src/FortOS.Modules.Agent/AgentModule.cs | 14 ++ .../Alerts/AlertEngine.cs | 12 +- .../Models/NAbilityConstants.cs | 4 + 14 files changed, 1004 insertions(+), 3 deletions(-) create mode 100644 eng/install/build-deb.sh create mode 100644 eng/install/install.sh create mode 100644 src/FortOS.Api/Controllers/AiController.cs create mode 100644 src/FortOS.Api/Controllers/RemoteController.cs create mode 100644 src/FortOS.Api/Services/AiAssistantService.cs create mode 100644 src/FortOS.Api/Services/RemoteAccessService.cs diff --git a/eng/install/build-deb.sh b/eng/install/build-deb.sh new file mode 100644 index 0000000..317827a --- /dev/null +++ b/eng/install/build-deb.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================ +# FortOS — build a .deb package (P1-4 one-click install) +# ---------------------------------------------------------------------------- +# Usage: +# bash eng/install/build-deb.sh [version] +# +# Produces: dist/fortos__amd64.deb +# +# The package installs to /opt/fortos, ships the fortos.env template and the +# systemd unit, and triggers `systemctl enable --now fortos` on install. +# Companion one-click script: eng/install/install.sh (no package needed). +# ============================================================================ +set -euo pipefail + +API_DIR="${1:-}" +VERSION="${2:-1.0.0}" +[[ -n "$API_DIR" ]] || { echo "用法: $0 [version]" >&2; exit 1; } +[[ -d "$API_DIR" ]] || { echo "发布目录不存在: $API_DIR" >&2; exit 1; } + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DIST="$ROOT/dist" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +mkdir -p "$STAGE/DEBIAN" "$STAGE/opt/fortos/api" "$STAGE/etc/fortos" "$STAGE/etc/systemd/system" "$STAGE/usr/lib/systemd/system-preset" + +# Binaries +cp -r "$API_DIR/." "$STAGE/opt/fortos/api/" + +# Env template +cat > "$STAGE/etc/fortos/fortos.env" <<'EOF' +ASPNETCORE_ENVIRONMENT=Production +ASPNETCORE_URLS=http://0.0.0.0:5000 +FortOS_DATA_ROOT=/srv/nas +FortOS_CONFIG_PATH=/srv/nas/config/nas.yaml +EOF + +# systemd unit + preset (start on install) +cat > "$STAGE/etc/systemd/system/fortos.service" <<'EOF' +[Unit] +Description=FortOS management service +Documentation=https://github.com/GeneralLibrary/fortos +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=/etc/fortos/fortos.env +WorkingDirectory=/opt/fortos/api +ExecStart=/opt/fortos/api/FortOS.Api +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +UMask=0027 + +[Install] +WantedBy=multi-user.target +EOF +echo "fortos.service enable" > "$STAGE/usr/lib/systemd/system-preset/90-fortos.preset" + +# Control file +cat > "$STAGE/DEBIAN/control" < +Description: FortOS — security-first Linux NAS management service + Deploys the FortOS API (REST/gRPC) with container agent orchestration, + file sharing, backup, AI assistant and Tailscale remote access. +EOF + +cat > "$STAGE/DEBIAN/postinst" <<'EOF' +#!/usr/bin/env bash +set -e +systemctl daemon-reload +systemctl enable fortos.service 2>/dev/null || true +mkdir -p /srv/nas/config +systemctl restart fortos.service 2>/dev/null || true +echo "FortOS 已安装。管理地址: http://<本机IP>:5000" +EOF +chmod 755 "$STAGE/DEBIAN/postinst" + +mkdir -p "$DIST" +dpkg-deb --build --root-owner-group "$STAGE" "$DIST/fortos_${VERSION}_amd64.deb" +echo "已生成: $DIST/fortos_${VERSION}_amd64.deb" diff --git a/eng/install/install.sh b/eng/install/install.sh new file mode 100644 index 0000000..1e7eee8 --- /dev/null +++ b/eng/install/install.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# ============================================================================ +# FortOS — Debian/Ubuntu one-click install (P1-4) +# ---------------------------------------------------------------------------- +# Usage: +# bash eng/install/install.sh +# +# What it does: +# 1. Detects the distro (Debian/Ubuntu) and installs runtime deps +# (dotnet runtime, docker, smb/nfs client tools as needed). +# 2. Downloads the FortOS API publish output (local publish dir, a release +# tarball URL, or an already-staged /opt/fortos) and installs it to +# /opt/fortos. +# 3. Registers fortos.service (systemd) and starts it. +# 4. Prints the management URL and first-run notes. +# +# Requires: bash, curl (or wget), systemd. Run as root. +# ============================================================================ +set -euo pipefail + +# ---- Config --------------------------------------------------------------- +FORTOS_DEST="${FORTOS_DEST:-/opt/fortos}" +FORTOS_DATA_ROOT="${FortOS_DATA_ROOT:-/srv/nas}" +FORTOS_ENV_FILE="${FORTOS_ENV_FILE:-/etc/fortos/fortos.env}" +# Source of the API binaries: one of +# local: — use a local publish output (e.g. local:./artifacts/fortos-api) +# url: — download a published tarball +# (unset) — if /opt/fortos/api already exists, keep it; else error with guidance +FORTOS_SOURCE="${FORTOS_SOURCE:-}" + +log() { printf '\033[1;36m[fortos]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[fortos]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[fortos]\033[0m %s\n' "$*" >&2; exit 1; } + +# ---- Prereqs -------------------------------------------------------------- +[[ $EUID -eq 0 ]] || die "请以 root 运行:sudo bash eng/install/install.sh" +command -v systemctl >/dev/null 2>&1 || die "需要 systemd 系统。" + +if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then + die "需要 curl 或 wget。" +fi + +# ---- Distro detection ----------------------------------------------------- +if ! command -v apt-get >/dev/null 2>&1; then + die "本脚本仅支持 Debian/Ubuntu(未检测到 apt-get)。" +fi +. /etc/os-release 2>/dev/null || true +log "检测到系统: ${PRETTY_NAME:-Debian/Ubuntu}" + +# ---- Runtime dependencies ------------------------------------------------- +log "安装运行时依赖(dotnet-runtime / docker / smb 客户端)…" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y + +install_pkg() { + if ! dpkg -s "$1" >/dev/null 2>&1; then + apt-get install -y "$1" + fi +} + +install_pkg ca-certificates +install_pkg curl + +# Docker: use the distro package when present, else docker.io (best-effort). +if ! command -v docker >/dev/null 2>&1; then + install_pkg docker.io || warn "Docker 安装失败,容器(Agent)功能将不可用;可稍后手动安装。" +fi +# SMB/NFS client tools for share access (optional but recommended). +install_pkg smbclient 2>/dev/null || true +install_pkg nfs-common 2>/dev/null || true + +# dotnet runtime: needed to run FortOS.Api. Prefer the runtime from the +# Microsoft feed; fall back to a distro package if unavailable. +if ! command -v dotnet >/dev/null 2>&1; then + log "安装 .NET Runtime…" + install_pkg dotnet-runtime-8.0 2>/dev/null \ + || curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --runtime aspnetcore --install-dir /usr/share/dotnet \ + || warn ".NET Runtime 安装失败,请手动安装后重试。" + export PATH="$PATH:/usr/share/dotnet" +fi + +# ---- Data root ------------------------------------------------------------ +mkdir -p "$FORTOS_DATA_ROOT" +log "数据目录: $FORTOS_DATA_ROOT" + +# ---- Install binaries ----------------------------------------------------- +install_from_local() { + local src="${1#local:}" + [[ -d "$src" ]] || die "本地发布目录不存在: $src" + log "复制本地发布产物: $src → $FORTOS_DEST" + mkdir -p "$FORTOS_DEST" + cp -r "$src/." "$FORTOS_DEST/" +} + +install_from_url() { + local url="${1#url:}" + local tmp + tmp="$(mktemp -d)" + log "下载发布包: $url" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$tmp/fortos.tar.gz" + else + wget -qO "$tmp/fortos.tar.gz" "$url" + fi + mkdir -p "$FORTOS_DEST" + tar -xzf "$tmp/fortos.tar.gz" -C "$FORTOS_DEST" + rm -rf "$tmp" +} + +case "$FORTOS_SOURCE" in + local:*) install_from_local "$FORTOS_SOURCE" ;; + url:*) install_from_url "$FORTOS_SOURCE" ;; + "") + if [[ ! -x "$FORTOS_DEST/api/FortOS.Api" ]]; then + die "未检测到 $FORTOS_DEST/api/FortOS.Api。请设置 FORTOS_SOURCE=local: 或 FORTOS_SOURCE=url: 提供发布产物。" + fi + log "复用已有安装: $FORTOS_DEST" + ;; + *) die "未知的 FORTOS_SOURCE 格式: $FORTOS_SOURCE" ;; +esac + +[[ -x "$FORTOS_DEST/api/FortOS.Api" ]] || die "发布产物缺少可执行文件 $FORTOS_DEST/api/FortOS.Api" + +# ---- Config file ---------------------------------------------------------- +mkdir -p "$(dirname "$FORTOS_ENV_FILE")" +if [[ ! -f "$FORTOS_ENV_FILE" ]]; then + cat > "$FORTOS_ENV_FILE" < "$SERVICE_FILE" </dev/null | awk '{print $1}') +log "安装完成。" +log " 管理地址: http://${IP:-<本机IP>}:5000" +log " 首次使用: 打开上述地址,注册首个管理员账号。" +log " 容器/影音/AI: 在管理界面的「容器」页部署模板(先确保 Docker 可用)。" +log " 远程访问: 在「设置 → 系统配置」开启 remote:enabled(Tailscale)即可免公网 IP 访问。" diff --git a/src/FortOS.Agent/Catalog/AgentCatalog.cs b/src/FortOS.Agent/Catalog/AgentCatalog.cs index f7fd890..16d067c 100644 --- a/src/FortOS.Agent/Catalog/AgentCatalog.cs +++ b/src/FortOS.Agent/Catalog/AgentCatalog.cs @@ -423,6 +423,227 @@ public sealed partial class AgentCatalog : IAgentCatalog restart: unless-stopped ports: - "${HOST_PORT}:${CONTAINER_PORT}" +""", + ["opencode"] = """ +id: opencode +name: OpenCode +logo: /logos/opencode.svg +version: 1.0.0 +description: OpenCode — 开源 AI 编程/运维 Agent(终端原生,24h 常驻),可连接 OpenAI 兼容端点(含本地 Ollama)。适合在 NAS 上做 AI 宿主机:手机 SSH 进容器即可指挥。 +capabilities_required: + - storage:share:media:read +parameters: + - name: image + type: string + required: false + default: ghcr.io/sst/opencode:latest + - name: data_dir + type: string + required: false + default: /root/.local/share/opencode + - name: HOST_PORT + type: int + required: false + default: "18790" + - name: CONTAINER_PORT + type: int + required: false + default: "18790" + - name: OPENAI_API_KEY + type: string + required: false + default: "" + - name: OPENAI_BASE_URL + type: string + required: false + default: http://host.docker.internal:11434/v1 + - name: OPENAI_MODEL + type: string + required: false + default: qwen2.5:7b +access: + - "终端交互: ssh 到宿主后 docker exec -it opencode" + - "手机指挥: 部署后开启 SSH(见 fortOS 网络页),手机终端进入容器即可用自然语言驱动 opencode" + - "对接本地 Ollama: 默认 OPENAI_BASE_URL 指向宿主 11434(Ollama),无需外网 API Key" + - "对接外部模型: 修改 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL 后重启" + - "文档: https://opencode.ai" +compose: + services: + "{{.AgentId}}": + image: "{{.ImageName}}" + restart: unless-stopped + ports: + - "${HOST_PORT}:${CONTAINER_PORT}" + environment: + OPENAI_API_KEY: "${OPENAI_API_KEY}" + OPENAI_BASE_URL: "${OPENAI_BASE_URL}" + OPENAI_MODEL: "${OPENAI_MODEL}" + extra_hosts: + - "host.docker.internal:host-gateway" +""", + ["hermes"] = """ +id: hermes +name: Hermes Agent +logo: /logos/hermes.svg +version: 1.0.0 +description: Hermes — 轻量常驻 AI 助手(OpenAI 兼容),面向"24 小时运行、手机指挥"场景:常驻监听,任务/问答经 API 或终端发起。适合与 OpenCode 配合做个人 AI 宿主。 +capabilities_required: + - storage:share:media:read +parameters: + - name: image + type: string + required: false + default: ghcr.io/anthropic-ai/hermes:latest + - name: data_dir + type: string + required: false + default: /data + - name: HOST_PORT + type: int + required: false + default: "18791" + - name: CONTAINER_PORT + type: int + required: false + default: "18791" + - name: OPENAI_API_KEY + type: string + required: false + default: "" + - name: OPENAI_BASE_URL + type: string + required: false + default: http://host.docker.internal:11434/v1 + - name: OPENAI_MODEL + type: string + required: false + default: qwen2.5:7b + - name: HERMES_WORKSPACE + type: string + required: false + default: /data/workspace +access: + - "API 地址: http://:18791 (OpenAI 兼容 chat/completions)" + - "手机指挥: 任何支持 OpenAI 兼容客户端的 App/脚本把 base URL 指向该地址即可对话" + - "对接本地 Ollama: 默认 OPENAI_BASE_URL 指向宿主 11434,无需外网 Key" + - "文档: https://hermes.example.ai" +compose: + services: + "{{.AgentId}}": + image: "{{.ImageName}}" + restart: unless-stopped + ports: + - "${HOST_PORT}:${CONTAINER_PORT}" + environment: + OPENAI_API_KEY: "${OPENAI_API_KEY}" + OPENAI_BASE_URL: "${OPENAI_BASE_URL}" + OPENAI_MODEL: "${OPENAI_MODEL}" + HERMES_WORKSPACE: "${HERMES_WORKSPACE}" + extra_hosts: + - "host.docker.internal:host-gateway" +""", + ["jellyfin"] = """ +id: jellyfin +name: Jellyfin +logo: /logos/jellyfin.svg +version: 1.0.0 +description: Jellyfin — 开源影音媒体中心(免费 Plex 替代),支持 H.265/HEVC 硬件转码直通(Intel/AMD /dev/dri)。可管理影视库并串流到手机/电视。 +capabilities_required: + - storage:share:media:read +parameters: + - name: image + type: string + required: false + default: jellyfin/jellyfin:latest + - name: data_dir + type: string + required: false + default: /config + - name: media_dir + type: string + required: false + default: /media + - name: HOST_PORT + type: int + required: false + default: "8096" + - name: CONTAINER_PORT + type: int + required: false + default: "8096" + - name: TZ + type: string + required: false + default: UTC +access: + - "Web 界面: http://:8096" + - "首次访问设置管理员账号与媒体库" + - "硬件转码: 部署时挂载 /dev/dri(Intel/AMD iGPU),Jellyfin 转码设置选择 QSV/VAAPI 即支持 H.265/HEVC" + - "手机端: Jellyfin 官方 App 连接 http://:8096,可配合 P0-3 远程访问在户外观看" + - "文档: https://jellyfin.org/docs" +compose: + services: + "{{.AgentId}}": + image: "{{.ImageName}}" + restart: unless-stopped + ports: + - "${HOST_PORT}:${CONTAINER_PORT}" + devices: + - /dev/dri:/dev/dri + environment: + TZ: "${TZ}" +""", + ["kodi"] = """ +id: kodi +name: Kodi (家庭 KTV / 影院) +logo: /logos/kodi.svg +version: 1.0.0 +description: Kodi — 开源家庭影院/媒体中心,配合点歌插件可做家庭 KTV。桌面/手机/电视多端客户端访问同一个媒体库。 +capabilities_required: + - storage:share:media:read +parameters: + - name: image + type: string + required: false + default: docker.io/linuxserver/kodi:latest + - name: data_dir + type: string + required: false + default: /config + - name: media_dir + type: string + required: false + default: /media + - name: PUID + type: int + required: false + default: "1000" + - name: PGID + type: int + required: false + default: "1000" + - name: TZ + type: string + required: false + default: Asia/Shanghai +access: + - "说明: 家庭 KTV 需要电视/显示器 + Kodi 客户端(Kodi 官方 App,全平台含 AppleTV)。NAS 上部署本服务托管媒体库与点歌插件。" + - "Web 远程控制: 安装 Kodi web 界面(默认 8080 端口)后可从手机浏览器遥控。" + - "点歌插件: 在 Kodi 内安装 KTV 点歌插件(如「酷我音乐」类 VOD 插件),AppleTV 端建议配合 Jellyfin 做媒体播放。" + - "文档: https://kodi.tv" +compose: + services: + "{{.AgentId}}": + image: "{{.ImageName}}" + restart: unless-stopped + ports: + - "8080:8080" + environment: + PUID: "${PUID}" + PGID: "${PGID}" + TZ: "${TZ}" + devices: + - /dev/dri:/dev/dri """, }; private readonly HttpClient _httpClient; diff --git a/src/FortOS.Agent/Compose/ComposeGenerator.cs b/src/FortOS.Agent/Compose/ComposeGenerator.cs index cdbadf6..3d31fcf 100644 --- a/src/FortOS.Agent/Compose/ComposeGenerator.cs +++ b/src/FortOS.Agent/Compose/ComposeGenerator.cs @@ -213,7 +213,7 @@ private static void ValidateUntrustedService(YamlMappingNode service, IReadOnlyL RejectHostNamespace(service, "network_mode"); RejectHostNamespace(service, "pid"); RejectHostNamespace(service, "ipc"); - RejectPresent(service, "devices"); + ValidateDevices(service); if (service.Children.TryGetValue(new YamlScalarNode("cap_add"), out var caps) && caps is YamlSequenceNode capList && capList.Children.OfType().Any(c => IsDangerousCapability(c.Value))) throw new InvalidDataException("Agent compose may not add dangerous Linux capabilities."); @@ -281,6 +281,37 @@ private static void ValidateUntrustedService(YamlMappingNode service, IReadOnlyL return null; } + /// + /// Validates a compose devices list. FortOS agents run with the least privilege + /// possible, so host devices are NOT generally mountable. The single exception is the + /// Intel/AMD GPU device group /dev/dri (video transcode hardware acceleration for + /// trusted media templates such as Jellyfin); anything else — including raw disks, host + /// sockets, or misc devices — is rejected. + /// + private static void ValidateDevices(YamlMappingNode service) + { + if (!service.Children.TryGetValue(new YamlScalarNode("devices"), out var devices) + || devices is not YamlSequenceNode deviceList) + { + return; + } + + foreach (var device in deviceList.Children) + { + var value = device is YamlScalarNode scalar ? scalar.Value : null; + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidDataException("Agent compose devices entries must be scalar paths."); + } + + var hostPath = value.Split(':', 2)[0]; + if (!hostPath.StartsWith("/dev/dri", StringComparison.Ordinal)) + { + throw new InvalidDataException($"Agent compose may only mount GPU devices under /dev/dri (got '{hostPath}')."); + } + } + } + /// /// Resolves the host-path roots allowed for volume bind mounts, using the same configuration /// keys (and the same data-root fallback) as AgentModule.ResolveAllowedRoots. diff --git a/src/FortOS.Api/Configuration/ConfigMetaRegistry.cs b/src/FortOS.Api/Configuration/ConfigMetaRegistry.cs index ea0e42b..d7da91b 100644 --- a/src/FortOS.Api/Configuration/ConfigMetaRegistry.cs +++ b/src/FortOS.Api/Configuration/ConfigMetaRegistry.cs @@ -75,7 +75,10 @@ public static class ConfigMetaRegistry new("access", "Access Control", "speedometer", "Rate limiting for the API surface", 2), new("observability", "Monitoring & Logs", "pulse", "Metrics exposure and logging behaviour", 3), new("storage", "Disk & Storage", "server", "Disk health and RAID pool management", 4), - new("advanced", "Advanced", "options", "Internal tuning options — change with care", 5), + new("ai", "AI Assistant", "bolt", "Natural-language management assistant settings", 5), + new("remote", "Remote Access", "link", "Tailscale-based remote access settings", 6), + new("docker", "Docker", "server", "Container registry and daemon settings", 7), + new("advanced", "Advanced", "options", "Internal tuning options — change with care", 8), ]; /// Whitelisted, user-editable configuration entries. @@ -122,6 +125,30 @@ public static class ConfigMetaRegistry new("agent:require_digest", "advanced", ConfigEntryType.Boolean, "Image Fingerprint Check", "When enabled, agent images must be pinned to an immutable sha256 digest (the image fingerprint) so a deployed image can never change underneath you. Disable to allow mutable tags like latest for convenience, at the cost of safety.", DefaultValue: "true", Order: 5), + + // ---- AI assistant (P0-1) ---- + new("ai:enabled", "ai", ConfigEntryType.Boolean, "AI Assistant", + "Enable the natural-language assistant (phone-side AI management entry).", + DefaultValue: "true", Order: 1), + new("ai:endpoint", "ai", ConfigEntryType.String, "AI Endpoint (OpenAI-compatible)", + "Base URL of the LLM endpoint. Defaults to the local Ollama instance (http://127.0.0.1:11434/v1).", + DefaultValue: "http://127.0.0.1:11434/v1", Order: 2), + new("ai:model", "ai", ConfigEntryType.String, "AI Model", + "Model name served by the endpoint (e.g. qwen2.5:7b, deepseek-r1:8b).", + DefaultValue: "qwen2.5:7b", Order: 3), + + // ---- Remote access (P0-3, Tailscale) ---- + new("remote:enabled", "remote", ConfigEntryType.Boolean, "Remote Access", + "Enable Tailscale-based remote access (no public IP or port forwarding needed).", + DefaultValue: "false", Order: 1), + new("remote:tailscale_hostname", "remote", ConfigEntryType.String, "Tailscale Device Name", + "Display name for this NAS in your Tailscale network.", + DefaultValue: "fortos", Order: 2), + + // ---- Docker management (P1-6, 1panel parity) ---- + new("docker:registry_mirrors", "docker", ConfigEntryType.Text, "Registry Mirrors (one per line)", + "Docker daemon registry mirrors, e.g. a domestic acceleration endpoint (https://docker.m.daocloud.io). One per line. Applied by restarting docker.", + Order: 1), ]; /// True if the key is whitelisted for dashboard editing. diff --git a/src/FortOS.Api/Controllers/AgentsController.cs b/src/FortOS.Api/Controllers/AgentsController.cs index 08fc03b..71c4e57 100644 --- a/src/FortOS.Api/Controllers/AgentsController.cs +++ b/src/FortOS.Api/Controllers/AgentsController.cs @@ -140,6 +140,14 @@ public object DeployStatus(string id) public Task> Logs(string id, [FromServices] MemoryLogStore logs, CancellationToken ct, [FromQuery] int tail = 100) => logs.QueryAsync(new LogQuery { AgentId = id, Limit = tail }, ct); + /// 读取已部署 agent 的 Compose 配置(P1-6 Docker 管理:可视化查看)。 + [HttpGet("{id}/compose")] + public async Task Compose(string id, [FromServices] AgentModule agents, CancellationToken ct) + { + var compose = await agents.GetComposeAsync(id, ct).ConfigureAwait(false); + return new { agentId = id, compose }; + } + /// /// External access info for a deployed agent: published ports, environment /// variable names to wire chat channels / clients, and integration notes. diff --git a/src/FortOS.Api/Controllers/AiController.cs b/src/FortOS.Api/Controllers/AiController.cs new file mode 100644 index 0000000..e664ace --- /dev/null +++ b/src/FortOS.Api/Controllers/AiController.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using FortOS.Api.Authorization; +using FortOS.Api.Middleware; +using FortOS.Api.Services; +using FortOS.Core; +using FortOS.Security.Models; +using Microsoft.AspNetCore.Mvc; + +namespace FortOS.Api.Controllers; + +/// AI 助手控制器(P0-1 手机端 AI 对话入口的服务端)。 +[Route("api/ai")] +public sealed class AiController : FortOSControllerBase +{ + private readonly AiAssistantService _ai; + + /// 初始化。 + public AiController(AiAssistantService ai) => _ai = ai; + + /// + /// AI 对话:自然语言提问,返回模型回复。 + /// stream=true 时以 SSE(text/event-stream)逐段推送回复,便于移动端打字机效果。 + /// + [RequiresCapability("ai:chat", NasDataLevel.Personal)] + [HttpPost("chat")] + public async Task Chat( + [FromBody] AiChatRequest request, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + await ApiProblem.WriteAsync(HttpContext, StatusCodes.Status400BadRequest, "AI_MESSAGE_EMPTY", "消息不能为空。").ConfigureAwait(false); + return; + } + + var history = request.History? + .Where(h => h.Role is "user" or "assistant") + .Select(h => new ChatMessage(h.Role, h.Content)) + .ToList(); + var aiRequest = new ChatRequest(request.Message, history, request.Stream); + if (!request.Stream) + { + var result = await _ai.ChatAsync(aiRequest, ct: ct).ConfigureAwait(false); + await HttpContext.Response.WriteAsJsonAsync(new { reply = result.Reply, model = result.Model, error = result.Error }, ct).ConfigureAwait(false); + return; + } + + // SSE 流式:每段 delta 以 data: 帧推送,结束时发送 [DONE]。 + HttpContext.Response.Headers.ContentType = "text/event-stream"; + await HttpContext.Response.WriteAsync("data: {\"start\":true}\n\n", ct).ConfigureAwait(false); + var streamed = await _ai.ChatAsync( + aiRequest, + // 复用请求取消令牌:客户端断开时停止推送,避免向已断开连接继续写。 + delta => _ = HttpContext.Response.WriteAsync($"data: {JsonSerializer.Serialize(new { delta })}\n\n", ct), + ct).ConfigureAwait(false); + if (!string.IsNullOrEmpty(streamed.Error)) + { + await HttpContext.Response.WriteAsync($"data: {JsonSerializer.Serialize(new { error = streamed.Error })}\n\n", ct).ConfigureAwait(false); + } + + await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct).ConfigureAwait(false); + } +} + +/// AI 对话请求体。 +public sealed record AiChatRequest(string Message, IReadOnlyList? History, bool Stream = false); + +/// 对话历史条目(role: user / assistant)。 +public sealed record AiChatHistoryItem(string Role, string Content); diff --git a/src/FortOS.Api/Controllers/RemoteController.cs b/src/FortOS.Api/Controllers/RemoteController.cs new file mode 100644 index 0000000..da0c9b1 --- /dev/null +++ b/src/FortOS.Api/Controllers/RemoteController.cs @@ -0,0 +1,28 @@ +using FortOS.Api.Authorization; +using FortOS.Api.Middleware; +using FortOS.Api.Services; +using FortOS.Core; +using FortOS.Security.Models; +using Microsoft.AspNetCore.Mvc; + +namespace FortOS.Api.Controllers; + +/// 远程访问控制器(P0-3 免穿透):Tailscale 状态查询与启停。 +[Route("api/remote")] +public sealed class RemoteController(RemoteAccessService remote) : FortOSControllerBase +{ + /// 查询远程访问状态(是否启用/已安装/已登录/设备名/IP)。 + [RequiresCapability("remote:access", NasDataLevel.Personal)] + [HttpGet] + public Task Status(CancellationToken ct) => remote.GetStatusAsync(ct); + + /// 启用远程访问(Tailscale 连接)。 + [RequiresCapability("remote:access", NasDataLevel.Personal)] + [HttpPost("enable")] + public Task Enable(CancellationToken ct) => remote.EnableAsync(ct); + + /// 禁用远程访问(Tailscale 断开)。 + [RequiresCapability("remote:access", NasDataLevel.Personal)] + [HttpPost("disable")] + public Task Disable(CancellationToken ct) => remote.DisableAsync(ct); +} diff --git a/src/FortOS.Api/Program.cs b/src/FortOS.Api/Program.cs index 465d7d4..5b8553f 100644 --- a/src/FortOS.Api/Program.cs +++ b/src/FortOS.Api/Program.cs @@ -57,6 +57,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); // FortOS runs only on Linux; synchronizes system users with smbpasswd so SMB clients can use the same credentials. builder.Services.AddSingleton(); builder.Services.AddFortOSAgent(); diff --git a/src/FortOS.Api/Services/AiAssistantService.cs b/src/FortOS.Api/Services/AiAssistantService.cs new file mode 100644 index 0000000..e7b33b2 --- /dev/null +++ b/src/FortOS.Api/Services/AiAssistantService.cs @@ -0,0 +1,180 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FortOS.Api.Services; + +/// +/// AI 助手服务(P0-1):对接 OpenAI 兼容的 chat/completions 端点(默认本地 ollama), +/// 为移动端 AI 对话入口提供自然语言 → 操作建议/执行的中转。 +/// 仅做协议中转与上下文拼装,不内置模型;端点、模型、密钥均可配置。 +/// +public sealed class AiAssistantService(HttpClient http, IConfiguration configuration) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// 配置键:LLM 端点(OpenAI 兼容,默认本地 ollama)。 + public const string EndpointKey = "ai:endpoint"; + /// 配置键:模型名。 + public const string ModelKey = "ai:model"; + /// 配置键:API 密钥(本地 ollama 可为空)。 + public const string ApiKeyKey = "ai:api_key"; + /// 配置键:AI 对话开关。 + public const string EnabledKey = "ai:enabled"; + + private const string DefaultEndpoint = "http://127.0.0.1:11434/v1"; + private const string DefaultModel = "qwen2.5:7b"; + + /// 系统提示词:约束 AI 以 fortOS 管理助手身份回答,给出可执行建议。 + private const string SystemPrompt = + "你是 FortOS 个人 NAS 的管理助手(运行在用户的家庭服务器上)。" + + "你的职责是帮助用户通过自然语言管理 NAS:文件、共享、备份、Agent 容器、系统状态。" + + "回答要求:1) 简洁、面向行动,优先给出可直接执行的建议;2) 涉及具体操作时说明调用哪个管理功能" + + "(如:文件在「文件」页、备份在「备份」页、容器在「Agent」页);" + + "3) 涉及删除、格式化、清空等危险操作时,明确提示需要用户二次确认;" + + "4) 不要编造 fortOS 不存在的功能。"; + + /// + /// 是否启用 AI 对话(默认启用;ai:enabled=false 时接口返回明确错误,便于部署方关闭)。 + /// + private bool IsEnabled() + => !string.Equals(configuration[EnabledKey], "false", StringComparison.OrdinalIgnoreCase); + + /// 发送对话请求,返回模型回复;流式时经 逐段推送。 + public async Task ChatAsync( + ChatRequest request, + Action? onDelta = null, + CancellationToken ct = default) + { + if (!IsEnabled()) + { + return new ChatResponse(null, null, "AI 对话未启用(ai:enabled=false)。"); + } + + var endpoint = configuration[EndpointKey] ?? DefaultEndpoint; + var model = configuration[ModelKey] ?? DefaultModel; + var apiKey = configuration[ApiKeyKey]; + + var messages = new List { new { role = "system", content = SystemPrompt } }; + if (request.History is not null) + { + // 历史消息原样透传(客户端已按 role 分组);最多保留 20 条防止上下文膨胀。 + foreach (var m in request.History.TakeLast(20)) + { + messages.Add(new { role = m.Role, content = m.Content }); + } + } + + messages.Add(new { role = "user", content = request.Message }); + + var payload = new + { + model, + messages, + stream = request.Stream, + }; + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(endpoint.TrimEnd('/') + "/"), "chat/completions")) + { + Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json"), + }; + if (!string.IsNullOrWhiteSpace(apiKey)) + { + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + + using var response = await http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return new ChatResponse(null, model, $"AI 服务返回 {(int)response.StatusCode}: {Truncate(body)}"); + } + + if (request.Stream) + { + return await ReadStreamingAsync(response, model, onDelta, ct).ConfigureAwait(false); + } + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return ParseNonStreaming(json, model); + } + + /// 解析非流式响应 `{ choices: [{ message: { content } }] }`。 + private static ChatResponse ParseNonStreaming(string json, string model) + { + try + { + using var doc = JsonDocument.Parse(json); + var content = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString(); + return new ChatResponse(content, model, null); + } + catch (Exception ex) + { + return new ChatResponse(null, model, $"AI 响应解析失败:{ex.Message}"); + } + } + + /// 读取 SSE 流式响应,逐段回调 delta,并拼装完整回复。 + private static async Task ReadStreamingAsync( + HttpResponseMessage response, + string model, + Action? onDelta, + CancellationToken ct) + { + var builder = new StringBuilder(); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream); + while (!ct.IsCancellationRequested && await reader.ReadLineAsync(ct) is { } line) + { + if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var data = line[5..].Trim(); + if (data == "[DONE]") + { + break; + } + + try + { + using var doc = JsonDocument.Parse(data); + var delta = doc.RootElement.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString(); + if (string.IsNullOrEmpty(delta)) + { + continue; + } + + builder.Append(delta); + onDelta?.Invoke(delta); + } + catch (JsonException) + { + // 忽略无法解析的 SSE 行(部分网关会混入注释/心跳)。 + } + } + + return new ChatResponse(builder.ToString(), model, null); + } + + private static string Truncate(string text) + => text.Length > 200 ? text[..200] + "…" : text; +} + +/// 对话消息(对齐 OpenAI chat 协议)。 +public sealed record ChatMessage(string Role, string Content); + +/// AI 对话请求(服务层契约)。 +public sealed record ChatRequest(string Message, IReadOnlyList? History = null, bool Stream = false); + +/// AI 对话响应(非流式)。 +public sealed record ChatResponse(string? Reply, string? Model, string? Error); diff --git a/src/FortOS.Api/Services/RemoteAccessService.cs b/src/FortOS.Api/Services/RemoteAccessService.cs new file mode 100644 index 0000000..89f2d00 --- /dev/null +++ b/src/FortOS.Api/Services/RemoteAccessService.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FortOS.Core; + +namespace FortOS.Api.Services; + +/// +/// 远程访问服务(P0-3 免穿透):基于 Tailscale 的零配置远程接入。 +/// Tailscale 利用 NAT 打洞 + DERP 中继,无需公网 IP/端口映射即可让手机在任意网络访问 NAS。 +/// 通过 执行 tailscale CLI;未安装时返回可安装指引。 +/// +public sealed class RemoteAccessService(IProcessManager process, IConfiguration configuration) +{ + /// 配置键:是否启用远程访问。 + public const string EnabledKey = "remote:enabled"; + /// 配置键:Tailscale 认证密钥(首次登录用;留空则输出交互登录 URL)。 + public const string AuthKeyKey = "remote:tailscale_auth_key"; + /// 配置键:设备在 Tailscale 网络中的显示名。 + public const string HostNameKey = "remote:tailscale_hostname"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + /// 读取当前状态(不修改系统)。 + public async Task GetStatusAsync(CancellationToken ct) + { + var enabled = IsEnabled(); + if (!enabled) + { + return new RemoteStatus(false, false, false, null, null, "远程访问未启用(remote:enabled=false)。"); + } + + var installed = await IsTailscaleInstalledAsync(ct).ConfigureAwait(false); + if (!installed) + { + return new RemoteStatus(true, false, false, null, null, "未检测到 tailscale,请先安装(apt install tailscale)。"); + } + + var status = await RunTailscaleAsync("status --json", ct).ConfigureAwait(false); + if (status is null) + { + return new RemoteStatus(true, true, false, null, null, "Tailscale 未登录或状态读取失败。"); + } + + using var doc = JsonDocument.Parse(status); + var root = doc.RootElement; + var loggedIn = root.TryGetProperty("BackendState", out var state) + && string.Equals(state.GetString(), "Running", StringComparison.OrdinalIgnoreCase); + var self = root.TryGetProperty("Self", out var selfProp) ? selfProp : default; + var hostName = self.ValueKind == JsonValueKind.Object && self.TryGetProperty("HostName", out var hn) + ? hn.GetString() + : configuration[HostNameKey]; + var ip = self.ValueKind == JsonValueKind.Object && self.TryGetProperty("TailscaleIPs", out var ips) + && ips.ValueKind == JsonValueKind.Array && ips.GetArrayLength() > 0 + ? ips[0].GetString() + : null; + return new RemoteStatus(true, true, loggedIn, hostName, ip, loggedIn ? "已连接。" : "Tailscale 已安装但未登录。"); + } + + /// 启用远程访问:tailscale up。已登录则直接连接;否则输出登录指引。 + public async Task EnableAsync(CancellationToken ct) + { + var authKey = configuration[AuthKeyKey]; + var hostName = configuration[HostNameKey]; + var args = string.IsNullOrWhiteSpace(authKey) + ? $"up --hostname={Quote(hostName ?? "fortos")}" + : $"up --hostname={Quote(hostName ?? "fortos")} --authkey={Quote(authKey)}"; + var result = await process.ExecuteCommandAsync(new ProcessStartConfig + { + ExecutablePath = "tailscale", + Arguments = args, + TimeoutSeconds = 60, + ThrowOnNonZeroExit = false, + }, ct).ConfigureAwait(false); + if (result.ExitCode != 0) + { + var message = TrimError(result.Stderr) ?? TrimError(result.Stdout); + return new RemoteStatus(true, true, false, hostName, null, $"启动失败:{message}"); + } + + return await GetStatusAsync(ct).ConfigureAwait(false); + } + + /// 禁用远程访问:tailscale down(设备保持注册,可随时再连)。 + public async Task DisableAsync(CancellationToken ct) + { + var result = await process.ExecuteCommandAsync(new ProcessStartConfig + { + ExecutablePath = "tailscale", + Arguments = "down", + TimeoutSeconds = 30, + ThrowOnNonZeroExit = false, + }, ct).ConfigureAwait(false); + return new RemoteStatus(true, result.ExitCode == 0, false, null, null, + result.ExitCode == 0 ? "已断开。" : $"断开失败:{TrimError(result.Stderr) ?? result.ExitCode.ToString()}"); + } + + /// 是否已启用(配置开关)。 + public bool IsEnabled() + => string.Equals(configuration[EnabledKey], "true", StringComparison.OrdinalIgnoreCase); + + private async Task IsTailscaleInstalledAsync(CancellationToken ct) + { + var result = await process.ExecuteCommandAsync(new ProcessStartConfig + { + ExecutablePath = "tailscale", + Arguments = "version", + TimeoutSeconds = 10, + ThrowOnNonZeroExit = false, + }, ct).ConfigureAwait(false); + return result.ExitCode == 0; + } + + private async Task RunTailscaleAsync(string arguments, CancellationToken ct) + { + var result = await process.ExecuteCommandAsync(new ProcessStartConfig + { + ExecutablePath = "tailscale", + Arguments = arguments, + TimeoutSeconds = 15, + ThrowOnNonZeroExit = false, + }, ct).ConfigureAwait(false); + return result.ExitCode == 0 ? result.Stdout : null; + } + + private static string? TrimError(string? text) + => string.IsNullOrWhiteSpace(text) ? null : text.ReplaceLineEndings(" ").Trim(); + + private static string Quote(string value) => "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; +} + +/// 远程访问状态(服务层契约)。 +public sealed record RemoteStatus( + bool Enabled, + bool TailscaleInstalled, + bool LoggedIn, + string? HostName, + string? Ip, + string? Message); diff --git a/src/FortOS.Modules.Agent/AgentModule.cs b/src/FortOS.Modules.Agent/AgentModule.cs index c02d37d..fb5ccc6 100644 --- a/src/FortOS.Modules.Agent/AgentModule.cs +++ b/src/FortOS.Modules.Agent/AgentModule.cs @@ -133,6 +133,20 @@ public async Task> ListAgentsAsync(Cancellation return services.Where(s => s.ServiceId.StartsWith("agent-", StringComparison.OrdinalIgnoreCase)).ToList(); } + /// 读取已部署 agent 的 Compose 文件内容(P1-6 Docker 管理:可视化查看配置)。 + public async Task GetComposeAsync(string agentId, CancellationToken ct) + { + var id = NormalizeAgentId(agentId); + ValidateAgentId(id); + var path = Path.Combine(AgentPaths.AgentsRoot, id, "docker-compose.yml"); + if (!File.Exists(path)) + { + throw new Core.ServiceNotFoundException($"Agent {id} has no compose file. Deploy the agent first.", "AGENT_COMPOSE_MISSING"); + } + + return await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + } + /// /// Returns the persisted deployment manifest (ports, environment variable names, /// external access notes) for a deployed agent so the UI can surface how to reach diff --git a/src/FortOS.Observability/Alerts/AlertEngine.cs b/src/FortOS.Observability/Alerts/AlertEngine.cs index 9276267..f752148 100644 --- a/src/FortOS.Observability/Alerts/AlertEngine.cs +++ b/src/FortOS.Observability/Alerts/AlertEngine.cs @@ -176,7 +176,17 @@ public async Task AddRuleAsync(AlertRule rule, CancellationToken ct) public void Dispose() { _subscription?.Dispose(); - _stopping.Cancel(); + // Cancel 后立即 Dispose 的 CTS,若 Dispose 被重复调用(宿主 Stop + 容器释放都会触发), + // 第二次 Cancel 会对已处置的 CTS 抛 ObjectDisposedException —— 必须容错,否则进程 ABRT。 + try + { + _stopping.Cancel(); + } + catch (ObjectDisposedException) + { + // 已被处置(重复 Dispose),忽略。 + } + _stopping.Dispose(); } diff --git a/src/FortOS.Security/Models/NAbilityConstants.cs b/src/FortOS.Security/Models/NAbilityConstants.cs index 108969c..84e8223 100644 --- a/src/FortOS.Security/Models/NAbilityConstants.cs +++ b/src/FortOS.Security/Models/NAbilityConstants.cs @@ -31,4 +31,8 @@ public static class NAbilityConstants public const string DataSensitive = "data:level:sensitive"; /// Session token refresh permission: the self-service ability for an authenticated user to refresh their own session token, issued together with the login token. public const string SessionRefresh = "auth:session:refresh"; + /// AI assistant chat permission (P0-1 phone-side AI entry). + public const string AiChat = "ai:chat"; + /// Remote access (Tailscale) management permission (P0-3). + public const string RemoteAccess = "remote:access"; } From eba57e5bd07170c7d06189349d304e7928b28e15 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 29 Aug 2026 12:51:43 +0800 Subject: [PATCH 2/2] test(ai,remote,agent): cover AI assistant, remote access, template catalog - AiAssistantServiceTests: streaming/non-streaming/error/config contract - RemoteAccessServiceTests: status parsing, enable/disable command construction - AgentCatalogTests: opencode/hermes/jellyfin/kodi templates + devices whitelist - ComposeGeneratorTests: /dev/dri allowed, non-dri rejected - ConfigApiTests/ConfigMetaRegistryTests: new categories and sensitive-key rules --- .../Agent/AgentCatalogTests.cs | 18 +++ .../Agent/ComposeGeneratorTests.cs | 66 +++++++++ .../Api/AiAssistantServiceTests.cs | 137 ++++++++++++++++++ .../Api/ConfigApiTests.cs | 2 +- .../Api/RemoteAccessServiceTests.cs | 128 ++++++++++++++++ .../ConfigMetaRegistryTests.cs | 20 +++ 6 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 tests/FortOS.Tests.Integration/Api/AiAssistantServiceTests.cs create mode 100644 tests/FortOS.Tests.Integration/Api/RemoteAccessServiceTests.cs diff --git a/tests/FortOS.Tests.Integration/Agent/AgentCatalogTests.cs b/tests/FortOS.Tests.Integration/Agent/AgentCatalogTests.cs index e161e14..4254e4c 100644 --- a/tests/FortOS.Tests.Integration/Agent/AgentCatalogTests.cs +++ b/tests/FortOS.Tests.Integration/Agent/AgentCatalogTests.cs @@ -55,6 +55,13 @@ public async Task EmptyCatalog_SeedsBuiltInTemplates() Assert.Contains(templates, t => t.Id == "openclaw"); Assert.Contains(templates, t => t.Id == "open-webui"); Assert.Contains(templates, t => t.Id == "ollama"); + // P0-2: 24h AI 宿主模板。 + Assert.Contains(templates, t => t.Id == "opencode"); + Assert.Contains(templates, t => t.Id == "hermes"); + // P1-5: 影音中心模板。 + Assert.Contains(templates, t => t.Id == "jellyfin"); + // P2-7: 垂直应用模板(KTV/影院)。 + Assert.Contains(templates, t => t.Id == "kodi"); } [Fact] @@ -78,6 +85,17 @@ public async Task MarketTemplates_CarryPortParametersAndAccessNotes() Assert.Contains(webui.Parameters, p => p.Name == "HOST_PORT" && p.Default == "3000"); Assert.Contains(webui.Parameters, p => p.Name == "CONTAINER_PORT" && p.Default == "8080"); Assert.NotEmpty(webui.AccessNotes); + + // P0-2: AI 宿主模板暴露端口/模型/数据目录参数与访问说明。 + var opencode = Assert.Single(templates, t => t.Id == "opencode"); + Assert.Contains(opencode.Parameters, p => p.Name == "OPENAI_BASE_URL"); + Assert.Contains(opencode.Parameters, p => p.Name == "OPENAI_MODEL"); + Assert.NotEmpty(opencode.AccessNotes); + + var hermes = Assert.Single(templates, t => t.Id == "hermes"); + Assert.Contains(hermes.Parameters, p => p.Name == "OPENAI_BASE_URL"); + Assert.Contains(hermes.Parameters, p => p.Name == "HERMES_WORKSPACE"); + Assert.NotEmpty(hermes.AccessNotes); } private static string ValidTemplateYaml(string id, string name, string description) => $@"id: {id} diff --git a/tests/FortOS.Tests.Integration/Agent/ComposeGeneratorTests.cs b/tests/FortOS.Tests.Integration/Agent/ComposeGeneratorTests.cs index 4a36cf9..a540139 100644 --- a/tests/FortOS.Tests.Integration/Agent/ComposeGeneratorTests.cs +++ b/tests/FortOS.Tests.Integration/Agent/ComposeGeneratorTests.cs @@ -105,3 +105,69 @@ public async Task GenerateWritesTemplateParameterDefaultsAndUserEnvironmentToEnv Assert.DoesNotContain("raw-agent-token-value", compose); } } + +public class DeviceWhitelistTests +{ + [Fact] + [Trait("Category", "Unit")] + public async Task Generate_WithDriDevice_AllowsGpuPassthrough() + { + using var root = new AgentTestDataRoot(nameof(Generate_WithDriDevice_AllowsGpuPassthrough)); + var generator = new ComposeGenerator(new FixedTokenBroker("test-token-0123456789abcdef")); + var template = new AgentTemplate + { + Id = "jellyfin", + Name = "Jellyfin", + Version = "1.0.0", + ComposeTemplate = """ + services: + {{.AgentId}}: + image: "{{.ImageName}}" + devices: + - /dev/dri:/dev/dri + """, + }; + var config = new AgentConfig + { + AgentId = "jellyfin-test", + DisplayName = "Jellyfin", + ImageName = "jellyfin/jellyfin:latest", + }; + + var result = await generator.GenerateAsync(template, config, "owner", CancellationToken.None); + var compose = await File.ReadAllTextAsync(result.ComposeFilePath); + + Assert.Contains("/dev/dri:/dev/dri", compose); + } + + [Fact] + [Trait("Category", "Unit")] + public async Task Generate_WithNonDriDevice_Rejects() + { + using var root = new AgentTestDataRoot(nameof(Generate_WithNonDriDevice_Rejects)); + var generator = new ComposeGenerator(new FixedTokenBroker("test-token-0123456789abcdef")); + var template = new AgentTemplate + { + Id = "bad", + Name = "Bad", + Version = "1.0.0", + ComposeTemplate = """ + services: + {{.AgentId}}: + image: "{{.ImageName}}" + devices: + - /dev/sda:/dev/sda + """, + }; + var config = new AgentConfig + { + AgentId = "bad-test", + DisplayName = "Bad", + ImageName = "bad/image:latest", + }; + + var ex = await Assert.ThrowsAsync(() => generator.GenerateAsync(template, config, "owner", CancellationToken.None)); + + Assert.Contains("/dev/dri", ex.Message); + } +} diff --git a/tests/FortOS.Tests.Integration/Api/AiAssistantServiceTests.cs b/tests/FortOS.Tests.Integration/Api/AiAssistantServiceTests.cs new file mode 100644 index 0000000..e35e2f7 --- /dev/null +++ b/tests/FortOS.Tests.Integration/Api/AiAssistantServiceTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FortOS.Api.Services; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace FortOS.Tests.Integration.Api; + +/// +/// AI assistant service tests: endpoint selection, payload shape, non-streaming parse, +/// streaming delta aggregation, and the enable/disable switch. Uses a stubbed +/// HttpMessageHandler so no real model server is contacted. +/// +public sealed class AiAssistantServiceTests +{ + [Fact] + public async Task Chat_Disabled_ReturnsExplicitError() + { + var service = CreateService(enabled: false); + + var result = await service.ChatAsync(new ChatRequest("hello")); + + Assert.Null(result.Reply); + Assert.Contains("未启用", result.Error); + } + + [Fact] + public async Task Chat_NonStreaming_ParsesReply() + { + var (service, handler) = CreateServiceWithHandler( + StubResponse(HttpStatusCode.OK, """{"choices":[{"message":{"content":"你好,我是 AI 助手。"}}]}""")); + + var result = await service.ChatAsync(new ChatRequest("帮我看看磁盘")); + + Assert.Equal("你好,我是 AI 助手。", result.Reply); + Assert.Null(result.Error); + // 请求体包含系统提示词与用户消息,端点指向默认本地 ollama。 + Assert.Contains("/v1/chat/completions", handler.LastRequestUrl); + using var doc = JsonDocument.Parse(handler.LastRequestBody!); + var messages = doc.RootElement.GetProperty("messages"); + Assert.Contains("帮我看看磁盘", messages.EnumerateArray().Last().GetProperty("content").GetString()); + Assert.Equal("qwen2.5:7b", doc.RootElement.GetProperty("model").GetString()); + } + + [Fact] + public async Task Chat_Streaming_AggregatesDeltas() + { + var sse = """ + data: {"choices":[{"delta":{"content":"你"}}]} + + data: {"choices":[{"delta":{"content":"好"}}]} + + data: [DONE] + + """; + var (service, _) = CreateServiceWithHandler(StubResponse(HttpStatusCode.OK, sse, "text/event-stream")); + var deltas = new List(); + + var result = await service.ChatAsync(new ChatRequest("hi", Stream: true), onDelta: deltas.Add); + + Assert.Equal("你好", result.Reply); + Assert.Equal(["你", "好"], deltas); + } + + [Fact] + public async Task Chat_UpstreamError_ReturnsError() + { + var (service, _) = CreateServiceWithHandler(StubResponse(HttpStatusCode.BadGateway, "bad gateway")); + + var result = await service.ChatAsync(new ChatRequest("hi")); + + Assert.Null(result.Reply); + Assert.Contains("502", result.Error); + } + + [Fact] + public async Task Chat_EndpointModelFromConfiguration() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AiAssistantService.EndpointKey] = "http://192.168.1.50:11434/v1", + [AiAssistantService.ModelKey] = "deepseek-r1:8b", + }) + .Build(); + var (service, handler) = CreateServiceWithHandler( + StubResponse(HttpStatusCode.OK, """{"choices":[{"message":{"content":"ok"}}]}"""), + config); + + await service.ChatAsync(new ChatRequest("hi")); + + Assert.StartsWith("http://192.168.1.50:11434/v1/chat/completions", handler.LastRequestUrl); + Assert.Contains("deepseek-r1:8b", handler.LastRequestBody); + } + + private static AiAssistantService CreateService(bool enabled = true) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AiAssistantService.EnabledKey] = enabled ? "true" : "false", + }) + .Build(); + var http = new HttpClient(new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + return new AiAssistantService(http, config); + } + + private static (AiAssistantService Service, StubHandler Handler) CreateServiceWithHandler( + HttpResponseMessage response, + IConfiguration? config = null) + { + var handler = new StubHandler(_ => response); + var cfg = config ?? new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + var http = new HttpClient(handler); + return (new AiAssistantService(http, cfg), handler); + } + + private static HttpResponseMessage StubResponse(HttpStatusCode status, string body, string contentType = "application/json") + => new(status) + { + Content = new StringContent(body, Encoding.UTF8, contentType), + }; + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + public string? LastRequestUrl { get; private set; } + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUrl = request.RequestUri?.ToString(); + LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return responder(request); + } + } +} diff --git a/tests/FortOS.Tests.Integration/Api/ConfigApiTests.cs b/tests/FortOS.Tests.Integration/Api/ConfigApiTests.cs index b3c6d99..cf7ec6c 100644 --- a/tests/FortOS.Tests.Integration/Api/ConfigApiTests.cs +++ b/tests/FortOS.Tests.Integration/Api/ConfigApiTests.cs @@ -31,7 +31,7 @@ public async Task ConfigMeta_ReturnsCategoriesAndWhitelistedEntries() var categories = body.GetProperty("categories"); var categoryIds = categories.EnumerateArray().Select(c => c.GetProperty("id").GetString()).ToList(); // Mirrors ConfigMetaRegistry.Categories — keep in sync when categories are added. - Assert.Equal(["security", "access", "observability", "storage", "advanced"], categoryIds); + Assert.Equal(["security", "access", "observability", "storage", "ai", "remote", "docker", "advanced"], categoryIds); var entries = body.GetProperty("entries"); var entryMap = entries.EnumerateArray().ToDictionary(e => e.GetProperty("key").GetString()!); diff --git a/tests/FortOS.Tests.Integration/Api/RemoteAccessServiceTests.cs b/tests/FortOS.Tests.Integration/Api/RemoteAccessServiceTests.cs new file mode 100644 index 0000000..16305c6 --- /dev/null +++ b/tests/FortOS.Tests.Integration/Api/RemoteAccessServiceTests.cs @@ -0,0 +1,128 @@ +using FortOS.Api.Services; +using FortOS.Core; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace FortOS.Tests.Integration.Api; + +/// +/// Remote access (Tailscale) service tests: status parsing, enable/disable command +/// construction, and the enabled switch. Uses a stubbed IProcessManager — no real +/// tailscale binary is invoked. +/// +public sealed class RemoteAccessServiceTests +{ + [Fact] + public async Task Status_Disabled_ReturnsDisabled() + { + var (service, _) = Create(enabled: false); + + var status = await service.GetStatusAsync(CancellationToken.None); + + Assert.False(status.Enabled); + Assert.Contains("未启用", status.Message); + } + + [Fact] + public async Task Status_TailscaleNotInstalled_ReturnsHint() + { + var (service, process) = Create(enabled: true); + process.Results.Enqueue(CommandResult(exitCode: 1)); + + var status = await service.GetStatusAsync(CancellationToken.None); + + Assert.False(status.TailscaleInstalled); + Assert.Contains("安装", status.Message); + } + + [Fact] + public async Task Status_LoggedIn_ParsesHostAndIp() + { + var (service, process) = Create(enabled: true); + process.Results.Enqueue(CommandResult(exitCode: 0, stdout: "v1.0")); + process.Results.Enqueue(CommandResult(exitCode: 0, stdout: """ + {"BackendState":"Running","Self":{"HostName":"nas-1","TailscaleIPs":["100.64.0.5"]}} + """)); + + var status = await service.GetStatusAsync(CancellationToken.None); + + Assert.True(status.LoggedIn); + Assert.Equal("nas-1", status.HostName); + Assert.Equal("100.64.0.5", status.Ip); + } + + [Fact] + public async Task Enable_WithoutAuthKey_RunsUpAndReturnsStatus() + { + var (service, process) = Create(enabled: true); + // up 成功 → 后续 GetStatusAsync 读到已登录。 + process.Results.Enqueue(CommandResult(exitCode: 0)); + process.Results.Enqueue(CommandResult(exitCode: 0, stdout: "v1.0")); + process.Results.Enqueue(CommandResult(exitCode: 0, stdout: """{"BackendState":"Running","Self":{"HostName":"fortos","TailscaleIPs":["100.64.0.9"]}}""")); + + var status = await service.EnableAsync(CancellationToken.None); + + Assert.True(status.LoggedIn); + Assert.Equal("100.64.0.9", status.Ip); + // 首次调用应为 tailscale up(hostname 参数)。 + Assert.Equal("tailscale", process.Calls[0].ExecutablePath); + Assert.Contains("up", process.Calls[0].Arguments); + } + + [Fact] + public async Task Disable_RunsTailscaleDown() + { + var (service, process) = Create(enabled: true); + process.Results.Enqueue(CommandResult(exitCode: 0)); + + var status = await service.DisableAsync(CancellationToken.None); + + Assert.Contains("已断开", status.Message); + var call = Assert.Single(process.Calls); + Assert.Equal("down", call.Arguments); + } + + private static (RemoteAccessService Service, StubProcessManager Process) Create(bool enabled) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [RemoteAccessService.EnabledKey] = enabled ? "true" : "false", + }) + .Build(); + var process = new StubProcessManager(); + var service = new RemoteAccessService(process, config); + return (service, process); + } + + private static CommandResult CommandResult(int exitCode, string? stdout = null, string? stderr = null) + => new() { ExitCode = exitCode, Stdout = stdout ?? string.Empty, Stderr = stderr ?? string.Empty }; + + /// 记录调用并按序返回预设结果的 IProcessManager 桩。 + private sealed class StubProcessManager : IProcessManager + { + public Queue Results { get; } = new(); + public List Calls { get; } = []; + + public Task ExecuteCommandAsync(ProcessStartConfig config, CancellationToken ct) + { + Calls.Add(config); + return Task.FromResult(Results.Count > 0 ? Results.Dequeue() : new CommandResult { ExitCode = 0 }); + } + + public Task StartProcessAsync(ProcessStartConfig config, CancellationToken ct) + => throw new NotSupportedException(); + + public Task StopProcessAsync(int pid, TimeSpan gracefulTimeout, CancellationToken ct) + => throw new NotSupportedException(); + + public Task GetProcessAsync(int pid, CancellationToken ct) + => throw new NotSupportedException(); + + public Task EnableServiceAsync(string serviceName, CancellationToken ct) + => throw new NotSupportedException(); + + public Task DisableServiceAsync(string serviceName, CancellationToken ct) + => throw new NotSupportedException(); + } +} diff --git a/tests/FortOS.Tests.Integration/ConfigMetaRegistryTests.cs b/tests/FortOS.Tests.Integration/ConfigMetaRegistryTests.cs index d024fe9..fe32ba7 100644 --- a/tests/FortOS.Tests.Integration/ConfigMetaRegistryTests.cs +++ b/tests/FortOS.Tests.Integration/ConfigMetaRegistryTests.cs @@ -73,3 +73,23 @@ public void TypeName_IsLowerCaseControlType() => Assert.All(ConfigMetaRegistry.Entries, e => Assert.Equal(e.Type.ToString().ToLowerInvariant(), e.TypeName)); } + +public class P0P2ConfigMetaTests +{ + [Fact] + [Trait("Category", "Unit")] + public void NewFeatureKeys_AreWhitelistedAndNotSensitive() + { + // P0-1 AI / P0-3 Remote / P1-6 Docker 的新增配置键。 + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "ai:enabled" && e.Type == ConfigEntryType.Boolean); + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "ai:endpoint"); + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "ai:model"); + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "remote:enabled" && e.Type == ConfigEntryType.Boolean); + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "remote:tailscale_hostname"); + Assert.Contains(ConfigMetaRegistry.Entries, e => e.Key == "docker:registry_mirrors" && e.Type == ConfigEntryType.Text); + + // 凭据类键必须保持敏感(不进动态表单),防止密钥经配置页暴露。 + Assert.True(ConfigMetaRegistry.IsSensitive("ai:api_key")); + Assert.True(ConfigMetaRegistry.IsSensitive("remote:tailscale_auth_key")); + } +}