jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

199 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command remote-proxy 是 visionA-backend 的 tunnel server 端(雛形雙 binary 之一)。
//
// 它:
// - 接受 local agent 的 WebSocket upgrade`/tunnel/connect`),建立 yamux tunnel
// - 唯一持有 session statein-memory不走 Redis見 ADR-006
// - 對 api-server 提供 internal HTTP API`/internal/forward/http`、`/internal/session/:token`
// - 定期清理過期 session對齊 tunnel.md §4.210s 心跳、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)
// 對外 muxtunnel port面向 local agent
tunnelMux := http.NewServeMux()
tunnelMux.HandleFunc("/tunnel/connect", relaySrv.HandleTunnelConnect)
tunnelMux.HandleFunc("/relay/status", relaySrv.HandleRelayStatus)
tunnelMux.HandleFunc("/healthz", healthzHandler)
// 內部 muxinternal 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)
}
}
}
}