jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

306 lines
11 KiB
Go
Raw 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 api-server 是 visionA-backend 的對前端 REST + WebSocket 伺服器。
//
// 雛形雙 binary 架構Q1 裁決):
// - api-server **無狀態**:所有 session 狀態都在 remote-proxy 那邊
// - 透過 ProxyClientStore + Forwarder 走 internal HTTP 跟 remote-proxy 溝通
//
// 對應文件:
// - `.autoflow/04-architecture/TDD.md` §2.4(雙 binary 部署)/ §10前端資料流
// - `.autoflow/04-architecture/api/api-spec.md`(前端用的 REST API
// - `.autoflow/04-architecture/api/api-internal.md`api-server ↔ remote-proxy
// - `.autoflow/04-architecture/tunnel.md` §5
package main
import (
"context"
"errors"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/api"
"visiona-backend/internal/auth"
"visiona-backend/internal/config"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/logger"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// defaultSigningSecret 與 config/load.go 保持一致 — 用於啟動警告。
const defaultSigningSecret = "dev-signing-secret-do-not-use-in-prod"
// shutdownTimeout 是收到 SIGINT/SIGTERM 後等待進行中請求完成的最長時間。
const shutdownTimeout = 10 * time.Second
// sessionCleanupInterval 是 OIDC user session store 的後台清理頻率。
// 設 5 分鐘是 dev / prod 都合理的值:足夠頻繁讓 idle session 不久留,
// 又不會過度消耗 CPU。
const sessionCleanupInterval = 5 * time.Minute
func main() {
cfg := config.Load()
log := logger.New(cfg.Logger.Level).With("service", "api-server")
// Validate config特別是 OIDC 啟用時的必填欄位)。
if err := cfg.Validate(); err != nil {
log.Error("invalid config", "error", err)
os.Exit(1)
}
// 啟動警告signing secret 為預設值(同 remote-proxy 行為)。
// 此 secret 同時給 storage presigned URL 與未來pairing token hash 用。
if cfg.Auth.SigningSecret == defaultSigningSecret {
log.Warn("signing secret 仍為預設 dev 值storage/pairing 共用)",
"action", "請在生產環境設定環境變數 VISIONA_STORAGE_SIGNING_SECRET",
"affects", "storage presigned URL, pairing token hash (phase 1)")
}
// ===== Storage =====
// 用 LocalFSPhase 0 雛形signing secret 共用同一份。
storageStore, err := storage.NewLocalFSStore(cfg.Storage.RootDir, cfg.Storage.BaseURL, cfg.Auth.SigningSecret)
if err != nil {
log.Error("failed to init storage", "error", err)
os.Exit(1)
}
log.Info("storage initialized",
"backend", cfg.Storage.Backend,
"root", cfg.Storage.RootDir,
"base_url", cfg.Storage.BaseURL)
// ===== Pairing / Session TokenOIDC 之外的雛形 token store =====
pairingStore := auth.NewInMemoryPairingStore()
sessionTokenStore := auth.NewInMemorySessionTokenStore()
// ===== OIDC + User SessionOB5唯一認證路徑 =====
// cfg.Validate() 已確保所有必填欄位存在,這裡可以放心 wire。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
oidcProvider, err := oidc.NewProvider(ctx, oidc.ProviderConfig{
IssuerURL: cfg.OIDC.IssuerURL,
ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret,
RedirectURL: cfg.OIDC.RedirectURL,
})
cancel()
if err != nil {
log.Error("failed to init OIDC provider",
"error", err,
"issuer", cfg.OIDC.IssuerURL,
"hint", "確認 IdP discovery (.well-known/openid-configuration) 可達")
os.Exit(1)
}
userSessionStore := usersession.NewInMemoryStore()
userSessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{
Name: cfg.UserSession.CookieName,
Domain: cfg.UserSession.CookieDomain,
Path: "/",
Secure: cfg.UserSession.CookieSecure,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(cfg.UserSession.AbsoluteTTL.Seconds()),
SigningKey: []byte(cfg.UserSession.Secret),
})
log.Info("OIDC initialized",
"issuer", cfg.OIDC.IssuerURL,
"client_id", cfg.OIDC.ClientID,
"redirect_url", cfg.OIDC.RedirectURL,
"frontend_url", cfg.OIDC.PostLoginURL,
"cookie_secure", cfg.UserSession.CookieSecure,
"absolute_ttl", cfg.UserSession.AbsoluteTTL,
"idle_ttl", cfg.UserSession.IdleTTL,
)
// ===== Sessionapi-server 端透過 ProxyClient 走 internal HTTP =====
proxyClient := session.NewHTTPProxyClient(cfg.Session.ProxyInternalURL, log)
forwarder := session.NewForwarder(cfg.Session.ProxyInternalURL, log)
sessionStore := session.NewProxyClientStore(proxyClient, forwarder)
log.Info("session store initialized",
"backend", "proxy-client",
"proxy_internal_url", cfg.Session.ProxyInternalURL)
// ===== Repositoriesin-memory雛形 =====
deviceRepo := device.NewInMemoryRepository()
modelRepo := model.NewInMemoryRepository()
// ===== ConverterstubPhase 2 才實作) =====
converterClient := converter.NewStubClient()
// ===== Phase 0.8 / 0.8b Conversion轉檔功能整合 =====
// 對齊 .autoflow/04-architecture/conversion.md、ADR-015。
//
// 啟用條件cfg.Conversion.Enabled() —
// ConverterBaseURL + FAABaseURL + ConverterAPIKey + FAAAPIKey 全部非空。
// 不啟用時 deps.Conversion 為 nil5 個 endpoint 自動回 501registerConversionRoutes 處理)。
//
// **Phase 0.8b T5**:完全切換至 pre-shared API key 認證 — 不再 wire MCTokenClient、
// 不再讀 OIDCConfig.ServiceClientID/Secret、不再有 tenant_id / delegated_ttl_sec
// 概念。參見 ADR-015 §6 變更影響清單。
var conversionService conversion.Service
if cfg.Conversion.Enabled() {
// 不再檢查 ServiceClientID/Secret —— Phase 0.8b 起 conversion 不依賴 OIDC service client。
// OIDCConfig.ServiceClientID/Secret 兩欄位仍保留供 backward compat但非 conversion 必需。)
converterAPIClient := conversion.NewConverterClient(conversion.ConverterClientOpts{
BaseURL: cfg.Conversion.ConverterBaseURL,
APIKey: cfg.Conversion.ConverterAPIKey,
Logger: log,
})
faaAPIClient := conversion.NewFAAClient(conversion.FAAClientOpts{
BaseURL: cfg.Conversion.FAABaseURL,
APIKey: cfg.Conversion.FAAAPIKey,
Logger: log,
})
ownership := conversion.NewOwnership(converterAPIClient, log)
// narrow adapter避免 conversion 直接 import internal/model / internal/storage
modelStoreAdapter := newConversionModelStoreAdapter(modelRepo)
storageAdapter := newConversionStorageAdapter(storageStore)
var convErr error
conversionService, convErr = conversion.NewService(conversion.FlowOpts{
Converter: converterAPIClient,
FAA: faaAPIClient,
Ownership: ownership,
ModelStore: modelStoreAdapter,
Storage: storageAdapter,
Logger: log,
})
if convErr != nil {
log.Error("failed to init conversion service", "error", convErr)
os.Exit(1)
}
log.Info("conversion service initialized",
"converter_base_url", cfg.Conversion.ConverterBaseURL,
"faa_base_url", cfg.Conversion.FAABaseURL,
// 安全:絕不印 key 全文 — 對齊 ADR-015 §3.5.3 部署檢查清單 #4
"converter_api_key_set", cfg.Conversion.ConverterAPIKey != "",
"faa_api_key_set", cfg.Conversion.FAAAPIKey != "")
} else {
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_FAA_BASE_URL + VISIONA_CONVERTER_API_KEY + VISIONA_FAA_API_KEY to enable)")
}
// ===== Seed demo data可選 =====
if cfg.Server.SeedDemoData {
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil {
log.Warn("seed demo data failed", "error", err)
}
}
// ===== API Router =====
gin.SetMode(gin.ReleaseMode)
// Phase 0.7 security fix C1StaticUserID 不再注入 Deps見 .autoflow/05-implementation/review/phase-0.7-security-audit.md
// dev seed 仍直接讀 cfg.Auth.StaticUserIDstage/prod 不影響VISIONA_SEED_DEMO_DATA=false
router := api.NewRouter(api.Deps{
Logger: log,
PairingStore: pairingStore,
SessionTokenStore: sessionTokenStore,
SessionStore: sessionStore,
Forwarder: forwarder,
DeviceRepo: deviceRepo,
ModelRepo: modelRepo,
Storage: storageStore,
Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
RelayPublicURL: cfg.Server.RelayPublicURL,
// OIDCOB5唯一認證路徑
OIDCProvider: oidcProvider,
SessionManager: userSessionMgr,
OIDCPostLoginURL: cfg.OIDC.PostLoginURL,
})
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
srv := &http.Server{
Addr: addr,
Handler: router,
ReadHeaderTimeout: 10 * time.Second, // 防 slow-loris對齊 security.md
}
// ===== User session cleanup goroutine =====
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
defer cleanupCancel()
go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log)
// ===== 啟動 server =====
errCh := make(chan error, 1)
go func() {
log.Info("api-server listening",
"addr", addr,
"proxy_internal_url", cfg.Session.ProxyInternalURL,
"seed_demo_data", cfg.Server.SeedDemoData,
"oidc_issuer", cfg.OIDC.IssuerURL,
)
if err := srv.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("api-server error, shutting down", "error", err)
}
// Graceful shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Warn("api-server shutdown error", "error", err)
}
cleanupCancel() // 停掉 user session cleanup goroutine
log.Info("api-server stopped")
}
// runUserSessionCleanup 是 OIDC user session store 的 background cleanup 迴圈。
//
// 每 sessionCleanupInterval 跑一次 store.CleanupExpired把 idle / absolute timeout
// 的 session 清掉。失敗只 log 不 paniccleanup 不應拖垮主 process
//
// ctx 取消process shutdown即退出。
func runUserSessionCleanup(ctx context.Context, store usersession.Store, idleTTL, absTTL time.Duration, log loggerLike) {
ticker := time.NewTicker(sessionCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
removed, err := store.CleanupExpired(cctx, idleTTL, absTTL)
cancel()
if err != nil {
log.Warn("user session cleanup failed", "error", err)
continue
}
if removed > 0 {
log.Info("user session cleanup", "removed", removed)
}
}
}
}
// loggerLike 是 runUserSessionCleanup 需要的最小 logger 介面,避免直接綁 *slog.Logger
// 而能在 test 中 stub。
type loggerLike interface {
Info(msg string, args ...any)
Warn(msg string, args ...any)
}