收紧安装 Agent 安全边界

This commit is contained in:
WuYongJie 2026-05-25 17:06:22 +08:00
parent 21f2efb0bf
commit c649b939c4
18 changed files with 3157 additions and 25 deletions

37
flnxi-agent-go/Dockerfile Normal file
View File

@ -0,0 +1,37 @@
# 构建阶段
FROM docker.m.daocloud.io/golang:1.22-alpine AS builder
WORKDIR /app
# 设置 Go 代理(国内加速)
ENV GOPROXY=https://goproxy.cn,direct
# 复制源码
COPY . .
# 下载依赖并编译(静态链接,减小体积)
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o agent .
# 运行阶段
FROM docker.m.daocloud.io/alpine:3.21
# 使用阿里云镜像源加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装 Docker CLI
RUN apk add --no-cache docker-cli ca-certificates tzdata
WORKDIR /app
# 从构建阶段复制二进制
COPY --from=builder /app/agent .
# 设置时区
ENV TZ=Asia/Shanghai
# 版本标识
ENV AGENT_VERSION=1.0.0
EXPOSE 9090
CMD ["./agent"]

97
flnxi-agent-go/README.md Normal file
View File

@ -0,0 +1,97 @@
# FLNXI Agent Go
`flnxi-agent-go` 是面向私有化部署场景的 Go 版安装与运维 Agent。
当前定位:Go 版 Agent 更适合作为后续私有化交付的轻量入口;但当前默认安装脚本还没把认证变量和完整版本链路全部接好,不能直接当成“已经可正式交付”的成品。若现场仍使用 Node 版 Agent,先确认既有安装链路和端口占用,再决定是否切换。
重要现状:
- `install.sh` 已不再默认拉取旧 Harbor `latest` 镜像,必须通过 `AGENT_IMAGE` 或 `--image` 显式传入完整 Agent 版本镜像。
- Go 版 Agent 内部安装和更新素材系统已改为读取完整版本镜像,不再默认拉取旧 Harbor / `latest`。
- 版本列表需要通过 `FLNXI_VERSION_LIST_URL` 显式配置来源;未配置时不会伪造 `latest` 或默认版本。
- 当前仍建议先作为内部验证入口,等管理界面和客户安装流程联调完成后,再提升为客户正式安装主入口。
它负责:
- 安装素材系统运行环境
- 执行更新
- 管理相关 Docker 容器
- 提供内置 Web 界面和 API
## 运行方式
### Docker 运行
```bash
docker run -d \
--name flnxi-agent-go \
-p 127.0.0.1:9090:9090 \
-v /var/run/docker.sock:/var/run/docker.sock \
-e AGENT_AUTH_USERNAME=admin \
-e AGENT_AUTH_PASSWORD='<generate-a-strong-random-password>' \
registry.example.com/flnxi/agent:1.0.1
```
默认只发布到宿主机回环地址,适合本机浏览器或反向代理访问。
如果确实需要局域网直连,再显式改成 `-p 0.0.0.0:9090:9090`(或你自己的内网 IP)。
如果使用仓库里的 `install.sh` 一键安装脚本,默认端口 `9090` 被占用时会自动递增查找下一个可用端口,并在安装完成后打印最终访问地址。
重要现状:
- 当前 `install.sh` 会把环境里的 `AGENT_AUTH_USERNAME` / `AGENT_AUTH_PASSWORD` 透传进容器,但要求两者同时存在。
- 如果没有同时设置这两个变量,安装完成后除 `/api/health` 外的管理页面和 API 仍会关闭。
- 如果要直接使用管理面,建议在执行安装脚本前先准备好这两个环境变量,或显式改用上面的 Docker 命令。
必须同时设置 `AGENT_AUTH_USERNAME` 和 `AGENT_AUTH_PASSWORD`。未配置时,除了 `/api/health` 以外的页面和 API 都会默认关闭。
## 环境变量
| 变量 | 说明 | 默认值 |
| --- | --- | --- |
| `PORT` | Agent 监听端口 | `9090` |
| `AGENT_AUTH_USERNAME` | Agent 管理账号,需和密码同时设置,否则管理面默认关闭 | 未设置 |
| `AGENT_AUTH_PASSWORD` | Agent 管理密码,需和账号同时设置,否则管理面默认关闭 | 未设置 |
| `AGENT_VERSION` | Agent 版本号,用于健康检查返回 | 未设置 |
| `GIN_MODE` | Gin 运行模式 | `release` |
| `AGENT_IMAGE` | `install.sh` 使用的完整 Agent 版本镜像,也可通过 `--image` 传入 | 未设置 |
| `FLNXI_MATERIAL_IMAGE` | 安装素材系统时使用的完整主应用版本镜像;也兼容读取 `APP_IMAGE` | 未设置 |
| `FLNXI_MATERIAL_IMAGE_REPOSITORY` | 更新时按版本号拼接的主应用镜像仓库,例如 `registry.example.com/flnxi/material/flnxi-material` | 未设置 |
| `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` |
| `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` |
| `FLNXI_VERSION_LIST_URL` | Agent 管理界面可选版本列表来源,返回 Registry tags 结构 | 未设置 |
版本说明:
- 当前 Go 版 Agent 的版本列表不再写死 Harbor;后续正式交付前,需要决定版本列表来自 Registry tags、许可证服务,还是发布管理接口。
- 它还没有像 Node 版 Agent 那样完整接入 `LICENSE_SERVER_URL` / `LICENSE_PROJECT_CODE` 的远端版本检查链路。
安装素材系统前,至少要给 Agent 容器配置:
```bash
-e FLNXI_MATERIAL_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.1
-e FLNXI_MATERIAL_IMAGE_REPOSITORY=registry.example.com/flnxi/material/flnxi-material
```
## 安全建议
- 生产环境必须设置 `AGENT_AUTH_USERNAME` 和 `AGENT_AUTH_PASSWORD`
- Docker 运行时建议默认使用 `-p 127.0.0.1:9090:9090`,仅在明确需要时才改为局域网暴露
- 该 Agent 可以直接控制 Docker,不应直接暴露到公网
- 更推荐放在内网、反向代理后面,或额外加局域网 ACL / 防火墙限制来源
- 当前认证是基础收口,不等于可以忽略后续的口令管理、网络隔离和审计
- 未配置认证时,除了 `/api/health` 以外的管理面会直接返回 `503`
- 安装流程现在会为 PostgreSQL、MinIO 和主应用自动生成随机内部凭据,不再内置固定弱口令
## 开发验证
如果本机没有安装 Go,可以用容器做最小编译验证:
```bash
docker run --rm \
-v "$PWD:/app" \
-w /app \
golang:1.22-alpine \
sh -c "go test ./internal/api -run TestDoesNotExist && go test . -run TestDoesNotExist"
```

View File

@ -0,0 +1 @@
1.0.5

46
flnxi-agent-go/build.ps1 Normal file
View File

