從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
6.6 KiB
Go
199 lines
6.6 KiB
Go
// Command remote-proxy 是 visionA-backend 的 tunnel server 端(雛形雙 binary 之一)。
|
||
//
|
||
// 它:
|
||
// - 接受 local agent 的 WebSocket upgrade(`/tunnel/connect`),建立 yamux tunnel
|
||
// - 唯一持有 session state(in-memory,不走 Redis;見 ADR-006)
|
||
// - 對 api-server 提供 internal HTTP API(`/internal/forward/http`、`/internal/session/:token`)
|
||
// - 定期清理過期 session(對齊 tunnel.md §4.2:10s 心跳、30s 判定掉線)
|
||
//
|
||
// 對應文件:
|
||
// - `.autoflow/04-architecture/TDD.md` §2.5 relay / §2.9 wsconn
|
||
// - `.autoflow/04-architecture/tunnel.md` §7.1 remote-proxy main 流程
|
||
// - `.autoflow/04-architecture/api/api-internal.md`
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"strconv"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"visiona-backend/internal/config"
|
||
"visiona-backend/internal/logger"
|
||
"visiona-backend/internal/relay"
|
||
"visiona-backend/internal/session"
|
||
)
|
||
|
||
// defaultSigningSecret 與 config/load.go 保持一致 — 用於啟動時警告提示。
|
||
const defaultSigningSecret = "dev-signing-secret-do-not-use-in-prod"
|
||
|
||
// sessionCleanupInterval 清理過期 session 的週期,對齊 tunnel.md §4.2。
|
||
const sessionCleanupInterval = 30 * time.Second
|
||
|
||
func main() {
|
||
cfg := config.Load()
|
||
log := logger.New(cfg.Logger.Level).With("service", "remote-proxy")
|
||
|
||
// B2 M2 修補:storage signing secret 為預設值時印 warning。
|
||
// 雖然 remote-proxy 本身不直接用 storage,但 remote-proxy / api-server 共用
|
||
// 同一份 config,若 env 忘了設,兩個 binary 都該提醒。
|
||
if cfg.Auth.SigningSecret == defaultSigningSecret {
|
||
log.Warn("VISIONA_STORAGE_SIGNING_SECRET 仍為預設 dev 值",
|
||
"action", "請在生產環境設定環境變數 VISIONA_STORAGE_SIGNING_SECRET")
|
||
}
|
||
|
||
// Session store — remote-proxy 是 session state 的唯一來源
|
||
store := session.NewInMemoryStore()
|
||
|
||
// Relay server(面向 local agent)
|
||
relaySrv := relay.NewServer(store, log, relay.Options{
|
||
KeepAliveInterval: cfg.Tunnel.HeartbeatInterval,
|
||
ConnectionWriteTimeout: 10 * time.Second,
|
||
})
|
||
// Internal server(面向 api-server)
|
||
internalSrv := relay.NewInternalServer(store, log)
|
||
|
||
// 對外 mux(tunnel port,面向 local agent)
|
||
tunnelMux := http.NewServeMux()
|
||
tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect)
|
||
tunnelMux.HandleFunc("/relay/status", relaySrv.HandleRelayStatus)
|
||
tunnelMux.HandleFunc("/healthz", healthzHandler)
|
||
|
||
// 內部 mux(internal port,面向 api-server)
|
||
internalMux := http.NewServeMux()
|
||
internalSrv.Routes(internalMux)
|
||
internalMux.HandleFunc("/healthz", healthzHandler)
|
||
|
||
tunnelAddr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.TunnelPort))
|
||
internalAddr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.InternalPort))
|
||
|
||
tunnelServer := &http.Server{
|
||
Addr: tunnelAddr,
|
||
Handler: tunnelMux,
|
||
// ReadHeaderTimeout 防 slow-loris(對齊 security.md)
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
}
|
||
internalServer := &http.Server{
|
||
Addr: internalAddr,
|
||
Handler: internalMux,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
}
|
||
|
||
// Cleanup goroutine — 每 30s 掃一次過期 session
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
var wg sync.WaitGroup
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
sessionCleanupLoop(ctx, store, cfg.Tunnel.IdleTimeout, log)
|
||
}()
|
||
|
||
// 啟動兩個 HTTP server
|
||
errCh := make(chan error, 2)
|
||
go func() {
|
||
log.Info("tunnel server listening",
|
||
"addr", tunnelAddr,
|
||
"keepalive_interval", cfg.Tunnel.HeartbeatInterval.String(),
|
||
"idle_timeout", cfg.Tunnel.IdleTimeout.String())
|
||
if err := tunnelServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
errCh <- err
|
||
}
|
||
}()
|
||
go func() {
|
||
log.Info("internal server listening", "addr", internalAddr)
|
||
if err := internalServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
errCh <- err
|
||
}
|
||
}()
|
||
|
||
// 等 signal 或錯誤
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
select {
|
||
case <-quit:
|
||
log.Info("shutdown signal received")
|
||
case err := <-errCh:
|
||
log.Error("server error, shutting down", "error", err)
|
||
}
|
||
|
||
// Graceful shutdown
|
||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer shutdownCancel()
|
||
|
||
_ = tunnelServer.Shutdown(shutdownCtx)
|
||
_ = internalServer.Shutdown(shutdownCtx)
|
||
relaySrv.Shutdown()
|
||
cancel()
|
||
wg.Wait()
|
||
|
||
// 結束時關閉所有 session 釋放資源
|
||
// B3 Review Minor #3 修補:原本用 CleanupExpired(ctx, 0) 當「清掉全部」的 flag
|
||
// 語意隱晦。改用明確命名的 helper,讓意圖清楚。
|
||
if removed, err := closeAllSessions(shutdownCtx, store); err != nil {
|
||
log.Warn("final session cleanup failed", "error", err, "removed", removed)
|
||
} else if removed > 0 {
|
||
log.Info("final session cleanup done", "removed", removed)
|
||
}
|
||
|
||
log.Info("remote-proxy stopped")
|
||
}
|
||
|
||
// closeAllSessions 在關機時關閉所有 active session。
|
||
//
|
||
// 實作上仍複用 `store.CleanupExpired(ctx, 0)`(cutoff = now,幾乎所有
|
||
// LastHeartbeat.Before(now) 為 true),但把「0 表示清全部」這個
|
||
// 隱晦 convention 包在 helper 裡,讓 main.go 的意圖清晰。
|
||
//
|
||
// B3 Review Minor #3 修補:避免日後 CleanupExpired 若改語意(如「0 = 不清」)
|
||
// 造成 shutdown 靜默失敗。
|
||
func closeAllSessions(ctx context.Context, store session.Store) (int, error) {
|
||
return store.CleanupExpired(ctx, 0)
|
||
}
|
||
|
||
// healthzHandler 簡易健康檢查 — K8s liveness / readiness 用。
|
||
func healthzHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||
}
|
||
|
||
// sessionCleanupLoop 週期性呼叫 store.CleanupExpired。
|
||
//
|
||
// 行為對齊 tunnel.md §4.2:每 30s 掃一次,idleTimeout 預設 30s。
|
||
func sessionCleanupLoop(ctx context.Context, store session.Store, idleTimeout time.Duration, log *slog.Logger) {
|
||
if idleTimeout <= 0 {
|
||
log.Warn("idle_timeout 設為 0 或負數,停用 session cleanup")
|
||
return
|
||
}
|
||
ticker := time.NewTicker(sessionCleanupInterval)
|
||
defer ticker.Stop()
|
||
|
||
log.Info("session cleanup loop started",
|
||
"interval", sessionCleanupInterval.String(),
|
||
"idle_timeout", idleTimeout.String())
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
removed, err := store.CleanupExpired(ctx, idleTimeout)
|
||
if err != nil {
|
||
log.Warn("session cleanup failed", "error", err)
|
||
continue
|
||
}
|
||
if removed > 0 {
|
||
log.Info("session cleanup removed expired sessions", "count", removed)
|
||
}
|
||
}
|
||
}
|
||
}
|