Initial commit: docker-compose-updater
Build and Push / build (push) Failing after 13m20s

Go 项目,包含:
- 服务端 updater:两阶段协议,ECDSA 签名验证,AES-GCM 加密
- 发送端 dcu-send:Gitea Action CLI
- internal/auth:加解密/签名/会话管理
- internal/docker:Docker CLI 容器查找/拉取/重建
- action/:Gitea Action 定义
- deploy/Dockerfile:多阶段构建
- .gitea/workflows/build.yaml:CI/CD
This commit is contained in:
ilovintit
2026-06-08 15:16:46 +08:00
commit cea9b941cf
21 changed files with 1874 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"gitea.songhuwan.com/actions/docker-compose-updater/internal/auth"
)
func main() {
signingKeyPEM := os.Getenv("SIGNING_KEY")
url := os.Getenv("UPDATER_URL")
project := os.Getenv("UPDATER_PROJECT")
service := os.Getenv("UPDATER_SERVICE")
action := os.Getenv("UPDATER_ACTION")
if signingKeyPEM == "" || url == "" || project == "" || service == "" || action == "" {
fmt.Fprintln(os.Stderr, "required env: SIGNING_KEY, UPDATER_URL, UPDATER_PROJECT, UPDATER_SERVICE, UPDATER_ACTION")
os.Exit(1)
}
if action != "update" && action != "pull" && action != "restart" {
fmt.Fprintf(os.Stderr, "invalid action: %s (must be update/pull/restart)\n", action)
os.Exit(1)
}
// 解析 ECDSA 私钥
signingKey, err := auth.ParseECDSAPrivateKey([]byte(signingKeyPEM))
if err != nil {
fmt.Fprintf(os.Stderr, "parse signing key: %v\n", err)
os.Exit(1)
}
client := &http.Client{Timeout: 30 * time.Second}
// === Phase 1: 获取会话密钥 ===
nonce := randomBase64(16)
ts := time.Now().Unix()
signData := fmt.Sprintf("1.%d.%s", ts, nonce)
sig, err := auth.Sign(signingKey, []byte(signData))
if err != nil {
fmt.Fprintf(os.Stderr, "sign: %v\n", err)
os.Exit(1)
}
phase1Body, _ := json.Marshal(map[string]any{
"v": 1,
"ts": ts,
"nonce": nonce,
"sig": sig,
})
baseURL := url
resp, err := client.Post(baseURL+"/session", "application/json", bytes.NewReader(phase1Body))
if err != nil {
fmt.Fprintf(os.Stderr, "phase1 request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "phase1 failed (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
var phase1Resp struct {
Key string `json:"key"`
KeyID string `json:"key_id"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &phase1Resp); err != nil {
fmt.Fprintf(os.Stderr, "parse phase1 response: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "phase1 ok, key_id=%s\n", phase1Resp.KeyID)
// === Phase 2: 发送加密请求 ===
payload, _ := json.Marshal(map[string]string{
"project": project,
"service": service,
"action": action,
})
sessionKey, err := base64.StdEncoding.DecodeString(phase1Resp.Key)
if err != nil {
fmt.Fprintf(os.Stderr, "decode session key: %v\n", err)
os.Exit(1)
}
ciphertext, err := auth.Encrypt(payload, sessionKey)
if err != nil {
fmt.Fprintf(os.Stderr, "encrypt: %v\n", err)
os.Exit(1)
}
phase2Nonce := randomBase64(16)
phase2Body, _ := json.Marshal(map[string]any{
"v": 1,
"ts": time.Now().Unix(),
"nonce": phase2Nonce,
"key_id": phase1Resp.KeyID,
"data": base64.StdEncoding.EncodeToString(ciphertext),
})
resp, err = client.Post(baseURL+"/hook", "application/json", bytes.NewReader(phase2Body))
if err != nil {
fmt.Fprintf(os.Stderr, "phase2 request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ = io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "phase2 failed (%d): %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
// 输出响应(供 Action 日志查看)
var result map[string]any
json.Unmarshal(body, &result)
fmt.Fprintf(os.Stderr, "result: %s\n", string(body))
// 打印关键信息到 stdout
if msg, ok := result["message"].(string); ok {
fmt.Println(msg)
}
}
func randomBase64(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"encoding/base64"
"log/slog"
"os"
"gitea.songhuwan.com/actions/docker-compose-updater/internal/auth"
"gitea.songhuwan.com/actions/docker-compose-updater/internal/config"
"gitea.songhuwan.com/actions/docker-compose-updater/internal/docker"
"gitea.songhuwan.com/actions/docker-compose-updater/internal/server"
)
// 构建时注入:-ldflags="-X main.publicKeyBase64=$(base64 -w0 < keys/signing-public.pem)"
var publicKeyBase64 string
func main() {
cfg := config.Load()
var logLevel slog.Level
switch cfg.LogLevel {
case "debug":
logLevel = slog.LevelDebug
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})))
if publicKeyBase64 == "" {
slog.Error("public key not set - build with -ldflags")
os.Exit(1)
}
pemBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
slog.Error("decode public key", "error", err)
os.Exit(1)
}
pubKey, err := auth.ParseECDSAPublicKey(pemBytes)
if err != nil {
slog.Error("parse public key", "error", err)
os.Exit(1)
}
slog.Info("public key loaded")
updater := docker.NewUpdater(cfg.DockerPullTimeout, cfg.DockerRestartTimeout)
srv := server.New(
cfg.Listen,
pubKey,
updater,
cfg.SessionTTL,
cfg.NonceTTL,
cfg.TimestampWindow,
)
slog.Info("starting server", "addr", cfg.Listen)
if err := srv.Start(); err != nil {
slog.Error("server exited", "error", err)
os.Exit(1)
}
}