@ -0,0 +1,46 @@
# FLNXI Agent Go 版本构建脚本
param(
[string]$Version = "1.0.0",
[switch]$Push,
[string]$ImageName = "registry.example.com/flnxi/agent"
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " FLNXI Agent Go 构建脚本" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 构建镜像
Write-Host "[1/3] 构建 Docker 镜像..." -ForegroundColor Yellow
docker build -t "${ImageName}:${Version}" .
if ($LASTEXITCODE -ne 0) {
Write-Host "构建失败!" -ForegroundColor Red
exit 1
}
# 显示镜像大小
Write-Host ""
Write-Host "[2/3] 镜像信息:" -ForegroundColor Yellow
docker images $ImageName --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# 推送镜像
if ($Push) {
Write-Host ""
Write-Host "[3/3] 推送到 Registry..." -ForegroundColor Yellow
docker push "${ImageName}:${Version}"
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "✅ 推送成功!" -ForegroundColor Green
}
} else {
Write-Host ""
Write-Host "跳过推送(使用 -Push 参数推送)" -ForegroundColor Gray
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 构建完成!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan

51
flnxi-agent-go/go.mod Normal file
View File

@ -0,0 +1,51 @@
module flnxi-agent
go 1.22
require (
github.com/docker/docker v24.0.7+incompatible
github.com/docker/go-connections v0.4.0
github.com/gin-gonic/gin v1.9.1
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.6.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

150
flnxi-agent-go/go.sum Normal file
View File

@ -0,0 +1,150 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

385
flnxi-agent-go/install.sh Normal file
View File

@ -0,0 +1,385 @@
#!/bin/bash
#
# FLNXI 一键部署脚本
# 用法: curl -sSL https://install.flnxi.com | bash
#
# 或者带参数:
# curl -sSL https://install.flnxi.com | bash -s -- --port 9090
#
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 默认配置
AGENT_PORT=${AGENT_PORT:-9090}
AGENT_PUBLISH_HOST=${AGENT_PUBLISH_HOST:-127.0.0.1}
MAX_PORT_SCAN_STEPS=${MAX_PORT_SCAN_STEPS:-20}
AGENT_IMAGE=${AGENT_IMAGE:-}
CONTAINER_NAME="flnxi-agent"
REQUESTED_AGENT_PORT=$AGENT_PORT
AGENT_AUTH_USERNAME=${AGENT_AUTH_USERNAME:-}
AGENT_AUTH_PASSWORD=${AGENT_AUTH_PASSWORD:-}
# 打印带颜色的消息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 显示 Banner
show_banner() {
echo -e "${BLUE}"
cat << 'EOF'
_____ _ _ ___ __ ___
| ___| | | \ | \ \/ /|_ _|
| |_ | | | \| |\ / | |
| _| | |___| |\ |/ \ | |
|_| |_____|_| \_/_/\_\|___|
智能运维平台 - 一键部署脚本
EOF
echo -e "${NC}"
echo ""
}
# 检测操作系统
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
elif [ -f /etc/redhat-release ]; then
OS="centos"
else
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
fi
print_info "检测到操作系统: $OS"
}
# 检查是否为 root 用户
check_root() {
if [ "$EUID" -ne 0 ]; then
print_warning "建议使用 root 用户运行此脚本"
print_info "尝试使用 sudo 继续..."
SUDO="sudo"
else
SUDO=""
fi
}
# 检查 Docker 是否已安装
check_docker() {
if command -v docker &> /dev/null; then
DOCKER_VERSION=$(docker --version | cut -d ' ' -f3 | cut -d ',' -f1)
print_success "Docker 已安装 (版本: $DOCKER_VERSION)"
# 设置 docker 命令(根据权限决定是否需要 sudo)
if docker ps &> /dev/null; then
DOCKER="docker"
else
DOCKER="sudo docker"
print_info "将使用 sudo 执行 docker 命令"
fi
return 0
else
return 1
fi
}
# 安装 Docker
install_docker() {
print_info "正在安装 Docker..."
case $OS in
ubuntu|debian)
$SUDO apt-get update
$SUDO apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
curl -fsSL https://download.docker.com/linux/$OS/gpg | $SUDO gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/$OS $(lsb_release -cs) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null
$SUDO apt-get update
$SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
;;
centos|rhel|fedora|rocky|almalinux)
$SUDO yum install -y yum-utils
$SUDO yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
$SUDO yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
;;
*)
print_warning "未知的操作系统,尝试使用官方安装脚本..."
curl -fsSL https://get.docker.com | $SUDO sh
;;
esac
# 启动 Docker 服务
$SUDO systemctl start docker
$SUDO systemctl enable docker
# 将当前用户添加到 docker 组
if [ -n "$SUDO_USER" ]; then
$SUDO usermod -aG docker $SUDO_USER
elif [ "$EUID" -ne 0 ]; then
$SUDO usermod -aG docker $USER
fi
print_success "Docker 安装完成"
}
# 检查指定端口是否被占用
is_port_in_use() {
local port="$1"
if command -v ss &> /dev/null; then
ss -H -tuln | awk '{print $5}' | grep -Eq "(^|\\]|:)$port$"
return $?
fi
if command -v netstat &> /dev/null; then
netstat -tuln 2>/dev/null | awk 'NR>2 {print $4}' | grep -Eq "(^|\\]|:)$port$"
return $?
fi
return 1
}
# 自动选择可用端口
ensure_available_port() {
local requested_port="$AGENT_PORT"
local candidate_port="$requested_port"
local attempt=0
while [ $attempt -le "$MAX_PORT_SCAN_STEPS" ]; do
if ! is_port_in_use "$candidate_port"; then
AGENT_PORT="$candidate_port"
if [ "$AGENT_PORT" != "$requested_port" ]; then
print_warning "端口 $requested_port 已被占用,自动改用 $AGENT_PORT"
else
print_success "端口 $AGENT_PORT 可用"
fi
return 0
fi
attempt=$((attempt + 1))
candidate_port=$((requested_port + attempt))
done
print_error "从端口 $requested_port 开始连续 $((MAX_PORT_SCAN_STEPS + 1)) 个端口都已被占用"
print_info "请使用 --port 参数指定其他端口,或释放对应端口后重试"
exit 1
}
# 停止并删除旧容器
cleanup_old_container() {
if $DOCKER ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
print_info "发现旧的 Agent 容器,正在清理..."
$DOCKER stop $CONTAINER_NAME 2>/dev/null || true
$DOCKER rm $CONTAINER_NAME 2>/dev/null || true
print_success "旧容器已清理"
fi
}
# 拉取并运行 Agent
run_agent() {
if [ -z "$AGENT_IMAGE" ]; then
print_error "未指定 Agent 镜像"
print_info "请通过 AGENT_IMAGE 环境变量或 --image 参数传入完整版本镜像,例如:registry.example.com/flnxi/agent:1.0.1"
exit 1
fi
print_info "正在拉取 Agent 镜像..."
$DOCKER pull $AGENT_IMAGE
print_info "正在启动 Agent..."
run_args=(
-d
--name "$CONTAINER_NAME"
--restart unless-stopped
-p "${AGENT_PUBLISH_HOST}:${AGENT_PORT}:9090"
-v /var/run/docker.sock:/var/run/docker.sock
-v flnxi-data:/data
)
run_args+=(
-e "FLNXI_MATERIAL_IMAGE=${FLNXI_MATERIAL_IMAGE:-$AGENT_IMAGE}"
-e "APP_IMAGE=${APP_IMAGE:-}"
-e "FLNXI_MATERIAL_IMAGE_REPOSITORY=${FLNXI_MATERIAL_IMAGE_REPOSITORY:-}"
-e "FLNXI_POSTGRES_IMAGE=${FLNXI_POSTGRES_IMAGE:-}"
-e "POSTGRES_IMAGE=${POSTGRES_IMAGE:-}"
-e "FLNXI_MINIO_IMAGE=${FLNXI_MINIO_IMAGE:-}"
-e "MINIO_IMAGE=${MINIO_IMAGE:-}"
-e "FLNXI_VERSION_LIST_URL=${FLNXI_VERSION_LIST_URL:-}"
)
if [ -n "$AGENT_AUTH_USERNAME" ] && [ -n "$AGENT_AUTH_PASSWORD" ]; then
run_args+=(
-e "AGENT_AUTH_USERNAME=$AGENT_AUTH_USERNAME"
-e "AGENT_AUTH_PASSWORD=$AGENT_AUTH_PASSWORD"
)
elif [ -n "$AGENT_AUTH_USERNAME" ] || [ -n "$AGENT_AUTH_PASSWORD" ]; then
print_warning "检测到 AGENT_AUTH_USERNAME / AGENT_AUTH_PASSWORD 只配置了一项,已忽略认证参数"
fi
run_args+=("$AGENT_IMAGE")
$DOCKER run "${run_args[@]}"
# 等待容器启动
sleep 2
if $DOCKER ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
print_success "Agent 启动成功"
else
print_error "Agent 启动失败"
$DOCKER logs $CONTAINER_NAME
exit 1
fi
}
# 获取本机 IP
get_local_ip() {
# 尝试多种方式获取 IP
LOCAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -z "$LOCAL_IP" ]; then
LOCAL_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1)
fi
if [ -z "$LOCAL_IP" ]; then
LOCAL_IP="localhost"
fi
echo $LOCAL_IP
}
# 显示完成信息
show_completion() {
LOCAL_IP=$(get_local_ip)
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} FLNXI Agent 部署完成! ${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
if [ "$AGENT_PUBLISH_HOST" = "127.0.0.1" ] || [ "$AGENT_PUBLISH_HOST" = "localhost" ]; then
echo -e "访问地址: ${BLUE}http://127.0.0.1:${AGENT_PORT}${NC}"
echo -e "${YELLOW}当前仅发布到宿主机回环地址。建议通过同机浏览器或反向代理访问。${NC}"
echo -e "${YELLOW}如需局域网直连,请重新部署并设置 --publish-host 0.0.0.0${NC}"
else
echo -e "访问地址: ${BLUE}http://${LOCAL_IP}:${AGENT_PORT}${NC}"
echo -e "${YELLOW}当前已对局域网暴露端口,请确保已配置 Agent 管理认证或前置反向代理。${NC}"
fi
echo ""
echo -e "常用命令:"
echo -e " 查看日志: ${YELLOW}docker logs -f $CONTAINER_NAME${NC}"
echo -e " 重启服务: ${YELLOW}docker restart $CONTAINER_NAME${NC}"
echo -e " 停止服务: ${YELLOW}docker stop $CONTAINER_NAME${NC}"
echo -e " 卸载服务: ${YELLOW}docker rm -f $CONTAINER_NAME${NC}"
echo ""
echo -e "请在浏览器中打开上述地址,完成系统安装向导。"
echo ""
}
# 解析命令行参数
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
--port)
AGENT_PORT="$2"
shift 2
;;
--image)
AGENT_IMAGE="$2"
shift 2
;;
--publish-host)
AGENT_PUBLISH_HOST="$2"
shift 2
;;
--name)
CONTAINER_NAME="$2"
shift 2
;;
--help|-h)
show_help
exit 0
;;
*)
print_error "未知参数: $1"
show_help
exit 1
;;
esac
done
}
# 显示帮助信息
show_help() {
echo "FLNXI 一键部署脚本"
echo ""
echo "用法: curl -sSL https://install.flnxi.com | bash -s -- [选项]"
echo ""
echo "选项:"
echo " --port PORT 指定 Agent 端口 (默认: 9090)"
echo " --publish-host 指定宿主机发布地址 (默认: 127.0.0.1)"
echo " --image IMAGE 指定 Agent 镜像,必须是完整版本镜像,例如 registry.example.com/flnxi/agent:1.0.1"
echo " --name NAME 指定容器名称 (默认: flnxi-agent)"
echo " --help, -h 显示帮助信息"
echo ""
echo "环境变量:"
echo " AGENT_IMAGE 必填;完整 Agent 版本镜像"
echo " AGENT_AUTH_USERNAME / AGENT_AUTH_PASSWORD 可选;若两者都设置,安装脚本会自动透传到容器"
echo ""
echo "示例:"
echo " curl -sSL https://install.flnxi.com | bash -s -- --image registry.example.com/flnxi/agent:1.0.1"
echo " curl -sSL https://install.flnxi.com | bash -s -- --image registry.example.com/flnxi/agent:1.0.1 --port 8080"
echo " curl -sSL https://install.flnxi.com | bash -s -- --image registry.example.com/flnxi/agent:1.0.1 --publish-host 0.0.0.0"
}
# 主函数
main() {
show_banner
parse_args "$@"
print_info "开始部署 FLNXI Agent..."
echo ""
# 检测环境
detect_os
check_root
# 检查并安装 Docker
if ! check_docker; then
print_info "Docker 未安装,开始安装..."
install_docker
fi
# 检查端口并自动选择可用端口
ensure_available_port
# 清理旧容器
cleanup_old_container
# 运行 Agent
run_agent
# 显示完成信息
show_completion
}
# 运行主函数
main "$@"

View File

@ -0,0 +1,130 @@
package api
import (
"crypto/subtle"
"encoding/base64"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
const agentAuthRealm = "FLNXI Agent"
const agentAuthConfigRequiredMessage = "Agent 管理认证未配置,管理面默认关闭"
type AgentAuthConfig struct {
Username string
Password string
IsConfigured bool
IsPartial bool
}
func LoadAgentAuthConfig() AgentAuthConfig {
username := strings.TrimSpace(os.Getenv("AGENT_AUTH_USERNAME"))
password := os.Getenv("AGENT_AUTH_PASSWORD")
return AgentAuthConfig{
Username: username,
Password: password,
IsConfigured: username != "" && password != "",
IsPartial: (username != "" && password == "") || (username == "" && password != ""),
}
}
func LogAgentSecurityConfig(config AgentAuthConfig) {
if config.IsPartial {
logMessage := "[Security] AGENT_AUTH_USERNAME 和 AGENT_AUTH_PASSWORD 需要同时设置;当前仅保留 /api/health,其余 Agent 管理面已关闭"
println(logMessage)
return
}
if config.IsConfigured {
println("[Security] Agent 管理认证已启用")
return
}
println("[Security] Agent 管理认证未配置;当前仅保留 /api/health,其余 Agent 管理面默认关闭")
}
func safeEqual(left, right string) bool {
leftBytes := []byte(left)
rightBytes := []byte(right)
if len(leftBytes) != len(rightBytes) {
return false
}
return subtle.ConstantTimeCompare(leftBytes, rightBytes) == 1
}
func parseBasicAuth(header string) (string, string, bool) {
if !strings.HasPrefix(header, "Basic ") {
return "", "", false
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, "Basic "))
if err != nil {
return "", "", false
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
func isAuthorized(header string, config AgentAuthConfig) bool {
if !config.IsConfigured {
return false
}
username, password, ok := parseBasicAuth(header)
if !ok {
return false
}
return safeEqual(username, config.Username) && safeEqual(password, config.Password)
}
func AgentSecurityMiddleware(config AgentAuthConfig) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "same-origin")
c.Header("Cache-Control", "no-store")
if c.Request.URL.Path == "/api/health" {
c.Next()
return
}
if !config.IsConfigured {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": agentAuthConfigRequiredMessage})
} else {
c.String(http.StatusServiceUnavailable, agentAuthConfigRequiredMessage)
}
c.Abort()
return
}
if !isAuthorized(c.GetHeader("Authorization"), config) {
c.Header("WWW-Authenticate", `Basic realm="`+agentAuthRealm+`", charset="UTF-8"`)
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要 Agent 管理认证"})
} else {
c.String(http.StatusUnauthorized, "需要 Agent 管理认证")
}
c.Abort()
return
}
c.Next()
}
}

