445 lines
16 KiB
Go
445 lines
16 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgconn"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/netip"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *Service) cookieName(account bool) string {
|
||
|
|
n := "__Host-onboarding"
|
||
|
|
if account {
|
||
|
|
n = "__Host-platform-onboarding"
|
||
|
|
}
|
||
|
|
if s.options.AllowLocalHTTP {
|
||
|
|
return strings.TrimPrefix(n, "__Host-")
|
||
|
|
}
|
||
|
|
return n
|
||
|
|
}
|
||
|
|
func (s *Service) secure(r *http.Request) bool {
|
||
|
|
if r.TLS != nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||
|
|
if err != nil {
|
||
|
|
host = r.RemoteAddr
|
||
|
|
}
|
||
|
|
ip, err := netip.ParseAddr(host)
|
||
|
|
if err != nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if s.options.AllowLocalHTTP && ip.IsLoopback() {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
for _, cidr := range s.options.TrustedProxyCIDRs {
|
||
|
|
prefix, err := netip.ParsePrefix(cidr)
|
||
|
|
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
func (s *Service) sameOrigin(r *http.Request) bool {
|
||
|
|
entry, _ := url.Parse(s.options.EntryURL)
|
||
|
|
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
|
||
|
|
}
|
||
|
|
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
|
||
|
|
for _, name := range []string{"time-challenges", "register", "setup-sessions", "handoffs", "poll", "claim-confirmations", "state-acks", "activation-attempts", "activation-results", "activation-acks", "setup-close"} {
|
||
|
|
s.mount(mux, "POST /api/v1/device/"+name, "device")
|
||
|
|
}
|
||
|
|
for _, name := range []string{"handoffs/exchange", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
|
||
|
|
s.mount(mux, "POST /api/v1/web/"+name, "web")
|
||
|
|
}
|
||
|
|
s.mount(mux, "GET /api/v1/web/setup/session", "bootstrap")
|
||
|
|
s.mount(mux, "GET /api/v1/web/onboarding/devices", "account")
|
||
|
|
s.mount(mux, "GET /api/v1/web/account/session", "account-session")
|
||
|
|
s.mount(mux, "POST /api/v1/web/auth/logout", "logout")
|
||
|
|
s.mount(mux, "POST /api/v1/ops/device-inventory", "ops")
|
||
|
|
s.mount(mux, "GET /api/v1/ops/device-inventory", "ops")
|
||
|
|
for _, name := range []string{"credential-deliveries", "release", "credential-rotations"} {
|
||
|
|
s.mount(mux, "POST /api/v1/ops/device-inventory/{id}/"+name, "ops")
|
||
|
|
}
|
||
|
|
s.mount(mux, "POST /api/v1/ops/onboarding-activations/{id}/reconcile", "ops")
|
||
|
|
}
|
||
|
|
func (s *Service) mount(mux *http.ServeMux, pattern, kind string) {
|
||
|
|
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, kind) })
|
||
|
|
}
|
||
|
|
func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
|
||
|
|
w.Header().Set("Cache-Control", "no-store")
|
||
|
|
w.Header().Set("Pragma", "no-cache")
|
||
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
|
|
id := r.Header.Get("X-Request-ID")
|
||
|
|
if id == "" && (r.Method == http.MethodGet || r.URL.Path == "/api/v1/device/time-challenges") {
|
||
|
|
id = uuid.NewString()
|
||
|
|
}
|
||
|
|
if !uuidV4(id) {
|
||
|
|
s.write(w, id, nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if r.URL.Path != "/api/v1/device/time-challenges" && !s.secure(r) {
|
||
|
|
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||
|
|
if host == "" {
|
||
|
|
host = "unknown"
|
||
|
|
}
|
||
|
|
if err := s.limit(r.Context(), "ip:"+host, 300, time.Minute); err != nil {
|
||
|
|
s.write(w, id, nil, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
|
||
|
|
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
|
||
|
|
s.write(w, id, nil, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var data any
|
||
|
|
var err error
|
||
|
|
switch kind {
|
||
|
|
case "device":
|
||
|
|
data, err = s.deviceHTTP(r, id)
|
||
|
|
case "web":
|
||
|
|
data, err = s.webHTTP(w, r, id)
|
||
|
|
case "bootstrap":
|
||
|
|
data, err = s.bootstrapHTTP(r)
|
||
|
|
case "ops":
|
||
|
|
data, err = s.opsHTTP(r, id)
|
||
|
|
case "account":
|
||
|
|
var p identity.Principal
|
||
|
|
p, err = s.browserPrincipal(r, false)
|
||
|
|
if err == nil {
|
||
|
|
data, err = s.AccountDevices(r.Context(), p)
|
||
|
|
}
|
||
|
|
case "account-session":
|
||
|
|
data, err = s.accountSessionHTTP(r)
|
||
|
|
case "logout":
|
||
|
|
data, err = s.logoutHTTP(w, r)
|
||
|
|
}
|
||
|
|
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
|
||
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
|
|
_ = json.NewEncoder(w).Encode(data)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.write(w, id, data, err)
|
||
|
|
}
|
||
|
|
func (s *Service) write(w http.ResponseWriter, id string, data any, err error) {
|
||
|
|
status := 200
|
||
|
|
code, message := "OK", ""
|
||
|
|
if err != nil {
|
||
|
|
data = nil
|
||
|
|
var e *Error
|
||
|
|
if errors.As(err, &e) {
|
||
|
|
status = e.Status
|
||
|
|
code = e.Code
|
||
|
|
message = e.Message
|
||
|
|
} else {
|
||
|
|
var db *pgconn.PgError
|
||
|
|
if errors.As(err, &db) && (db.Code == "23505" || db.Code == "P0001") {
|
||
|
|
status = 409
|
||
|
|
code = "RESOURCE_STATE_CONFLICT"
|
||
|
|
message = "资源状态已变化,请刷新后重试"
|
||
|
|
} else {
|
||
|
|
status = 500
|
||
|
|
code = "INTERNAL_ERROR"
|
||
|
|
message = "服务暂不可用,请按原请求号重试"
|
||
|
|
kind := "internal"
|
||
|
|
if errors.As(err, &db) {
|
||
|
|
kind = "postgres:" + db.Code
|
||
|
|
}
|
||
|
|
s.options.Logger.Error("onboarding request failed", "request_id", id, "error_type", kind)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if status == 429 {
|
||
|
|
w.Header().Set("Retry-After", "60")
|
||
|
|
}
|
||
|
|
if status == 503 {
|
||
|
|
w.Header().Set("Retry-After", "2")
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
if err != nil {
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "code": code, "message": message, "request_id": id})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "code": code, "request_id": id, "server_time_ms": s.now().UnixMilli(), "data": data})
|
||
|
|
}
|
||
|
|
func readBody(r *http.Request) ([]byte, error) {
|
||
|
|
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||
|
|
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
|
||
|
|
}
|
||
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fail(400, "INVALID_JSON", "请求正文读取失败")
|
||
|
|
}
|
||
|
|
if len(raw) > maxBody {
|
||
|
|
return nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
|
||
|
|
}
|
||
|
|
return raw, nil
|
||
|
|
}
|
||
|
|
func decodeCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
|
||
|
|
var in T
|
||
|
|
if err := StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return fn(in)
|
||
|
|
}
|
||
|
|
func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
|
||
|
|
if r.URL.RawQuery != "" {
|
||
|
|
return nil, fail(400, "QUERY_FORBIDDEN", "设备请求不接受query参数")
|
||
|
|
}
|
||
|
|
ctx := r.Context()
|
||
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
|
||
|
|
if path == "time-challenges" {
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return decodeDeviceCall(raw, func(in TimeChallengeInput) (map[string]any, error) { return s.TimeChallenge(ctx, in) })
|
||
|
|
}
|
||
|
|
d, raw, err := s.authenticate(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err = s.limit(ctx, "device:"+d.ID, 180, time.Minute); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||
|
|
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
|
||
|
|
}
|
||
|
|
switch path {
|
||
|
|
case "register":
|
||
|
|
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
|
||
|
|
case "setup-sessions":
|
||
|
|
return decodeDeviceCall(raw, func(in SetupInput) (map[string]any, error) { return s.Setup(ctx, d, id, raw, in) })
|
||
|
|
case "handoffs":
|
||
|
|
if !s.Enabled() {
|
||
|
|
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
|
||
|
|
}
|
||
|
|
return decodeDeviceCall(raw, func(in HandoffInput) (map[string]any, error) { return s.Handoff(ctx, d, id, raw, in) })
|
||
|
|
case "poll":
|
||
|
|
return decodeDeviceCall(raw, func(in PollInput) (map[string]any, error) { return s.Poll(ctx, d, in) })
|
||
|
|
case "claim-confirmations":
|
||
|
|
return decodeDeviceCall(raw, func(in ConfirmationInput) (map[string]any, error) { return s.Confirm(ctx, d, id, raw, in) })
|
||
|
|
case "state-acks":
|
||
|
|
return decodeDeviceCall(raw, func(in StateAckInput) (map[string]any, error) { return s.StateAck(ctx, d, id, raw, in) })
|
||
|
|
case "activation-attempts":
|
||
|
|
return decodeDeviceCall(raw, func(in AttemptInput) (map[string]any, error) { return s.Attempt(ctx, d, id, raw, in) })
|
||
|
|
case "activation-results":
|
||
|
|
return decodeDeviceCall(raw, func(in ResultInput) (map[string]any, error) { return s.ActivationResult(ctx, d, id, raw, in) })
|
||
|
|
case "activation-acks":
|
||
|
|
return decodeDeviceCall(raw, func(in ActivationAckInput) (map[string]any, error) { return s.ActivationAck(ctx, d, id, raw, in) })
|
||
|
|
case "setup-close":
|
||
|
|
return decodeDeviceCall(raw, func(in CloseInput) (map[string]any, error) { return s.Close(ctx, d, id, raw, in) })
|
||
|
|
}
|
||
|
|
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||
|
|
}
|
||
|
|
func (s *Service) browserSession(r *http.Request) (WebSession, error) {
|
||
|
|
c, err := r.Cookie(s.cookieName(false))
|
||
|
|
if err != nil || !validB64(c.Value, 32) {
|
||
|
|
return WebSession{}, fail(401, "WEB_SESSION_EXPIRED", "请从设备打开设置入口")
|
||
|
|
}
|
||
|
|
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
|
||
|
|
}
|
||
|
|
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
|
||
|
|
for _, item := range []struct {
|
||
|
|
value string
|
||
|
|
account bool
|
||
|
|
age int
|
||
|
|
}{{out.Token, false, 1800}, {out.AccountToken, true, 86400}} {
|
||
|
|
if item.value != "" {
|
||
|
|
http.SetCookie(w, &http.Cookie{Name: s.cookieName(item.account), Value: item.value, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: item.age})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
|
||
|
|
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||
|
|
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||
|
|
}
|
||
|
|
p, err := s.options.TokenVerifier.Verify(r.Context(), strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||
|
|
if err != nil {
|
||
|
|
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
|
||
|
|
}
|
||
|
|
return p, nil
|
||
|
|
}
|
||
|
|
func (s *Service) browserPrincipal(r *http.Request, write bool) (identity.Principal, error) {
|
||
|
|
if r.Header.Get("Authorization") != "" {
|
||
|
|
return s.bearer(r)
|
||
|
|
}
|
||
|
|
if write && !s.sameOrigin(r) {
|
||
|
|
return identity.Principal{}, errAccess
|
||
|
|
}
|
||
|
|
c, err := r.Cookie(s.cookieName(true))
|
||
|
|
if err != nil {
|
||
|
|
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||
|
|
}
|
||
|
|
return s.AccountPrincipal(r.Context(), c.Value, r.Header.Get("X-CSRF-Token"), write)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only explicitly selected customer routes receive this cookie adapter.
|
||
|
|
func (s *Service) CustomerAuth(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if !s.secure(r) {
|
||
|
|
s.write(w, r.Header.Get("X-Request-ID"), nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
p, err := s.browserPrincipal(r, r.Method != http.MethodGet && r.Method != http.MethodHead)
|
||
|
|
if err != nil {
|
||
|
|
s.write(w, r.Header.Get("X-Request-ID"), nil, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
next.ServeHTTP(w, r.WithContext(identity.WithPrincipal(r.Context(), p)))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (any, error) {
|
||
|
|
if !s.sameOrigin(r) || r.URL.RawQuery != "" {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/web/")
|
||
|
|
ctx := r.Context()
|
||
|
|
if path == "handoffs/exchange" {
|
||
|
|
if !s.Enabled() {
|
||
|
|
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
|
||
|
|
}
|
||
|
|
var in ExchangeInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out, e := s.Exchange(ctx, in)
|
||
|
|
if e == nil {
|
||
|
|
s.issueCookies(w, out)
|
||
|
|
}
|
||
|
|
return out, e
|
||
|
|
}
|
||
|
|
ws, err := s.browserSession(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err = s.checkCSRF(ctx, ws.Hash, r.Header.Get("X-CSRF-Token")); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
switch path {
|
||
|
|
case "setup/auth-link":
|
||
|
|
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
p, e := s.bearer(r)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
out, e := s.AuthLink(ctx, ws, p)
|
||
|
|
if e == nil {
|
||
|
|
s.issueCookies(w, out)
|
||
|
|
}
|
||
|
|
return out, e
|
||
|
|
case "auth/sms/send":
|
||
|
|
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) { return s.SendSMS(ctx, ws, id, in) })
|
||
|
|
case "auth/sms/verify":
|
||
|
|
var in SMSVerifyInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out, e := s.VerifySMS(ctx, ws, in)
|
||
|
|
if e == nil {
|
||
|
|
s.issueCookies(w, out)
|
||
|
|
}
|
||
|
|
return out, e
|
||
|
|
case "claims":
|
||
|
|
if !s.Enabled() || s.options.PauseClaims {
|
||
|
|
return nil, fail(503, "CLAIMS_PAUSED", "新的绑定申请暂时暂停")
|
||
|
|
}
|
||
|
|
return decodeCall(raw, func(in ClaimInput) (map[string]any, error) { return s.Claim(ctx, ws, id, raw, in) })
|
||
|
|
case "claims/cancel":
|
||
|
|
return decodeCall(raw, func(in CancelInput) (map[string]any, error) { return s.Cancel(ctx, ws, id, raw, in) })
|
||
|
|
case "setup/status":
|
||
|
|
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.WebStatus(ctx, ws) })
|
||
|
|
case "setup/ack":
|
||
|
|
return decodeCall(raw, func(in WebAckInput) (map[string]any, error) { return s.WebAck(ctx, ws, id, raw, in) })
|
||
|
|
case "profiles":
|
||
|
|
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.Profiles(ctx, ws) })
|
||
|
|
case "profiles/create":
|
||
|
|
return decodeCall(raw, func(in ProfileInput) (map[string]any, error) { return s.CreateProfile(ctx, ws, id, raw, in) })
|
||
|
|
}
|
||
|
|
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||
|
|
}
|
||
|
|
func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
|
||
|
|
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
ws, err := s.browserSession(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
token := randomToken(32)
|
||
|
|
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, ws.Hash, digest([]byte(token)))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
data, err := s.WebStatus(r.Context(), ws)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if c, e := r.Cookie(s.cookieName(true)); e == nil && validB64(c.Value, 32) && ws.UserID != "" {
|
||
|
|
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND user_id=$3::uuid AND expires_at>now() AND revoked_at IS NULL`, digest([]byte(c.Value)), digest([]byte(token)), ws.UserID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return map[string]any{"csrf_token": token, "data": data}, nil
|
||
|
|
}
|
||
|
|
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
|
||
|
|
p, err := s.bearer(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
tenant := r.Header.Get("X-Tenant-ID")
|
||
|
|
ctx := r.Context()
|
||
|
|
if r.Method == http.MethodGet {
|
||
|
|
return s.OpsList(ctx, p, tenant)
|
||
|
|
}
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
device := r.PathValue("id")
|
||
|
|
switch {
|
||
|
|
case r.URL.Path == "/api/v1/ops/device-inventory":
|
||
|
|
if !s.Enabled() {
|
||
|
|
return nil, fail(503, "ENROLLMENT_PAUSED", "生产登记入口暂停")
|
||
|
|
}
|
||
|
|
return decodeCall(raw, func(in InventoryInput) (map[string]any, error) {
|
||
|
|
return s.CreateInventory(ctx, p, tenant, id, r.Header.Get("Idempotency-Key"), raw, in)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/credential-deliveries"):
|
||
|
|
return decodeCall(raw, func(in DeliveryInput) (map[string]any, error) {
|
||
|
|
return s.DeliverCredential(ctx, p, tenant, device, id, in)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/release"):
|
||
|
|
return decodeCall(raw, func(in ReleaseInput) (map[string]any, error) {
|
||
|
|
return s.ReleaseInventory(ctx, p, tenant, device, id, in)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/credential-rotations"):
|
||
|
|
return decodeCall(raw, func(in RotationInput) (map[string]any, error) {
|
||
|
|
return s.Rotate(ctx, p, tenant, device, r.Header.Get("Idempotency-Key"), id, raw, in)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/reconcile"):
|
||
|
|
return decodeCall(raw, func(in ReconcileInput) (map[string]any, error) { return s.Reconcile(ctx, p, tenant, device, id, in) })
|
||
|
|
}
|
||
|
|
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||
|
|
}
|