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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user