View File

@ -0,0 +1,824 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
"flnxi-agent/internal/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin"
)
// hostIP 存储从请求中获取的宿主机 IP
var hostIP string
var hostIPMutex sync.RWMutex
// getHostIP 获取宿主机 IP(优先使用从请求中获取的 IP)
func getHostIP() string {
hostIPMutex.RLock()
ip := hostIP
hostIPMutex.RUnlock()
if ip != "" {
return ip
}
// 回退:尝试获取本机 IP(在容器内可能不准确)
return getLocalIP()
}
// setHostIP 设置宿主机 IP
func setHostIP(ip string) {
hostIPMutex.Lock()
hostIP = ip
hostIPMutex.Unlock()
}
// extractHostIP 从请求中提取宿主机 IP
// 优先从请求的 Host 头获取(用户访问 Agent 的地址就是宿主机地址)
func extractHostIP(c *gin.Context) string {
// 1. 从 Host 头获取(最可靠,因为用户是通过宿主机 IP 访问 Agent 的)
host := c.Request.Host
if host != "" {
// 去掉端口号
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
// 验证是有效的 IP 地址
if ip := net.ParseIP(host); ip != nil && !ip.IsLoopback() {
return host
}
}
// 2. 从 X-Forwarded-For 获取
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if len(parts) > 0 {
ip := strings.TrimSpace(parts[0])
if parsed := net.ParseIP(ip); parsed != nil && !parsed.IsLoopback() {
return ip
}
}
}
// 3. 从 X-Real-IP 获取
if xri := c.GetHeader("X-Real-IP"); xri != "" {
if parsed := net.ParseIP(xri); parsed != nil && !parsed.IsLoopback() {
return xri
}
}
return ""
}
// getLocalIP 获取本机 IP 地址(回退方案)
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "localhost"
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
return "localhost"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func resolveInstallImages(req InstallRequest) (string, string, string, error) {
materialImage := firstNonEmpty(req.MaterialImage, os.Getenv(envMaterialImage), os.Getenv(envMaterialImageCompat))
if materialImage == "" {
return "", "", "", fmt.Errorf("%s or %s is required", envMaterialImage, envMaterialImageCompat)
}
postgresImage := firstNonEmpty(req.PostgresImage, os.Getenv(envPostgresImage), os.Getenv(envPostgresImageCompat), "postgres:15-alpine")
minioImage := firstNonEmpty(req.MinioImage, os.Getenv(envMinioImage), os.Getenv(envMinioImageCompat), "minio/minio:RELEASE.2025-04-22T22-12-26Z")
return materialImage, postgresImage, minioImage, nil
}
func resolveUpdateImage(req UpdateRequest) (string, error) {
if image := firstNonEmpty(req.Image); image != "" {
return image, nil
}
repository := firstNonEmpty(os.Getenv(envMaterialImageRepository))
if repository == "" {
if current := firstNonEmpty(os.Getenv(envMaterialImage), os.Getenv(envMaterialImageCompat)); current != "" {
if idx := strings.LastIndex(current, ":"); idx > strings.LastIndex(current, "/") {
repository = current[:idx]
}
}
}
if repository == "" {
return "", fmt.Errorf("%s is required when update image is not provided", envMaterialImageRepository)
}
version := strings.TrimSpace(req.Version)
if version == "" {
return "", fmt.Errorf("version is required")
}
if version == "latest" {
return "", fmt.Errorf("latest is not allowed for customer updates; use a fixed version tag")
}
return repository + ":" + version, nil
}
const (
envMaterialImage = "FLNXI_MATERIAL_IMAGE"
envMaterialImageCompat = "APP_IMAGE"
envMaterialImageRepository = "FLNXI_MATERIAL_IMAGE_REPOSITORY"
envPostgresImage = "FLNXI_POSTGRES_IMAGE"
envPostgresImageCompat = "POSTGRES_IMAGE"
envMinioImage = "FLNXI_MINIO_IMAGE"
envMinioImageCompat = "MINIO_IMAGE"
envVersionListURL = "FLNXI_VERSION_LIST_URL"
)
var (
installStatus = &InstallStatus{Status: "idle"}
updateStatus = &UpdateStatus{Status: "idle"}
statusMutex sync.RWMutex
)
type InstallStatus struct {
Status string `json:"status"`
Step string `json:"step"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
type UpdateStatus struct {
Status string `json:"status"`
Step string `json:"step"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
type InstallRequest struct {
LicenseKey string `json:"licenseKey"`
DataPath string `json:"dataPath"`
WebPort int `json:"webPort"`
AuthToken string `json:"authToken,omitempty"`
MaterialImage string `json:"materialImage,omitempty"`
PostgresImage string `json:"postgresImage,omitempty"`
MinioImage string `json:"minioImage,omitempty"`
}
type UpdateRequest struct {
Version string `json:"version"`
AuthToken string `json:"authToken"`
Image string `json:"image,omitempty"`
}
func GetSystemInfo(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := dc.GetDockerInfo(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"os": runtime.GOOS, "arch": runtime.GOARCH,
"dockerVersion": info.ServerVersion, "containers": info.Containers,
"containersRunning": info.ContainersRunning, "images": info.Images,
"memTotal": info.MemTotal, "cpus": info.NCPU,
})
}
}
func Install(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
var req InstallRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 从请求中获取宿主机 IP(用户访问 Agent UI 的地址)
clientIP := extractHostIP(c)
if clientIP != "" {
setHostIP(clientIP)
}
statusMutex.Lock()
if installStatus.Status == "running" {
statusMutex.Unlock()
c.JSON(http.StatusConflict, gin.H{"error": "安装正在进行中"})
return
}
installStatus = &InstallStatus{Status: "running", Step: "初始化", Progress: 0}
statusMutex.Unlock()
go runInstall(dc, req)
c.JSON(http.StatusOK, gin.H{"message": "安装已开始"})
}
}
func GetInstallStatus(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
statusMutex.RLock()
defer statusMutex.RUnlock()
c.JSON(http.StatusOK, installStatus)
}
}
func runInstall(dc *docker.Client, req InstallRequest) {
ctx := context.Background()
setStep := func(step string, progress int, msg string) {
statusMutex.Lock()
installStatus.Step, installStatus.Progress, installStatus.Message = step, progress, msg
statusMutex.Unlock()
}
setErr := func(e string) {
statusMutex.Lock()
installStatus.Status, installStatus.Error = "failed", e
statusMutex.Unlock()
}
authStr := ""
if req.AuthToken != "" {
b, _ := json.Marshal(map[string]string{"username": "token", "password": req.AuthToken})
authStr = base64.URLEncoding.EncodeToString(b)
}
secrets, err := generateInstallSecrets()
if err != nil {
setErr("生成安装凭据失败: " + err.Error())
return
}
materialImage, postgresImage, minioImage, err := resolveInstallImages(req)
if err != nil {
setErr("安装镜像配置缺失: " + err.Error())
return
}
// 拉取素材系统镜像 (0-30%)
setStep("拉取镜像", 1, "正在拉取素材系统镜像...")
err = dc.PullImageWithProgress(ctx, materialImage, authStr, func(current, total int64, status string) {
pct := 1
if total > 0 {
pct = int(float64(current)/float64(total)*29) + 1
}
setStep("拉取镜像", pct, "素材系统: "+status)
})
if err != nil {
setErr("拉取镜像失败: " + err.Error())
return
}
// 拉取数据库镜像 (30-50%)
setStep("拉取镜像", 30, "正在拉取数据库镜像...")
err = dc.PullImageWithProgress(ctx, postgresImage, "", func(current, total int64, status string) {
pct := 30
if total > 0 {
pct = int(float64(current)/float64(total)*20) + 30
}
setStep("拉取镜像", pct, "数据库: "+status)
})
if err != nil {
setErr("拉取数据库镜像失败: " + err.Error())
return
}
// 拉取存储镜像 (50-60%)
setStep("拉取镜像", 50, "正在拉取存储镜像...")
err = dc.PullImageWithProgress(ctx, minioImage, "", func(current, total int64, status string) {
pct := 50
if total > 0 {
pct = int(float64(current)/float64(total)*10) + 50
}
setStep("拉取镜像", pct, "存储服务: "+status)
})
if err != nil {
setErr("拉取存储镜像失败: " + err.Error())
return
}
setStep("准备环境", 60, "创建数据目录...")
webPort := req.WebPort
if webPort == 0 {
webPort = 3000
}
dataPath := req.DataPath
if dataPath == "" {
dataPath = "/opt/flnxi"
}
// 创建数据目录并设置权限(通过 alpine 容器执行)
if err := dc.ExecOnHost(ctx, "mkdir -p "+dataPath+"/postgres "+dataPath+"/minio "+dataPath+"/material && chmod -R 777 "+dataPath); err != nil {
setErr("创建数据目录失败: " + err.Error())
return
}
// 创建 Docker 网络
setStep("准备环境", 62, "创建 Docker 网络...")
if err := dc.CreateNetworkIfNotExists(ctx, "flnxi-network"); err != nil {
setErr("创建网络失败: " + err.Error())
return
}
// 启动数据库容器
setStep("启动服务", 65, "启动数据库...")
dbCfg := &container.Config{
Image: postgresImage,
Env: []string{
"POSTGRES_USER=" + secrets.DbUser,
"POSTGRES_PASSWORD=" + secrets.DbPassword,
"POSTGRES_DB=" + secrets.DbName,
},
}
dbHcfg := &container.HostConfig{
Binds: []string{dataPath + "/postgres:/var/lib/postgresql/data"},
NetworkMode: "flnxi-network",
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
}
if _, err := dc.CreateAndStartContainer(ctx, dbCfg, dbHcfg, "flnxi-db"); err != nil {
setErr("启动数据库失败: " + err.Error())
return
}
// 启动 MinIO 存储(暴露 9000 端口供浏览器访问)
setStep("启动服务", 75, "启动存储服务...")
minioCfg := &container.Config{
Image: minioImage,
Env: []string{
"MINIO_ROOT_USER=" + secrets.MinioRootUser,
"MINIO_ROOT_PASSWORD=" + secrets.MinioRootPassword,
},
Cmd: []string{"server", "/data", "--console-address", ":9001"},
ExposedPorts: nat.PortSet{"9000/tcp": struct{}{}, "9001/tcp": struct{}{}},
}
minioHcfg := &container.HostConfig{
Binds: []string{dataPath + "/minio:/data"},
NetworkMode: "flnxi-network",
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
PortBindings: nat.PortMap{
"9000/tcp": []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "9000"}},
"9001/tcp": []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "9001"}},
},
}
if _, err := dc.CreateAndStartContainer(ctx, minioCfg, minioHcfg, "flnxi-minio"); err != nil {
setErr("启动存储服务失败: " + err.Error())
return
}
// 等待数据库就绪
setStep("启动服务", 80, "等待数据库就绪...")
time.Sleep(5 * time.Second)
// 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP)
localIP := getHostIP()
// 启动素材系统
setStep("启动服务", 90, "启动素材系统...")
cfg := &container.Config{
Image: materialImage,
User: "0", // 以 root 用户运行,确保有权限写入挂载目录
Env: []string{
"LICENSE_KEY=" + req.LicenseKey,
"DATABASE_URL=postgresql://" + secrets.DbUser + ":" + secrets.DbPassword + "@flnxi-db:5432/" + secrets.DbName,
// S3/MinIO 配置(内部访问使用 Docker 网络)
"S3_ENDPOINT=http://flnxi-minio:9000",
"S3_ACCESS_KEY_ID=" + secrets.MinioRootUser,
"S3_SECRET_ACCESS_KEY=" + secrets.MinioRootPassword,
"S3_BUCKET=" + secrets.S3Bucket,
"S3_REGION=us-east-1",
"S3_ENABLE_PATH_STYLE=1",
// S3 公开访问配置(浏览器访问使用宿主机 IP)
"S3_PUBLIC_DOMAIN=http://" + localIP + ":9000/" + secrets.S3Bucket,
"SERVER_IP=" + localIP,
"MINIO_API_PORT=9000",
// NextAuth 配置
"NEXTAUTH_SECRET=" + secrets.NextAuthSecret,
"NEXTAUTH_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort),
"APP_PUBLIC_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort),
},
ExposedPorts: nat.PortSet{"3000/tcp": struct{}{}},
}
hcfg := &container.HostConfig{
PortBindings: nat.PortMap{"3000/tcp": []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: fmt.Sprintf("%d", webPort)}}},
Binds: []string{dataPath + "/material:/app/data"},
NetworkMode: "flnxi-network",
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
}
if _, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, "flnxi-material"); err != nil {
setErr("启动素材系统失败: " + err.Error())
return
}
statusMutex.Lock()
installStatus.Status, installStatus.Step, installStatus.Progress, installStatus.Message = "success", "完成", 100, "安装完成"
statusMutex.Unlock()
}
func Update(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
var req UpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
statusMutex.Lock()
if updateStatus.Status == "running" {
statusMutex.Unlock()
c.JSON(http.StatusConflict, gin.H{"error": "更新正在进行中"})
return
}
updateStatus = &UpdateStatus{Status: "running", Step: "初始化", Progress: 0}
statusMutex.Unlock()
go runUpdate(dc, req)
c.JSON(http.StatusOK, gin.H{"message": "更新已开始"})
}
}
func GetUpdateStatus(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
statusMutex.RLock()
defer statusMutex.RUnlock()
c.JSON(http.StatusOK, updateStatus)
}
}
func runUpdate(dc *docker.Client, req UpdateRequest) {
ctx := context.Background()
setStep := func(step string, progress int, msg string) {
statusMutex.Lock()
updateStatus.Step, updateStatus.Progress, updateStatus.Message = step, progress, msg
statusMutex.Unlock()
}
setErr := func(e string) {
statusMutex.Lock()
updateStatus.Status, updateStatus.Error = "failed", e
statusMutex.Unlock()
}
authStr := ""
if req.AuthToken != "" {
b, _ := json.Marshal(map[string]string{"username": "token", "password": req.AuthToken})
authStr = base64.URLEncoding.EncodeToString(b)
}
setStep("准备更新", 5, "获取当前容器信息...")
cont, err := dc.GetContainer(ctx, "flnxi-material")
if err != nil || cont == nil {
setErr("找不到素材系统容器")
return
}
// 记录旧镜像 ID,用于后续清理
oldImageID := cont.ImageID
image, err := resolveUpdateImage(req)
if err != nil {
setErr("更新镜像配置缺失: " + err.Error())
return
}
setStep("拉取镜像", 10, "正在拉取新版本镜像...")
if err := dc.PullImage(ctx, image, authStr); err != nil {
setErr("拉取镜像失败: " + err.Error())
return
}
setStep("停止服务", 40, "停止当前容器...")
if err := dc.StopContainer(ctx, cont.ID); err != nil {
setErr("停止容器失败: " + err.Error())
return
}
// 保存旧容器的配置信息,用于创建新容器
oldConfig := cont
setStep("清理旧容器", 50, "删除旧容器...")
if err := dc.RemoveContainer(ctx, cont.ID); err != nil {
setErr("删除容器失败: " + err.Error())
return
}
setStep("启动新版本", 60, "创建新容器...")
// 使用旧容器的配置创建新容器
cfg := &container.Config{
Image: image,
User: "0",
Env: oldConfig.Config.Env,
ExposedPorts: oldConfig.Config.ExposedPorts,
}
hcfg := &container.HostConfig{
PortBindings: oldConfig.HostConfig.PortBindings,
Binds: oldConfig.HostConfig.Binds,
NetworkMode: oldConfig.HostConfig.NetworkMode,
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
}
newID, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, "flnxi-material")
if err != nil {
setErr("启动新容器失败: " + err.Error())
return
}
setStep("健康检查", 80, "等待服务就绪...")
if err := dc.WaitForHealthy(ctx, newID, 60*time.Second); err != nil {
setErr("健康检查失败: " + err.Error())
return
}
// 清理旧镜像
setStep("清理旧版本", 90, "清理旧镜像...")
if oldImageID != "" {
// 尝试删除旧镜像,忽略错误(可能被其他容器使用)
_ = dc.RemoveImage(ctx, oldImageID)
}
// 清理悬空镜像
setStep("清理旧版本", 95, "清理悬空镜像...")
_ = dc.PruneImages(ctx)
statusMutex.Lock()
updateStatus.Status, updateStatus.Step, updateStatus.Progress, updateStatus.Message = "success", "完成", 100, "更新完成"
statusMutex.Unlock()
}
func ListContainers(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
containers, err := dc.ListContainers(context.Background(), "flnxi")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]gin.H, 0)
for _, ct := range containers {
result = append(result, gin.H{
"id": ct.ID[:12], "name": ct.Names[0], "image": ct.Image,
"state": ct.State, "status": ct.Status, "created": ct.Created,
})
}
c.JSON(http.StatusOK, result)
}
}
func RestartContainer(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
cont, err := dc.GetContainer(context.Background(), name)
if err != nil || cont == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "容器不存在"})
return
}
if err := dc.RestartContainer(context.Background(), cont.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "重启成功"})
}
}
func StopContainer(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
cont, err := dc.GetContainer(context.Background(), name)
if err != nil || cont == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "容器不存在"})
return
}
if err := dc.StopContainer(context.Background(), cont.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "停止成功"})
}
}
func StartContainer(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
cont, err := dc.GetContainer(context.Background(), name)
if err != nil || cont == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "容器不存在"})
return
}
if err := dc.StartContainer(context.Background(), cont.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "启动成功"})
}
}
func HealthCheck(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
_, err := dc.GetDockerInfo(context.Background())
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "error": "Docker 连接失败"})
return
}
cont, _ := dc.GetContainer(context.Background(), "flnxi-material")
materialStatus := "not_found"
if cont != nil {
materialStatus = cont.State
}
c.JSON(http.StatusOK, gin.H{"status": "healthy", "agent": os.Getenv("AGENT_VERSION"), "material": materialStatus})
}
}
// GetVersions returns versions from a configured version-list endpoint.
func GetVersions(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
versionListURL := strings.TrimSpace(os.Getenv(envVersionListURL))
if versionListURL == "" {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "not_configured",
})
return
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", versionListURL, nil)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "fallback",
})
return
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "fallback",
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "fallback",
})
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "fallback",
})
return
}
var tagsResp struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal(body, &tagsResp); err != nil {
c.JSON(http.StatusOK, gin.H{
"versions": []string{},
"latest": "",
"source": "fallback",
})
return
}
// 过滤并排序版本号
versions := filterAndSortVersions(tagsResp.Tags)
latest := findLatestVersion(versions)
c.JSON(http.StatusOK, gin.H{
"versions": versions,
"latest": latest,
"source": "configured",
})
}
}
// filterAndSortVersions 过滤并排序版本号
func filterAndSortVersions(tags []string) []string {
var versions []string
for _, tag := range tags {
if tag == "latest" {
continue
}
// 只保留语义化版本号 (如 1.0.0, 1.0.1)
if isSemanticVersion(tag) {
versions = append(versions, tag)
}
}
// 按版本号降序排序
sortVersionsDesc(versions)
return versions
}
// isSemanticVersion 检查是否是语义化版本号
func isSemanticVersion(tag string) bool {
parts := splitVersion(tag)
return len(parts) >= 2
}
// splitVersion 分割版本号
func splitVersion(v string) []int {
var parts []int
for _, p := range splitString(v, '.') {
n := 0
for _, c := range p {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
break
}
}
parts = append(parts, n)
}
return parts
}
// splitString 简单的字符串分割
func splitString(s string, sep byte) []string {
var result []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == sep {
result = append(result, s[start:i])
start = i + 1
}
}
result = append(result, s[start:])
return result
}
// sortVersionsDesc 按版本号降序排序
func sortVersionsDesc(versions []string) {
for i := 0; i < len(versions)-1; i++ {
for j := i + 1; j < len(versions); j++ {
if compareVersions(versions[i], versions[j]) < 0 {
versions[i], versions[j] = versions[j], versions[i]
}
}
}
}
// compareVersions 比较两个版本号
func compareVersions(v1, v2 string) int {
p1 := splitVersion(v1)
p2 := splitVersion(v2)
maxLen := len(p1)
if len(p2) > maxLen {
maxLen = len(p2)
}
for i := 0; i < maxLen; i++ {
n1, n2 := 0, 0
if i < len(p1) {
n1 = p1[i]
}
if i < len(p2) {
n2 = p2[i]
}
if n1 != n2 {
return n1 - n2
}
}
return 0
}
// findLatestVersion 找到最新的语义化版本
func findLatestVersion(versions []string) string {
for _, v := range versions {
if v != "latest" && isSemanticVersion(v) {
return v
}
}
if len(versions) > 0 {
return versions[0]
}
return ""
}

