feat(updater): generate shared token without init container
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **updater Token 零配置且无额外容器**:主程序首次启动时在共享卷中自动生成 `/run/havline-updater/updater.token`,sidecar 等待并读取同一文件;主服务与 sidecar 支持 `HAVLINE_UPDATER_TOKEN_FILE`,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- **frps 启动失败不再误报成功**:主程序现在保留 Agent 返回的 `ok=false` 与错误内容,`systemctl start` 后 frps 未进入运行状态时会如实返回失败,不再固定提示「启动成功」
|
- **frps 启动失败不再误报成功**:主程序现在保留 Agent 返回的 `ok=false` 与错误内容,`systemctl start` 后 frps 未进入运行状态时会如实返回失败,不再固定提示「启动成功」
|
||||||
@@ -15,6 +19,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
### 测试
|
### 测试
|
||||||
|
|
||||||
- 新增 Agent 操作结果解析、Agent 版本解析、frps vhost 端口冲突校验和隧道提示文案测试
|
- 新增 Agent 操作结果解析、Agent 版本解析、frps vhost 端口冲突校验和隧道提示文案测试
|
||||||
|
- 新增 updater Token 环境变量优先级、共享文件生成与解析、等待超时、缺失文件和空文件测试
|
||||||
- `go test ./...`、`npm run typecheck` 通过
|
- `go test ./...`、`npm run typecheck` 通过
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -6,11 +6,25 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/havline/havline/internal/updater"
|
"github.com/havline/havline/internal/updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
token, tokenSource, err := updater.WaitForToken(
|
||||||
|
envOr("HAVLINE_UPDATER_TOKEN", ""),
|
||||||
|
envOr("HAVLINE_UPDATER_TOKEN_FILE", updater.DefaultTokenPath),
|
||||||
|
2*time.Minute,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
log.Fatal("HAVLINE_UPDATER_TOKEN 或 HAVLINE_UPDATER_TOKEN_FILE 未配置")
|
||||||
|
}
|
||||||
|
log.Printf("updater Token 已就绪:%s", tokenSource)
|
||||||
|
|
||||||
server, err := updater.NewServer(updater.Config{
|
server, err := updater.NewServer(updater.Config{
|
||||||
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
||||||
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
|
ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"),
|
||||||
@@ -24,6 +38,7 @@ func main() {
|
|||||||
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
|
ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""),
|
||||||
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
|
SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""),
|
||||||
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
|
DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"),
|
||||||
|
Token: token,
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 用法:
|
# 用法:
|
||||||
# mkdir -p /opt/havline && cd /opt/havline
|
# mkdir -p /opt/havline && cd /opt/havline
|
||||||
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
|
# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来):
|
||||||
# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址>
|
# # curl -fsSLo docker-compose.hub.yml https://raw.githubusercontent.com/mrlee233/havline/main/docker-compose.hub.yml
|
||||||
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
|
# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里
|
||||||
#
|
#
|
||||||
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
|
# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入
|
||||||
@@ -20,7 +20,7 @@ name: havline
|
|||||||
services:
|
services:
|
||||||
havline:
|
havline:
|
||||||
build:
|
build:
|
||||||
context: https://jayhub.top/mrlee/Havline.git#main
|
context: https://github.com/mrlee233/havline.git#main
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
@@ -49,6 +49,9 @@ services:
|
|||||||
HAVLINE_DATA_DIR: /data
|
HAVLINE_DATA_DIR: /data
|
||||||
HAVLINE_LISTEN: :6893
|
HAVLINE_LISTEN: :6893
|
||||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||||
|
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
|
||||||
|
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||||
|
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||||
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
# 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换
|
||||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||||
@@ -58,7 +61,7 @@ services:
|
|||||||
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
# 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发
|
||||||
updater:
|
updater:
|
||||||
build:
|
build:
|
||||||
context: https://jayhub.top/mrlee/Havline.git#main
|
context: https://github.com/mrlee233/havline.git#main
|
||||||
target: updater
|
target: updater
|
||||||
args:
|
args:
|
||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
@@ -74,11 +77,13 @@ services:
|
|||||||
- havline-updater-sock:/run/havline-updater
|
- havline-updater-sock:/run/havline-updater
|
||||||
environment:
|
environment:
|
||||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||||
|
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||||
|
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||||
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
||||||
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
|
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml
|
||||||
HAVLINE_UPDATE_SERVICE: havline
|
HAVLINE_UPDATE_SERVICE: havline
|
||||||
HAVLINE_UPDATE_MODE: git
|
HAVLINE_UPDATE_MODE: git
|
||||||
HAVLINE_UPDATE_REPO: https://jayhub.top/mrlee/Havline.git
|
HAVLINE_UPDATE_REPO: https://github.com/mrlee233/havline.git
|
||||||
HAVLINE_UPDATE_BRANCH: main
|
HAVLINE_UPDATE_BRANCH: main
|
||||||
HAVLINE_UPDATE_APP_URL: http://havline:6893
|
HAVLINE_UPDATE_APP_URL: http://havline:6893
|
||||||
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
|
HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# 源码仓库:https://github.com/mrlee233/havline.git(main 分支)
|
||||||
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
||||||
# havline-data 会变成新空卷(看起来像「数据丢了」)。
|
# havline-data 会变成新空卷(看起来像「数据丢了」)。
|
||||||
name: havline
|
name: havline
|
||||||
@@ -34,6 +35,9 @@ services:
|
|||||||
HAVLINE_DATA_DIR: /data
|
HAVLINE_DATA_DIR: /data
|
||||||
HAVLINE_LISTEN: :6893
|
HAVLINE_LISTEN: :6893
|
||||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||||
|
# 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖
|
||||||
|
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||||
|
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||||
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
# 留空即自动:应用会在数据目录里生成并持久化 <DataDir>/session_secret(0600)
|
||||||
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
# 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换
|
||||||
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-}
|
||||||
@@ -58,6 +62,8 @@ services:
|
|||||||
- havline-updater-sock:/run/havline-updater
|
- havline-updater-sock:/run/havline-updater
|
||||||
environment:
|
environment:
|
||||||
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock
|
||||||
|
HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-}
|
||||||
|
HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token
|
||||||
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
HAVLINE_UPDATE_PROJECT_DIR: /workspace
|
||||||
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
|
HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml
|
||||||
HAVLINE_UPDATE_SERVICE: havline
|
HAVLINE_UPDATE_SERVICE: havline
|
||||||
|
|||||||
+9
-1
@@ -68,6 +68,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
}
|
}
|
||||||
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
|
logger.Info("会话密钥已就绪", "module", "SYSTEM", "source", secretSource)
|
||||||
|
|
||||||
|
updaterToken, updaterTokenSource, err := updater.EnsureToken(cfg.UpdaterToken, cfg.UpdaterTokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if updaterToken != "" {
|
||||||
|
logger.Info("updater Token 已就绪", "module", "SYSTEM", "source", updaterTokenSource)
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := db.Open(cfg.DBPath())
|
conn, err := db.Open(cfg.DBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -122,7 +130,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
|||||||
proxySvc.SetExitSync(cloudflareSvc)
|
proxySvc.SetExitSync(cloudflareSvc)
|
||||||
startedAt := time.Now().UTC().Format(time.RFC3339)
|
startedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
|
||||||
updaterClient := updater.NewClient(cfg.UpdaterSocket)
|
updaterClient := updater.NewClient(cfg.UpdaterSocket, updaterToken)
|
||||||
|
|
||||||
handler := api.NewRouter(api.Deps{
|
handler := api.NewRouter(api.Deps{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Config struct {
|
|||||||
FrpcBin string
|
FrpcBin string
|
||||||
FrpPIDFile string
|
FrpPIDFile string
|
||||||
UpdaterSocket string
|
UpdaterSocket string
|
||||||
|
UpdaterToken string
|
||||||
|
UpdaterTokenFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -40,7 +42,9 @@ func Load() Config {
|
|||||||
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443),
|
||||||
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"),
|
||||||
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
|
FrpPIDFile: envOr("HAVLINE_FRP_PID", dataDir+"/frp/frpc.pid"),
|
||||||
UpdaterSocket: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"),
|
UpdaterSocket: updaterSocket(),
|
||||||
|
UpdaterToken: envOr("HAVLINE_UPDATER_TOKEN", ""),
|
||||||
|
UpdaterTokenFile: envOr("HAVLINE_UPDATER_TOKEN_FILE", defaultUpdaterTokenFile()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,3 +236,15 @@ func envIntOr(key string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原生应用显式传空值以禁用 Docker 升级;未配置时保留容器默认值。
|
||||||
|
func updaterSocket() string {
|
||||||
|
if value, ok := os.LookupEnv("HAVLINE_UPDATER_SOCKET"); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "/run/havline-updater/updater.sock"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultUpdaterTokenFile() string {
|
||||||
|
return "/run/havline-updater/updater.token"
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) {
|
|||||||
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
|
t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadUpdaterTokenFile(t *testing.T) {
|
||||||
|
t.Setenv("HAVLINE_UPDATER_TOKEN_FILE", "/tmp/havline-updater.token")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.UpdaterTokenFile != "/tmp/havline-updater.token" {
|
||||||
|
t.Fatalf("updater token file %q", cfg.UpdaterTokenFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Client 通过共享 Unix Socket 调用 havline-updater sidecar。
|
// Client 通过共享 Unix Socket 调用 havline-updater sidecar。
|
||||||
|
const tokenHeader = "X-Havline-Updater-Token"
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
socketPath string
|
socketPath string
|
||||||
|
token string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(socketPath string) *Client {
|
func NewClient(socketPath, token string) *Client {
|
||||||
socketPath = strings.TrimSpace(socketPath)
|
socketPath = strings.TrimSpace(socketPath)
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
@@ -29,6 +32,7 @@ func NewClient(socketPath string) *Client {
|
|||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
socketPath: socketPath,
|
socketPath: socketPath,
|
||||||
|
token: strings.TrimSpace(token),
|
||||||
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
|
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,9 @@ func (c *Client) request(ctx context.Context, method, path string, payload any,
|
|||||||
if payload != nil {
|
if payload != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set(tokenHeader, c.token)
|
||||||
|
}
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package updater
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -34,6 +35,7 @@ type Config struct {
|
|||||||
ProjectName string
|
ProjectName string
|
||||||
SelfImage string
|
SelfImage string
|
||||||
DockerSocket string
|
DockerSocket string
|
||||||
|
Token string
|
||||||
BuildTimeout time.Duration
|
BuildTimeout time.Duration
|
||||||
HealthTimeout time.Duration
|
HealthTimeout time.Duration
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,10 @@ func NewServer(cfg Config, logger *slog.Logger) (*Server, error) {
|
|||||||
cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir)
|
cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir)
|
||||||
cfg.ProjectName = strings.TrimSpace(cfg.ProjectName)
|
cfg.ProjectName = strings.TrimSpace(cfg.ProjectName)
|
||||||
cfg.SelfImage = strings.TrimSpace(cfg.SelfImage)
|
cfg.SelfImage = strings.TrimSpace(cfg.SelfImage)
|
||||||
|
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||||
|
if cfg.Token == "" {
|
||||||
|
return nil, fmt.Errorf("HAVLINE_UPDATER_TOKEN 未设置,拒绝启动未鉴权的升级服务")
|
||||||
|
}
|
||||||
if cfg.BuildTimeout <= 0 {
|
if cfg.BuildTimeout <= 0 {
|
||||||
cfg.BuildTimeout = 30 * time.Minute
|
cfg.BuildTimeout = 30 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /status", s.handleStatus)
|
mux.HandleFunc("GET /status", s.handleStatus)
|
||||||
mux.HandleFunc("POST /apply", s.handleApply)
|
mux.HandleFunc("POST /apply", s.handleApply)
|
||||||
return mux
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
provided := strings.TrimSpace(r.Header.Get(tokenHeader))
|
||||||
|
if len(provided) != len(s.cfg.Token) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.cfg.Token)) != 1 {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "updater 鉴权失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve 在共享 Unix Socket 上提供服务。
|
// Serve 在共享 Unix Socket 上提供服务。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package updater
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -29,6 +31,7 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
|
|||||||
server, err := NewServer(Config{
|
server, err := NewServer(Config{
|
||||||
ProjectDir: t.TempDir(),
|
ProjectDir: t.TempDir(),
|
||||||
ComposeFile: "docker-compose.yml",
|
ComposeFile: "docker-compose.yml",
|
||||||
|
Token: "test-token",
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -36,17 +39,17 @@ func TestNewServerDefaultsAndValidation(t *testing.T) {
|
|||||||
if !server.state.Enabled || server.cfg.Mode != "local" {
|
if !server.state.Enabled || server.cfg.Mode != "local" {
|
||||||
t.Fatalf("默认状态不正确: %#v", server.state)
|
t.Fatalf("默认状态不正确: %#v", server.state)
|
||||||
}
|
}
|
||||||
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid"}, nil); err == nil {
|
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid", Token: "test-token"}, nil); err == nil {
|
||||||
t.Fatal("非法 mode 应被拒绝")
|
t.Fatal("非法 mode 应被拒绝")
|
||||||
}
|
}
|
||||||
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git"}, nil); err == nil {
|
if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git", Token: "test-token"}, nil); err == nil {
|
||||||
t.Fatal("git 模式缺少 RepoURL 应被拒绝")
|
t.Fatal("git 模式缺少 RepoURL 应被拒绝")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureComposeFile(t *testing.T) {
|
func TestEnsureComposeFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
server, err := NewServer(Config{ProjectDir: dir, ComposeFile: "docker-compose.yml", ProjectName: "havline"}, nil)
|
server, err := NewServer(Config{ProjectDir: dir, ComposeFile: "docker-compose.yml", ProjectName: "havline", Token: "test-token"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,7 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
|
|||||||
ComposeFile: "docker-compose.hub.yml",
|
ComposeFile: "docker-compose.hub.yml",
|
||||||
HostProjectDir: hostDir,
|
HostProjectDir: hostDir,
|
||||||
ProjectName: "havline",
|
ProjectName: "havline",
|
||||||
|
Token: "test-token",
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -86,6 +90,39 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewServerRequiresToken(t *testing.T) {
|
||||||
|
if _, err := NewServer(Config{ProjectDir: t.TempDir()}, nil); err == nil {
|
||||||
|
t.Fatal("缺少 Token 时应拒绝启动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRequiresToken(t *testing.T) {
|
||||||
|
server, err := NewServer(Config{ProjectDir: t.TempDir(), Token: "test-token"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, token := range []string{"", "wrong-token"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://unix/status", nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set(tokenHeader, token)
|
||||||
|
}
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("Token %q 应返回 401,实际为 %d", token, res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://unix/status", nil)
|
||||||
|
req.Header.Set(tokenHeader, "test-token")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("正确 Token 应返回 200,实际为 %d", res.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsContainerPath(t *testing.T) {
|
func TestIsContainerPath(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTokenPath 是主服务与 updater sidecar 共享 Token 的默认位置。
|
||||||
|
const DefaultTokenPath = "/run/havline-updater/updater.token"
|
||||||
|
|
||||||
|
const tokenWaitInterval = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// ResolveToken 解析 updater Token:显式环境变量优先,其次读取共享文件。
|
||||||
|
// 文件不存在时返回空 Token,不视为错误,便于不含 updater 的部署直接启动。
|
||||||
|
func ResolveToken(token, tokenFile string) (string, string, error) {
|
||||||
|
if value := strings.TrimSpace(token); value != "" {
|
||||||
|
return value, "环境变量", nil
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(tokenFile)
|
||||||
|
if path == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("读取 updater Token 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(string(data))
|
||||||
|
if value == "" {
|
||||||
|
return "", "", fmt.Errorf("updater Token 文件为空: %s", path)
|
||||||
|
}
|
||||||
|
return value, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureToken 确保共享 Token 文件存在。环境变量优先;未配置时由主程序生成
|
||||||
|
// 并原子写入共享卷,updater 随后读取同一文件。
|
||||||
|
func EnsureToken(token, tokenFile string) (string, string, error) {
|
||||||
|
if value := strings.TrimSpace(token); value != "" {
|
||||||
|
return value, "环境变量", nil
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(tokenFile)
|
||||||
|
if path == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if value := strings.TrimSpace(string(data)); value != "" {
|
||||||
|
return value, path, nil
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", "", fmt.Errorf("读取 updater Token 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
value, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := writeTokenFile(path, value); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return value, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForToken 等待主程序在共享卷中创建 Token 文件;环境变量已设置时立即返回。
|
||||||
|
func WaitForToken(token, tokenFile string, timeout time.Duration) (string, string, error) {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
value, source, err := ResolveToken(token, tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if value != "" {
|
||||||
|
return value, source, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return "", "", fmt.Errorf("等待 updater Token 文件超时: %s", tokenFile)
|
||||||
|
}
|
||||||
|
time.Sleep(tokenWaitInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("生成 updater Token 失败: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTokenFile(path, token string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("创建 updater Token 目录失败: %w", err)
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("写入 updater Token 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("保存 updater Token 文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return os.Chmod(path, 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTokenPrefersEnvironment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "updater.token")
|
||||||
|
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, source, err := ResolveToken("env-token", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "env-token" || source != "环境变量" {
|
||||||
|
t.Fatalf("环境变量应优先,实际 token=%q source=%q", token, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTokenFromFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "updater.token")
|
||||||
|
if err := os.WriteFile(path, []byte(" file-token \n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, source, err := ResolveToken("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "file-token" || source != path {
|
||||||
|
t.Fatalf("文件 Token 解析不正确,实际 token=%q source=%q", token, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTokenMissingFile(t *testing.T) {
|
||||||
|
token, source, err := ResolveToken("", filepath.Join(t.TempDir(), "missing.token"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "" || source != "" {
|
||||||
|
t.Fatalf("文件不存在时应返回空 Token,实际 token=%q source=%q", token, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTokenEmptyFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "updater.token")
|
||||||
|
if err := os.WriteFile(path, []byte("\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := ResolveToken("", path); err == nil {
|
||||||
|
t.Fatal("空 Token 文件应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureTokenCreatesAndReusesFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "updater.token")
|
||||||
|
token, source, err := EnsureToken("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(token) != 64 || source != path {
|
||||||
|
t.Fatalf("生成的 Token 不正确,实际 token=%q source=%q", token, source)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != token+"\n" {
|
||||||
|
t.Fatalf("Token 文件内容不正确: %q", string(data))
|
||||||
|
}
|
||||||
|
again, _, err := EnsureToken("", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again != token {
|
||||||
|
t.Fatalf("重复调用应复用文件 Token,实际 %q", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForTokenReadsExistingFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "updater.token")
|
||||||
|
if err := os.WriteFile(path, []byte("file-token\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _, err := WaitForToken("", path, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if token != "file-token" {
|
||||||
|
t.Fatalf("等待 Token 结果不正确: %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForTokenTimesOut(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "missing.token")
|
||||||
|
if _, _, err := WaitForToken("", path, 50*time.Millisecond); err == nil {
|
||||||
|
t.Fatal("文件不存在时应等待超时")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user