對齊 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>
306 lines
11 KiB
Go
306 lines
11 KiB
Go
// 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 =====
|
||
// 用 LocalFS(Phase 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 Token(OIDC 之外的雛形 token store) =====
|
||
pairingStore := auth.NewInMemoryPairingStore()
|
||
sessionTokenStore := auth.NewInMemorySessionTokenStore()
|
||
|
||
// ===== OIDC + User Session(OB5:唯一認證路徑) =====
|
||
// 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,
|
||
)
|
||
|
||
// ===== Session(api-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)
|
||
|
||
// ===== Repositories(in-memory,雛形) =====
|
||
deviceRepo := device.NewInMemoryRepository()
|
||
modelRepo := model.NewInMemoryRepository()
|
||
|
||
// ===== Converter(stub,Phase 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 為 nil,5 個 endpoint 自動回 501(registerConversionRoutes 處理)。
|
||
//
|
||
// **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 C1:StaticUserID 不再注入 Deps(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
// dev seed 仍直接讀 cfg.Auth.StaticUserID;stage/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.8(nil 時 /api/conversion/* 回 501)
|
||
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
|
||
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
|
||
RelayPublicURL: cfg.Server.RelayPublicURL,
|
||
|
||
// OIDC(OB5:唯一認證路徑)
|
||
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 不 panic(cleanup 不應拖垮主 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)
|
||
}
|