View File

@ -0,0 +1,62 @@
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
)
type InstallSecrets struct {
DbUser string
DbPassword string
DbName string
MinioRootUser string
MinioRootPassword string
S3Bucket string
NextAuthSecret string
}
func generateInstallSecrets() (InstallSecrets, error) {
dbPassword, err := randomBase64URL(18)
if err != nil {
return InstallSecrets{}, err
}
minioPassword, err := randomBase64URL(18)
if err != nil {
return InstallSecrets{}, err
}
nextAuthSecret, err := randomHex(32)
if err != nil {
return InstallSecrets{}, err
}
return InstallSecrets{
DbUser: "flnxi",
DbPassword: dbPassword,
DbName: "flnxi",
MinioRootUser: "flnxi",
MinioRootPassword: minioPassword,
S3Bucket: "flnxi",
NextAuthSecret: nextAuthSecret,
}, nil
}
func randomBase64URL(byteLength int) (string, error) {
bytes := make([]byte, byteLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func randomHex(byteLength int) (string, error) {
bytes := make([]byte, byteLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@ -0,0 +1,347 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
type Client struct {
cli *client.Client
}
func NewClient() (*Client, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &Client{cli: cli}, nil
}
func (c *Client) Close() error {
return c.cli.Close()
}
// PullImage 拉取镜像
func (c *Client) PullImage(ctx context.Context, image string, authStr string) error {
return c.PullImageWithProgress(ctx, image, authStr, nil)
}
// PullImageWithProgress 拉取镜像并报告进度
func (c *Client) PullImageWithProgress(ctx context.Context, image string, authStr string, onProgress func(current, total int64, status string)) error {
opts := types.ImagePullOptions{}
if authStr != "" {
opts.RegistryAuth = authStr
}
reader, err := c.cli.ImagePull(ctx, image, opts)
if err != nil {
return err
}
defer reader.Close()
// 解析 Docker 拉取进度
decoder := json.NewDecoder(reader)
layerProgress := make(map[string]int64)
layerTotal := make(map[string]int64)
for {
var event struct {
Status string `json:"status"`
ID string `json:"id"`
Progress string `json:"progress"`
ProgressDetail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
}
if err := decoder.Decode(&event); err != nil {
if err == io.EOF {
break
}
continue
}
if event.ID != "" && event.ProgressDetail.Total > 0 {
layerProgress[event.ID] = event.ProgressDetail.Current
layerTotal[event.ID] = event.ProgressDetail.Total
}
if onProgress != nil {
var current, total int64
for id, t := range layerTotal {
total += t
current += layerProgress[id]
}
status := event.Status
if event.ID != "" {
status = event.Status + " " + event.ID
}
onProgress(current, total, status)
}
}
return nil
}
// ListContainers 列出容器
func (c *Client) ListContainers(ctx context.Context, nameFilter string) ([]types.Container, error) {
opts := types.ContainerListOptions{All: true}
if nameFilter != "" {
opts.Filters = filters.NewArgs(filters.Arg("name", nameFilter))
}
return c.cli.ContainerList(ctx, opts)
}
// ContainerInfo 容器详细信息
type ContainerInfo struct {
ID string
ImageID string
State string
Config *container.Config
HostConfig *container.HostConfig
}
// GetContainer 获取容器信息
func (c *Client) GetContainer(ctx context.Context, name string) (*ContainerInfo, error) {
containers, err := c.ListContainers(ctx, name)
if err != nil {
return nil, err
}
for _, cont := range containers {
for _, n := range cont.Names {
if n == "/"+name || n == name {
// 获取详细信息
inspect, err := c.cli.ContainerInspect(ctx, cont.ID)
if err != nil {
return nil, err
}
return &ContainerInfo{
ID: cont.ID,
ImageID: cont.ImageID,
State: cont.State,
Config: inspect.Config,
HostConfig: inspect.HostConfig,
}, nil
}
}
}
return nil, nil
}
// StopContainer 停止容器
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
timeout := 30
return c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
}
// RemoveContainer 删除容器
func (c *Client) RemoveContainer(ctx context.Context, containerID string) error {
return c.cli.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{Force: true})
}
// StartContainer 启动容器
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
return c.cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{})
}
// RestartContainer 重启容器
func (c *Client) RestartContainer(ctx context.Context, containerID string) error {
timeout := 30
return c.cli.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
}
// CreateAndStartContainer 创建并启动容器(如果已存在则跳过创建,直接启动)
func (c *Client) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) {
// 检查容器是否已存在
existing, err := c.GetContainer(ctx, name)
if err != nil {
return "", err
}
if existing != nil {
// 容器已存在,确保它在运行
if existing.State != "running" {
if err := c.cli.ContainerStart(ctx, existing.ID, types.ContainerStartOptions{}); err != nil {
return "", err
}
}
return existing.ID, nil
}
// 容器不存在,创建新容器
resp, err := c.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
if err != nil {
return "", err
}
if err := c.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
return "", err
}
return resp.ID, nil
}
// WaitForHealthy 等待容器健康
func (c *Client) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
inspect, err := c.cli.ContainerInspect(ctx, containerID)
if err != nil {
return err
}
if inspect.State.Health != nil && inspect.State.Health.Status == "healthy" {
return nil
}
if inspect.State.Running {
// 如果没有健康检查,只要运行就算成功
if inspect.State.Health == nil {
return nil
}
}
time.Sleep(2 * time.Second)
}
return nil
}
// GetDockerInfo 获取 Docker 信息
func (c *Client) GetDockerInfo(ctx context.Context) (types.Info, error) {
return c.cli.Info(ctx)
}
// CreateNetworkIfNotExists 创建网络(如果不存在)
func (c *Client) CreateNetworkIfNotExists(ctx context.Context, name string) error {
networks, err := c.cli.NetworkList(ctx, types.NetworkListOptions{
Filters: filters.NewArgs(filters.Arg("name", name)),
})
if err != nil {
return err
}
for _, n := range networks {
if n.Name == name {
return nil // 网络已存在
}
}
_, err = c.cli.NetworkCreate(ctx, name, types.NetworkCreate{
Driver: "bridge",
})
return err
}
// RemoveImage 删除镜像
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.cli.ImageRemove(ctx, imageID, types.ImageRemoveOptions{Force: false, PruneChildren: true})
return err
}
// PruneImages 清理悬空镜像
func (c *Client) PruneImages(ctx context.Context) error {
_, err := c.cli.ImagesPrune(ctx, filters.NewArgs(filters.Arg("dangling", "true")))
return err
}
// ExecInContainer 在容器内执行命令
func (c *Client) ExecInContainer(ctx context.Context, containerName string, cmd []string) error {
// 获取容器
cont, err := c.GetContainer(ctx, containerName)
if err != nil || cont == nil {
return fmt.Errorf("容器 %s 不存在", containerName)
}
// 创建 exec 实例
execConfig := types.ExecConfig{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execResp, err := c.cli.ContainerExecCreate(ctx, cont.ID, execConfig)
if err != nil {
return err
}
// 执行命令
if err := c.cli.ContainerExecStart(ctx, execResp.ID, types.ExecStartCheck{}); err != nil {
return err
}
// 等待执行完成
for {
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
if err != nil {
return err
}
if !inspect.Running {
if inspect.ExitCode != 0 {
return fmt.Errorf("命令执行失败,退出码: %d", inspect.ExitCode)
}
break
}
time.Sleep(100 * time.Millisecond)
}
return nil
}
// ExecOnHost 在主机上执行命令(通过临时容器)
func (c *Client) ExecOnHost(ctx context.Context, cmd string) error {
// 使用可配置的 alpine 镜像,默认走官方镜像,不再绑定旧 Harbor
alpineImage := os.Getenv("ALPINE_IMAGE")
if alpineImage == "" {
alpineImage = "alpine:3.21"
}
// 先拉取镜像
if err := c.PullImage(ctx, alpineImage, ""); err != nil {
return fmt.Errorf("拉取 alpine 镜像失败: %w", err)
}
cfg := &container.Config{
Image: alpineImage,
Cmd: []string{"sh", "-c", cmd},
}
hcfg := &container.HostConfig{
Binds: []string{"/:/host"},
AutoRemove: true,
}
// 修改命令,使其在 /host 目录下执行
cfg.Cmd = []string{"sh", "-c", "cd /host && " + cmd}
resp, err := c.cli.ContainerCreate(ctx, cfg, hcfg, nil, nil, "")
if err != nil {
return err
}
if err := c.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
return err
}
// 等待容器完成
statusCh, errCh := c.cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
return err
}
case status := <-statusCh:
if status.StatusCode != 0 {
return fmt.Errorf("命令执行失败,退出码: %d", status.StatusCode)
}
}
return nil
}

