From a7d71752e30a95c3210c5de91e82b019f464c51e Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Mon, 5 Oct 2026 19:11:05 +0800 Subject: [PATCH] feat(updater): generate shared token without init container --- CHANGELOG.md | 5 ++ cmd/havline-updater/main.go | 15 +++++ docker-compose.hub.yml | 13 ++-- docker-compose.yml | 6 ++ internal/app/app.go | 10 ++- internal/config/config.go | 18 ++++- internal/config/config_test.go | 9 +++ internal/updater/client.go | 9 ++- internal/updater/server.go | 15 ++++- internal/updater/server_test.go | 43 +++++++++++- internal/updater/token.go | 112 ++++++++++++++++++++++++++++++++ internal/updater/token_test.go | 107 ++++++++++++++++++++++++++++++ 12 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 internal/updater/token.go create mode 100644 internal/updater/token_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 95bc15b..4292ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 新增 + +- **updater Token 零配置且无额外容器**:主程序首次启动时在共享卷中自动生成 `/run/havline-updater/updater.token`,sidecar 等待并读取同一文件;主服务与 sidecar 支持 `HAVLINE_UPDATER_TOKEN_FILE`,旧部署仍可用 `HAVLINE_UPDATER_TOKEN` 覆盖 + ### 修复 - **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 端口冲突校验和隧道提示文案测试 +- 新增 updater Token 环境变量优先级、共享文件生成与解析、等待超时、缺失文件和空文件测试 - `go test ./...`、`npm run typecheck` 通过 ### 变更 diff --git a/cmd/havline-updater/main.go b/cmd/havline-updater/main.go index c462b69..c51d150 100644 --- a/cmd/havline-updater/main.go +++ b/cmd/havline-updater/main.go @@ -6,11 +6,25 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/havline/havline/internal/updater" ) 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{ SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"), @@ -24,6 +38,7 @@ func main() { ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""), SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""), DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"), + Token: token, }, nil) if err != nil { log.Fatal(err) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index 19b6439..f211466 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -3,7 +3,7 @@ # 用法: # mkdir -p /opt/havline && cd /opt/havline # # 取本文件(按你仓库页面上的「原始文件」链接下载,或 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 # 会话密钥由应用首次启动自动生成并持久化在数据卷里 # # 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 @@ -20,7 +20,7 @@ name: havline services: havline: build: - context: https://jayhub.top/mrlee/Havline.git#main + context: https://github.com/mrlee233/havline.git#main args: 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} @@ -49,6 +49,9 @@ services: HAVLINE_DATA_DIR: /data HAVLINE_LISTEN: :6893 HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + # 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖 + HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-} + HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} @@ -58,7 +61,7 @@ services: # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 updater: build: - context: https://jayhub.top/mrlee/Havline.git#main + context: https://github.com/mrlee233/havline.git#main target: updater args: GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} @@ -74,11 +77,13 @@ services: - havline-updater-sock:/run/havline-updater environment: 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_COMPOSE_FILE: docker-compose.hub.yml HAVLINE_UPDATE_SERVICE: havline 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_APP_URL: http://havline:6893 HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-} diff --git a/docker-compose.yml b/docker-compose.yml index 676d99b..200e89b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +# 源码仓库:https://github.com/mrlee233/havline.git(main 分支) # 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目, # havline-data 会变成新空卷(看起来像「数据丢了」)。 name: havline @@ -34,6 +35,9 @@ services: HAVLINE_DATA_DIR: /data HAVLINE_LISTEN: :6893 HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + # 主程序首次启动时生成共享 Token;旧部署仍可用环境变量覆盖 + HAVLINE_UPDATER_TOKEN: ${HAVLINE_UPDATER_TOKEN:-} + HAVLINE_UPDATER_TOKEN_FILE: /run/havline-updater/updater.token # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} @@ -58,6 +62,8 @@ services: - havline-updater-sock:/run/havline-updater environment: 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_COMPOSE_FILE: docker-compose.yml HAVLINE_UPDATE_SERVICE: havline diff --git a/internal/app/app.go b/internal/app/app.go index 3b78fd5..bd895d0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -68,6 +68,14 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) } 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()) if err != nil { return nil, err @@ -122,7 +130,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) proxySvc.SetExitSync(cloudflareSvc) startedAt := time.Now().UTC().Format(time.RFC3339) 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{ Config: cfg, diff --git a/internal/config/config.go b/internal/config/config.go index 361df71..b9e0442 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ type Config struct { FrpcBin string FrpPIDFile string UpdaterSocket string + UpdaterToken string + UpdaterTokenFile string } func Load() Config { @@ -40,7 +42,9 @@ func Load() Config { NginxDefaultHTTPSPort: envIntOr("HAVLINE_NGINX_HTTPS_PORT", 443), FrpcBin: envOr("HAVLINE_FRPC_BIN", "frpc"), 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 } + +// 原生应用显式传空值以禁用 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" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df7b5d0..e1a514f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -33,3 +33,12 @@ func TestLoadNginxDefaultPortsFallback(t *testing.T) { 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) + } +} diff --git a/internal/updater/client.go b/internal/updater/client.go index 98984ef..ac475e8 100644 --- a/internal/updater/client.go +++ b/internal/updater/client.go @@ -14,12 +14,15 @@ import ( ) // Client 通过共享 Unix Socket 调用 havline-updater sidecar。 +const tokenHeader = "X-Havline-Updater-Token" + type Client struct { socketPath string + token string http *http.Client } -func NewClient(socketPath string) *Client { +func NewClient(socketPath, token string) *Client { socketPath = strings.TrimSpace(socketPath) transport := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { @@ -29,6 +32,7 @@ func NewClient(socketPath string) *Client { } return &Client{ socketPath: socketPath, + token: strings.TrimSpace(token), 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 { req.Header.Set("Content-Type", "application/json") } + if c.token != "" { + req.Header.Set(tokenHeader, c.token) + } resp, err := c.http.Do(req) if err != nil { return err diff --git a/internal/updater/server.go b/internal/updater/server.go index 2814475..a4fd1d7 100644 --- a/internal/updater/server.go +++ b/internal/updater/server.go @@ -2,6 +2,7 @@ package updater import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -34,6 +35,7 @@ type Config struct { ProjectName string SelfImage string DockerSocket string + Token string BuildTimeout 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.ProjectName = strings.TrimSpace(cfg.ProjectName) 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 { cfg.BuildTimeout = 30 * time.Minute } @@ -126,7 +132,14 @@ func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /status", s.handleStatus) 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 上提供服务。 diff --git a/internal/updater/server_test.go b/internal/updater/server_test.go index bd8ce58..678e480 100644 --- a/internal/updater/server_test.go +++ b/internal/updater/server_test.go @@ -2,6 +2,8 @@ package updater import ( "context" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -29,6 +31,7 @@ func TestNewServerDefaultsAndValidation(t *testing.T) { server, err := NewServer(Config{ ProjectDir: t.TempDir(), ComposeFile: "docker-compose.yml", + Token: "test-token", }, nil) if err != nil { t.Fatal(err) @@ -36,17 +39,17 @@ func TestNewServerDefaultsAndValidation(t *testing.T) { if !server.state.Enabled || server.cfg.Mode != "local" { 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 应被拒绝") } - 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 应被拒绝") } } func TestEnsureComposeFile(t *testing.T) { 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 { t.Fatal(err) } @@ -70,6 +73,7 @@ func TestResolveComposePlanUsesHostProjectDir(t *testing.T) { ComposeFile: "docker-compose.hub.yml", HostProjectDir: hostDir, ProjectName: "havline", + Token: "test-token", }, nil) if err != nil { 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) { cases := []struct { path string diff --git a/internal/updater/token.go b/internal/updater/token.go new file mode 100644 index 0000000..254dc8a --- /dev/null +++ b/internal/updater/token.go @@ -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) +} diff --git a/internal/updater/token_test.go b/internal/updater/token_test.go new file mode 100644 index 0000000..bf74eb7 --- /dev/null +++ b/internal/updater/token_test.go @@ -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("文件不存在时应等待超时") + } +}