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

293 lines
9.5 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.

// devices.go — /api/devices/* 的 handler 實作。
//
// 雛形分兩種資料來源:
// 1. 純雲端(讀 DeviceRepoGET /api/devices、GET /api/devices/:id
// — 回報使用者已配對的裝置清單,合併即時 tunnel 連線狀態
// 2. 走 tunnel proxy呼叫 local agentscan / connect / disconnect / flash / inference
// — 這些操作實際執行在 local agentUSB 插的那台機器)
//
// 對齊 api-spec.md §3 + feature-device-management.md。
package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/device"
"visiona-backend/internal/session"
)
// registerDeviceRoutes 註冊 /api/devices/* 的 routes。
func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// 純雲端讀取類
g.GET("/devices", devicesListHandler(deps))
g.GET("/devices/:id", devicesGetHandler(deps))
// 走 tunnel proxy 的操作類
proxy := newProxyHandler(deps, proxyOptions{})
g.POST("/devices/scan", proxy)
g.POST("/devices/:id/connect", proxy)
g.POST("/devices/:id/disconnect", proxy)
g.POST("/devices/:id/flash", proxy)
g.POST("/devices/:id/inference/start", proxy)
g.POST("/devices/:id/inference/stop", proxy)
// Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
}
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
//
// 合併雲端 DeviceRepo 的 metadata 與 Session 狀態tunnel_online
type DeviceListItem struct {
// 基本 metadata來自 DeviceRepo
ID string `json:"id"`
Name string `json:"name"`
DeviceType string `json:"device_type"`
SerialNumber string `json:"serial_number,omitempty"`
// 狀態
RemoteStatus string `json:"remote_status"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
LastConnectedAt *time.Time `json:"last_connected_at,omitempty"`
USBStatus string `json:"status"` // USB-level
// Tunnel 即時狀態(若有)
TunnelOnline bool `json:"tunnel_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// devicesListHandler 實作 GET /api/devices。
//
// 行為:從 DeviceRepo 列出當前 user 的裝置,再合併 SessionStore 的 tunnel 狀態:
// - 若該 user 有 active session → tunnel_online = truelast_seen_at 從 session 更新
// - 無 active session → 仍列出,但 tunnel_online = false
//
// Phase 1 會改為 DB JOIN + presigned URL雛形 in-memory 足夠。
func devicesListHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteSuccess(c, http.StatusOK, []DeviceListItem{})
return
}
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
devices, err := deps.DeviceRepo.List(ctx, userID)
if err != nil {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"list devices failed: "+err.Error(), nil)
return
}
// 查 tunnel 狀態(雛形:列全部 session 找當前 user 的;為空不致命)
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
out := make([]DeviceListItem, 0, len(devices))
for _, d := range devices {
item := DeviceListItem{
ID: d.ID,
Name: d.Name,
DeviceType: d.DeviceType,
SerialNumber: d.SerialNumber,
RemoteStatus: d.RemoteStatus,
LastSeenAt: d.LastSeenAt,
LastConnectedAt: d.LastConnectedAt,
USBStatus: d.Status,
TunnelOnline: tunnelAlive,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
// 如果雲端沒記錄 LastSeenAt 但 tunnel 活著,就用 session 的 lastSeen 填
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
ls := lastSeen
item.LastSeenAt = &ls
}
out = append(out, item)
}
WriteSuccess(c, http.StatusOK, out)
}
}
// devicesGetHandler 實作 GET /api/devices/:id。
//
// 雛形:直接從 DeviceRepo 讀(不走 tunnel。ownership 檢查以 OwnerUserID 比對。
// 若要即時查 USB 狀態,前端可再打 POST /api/devices/:id/connect 等 proxy 端點。
func devicesGetHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
return
}
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
return
}
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
d, err := deps.DeviceRepo.Get(ctx, id)
if err != nil {
if errors.Is(err, device.ErrNotFound) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
return
}
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"get device failed: "+err.Error(), nil)
return
}
// Ownership 檢查(雛形單一 user但仍守住這道
if d.OwnerUserID != userID {
WriteError(c, http.StatusForbidden, ErrCodeForbidden,
"not owner of this device", nil)
return
}
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
item := DeviceListItem{
ID: d.ID,
Name: d.Name,
DeviceType: d.DeviceType,
SerialNumber: d.SerialNumber,
RemoteStatus: d.RemoteStatus,
LastSeenAt: d.LastSeenAt,
LastConnectedAt: d.LastConnectedAt,
USBStatus: d.Status,
TunnelOnline: tunnelAlive,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
ls := lastSeen
item.LastSeenAt = &ls
}
WriteSuccess(c, http.StatusOK, item)
}
}
// devicesUnpairHandler 實作 POST /api/devices/:id/unpair。
//
// 雛形行為:
// 1. 驗證 device ownership
// 2. 軟刪 DeviceRepo entry
// 3. 若該 user 有 active session → 發 CloseSessionbest-effort
//
// 真正的 Session Token 撤銷Phase 1需要 PairingStore/SessionTokenStore 支援。
func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteNotImplemented(c, "device repo not configured")
return
}
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
return
}
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return
}
userID := uc.UserID
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
d, err := deps.DeviceRepo.Get(ctx, id)
if err != nil {
if errors.Is(err, device.ErrNotFound) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
return
}
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"get device failed: "+err.Error(), nil)
return
}
if d.OwnerUserID != userID {
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
return
}
// 軟刪
if err := deps.DeviceRepo.Delete(ctx, id); err != nil {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"delete device failed: "+err.Error(), nil)
return
}
// best-effort關閉該 user 的 session雛形單裝置假設
if deps.SessionStore != nil {
if token, tokErr := pickActiveSessionToken(ctx, deps.SessionStore, userID, deps.Logger); tokErr == nil {
_ = deps.SessionStore.Unregister(ctx, token)
}
}
logOrDefault(deps.Logger).Info("devices: unpaired",
"device_id", id,
"user_id", userID,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, gin.H{"id": id, "unpaired": true})
}
}
// resolveTunnelStatus 回報當前 user 是否有 active tunnel以及最新心跳時間。
//
// 雛形單裝置假設:只看第一筆 match 的 session。多裝置時 Phase 1 擴充。
// 失敗一律 return (false, zero time) 不 raise — 給 list/get 用,不該因此 fail。
//
// Phase 0.7 security audit M2寬鬆比對暫保留待人工介入。
// 詳細理由見 pickActiveSessionToken 註解relay 端 LocalHandle.Summary 不帶 UserID。
// 修復 caller (handler) 已先做 strict UserContext 檢查userID 必非空。
func resolveTunnelStatus(ctx context.Context, store session.Store, userID string) (bool, time.Time) {
if store == nil || userID == "" {
return false, time.Time{}
}
summaries, err := store.List(ctx)
if err != nil {
return false, time.Time{}
}
for _, s := range summaries {
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserIDM2 待人工介入)。
if s.UserID == "" || s.UserID == userID {
return true, s.LastHeartbeat
}
}
return false, time.Time{}
}