98
flnxi-agent-go/main.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"os"
"flnxi-agent/internal/api"
"flnxi-agent/internal/docker"
"github.com/gin-gonic/gin"
)
//go:embed web/dist/*
var webFS embed.FS
func main() {
// 初始化 Docker 客户端
dockerClient, err := docker.NewClient()
if err != nil {
log.Fatalf("无法连接 Docker: %v", err)
}
defer dockerClient.Close()
// 设置 Gin 模式
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
authConfig := api.LoadAgentAuthConfig()
api.LogAgentSecurityConfig(authConfig)
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
r.Use(api.AgentSecurityMiddleware(authConfig))
// 禁用自动重定向
r.RedirectTrailingSlash = false
r.RedirectFixedPath = false
// API 路由
apiGroup := r.Group("/api")
{
// 系统信息
apiGroup.GET("/info", api.GetSystemInfo(dockerClient))
// 安装相关
apiGroup.POST("/install", api.Install(dockerClient))
apiGroup.GET("/install/status", api.GetInstallStatus(dockerClient))
// 更新相关
apiGroup.POST("/update", api.Update(dockerClient))
apiGroup.GET("/update/status", api.GetUpdateStatus(dockerClient))
apiGroup.GET("/versions", api.GetVersions(dockerClient))
// 容器管理
apiGroup.GET("/containers", api.ListContainers(dockerClient))
apiGroup.POST("/containers/:name/restart", api.RestartContainer(dockerClient))
apiGroup.POST("/containers/:name/stop", api.StopContainer(dockerClient))
apiGroup.POST("/containers/:name/start", api.StartContainer(dockerClient))
// 健康检查
apiGroup.GET("/health", api.HealthCheck(dockerClient))
}
// 静态文件服务
webDist, err := fs.Sub(webFS, "web/dist")
if err != nil {
log.Fatalf("无法加载静态文件: %v", err)
}
// 读取 index.html 内容
indexHTML, err := fs.ReadFile(webDist, "index.html")
if err != nil {
log.Fatalf("无法读取 index.html: %v", err)
}
// 根路径直接返回 index.html 内容
r.GET("/", func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
})
// 静态资源
r.StaticFS("/static", http.FS(webDist))
port := os.Getenv("PORT")
if port == "" {
port = "9090"
}
log.Printf("FLNXI Agent 启动在端口 %s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("启动失败: %v", err)
}
}

