收紧安装 Agent 安全边界
This commit is contained in:
parent
21f2efb0bf
commit
c649b939c4
37
flnxi-agent-go/Dockerfile
Normal file
37
flnxi-agent-go/Dockerfile
Normal 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
97
flnxi-agent-go/README.md
Normal 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"
|
||||
```
|
||||
1
flnxi-agent-go/VERSION.txt
Normal file
1
flnxi-agent-go/VERSION.txt
Normal file
@ -0,0 +1 @@
|
||||
1.0.5
|
||||
46
flnxi-agent-go/build.ps1
Normal file
46
flnxi-agent-go/build.ps1
Normal 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
51
flnxi-agent-go/go.mod
Normal 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
150
flnxi-agent-go/go.sum
Normal 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
385
flnxi-agent-go/install.sh
Normal 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 "$@"
|
||||
130
flnxi-agent-go/internal/api/auth.go
Normal file
130
flnxi-agent-go/internal/api/auth.go
Normal 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()
|
||||
}
|
||||
}
|
||||
824
flnxi-agent-go/internal/api/handlers.go
Normal file
824
flnxi-agent-go/internal/api/handlers.go
Normal 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 ""
|
||||
}
|
||||
62
flnxi-agent-go/internal/api/install_secrets.go
Normal file
62
flnxi-agent-go/internal/api/install_secrets.go
Normal 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
|
||||
}
|
||||
347
flnxi-agent-go/internal/docker/client.go
Normal file
347
flnxi-agent-go/internal/docker/client.go
Normal 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
98
flnxi-agent-go/main.go
Normal 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
628
flnxi-agent-go/web/dist/index.html
vendored
Normal 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>
|
||||
@ -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
196
flnxi-agent/src/auth.ts
Normal 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);
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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());
|
||||
|
||||
// 静态文件服务(前端)
|
||||
|
||||
@ -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 更新失败');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user