diff --git a/.gitignore b/.gitignore index 1e225d7..0fd790c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ dist/ output/ web/public/111favicon.png web/src/assets/brand/logo00.png +fnos/*.fpk +fnos/.build/ +fnos/dist/ # scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 scripts/* !scripts/agent-install.sh diff --git a/docs/fnos-app-packaging-plan.md b/docs/fnos-app-packaging-plan.md index 6c663c5..76e22bb 100644 --- a/docs/fnos-app-packaging-plan.md +++ b/docs/fnos-app-packaging-plan.md @@ -1001,6 +1001,25 @@ Havline 适合做飞牛 Docker 应用,不适合首发做成 CGI 或原生应 首发使用飞牛应用中心升级。`manifest.changelog` 应面向用户简洁表达本次变更; Havline 内部升级页面、`havline-updater` sidecar 和 Docker Socket 默认不进入 fnOS 包。 +11. **端口入口和容器端口必须区分** + + `app/ui/config` 的 `port` 支持使用向导变量,因此管理入口可以写成: + + ```json + "port": "${wizard_service_port}" + ``` + + Compose 中应把宿主机端口映射到 Havline 容器内的固定端口: + + ```yaml + ports: + - "${wizard_service_port}:6893" + - "${wizard_nginx_http_port}:80" + - "${wizard_nginx_https_port}:443" + ``` + + 不要把 `18080` 或 `9443` 当成容器内部监听端口。它们是宿主机端口,容器内仍然是 `80/443`。 + ### 19.3 建议的首发实施顺序 阶段 1 只做“能安装、能打开、能保留数据”: @@ -1014,6 +1033,14 @@ Havline 适合做飞牛 Docker 应用,不适合首发做成 CGI 或原生应 7. 构建 amd64 / arm64 镜像。 8. 用 `fnpack build` 打包并在飞牛测试设备安装验证。 +当前已经在仓库内生成: + +```text +fnos/havline/ +``` + +并补上了首发骨架所需的基础配置。 + 阶段 1 暂不实现: - Havline 自带 updater; diff --git a/fnos/README.md b/fnos/README.md new file mode 100644 index 0000000..839bf6a --- /dev/null +++ b/fnos/README.md @@ -0,0 +1,76 @@ +# Havline fnOS 应用打包 + +本目录保存 Havline 的飞牛 fnOS 应用工程。 + +- 应用工程:[`havline/`](havline/) +- 官方工具:`fnpack` +- 当前工具版本:`1.2.3` + +## 当前状态 + +已经完成: + +- 使用 `fnpack create havline --template docker` 生成官方 Docker 应用骨架; +- 配置 `manifest`、应用入口、Docker Compose、权限和资源; +- 增加安装向导、配置向导和卸载向导; +- 增加安装、升级、配置和卸载生命周期脚本; +- 支持通过环境变量 `HAVLINE_INITIAL_ADMIN_PASSWORD` 初始化管理员。 + +仍需完成: + +- 将 `app/docker/docker-compose.yaml` 中的 `ghcr.io/your-org/havline` 替换为实际多架构镜像地址; +- 将模板图标替换为 Havline 正式图标; +- 在飞牛测试设备验证安装、启动、入口、升级和卸载; +- 验证 amd64 与 arm64 镜像均可用。 + +## 打包 + +Windows PowerShell: + +```powershell +cd F:\Fonu-main\havline +.\fnos\build.ps1 +``` + +脚本会先复制一份不含 `.DS_Store` 的干净工程,再调用 `fnpack` 打包,输出到: + +```text +fnos/dist/havline-.fpk +``` + +如果 `fnpack` 已在 PATH 中,也可以传入路径: + +```powershell +.\fnos\build.ps1 -FnpackPath fnpack +``` + +## 测试安装 + +```bash +appcenter-cli install-fpk havline.fpk +``` + +安装后检查: + +1. 应用中心显示 Havline; +2. Docker 镜像可以拉取; +3. Havline 容器能够启动; +4. 桌面入口可以打开管理界面; +5. 数据写入 `${TRIM_PKGVAR}`; +6. 卸载默认保留数据。 + +## 当前默认端口 + +| 用途 | 安装向导默认值 | 容器端口 | +| --- | ---: | ---: | +| 管理界面 | `6893` | `6893` | +| HTTP 反向代理 | `18080` | `80` | +| HTTPS 反向代理 | `9443` | `443` | + +## 首发边界 + +- 不挂载 Docker Socket。 +- 不启动 Havline 自带 updater。 +- 不使用 host 网络。 +- 不共享数据库、证书、密钥、Nginx、FRP 和 cloudflared 目录。 +- 应用中心负责安装、升级、启动、停止和卸载。 diff --git a/fnos/build.ps1 b/fnos/build.ps1 new file mode 100644 index 0000000..97ec252 --- /dev/null +++ b/fnos/build.ps1 @@ -0,0 +1,41 @@ +param( + [string]$FnpackPath = "" +) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$project = Join-Path $root "fnos\havline" +$version = (Get-Content -Raw (Join-Path $root "VERSION")).Trim() + +if (-not (Test-Path -LiteralPath $project)) { + throw "找不到飞牛应用工程:$project" +} + +if (-not $FnpackPath) { + $localTool = Join-Path (Split-Path $root -Parent) "fnos\fnpack.exe" + $FnpackPath = if (Test-Path -LiteralPath $localTool) { $localTool } else { "fnpack" } +} + +$stamp = Get-Date -Format "yyyyMMddHHmmss" +$stage = Join-Path $PSScriptRoot ".build\havline-$stamp" +New-Item -ItemType Directory -Force $stage | Out-Null +robocopy $project $stage /E /XF .DS_Store /NFL /NDL /NJH /NJS /NP | Out-Null +if ($LASTEXITCODE -ge 8) { + throw "复制飞牛应用工程失败,robocopy 退出码:$LASTEXITCODE" +} + +Push-Location $PSScriptRoot +try { + & $FnpackPath build -d $stage + if ($LASTEXITCODE -ne 0) { + throw "fnpack build 失败,退出码:$LASTEXITCODE" + } + $output = Join-Path $PSScriptRoot "havline.fpk" + $dist = Join-Path $PSScriptRoot "dist" + New-Item -ItemType Directory -Force $dist | Out-Null + Copy-Item -LiteralPath $output -Destination (Join-Path $dist "havline-$version.fpk") -Force + Write-Output "FPK: $(Join-Path $dist "havline-$version.fpk")" +} +finally { + Pop-Location +} diff --git a/fnos/havline/ICON.PNG b/fnos/havline/ICON.PNG new file mode 100644 index 0000000..c221ba6 Binary files /dev/null and b/fnos/havline/ICON.PNG differ diff --git a/fnos/havline/ICON_256.PNG b/fnos/havline/ICON_256.PNG new file mode 100644 index 0000000..d5c1bd4 Binary files /dev/null and b/fnos/havline/ICON_256.PNG differ diff --git a/fnos/havline/app/docker/docker-compose.yaml b/fnos/havline/app/docker/docker-compose.yaml new file mode 100644 index 0000000..ea42396 --- /dev/null +++ b/fnos/havline/app/docker/docker-compose.yaml @@ -0,0 +1,21 @@ +services: + havline: + # TODO: 发布多架构镜像后,把 your-org 替换为实际镜像命名空间。 + image: ghcr.io/your-org/havline:${TRIM_APPVER} + container_name: havline + restart: unless-stopped + ports: + - "${wizard_service_port}:6893" + - "${wizard_nginx_http_port}:80" + - "${wizard_nginx_https_port}:443" + volumes: + - "${TRIM_PKGVAR}:/data" + environment: + HAVLINE_DATA_DIR: /data + HAVLINE_LISTEN: ":6893" + HAVLINE_NGINX_HTTP_PORT: "80" + HAVLINE_NGINX_HTTPS_PORT: "443" + HAVLINE_SESSION_SECRET: "" + HAVLINE_UPDATER_SOCKET: "" + HAVLINE_INITIAL_ADMIN_PASSWORD: "${wizard_initial_admin_password}" + TZ: "${wizard_timezone}" diff --git a/fnos/havline/app/ui/config b/fnos/havline/app/ui/config new file mode 100644 index 0000000..a4dadc3 --- /dev/null +++ b/fnos/havline/app/ui/config @@ -0,0 +1,16 @@ +{ + ".url": { + "havline.main": { + "title": "Havline", + "icon": "images/icon_{0}.png", + "type": "iframe", + "protocol": "http", + "port": "${wizard_service_port}", + "url": "/", + "allUsers": false, + "control": { + "accessPerm": "readonly" + } + } + } +} diff --git a/fnos/havline/app/ui/images/icon_256.png b/fnos/havline/app/ui/images/icon_256.png new file mode 100644 index 0000000..d5c1bd4 Binary files /dev/null and b/fnos/havline/app/ui/images/icon_256.png differ diff --git a/fnos/havline/app/ui/images/icon_64.png b/fnos/havline/app/ui/images/icon_64.png new file mode 100644 index 0000000..c221ba6 Binary files /dev/null and b/fnos/havline/app/ui/images/icon_64.png differ diff --git a/fnos/havline/cmd/config_callback b/fnos/havline/cmd/config_callback new file mode 100644 index 0000000..a87b239 --- /dev/null +++ b/fnos/havline/cmd/config_callback @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +for var in wizard_service_port wizard_nginx_http_port wizard_nginx_https_port; do + value="${!var:-}" + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + echo "$var 必须是数字端口" > "$TRIM_TEMP_LOGFILE" + exit 1 + fi +done + +exit 0 diff --git a/fnos/havline/cmd/config_init b/fnos/havline/cmd/config_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos/havline/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/havline/cmd/install_callback b/fnos/havline/cmd/install_callback new file mode 100644 index 0000000..e380a62 --- /dev/null +++ b/fnos/havline/cmd/install_callback @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p "$TRIM_PKGVAR" "$TRIM_PKGVAR/backups" +chown -R "$TRIM_USERNAME:$TRIM_GROUPNAME" "$TRIM_PKGVAR" + +echo "Havline 数据目录已准备:$TRIM_PKGVAR" +exit 0 diff --git a/fnos/havline/cmd/install_init b/fnos/havline/cmd/install_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos/havline/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/havline/cmd/main b/fnos/havline/cmd/main new file mode 100644 index 0000000..40a9506 --- /dev/null +++ b/fnos/havline/cmd/main @@ -0,0 +1,29 @@ +#!/bin/bash +set -u + +CONTAINER_NAME="havline" +LOG_FILE="${TRIM_TEMP_LOGFILE:-/dev/stderr}" + +log_error() { + echo "$1" > "$LOG_FILE" +} + +is_running() { + docker inspect "$CONTAINER_NAME" 2>/dev/null | grep -q '"Status": "running"' +} + +case "${1:-}" in + start|stop) + exit 0 + ;; + status) + if is_running; then + exit 0 + fi + exit 3 + ;; + *) + log_error "Unknown command: ${1:-}" + exit 1 + ;; +esac diff --git a/fnos/havline/cmd/uninstall_callback b/fnos/havline/cmd/uninstall_callback new file mode 100644 index 0000000..7c99014 --- /dev/null +++ b/fnos/havline/cmd/uninstall_callback @@ -0,0 +1,8 @@ +#!/bin/bash +set -u + +if [ "${wizard_uninstall_mode:-keep}" = "purge" ]; then + rm -rf "$TRIM_PKGVAR" +fi + +exit 0 diff --git a/fnos/havline/cmd/uninstall_init b/fnos/havline/cmd/uninstall_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos/havline/cmd/uninstall_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/havline/cmd/upgrade_callback b/fnos/havline/cmd/upgrade_callback new file mode 100644 index 0000000..c22a518 --- /dev/null +++ b/fnos/havline/cmd/upgrade_callback @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +if [ -f "$TRIM_PKGVAR/havline.db" ] && [ ! -r "$TRIM_PKGVAR/havline.db" ]; then + echo "数据库不可读:$TRIM_PKGVAR/havline.db" > "$TRIM_TEMP_LOGFILE" + exit 1 +fi + +if [ -f "$TRIM_PKGVAR/session_secret" ] && [ ! -r "$TRIM_PKGVAR/session_secret" ]; then + echo "会话密钥不可读:$TRIM_PKGVAR/session_secret" > "$TRIM_TEMP_LOGFILE" + exit 1 +fi + +exit 0 diff --git a/fnos/havline/cmd/upgrade_init b/fnos/havline/cmd/upgrade_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/fnos/havline/cmd/upgrade_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/fnos/havline/config/privilege b/fnos/havline/config/privilege new file mode 100644 index 0000000..cff6a3a --- /dev/null +++ b/fnos/havline/config/privilege @@ -0,0 +1,7 @@ +{ + "defaults": { + "run-as": "package" + }, + "username": "havline", + "groupname": "havline" +} diff --git a/fnos/havline/config/resource b/fnos/havline/config/resource new file mode 100644 index 0000000..633747b --- /dev/null +++ b/fnos/havline/config/resource @@ -0,0 +1,17 @@ +{ + "docker-project": { + "projects": [ + { + "name": "havline", + "path": "docker" + } + ] + }, + "data-share": { + "shares": [ + { + "name": "havline/backups" + } + ] + } +} diff --git a/fnos/havline/manifest b/fnos/havline/manifest new file mode 100644 index 0000000..ad86aa8 --- /dev/null +++ b/fnos/havline/manifest @@ -0,0 +1,14 @@ +appname = havline +version = 1.9.0 +display_name = Havline +desc = NAS 公网访问管理:反向代理、DDNS、HTTPS 证书、FRP、Cloudflare Tunnel 与一键升级 +platform = all +source = thirdparty +maintainer = Havline +maintainer_url = http://45.207.223.190:10082/mrlee/Havline +distributor = Havline +desktop_uidir = ui +desktop_applaunchname = havline.main +checkport = false +ctl_stop = true +changelog = 首发飞牛应用:支持安装向导、端口配置、数据持久化和应用中心升级 diff --git a/fnos/havline/wizard/config b/fnos/havline/wizard/config new file mode 100644 index 0000000..dd0a823 --- /dev/null +++ b/fnos/havline/wizard/config @@ -0,0 +1,42 @@ +[ + { + "stepTitle": "端口设置", + "items": [ + { + "type": "text", + "field": "wizard_nginx_http_port", + "label": "HTTP 反向代理端口", + "initValue": "18080", + "rules": [ + { "required": true, "message": "请输入 HTTP 端口" }, + { "pattern": "^[0-9]+$", "message": "端口必须是数字" } + ] + }, + { + "type": "text", + "field": "wizard_nginx_https_port", + "label": "HTTPS 反向代理端口", + "initValue": "9443", + "rules": [ + { "required": true, "message": "请输入 HTTPS 端口" }, + { "pattern": "^[0-9]+$", "message": "端口必须是数字" } + ] + } + ] + }, + { + "stepTitle": "基础设置", + "items": [ + { + "type": "select", + "field": "wizard_timezone", + "label": "时区", + "initValue": "Asia/Shanghai", + "options": [ + { "label": "Asia/Shanghai", "value": "Asia/Shanghai" }, + { "label": "UTC", "value": "UTC" } + ] + } + ] + } +] diff --git a/fnos/havline/wizard/install b/fnos/havline/wizard/install new file mode 100644 index 0000000..f187338 --- /dev/null +++ b/fnos/havline/wizard/install @@ -0,0 +1,70 @@ +[ + { + "stepTitle": "端口设置", + "items": [ + { + "type": "text", + "field": "wizard_service_port", + "label": "Havline 管理端口", + "initValue": "6893", + "rules": [ + { "required": true, "message": "请输入管理端口" }, + { "pattern": "^[0-9]+$", "message": "端口必须是数字" } + ] + }, + { + "type": "text", + "field": "wizard_nginx_http_port", + "label": "HTTP 反向代理端口", + "initValue": "18080", + "rules": [ + { "required": true, "message": "请输入 HTTP 端口" }, + { "pattern": "^[0-9]+$", "message": "端口必须是数字" } + ] + }, + { + "type": "text", + "field": "wizard_nginx_https_port", + "label": "HTTPS 反向代理端口", + "initValue": "9443", + "rules": [ + { "required": true, "message": "请输入 HTTPS 端口" }, + { "pattern": "^[0-9]+$", "message": "端口必须是数字" } + ] + } + ] + }, + { + "stepTitle": "管理员初始化", + "items": [ + { + "type": "password", + "field": "wizard_initial_admin_password", + "label": "初始管理员密码", + "rules": [ + { "required": true, "message": "请输入初始管理员密码" }, + { "min": 8, "message": "密码至少 8 位" } + ] + }, + { + "type": "tips", + "helpText": "首次启动时使用该密码创建 admin 管理员。登录后请在系统设置中修改密码。" + } + ] + }, + { + "stepTitle": "基础设置", + "items": [ + { + "type": "select", + "field": "wizard_timezone", + "label": "时区", + "initValue": "Asia/Shanghai", + "options": [ + { "label": "Asia/Shanghai", "value": "Asia/Shanghai" }, + { "label": "UTC", "value": "UTC" } + ] + } + ] + } +] diff --git a/fnos/havline/wizard/uninstall b/fnos/havline/wizard/uninstall new file mode 100644 index 0000000..3545dd5 --- /dev/null +++ b/fnos/havline/wizard/uninstall @@ -0,0 +1,21 @@ +[ + { + "stepTitle": "卸载选项", + "items": [ + { + "type": "radio", + "field": "wizard_uninstall_mode", + "label": "数据处理", + "initValue": "keep", + "options": [ + { "label": "保留数据(推荐)", "value": "keep" }, + { "label": "删除全部数据", "value": "purge" } + ] + }, + { + "type": "tips", + "helpText": "删除数据将移除数据库、证书、Nginx 配置、FRP 与 Cloudflare 配置,且无法恢复。" + } + ] + } +] diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 160b9ab..78c5153 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "log/slog" + "os" + "strings" "time" "golang.org/x/crypto/bcrypt" @@ -45,20 +47,35 @@ func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) erro return nil } - password, err := generatePassword(16) - if err != nil { - return err + password := strings.TrimSpace(os.Getenv("HAVLINE_INITIAL_ADMIN_PASSWORD")) + generated := false + if password == "" { + var err error + password, err = generatePassword(16) + if err != nil { + return err + } + generated = true } if err := s.Setup(ctx, defaultAdminUsername, password); err != nil { return err } - log.Info( - "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码", - "module", "SYSTEM", - "username", defaultAdminUsername, - "password", password, - ) + if generated { + log.Info( + "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码", + "module", "SYSTEM", + "username", defaultAdminUsername, + "password", password, + ) + } else { + log.Info( + "首次启动已使用 HAVLINE_INITIAL_ADMIN_PASSWORD 创建管理员账户,请在登录后尽快修改密码", + "module", "SYSTEM", + "username", defaultAdminUsername, + "password_source", "environment", + ) + } return nil } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 776fc2a..c58a80f 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -39,3 +39,26 @@ func TestEnsureDefaultAdminCreatesOnce(t *testing.T) { t.Fatalf("expected unauthorized for wrong password, got %v", err) } } + +func TestEnsureDefaultAdminUsesEnvPassword(t *testing.T) { + conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer conn.Close() + + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("migrate: %v", err) + } + + t.Setenv("HAVLINE_INITIAL_ADMIN_PASSWORD", "env-password-123") + svc := New(conn) + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil { + t.Fatalf("bootstrap with env password: %v", err) + } + if _, err := svc.Login(context.Background(), "admin", "env-password-123"); err != nil { + t.Fatalf("expected env password login to succeed, got %v", err) + } +}