628
flnxi-agent-go/web/dist/index.html vendored Normal file
View File

@ -0,0 +1,628 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLNXI 素材系统 - 管理面板</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 520px;
width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 24px;
}
h1 {
color: #333;
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
}
.subtitle {
color: #666;
font-size: 14px;
}
.tabs {
display: flex;
border-bottom: 1px solid #e9ecef;
margin-bottom: 20px;
}
.tab {
flex: 1;
padding: 12px;
text-align: center;
cursor: pointer;
color: #666;
font-size: 14px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover { color: #1890ff; }
.tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: 500;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.info-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
margin-bottom: 20px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid #e9ecef;
}
.info-row:last-child { border-bottom: none; }
.info-label { color: #666; }
.info-value { color: #333; font-weight: 500; }
.form-group { margin-bottom: 16px; }
label {
display: block;
margin-bottom: 6px;
color: #333;
font-size: 14px;
font-weight: 500;
}
.label-hint {
font-weight: normal;
color: #999;
font-size: 12px;
}
input, select {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
input:focus, select:focus {
outline: none;
border-color: #1890ff;
}
input::placeholder { color: #bbb; }
.btn {
width: 100%;
padding: 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 8px;
}
.btn:hover { background: #40a9ff; }
.btn:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-success { background: #52c41a; }
.btn-success:hover { background: #73d13d; }
.btn-warning { background: #faad14; }
.btn-warning:hover { background: #ffc53d; }
.progress { display: none; margin-top: 20px; }
.progress-bar {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #1890ff;
width: 0%;
transition: width 0.3s;
}
.progress-text {
margin-top: 8px;
color: #666;
font-size: 13px;
text-align: center;
}
.status {
padding: 12px;
border-radius: 6px;
margin-top: 16px;
display: none;
font-size: 14px;
}
.status.success { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.status.error { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
.status.info { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.tip {
font-size: 12px;
color: #999;
text-align: center;
margin-top: 16px;
}
.version-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.version-current { background: #e6f7ff; color: #1890ff; }
.version-new { background: #f6ffed; color: #52c41a; }
.update-available {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.update-info { font-size: 13px; color: #d48806; }
.container-list {
margin-top: 16px;
}
.container-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 8px;
}
.container-name { font-weight: 500; color: #333; }
.container-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.container-status.running { background: #f6ffed; color: #52c41a; }
.container-status.stopped { background: #fff2f0; color: #ff4d4f; }
.container-actions button {
padding: 4px 12px;
font-size: 12px;
margin-left: 8px;
border: 1px solid #d9d9d9;
background: white;
border-radius: 4px;
cursor: pointer;
}
.container-actions button:hover { border-color: #1890ff; color: #1890ff; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>FLNXI 素材系统</h1>
<p class="subtitle">管理面板</p>
</div>
<div class="tabs">
<div class="tab active" data-tab="status">系统状态</div>
<div class="tab" data-tab="install">安装</div>
<div class="tab" data-tab="update">更新</div>
</div>
<!-- 系统状态 Tab -->
<div class="tab-content active" id="tab-status">
<div class="info-card" id="sysInfo">
<div class="info-row">
<span class="info-label">系统信息</span>
<span class="info-value">加载中...</span>
</div>
</div>
<div class="container-list" id="containerList">
<div style="text-align: center; color: #999; padding: 20px;">加载中...</div>
</div>
</div>
<!-- 安装 Tab -->
<div class="tab-content" id="tab-install">
<form id="installForm">
<div class="form-group">
<label>许可证密钥 <span class="label-hint">(可选,留空免费使用)</span></label>
<input type="text" id="licenseKey" placeholder="XXXX-XXXX-XXXX-XXXX">
</div>
<div class="form-group">
<label>数据存储路径</label>
<input type="text" id="dataPath" value="/opt/flnxi" placeholder="/opt/flnxi">
</div>
<div class="form-group">
<label>Web 端口</label>
<input type="number" id="webPort" value="3000" min="1" max="65535">
</div>
<button type="submit" class="btn" id="installBtn">开始安装</button>
</form>
<div class="progress" id="installProgress">
<div class="progress-bar"><div class="progress-fill" id="installProgressFill"></div></div>
<p class="progress-text" id="installProgressText">准备中...</p>
</div>
<div class="status" id="installStatus"></div>
<p class="tip">安装过程需要几分钟,请耐心等待</p>
</div>
<!-- 更新 Tab -->
<div class="tab-content" id="tab-update">
<div class="info-card" id="versionInfo">
<div class="info-row">
<span class="info-label">当前版本</span>
<span class="info-value" id="currentVersion">检测中...</span>
</div>
<div class="info-row">
<span class="info-label">最新版本</span>
<span class="info-value" id="latestVersion">检测中...</span>
</div>
</div>
<div class="update-available" id="updateAvailable" style="display: none;">
<span class="update-info">🎉 发现新版本,建议更新</span>
</div>
<div class="form-group">
<label>目标版本</label>
<select id="targetVersion">
<option value="">检测中...</option>
</select>
</div>
<button class="btn btn-success" id="updateBtn" disabled>检查更新中...</button>
<div class="progress" id="updateProgress">
<div class="progress-bar"><div class="progress-fill" id="updateProgressFill"></div></div>
<p class="progress-text" id="updateProgressText">准备中...</p>
</div>
<div class="status" id="updateStatus"></div>
<p class="tip">更新过程中服务会短暂中断,请确保没有用户正在使用</p>
</div>
</div>
<script>
const API = '';
let currentVersion = '';
let latestVersion = '';
// Tab 切换
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
// 获取系统信息
async function getSystemInfo() {
try {
const res = await fetch(API + '/api/info');
const data = await res.json();
document.getElementById('sysInfo').innerHTML = `
<div class="info-row">
<span class="info-label">操作系统</span>
<span class="info-value">${data.os} / ${data.arch}</span>
</div>
<div class="info-row">
<span class="info-label">Docker</span>
<span class="info-value">${data.dockerVersion}</span>
</div>
<div class="info-row">
<span class="info-label">CPU</span>
<span class="info-value">${data.cpus} 核</span>
</div>
<div class="info-row">
<span class="info-label">内存</span>
<span class="info-value">${(data.memTotal / 1024 / 1024 / 1024).toFixed(1)} GB</span>
</div>
`;
} catch (e) {
document.getElementById('sysInfo').innerHTML = `
<div class="info-row">
<span class="info-label">状态</span>
<span class="info-value" style="color:#ff4d4f">无法连接到 Agent</span>
</div>
`;
}
}
// 获取容器列表
async function getContainers() {
try {
const res = await fetch(API + '/api/containers');
const containers = await res.json();
if (containers.length === 0) {
document.getElementById('containerList').innerHTML = `
<div style="text-align: center; color: #999; padding: 20px;">
未检测到已安装的服务,请先安装
</div>
`;
return;
}
let html = '<h4 style="margin-bottom: 12px; color: #333; font-size: 14px;">运行中的服务</h4>';
containers.forEach(c => {
const name = c.name.replace(/^\//, '');
const isRunning = c.state === 'running';
html += `
<div class="container-item">
<div>
<span class="container-name">${name}</span>
<span class="container-status ${isRunning ? 'running' : 'stopped'}">${isRunning ? '运行中' : '已停止'}</span>
</div>
<div class="container-actions">
${isRunning
? `<button onclick="restartContainer('${name}')">重启</button>`
: `<button onclick="startContainer('${name}')">启动</button>`
}
</div>
</div>
`;
// 获取素材系统版本
if (name === 'flnxi-material' && c.image) {
const match = c.image.match(/:([^:]+)$/);
currentVersion = match ? match[1] : 'unknown';
}
});
document.getElementById('containerList').innerHTML = html;
} catch (e) {
document.getElementById('containerList').innerHTML = `
<div style="text-align: center; color: #ff4d4f; padding: 20px;">
获取容器列表失败
</div>
`;
}
}
// 检查版本更新
async function checkVersion() {
try {
// 获取当前版本
await getContainers();
document.getElementById('currentVersion').innerHTML = currentVersion
? `${currentVersion} <span class="version-badge version-current">当前</span>`
: '未安装';
// 从 Agent API 获取可用版本列表
let versions = [];
latestVersion = '';
try {
const versionRes = await fetch(API + '/api/versions');
if (versionRes.ok) {
const versionData = await versionRes.json();
if (versionData.versions && versionData.versions.length > 0) {
versions = versionData.versions;
}
if (versionData.latest) {
latestVersion = versionData.latest;
}
}
} catch (e) {
console.warn('获取版本列表失败:', e);
}
document.getElementById('latestVersion').innerHTML = latestVersion
? `${latestVersion} <span class="version-badge version-new">最新</span>`
: '未配置';
const select = document.getElementById('targetVersion');
select.innerHTML = versions.map(v =>
`<option value="${v}" ${v === latestVersion ? 'selected' : ''}>${v}${v === latestVersion ? ' (推荐)' : ''}</option>`
).join('');
const btn = document.getElementById('updateBtn');
if (versions.length === 0) {
btn.textContent = '未配置版本来源';
btn.disabled = true;
btn.classList.remove('btn-success');
return;
}
if (currentVersion && latestVersion && currentVersion !== latestVersion) {
document.getElementById('updateAvailable').style.display = 'flex';
btn.textContent = '立即更新';
btn.disabled = false;
} else if (currentVersion && latestVersion && currentVersion === latestVersion) {
btn.textContent = '已是最新版本';
btn.disabled = true;
btn.classList.remove('btn-success');
} else {
btn.textContent = '开始更新';
btn.disabled = false;
}
} catch (e) {
console.error('检查版本失败:', e);
}
}
// 执行更新
document.getElementById('updateBtn').addEventListener('click', async () => {
const version = document.getElementById('targetVersion').value;
if (!version) return;
const btn = document.getElementById('updateBtn');
const progress = document.getElementById('updateProgress');
const status = document.getElementById('updateStatus');
btn.disabled = true;
btn.textContent = '更新中...';
progress.style.display = 'block';
status.style.display = 'none';
try {
await fetch(API + '/api/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version, authToken: '' })
});
pollUpdateStatus();
} catch (e) {
showUpdateError('更新请求失败: ' + e.message);
btn.disabled = false;
btn.textContent = '重试更新';
}
});
async function pollUpdateStatus() {
try {
const res = await fetch(API + '/api/update/status');
const data = await res.json();
document.getElementById('updateProgressFill').style.width = data.progress + '%';
document.getElementById('updateProgressText').textContent = data.message || data.step;
if (data.status === 'running') {
setTimeout(pollUpdateStatus, 1000);
} else if (data.status === 'success') {
showUpdateSuccess();
} else if (data.status === 'failed') {
showUpdateError(data.error);
}
} catch (e) {
showUpdateError('获取状态失败');
}
}
function showUpdateSuccess() {
const status = document.getElementById('updateStatus');
status.className = 'status success';
status.innerHTML = '✓ 更新完成!页面将在 3 秒后刷新...';
status.style.display = 'block';
setTimeout(() => location.reload(), 3000);
}
function showUpdateError(msg) {
const status = document.getElementById('updateStatus');
status.className = 'status error';
status.textContent = msg;
status.style.display = 'block';
document.getElementById('updateBtn').disabled = false;
document.getElementById('updateBtn').textContent = '重试更新';
}
// 容器操作
async function restartContainer(name) {
if (!confirm(`确定要重启 ${name} 吗?`)) return;
try {
await fetch(API + `/api/containers/${name}/restart`, { method: 'POST' });
alert('重启成功');
getContainers();
} catch (e) {
alert('重启失败: ' + e.message);
}
}
async function startContainer(name) {
try {
await fetch(API + `/api/containers/${name}/start`, { method: 'POST' });
alert('启动成功');
getContainers();
} catch (e) {
alert('启动失败: ' + e.message);
}
}
// 安装表单
document.getElementById('installForm').onsubmit = async (e) => {
e.preventDefault();
const btn = document.getElementById('installBtn');
const progress = document.getElementById('installProgress');
const status = document.getElementById('installStatus');
btn.disabled = true;
progress.style.display = 'block';
status.style.display = 'none';
try {
await fetch(API + '/api/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey: document.getElementById('licenseKey').value || '',
dataPath: document.getElementById('dataPath').value,
webPort: parseInt(document.getElementById('webPort').value)
})
});
pollInstallStatus();
} catch (e) {
showInstallError('安装请求失败: ' + e.message);
btn.disabled = false;
}
};
async function pollInstallStatus() {
try {
const res = await fetch(API + '/api/install/status');
const data = await res.json();
document.getElementById('installProgressFill').style.width = data.progress + '%';
document.getElementById('installProgressText').textContent = data.message || data.step;
if (data.status === 'running') {
setTimeout(pollInstallStatus, 1000);
} else if (data.status === 'success') {
showInstallSuccess();
} else if (data.status === 'failed') {
showInstallError(data.error);
}
} catch (e) {
showInstallError('获取状态失败');
}
}
function showInstallSuccess() {
const status = document.getElementById('installStatus');
const port = document.getElementById('webPort').value;
status.className = 'status success';
status.innerHTML = `
<div style="margin-bottom: 12px; font-weight: 500;">✓ 安装完成!</div>
<div style="background: #fff; padding: 12px; border-radius: 4px; margin-bottom: 12px;">
<div style="margin-bottom: 8px; color: #333;">访问地址:<a href="http://${location.hostname}:${port}" target="_blank" style="color: #1890ff;">http://${location.hostname}:${port}</a></div>
<div style="border-top: 1px solid #e9ecef; padding-top: 8px; margin-top: 8px;">
<div style="color: #666; font-size: 12px; margin-bottom: 4px;">管理员账号说明:</div>
<div style="color: #333;">初始管理员信息请以当前安装流程返回结果为准。</div>
<div style="color: #333;">系统不再内置固定默认密码。</div>
</div>
</div>
<div style="font-size: 12px; color: #52c41a;">首次登录后请立即完成密码修改</div>
`;
status.style.display = 'block';
document.getElementById('installBtn').disabled = false;
getContainers();
}
function showInstallError(msg) {
const status = document.getElementById('installStatus');
status.className = 'status error';
status.textContent = msg;
status.style.display = 'block';
document.getElementById('installBtn').disabled = false;
}
// 初始化
getSystemInfo();
getContainers();
checkVersion();
</script>
</body>
</html>

View File

@ -1,6 +1,10 @@
# FLNXI Agent
FLNXI 素材系统安装助手,用于在 NAS(飞牛 fnOS、群晖等)上一键安装素材系统。
FLNXI 素材系统历史 Node 版安装助手。
当前定位:Node 版 Agent 只用于查历史实现和维护已有交付链路,不作为新客户默认安装入口。新交付优先评估 `flnxi-agent-go/`,不要把两个 Agent 同时暴露到同一台机器的公网入口。
正式客户安装、升级和发布不要照抄本目录的镜像名或命令。当前项目主线应以 `deploy-registry.ps1`、`docker-compose.prod.yml` 和明确版本号的镜像为准。
## 功能
@ -10,19 +14,27 @@ FLNXI 素材系统安装助手,用于在 NAS(飞牛 fnOS、群晖等)上
- 📈 系统状态监控
- 📝 日志查看
## 使用方法
## 历史调试方式
### Docker 安装(推荐)
### Docker 调试运行
```bash
docker run -d \
--name flnxi-agent \
-p 3080:3080 \
-p 127.0.0.1:3080:3080 \
-v /var/run/docker.sock:/var/run/docker.sock \
-e AGENT_AUTH_USERNAME=admin \
-e AGENT_AUTH_PASSWORD='<generate-a-strong-random-password>' \
flnxi/flnxi-agent:latest
```
然后访问 `http://your-nas-ip:3080` 开始安装。
默认只发布到宿主机回环地址,适合本机浏览器或反向代理访问。
这个镜像名保留的是旧链路习惯,不代表当前客户交付主线已经切到它,也不代表可以用 `latest` 做正式升级依据。
如果确实需要局域网直连,再显式改成 `-p 0.0.0.0:3080:3080`(或你自己的内网 IP)。
必须同时配置 `AGENT_AUTH_USERNAME` 和 `AGENT_AUTH_PASSWORD`。未配置时,除了 `/api/health` 之外的页面、API 和 WebSocket 都会默认关闭。
### 本地开发
@ -43,7 +55,24 @@ npm run build
| 变量 | 说明 | 默认值 |
|------|------|--------|
| PORT | Agent 服务端口 | 3080 |
| LICENSE_SERVER_URL | 许可证服务器地址 | http://45.249.208.118:3001 |
| LICENSE_SERVER_URL | 许可证服务器地址 | https://license.flnxi.com |
| LICENSE_PROJECT_CODE | 当前 Agent 所属项目编码,用于命中对应项目的版本/验证链路 | 未设置 |
| AGENT_AUTH_USERNAME | Agent 管理账号,需和密码同时设置,否则管理面默认关闭 | 未设置 |
| AGENT_AUTH_PASSWORD | Agent 管理密码,需和账号同时设置,否则管理面默认关闭 | 未设置 |
| AGENT_ALLOWED_ORIGINS | 额外允许的 Origin,逗号分隔,主要用于特殊开发/反向代理场景 | 未设置 |
说明:
- `LICENSE_PROJECT_CODE` 未配置时,版本检查链路会报错,不只是“跳过版本检查”。
- 示例里的 `AGENT_AUTH_PASSWORD` 只是占位值,正式环境必须替换成强随机密码。
## 安全建议
- 生产环境必须设置 `AGENT_AUTH_USERNAME` 和 `AGENT_AUTH_PASSWORD`
- Docker 运行时建议默认使用 `-p 127.0.0.1:3080:3080`,仅在明确需要时才改为局域网暴露
- 如果 Agent 需要暴露到局域网,优先通过反向代理或访问控制进一步限制来源
- 未配置认证时,除了 `/api/health` 以外的管理面会直接返回 `503`
- 安装流程会为 PostgreSQL、MinIO 和主应用自动生成随机内部凭据,不再使用源码里写死的弱默认值
## 架构

196
flnxi-agent/src/auth.ts Normal file
View File

@ -0,0 +1,196 @@
import { timingSafeEqual } from 'crypto';
import type { IncomingHttpHeaders, IncomingMessage } from 'http';
import type { NextFunction, Request, Response } from 'express';
const AUTH_REALM = 'FLNXI Agent';
const AUTH_CONFIG_REQUIRED_MESSAGE = 'Agent 管理认证未配置,管理面默认关闭';
const DEV_ALLOWED_ORIGINS = new Set([
'http://localhost:3080',
'http://127.0.0.1:3080',
'http://localhost:5173',
'http://127.0.0.1:5173',
]);
function getAgentAuthConfig() {
const username = process.env.AGENT_AUTH_USERNAME?.trim() || '';
const password = process.env.AGENT_AUTH_PASSWORD || '';
return {
username,
password,
isConfigured: Boolean(username && password),
isPartial: Boolean((username && !password) || (!username && password)),
};
}
function normalizeOrigin(origin: string): string | null {
try {
const url = new URL(origin);
return `${url.protocol}//${url.host}`.toLowerCase();
} catch {
return null;
}
}
function normalizeHost(rawHost?: string | string[] | null): string {
if (!rawHost) return '';
return Array.isArray(rawHost) ? rawHost[0].toLowerCase() : rawHost.toLowerCase();
}
function getAllowedOrigins() {
const explicit = (process.env.AGENT_ALLOWED_ORIGINS || '')
.split(',')
.map((item) => normalizeOrigin(item.trim()))
.filter((item): item is string => Boolean(item));
return new Set([...DEV_ALLOWED_ORIGINS, ...explicit]);
}
function safeEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
}
function parseBasicAuth(header?: string | string[]) {
const rawHeader = Array.isArray(header) ? header[0] : header;
if (!rawHeader || !rawHeader.startsWith('Basic ')) {
return null;
}
try {
const decoded = Buffer.from(rawHeader.slice(6), 'base64').toString('utf8');
const separatorIndex = decoded.indexOf(':');
if (separatorIndex === -1) {
return null;
}
return {
username: decoded.slice(0, separatorIndex),
password: decoded.slice(separatorIndex + 1),
};
} catch {
return null;
}
}
function hasValidCredentials(headers: IncomingHttpHeaders) {
const config = getAgentAuthConfig();
if (!config.isConfigured) {
return false;
}
const credentials = parseBasicAuth(headers.authorization);
if (!credentials) {
return false;
}
return safeEqual(credentials.username, config.username) && safeEqual(credentials.password, config.password);
}
function isOriginAllowed(originHeader: string | undefined, hostHeader?: string | string[]) {
if (!originHeader) {
return true;
}
const normalizedOrigin = normalizeOrigin(originHeader);
if (!normalizedOrigin) {
return false;
}
const originHost = new URL(normalizedOrigin).host;
const normalizedHost = normalizeHost(hostHeader);
if (normalizedHost && originHost === normalizedHost) {
return true;
}
return getAllowedOrigins().has(normalizedOrigin);
}
export function logAgentSecurityConfig() {
const config = getAgentAuthConfig();
if (config.isPartial) {
console.error('[Security] AGENT_AUTH_USERNAME 和 AGENT_AUTH_PASSWORD 需要同时设置;当前仅保留 /api/health,其余 Agent 管理面已关闭');
return;
}
if (config.isConfigured) {
console.log('[Security] Agent 管理认证已启用');
return;
}
console.error('[Security] Agent 管理认证未配置;当前仅保留 /api/health,其余 Agent 管理面默认关闭');
}
export function applySecurityHeaders(_req: Request, res: Response, next: NextFunction) {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Cache-Control', 'no-store');
next();
}
export function requireAgentAuth(req: Request, res: Response, next: NextFunction) {
if (req.path === '/api/health') {
next();
return;
}
const config = getAgentAuthConfig();
if (!config.isConfigured) {
if (req.path.startsWith('/api/')) {
res.status(503).json({ error: AUTH_CONFIG_REQUIRED_MESSAGE });
return;
}
res.status(503).send(AUTH_CONFIG_REQUIRED_MESSAGE);
return;
}
if (!hasValidCredentials(req.headers)) {
res.setHeader('WWW-Authenticate', `Basic realm="${AUTH_REALM}", charset="UTF-8"`);
if (req.path.startsWith('/api/')) {
res.status(401).json({ error: '需要 Agent 管理认证' });
return;
}
res.status(401).send('需要 Agent 管理认证');
return;
}
next();
}
export function allowAgentSocketRequest(
req: IncomingMessage,
callback: (errorMessage: string | null | undefined, success: boolean) => void
) {
const config = getAgentAuthConfig();
if (!config.isConfigured) {
callback(AUTH_CONFIG_REQUIRED_MESSAGE, false);
return;
}
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
const hostHeader = req.headers['x-forwarded-host'] || req.headers.host;
if (!isOriginAllowed(originHeader, hostHeader)) {
callback('Origin not allowed', false);
return;
}
if (!hasValidCredentials(req.headers)) {
callback('Unauthorized', false);
return;
}
callback(undefined, true);
}

View File

@ -1,3 +1,4 @@
import crypto from 'crypto';
import Docker from 'dockerode';
import { EventEmitter } from 'events';
@ -17,6 +18,16 @@ export interface InstallProgress {
type ProgressCallback = (progress: InstallProgress) => void;
type InstallSecrets = {
dbUser: string;
dbPassword: string;
dbName: string;
minioRootUser: string;
minioRootPassword: string;
bucket: string;
nextAuthSecret: string;
};
// Harbor 镜像仓库地址
const HARBOR_REGISTRY = 'harbor.flnxi.com/flnxi';
@ -49,6 +60,7 @@ const CONTAINERS = {
export class Installer extends EventEmitter {
private docker: Docker;
private installSecrets: InstallSecrets | null = null;
constructor() {
super();
@ -80,6 +92,7 @@ export class Installer extends EventEmitter {
*/
async install(config: InstallConfig, onProgress: ProgressCallback): Promise<void> {
const { webPort, dbPort = 5432, minioPort = 9000, dataPath } = config;
this.installSecrets = this.createInstallSecrets();
try {
// Step 1: 拉取镜像(优先 Harbor,失败后 fallback 到 Docker Hub)
@ -130,6 +143,26 @@ export class Installer extends EventEmitter {
}
}
private createInstallSecrets(): InstallSecrets {
return {
dbUser: 'flnxi',
dbPassword: crypto.randomBytes(18).toString('base64url'),
dbName: 'flnxi_material',
minioRootUser: 'flnxi',
minioRootPassword: crypto.randomBytes(18).toString('base64url'),
bucket: 'materials',
nextAuthSecret: crypto.randomBytes(32).toString('hex'),
};
}
private requireInstallSecrets(): InstallSecrets {
if (!this.installSecrets) {
throw new Error('安装凭据未初始化');
}
return this.installSecrets;
}
/**
* 拉取镜像(支持 fallback)
*/
@ -208,14 +241,15 @@ export class Installer extends EventEmitter {
*/
private async startPostgres(dataPath: string, port: number): Promise<void> {
await this.removeContainer(CONTAINERS.postgres);
const secrets = this.requireInstallSecrets();
const container = await this.docker.createContainer({
Image: this.usedImages.postgres,
name: CONTAINERS.postgres,
Env: [
'POSTGRES_USER=flnxi',
'POSTGRES_PASSWORD=flnxi_secret',
'POSTGRES_DB=flnxi_material',
`POSTGRES_USER=${secrets.dbUser}`,
`POSTGRES_PASSWORD=${secrets.dbPassword}`,
`POSTGRES_DB=${secrets.dbName}`,
],
HostConfig: {
Binds: [`${dataPath}/postgres:/var/lib/postgresql/data`],
@ -233,14 +267,15 @@ export class Installer extends EventEmitter {
*/
private async startMinio(dataPath: string, port: number): Promise<void> {
await this.removeContainer(CONTAINERS.minio);
const secrets = this.requireInstallSecrets();
const container = await this.docker.createContainer({
Image: this.usedImages.minio,
name: CONTAINERS.minio,
Cmd: ['server', '/data', '--console-address', ':9001'],
Env: [
'MINIO_ROOT_USER=flnxi',
'MINIO_ROOT_PASSWORD=flnxi_secret',
`MINIO_ROOT_USER=${secrets.minioRootUser}`,
`MINIO_ROOT_PASSWORD=${secrets.minioRootPassword}`,
],
HostConfig: {
Binds: [`${dataPath}/minio:/data`],
@ -279,18 +314,21 @@ export class Installer extends EventEmitter {
*/
private async startMaterial(port: number, dataPath: string): Promise<void> {
await this.removeContainer(CONTAINERS.material);
const secrets = this.requireInstallSecrets();
const container = await this.docker.createContainer({
Image: this.usedImages.material,
name: CONTAINERS.material,
Env: [
'DATABASE_URL=postgresql://flnxi:flnxi_secret@flnxi-postgres:5432/flnxi_material',
`DATABASE_URL=postgresql://${secrets.dbUser}:${secrets.dbPassword}@flnxi-postgres:5432/${secrets.dbName}`,
'S3_ENDPOINT=http://flnxi-minio:9000',
'S3_ACCESS_KEY=flnxi',
'S3_SECRET_KEY=flnxi_secret',
'S3_BUCKET=materials',
`S3_ACCESS_KEY=${secrets.minioRootUser}`,
`S3_SECRET_KEY=${secrets.minioRootPassword}`,
`S3_ACCESS_KEY_ID=${secrets.minioRootUser}`,
`S3_SECRET_ACCESS_KEY=${secrets.minioRootPassword}`,
`S3_BUCKET=${secrets.bucket}`,
'REDIS_URL=redis://flnxi-redis:6379',
'NEXTAUTH_SECRET=flnxi-material-secret-key',
`NEXTAUTH_SECRET=${secrets.nextAuthSecret}`,
`NEXTAUTH_URL=http://localhost:${port}`,
],
HostConfig: {

View File

@ -1,18 +1,20 @@
import express, { Request, Response } from 'express';
import { createServer } from 'http';
import { Server, Socket } from 'socket.io';
import cors from 'cors';
import path from 'path';
import { Installer, InstallProgress } from './installer';
import { SystemManager, UpdateProgress } from './system-manager';
import {
allowAgentSocketRequest,
applySecurityHeaders,
logAgentSecurityConfig,
requireAgentAuth,
} from './auth';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
allowRequest: allowAgentSocketRequest,
});
const PORT = process.env.PORT || 3080;
@ -20,7 +22,9 @@ const installer = new Installer();
const systemManager = new SystemManager();
// 中间件
app.use(cors());
logAgentSecurityConfig();
app.use(applySecurityHeaders);
app.use(requireAgentAuth);
app.use(express.json());
// 静态文件服务(前端)

View File

@ -2,7 +2,8 @@ import Docker from 'dockerode';
import fs from 'fs';
import path from 'path';
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || 'http://45.249.208.118:3001';
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || 'https://license.flnxi.com';
const LICENSE_PROJECT_CODE = process.env.LICENSE_PROJECT_CODE?.trim();
const CONTAINER_NAME = 'flnxi-material';
const AGENT_CONTAINER_NAME = 'flnxi-agent';
@ -47,6 +48,14 @@ export interface UpdateProgress {
type ProgressCallback = (progress: UpdateProgress) => void;
function getVersionApiUrl(action: 'check' | 'agent'): string {
if (!LICENSE_PROJECT_CODE) {
throw new Error('LICENSE_PROJECT_CODE 环境变量未配置');
}
return `${LICENSE_SERVER_URL}/api/version/${encodeURIComponent(LICENSE_PROJECT_CODE)}/${action}`;
}
export class SystemManager {
private docker: Docker;
@ -128,7 +137,7 @@ export class SystemManager {
try {
const currentVersion = await this.getVersion();
const res = await fetch(`${LICENSE_SERVER_URL}/api/version/check`);
const res = await fetch(getVersionApiUrl('check'));
if (!res.ok) {
throw new Error('检查更新失败');
}
@ -341,7 +350,7 @@ export class SystemManager {
try {
const currentVersion = this.getAgentVersion();
const res = await fetch(`${LICENSE_SERVER_URL}/api/version/agent`);
const res = await fetch(getVersionApiUrl('agent'));
if (!res.ok) {
throw new Error('检查 Agent 更新失败');
}