// 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) } } } }