fix(sync): degrade desktop sync config render failures
This commit is contained in:
parent
cc684f7c2a
commit
c849f08144
11
api/api.go
11
api/api.go
@ -80,6 +80,7 @@ type handler struct {
|
||||
oauthProviders map[string]auth.OAuthProvider
|
||||
oauthFrontendURL string
|
||||
publicURL string
|
||||
xrayConfigRenderer func(*store.User) (string, string, []string, error)
|
||||
agentRegistry agentRegistry
|
||||
db *gorm.DB
|
||||
stripe *stripeClient
|
||||
@ -222,6 +223,16 @@ func WithServerPublicURL(url string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithXrayConfigRenderer overrides sync config rendering.
|
||||
// It exists primarily to make sync endpoint behavior testable.
|
||||
func WithXrayConfigRenderer(renderer func(*store.User) (string, string, []string, error)) Option {
|
||||
return func(h *handler) {
|
||||
if renderer != nil {
|
||||
h.xrayConfigRenderer = renderer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOAuthFrontendURL configures the frontend URL for OAuth2 redirects.
|
||||
func WithOAuthFrontendURL(url string) Option {
|
||||
return func(h *handler) {
|
||||
|
||||
169
api/api_test.go
169
api/api_test.go
@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@ -38,6 +39,19 @@ type apiResponse struct {
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type syncConfigResponse struct {
|
||||
Changed bool `json:"changed"`
|
||||
Version int64 `json:"version"`
|
||||
RenderedJSON string `json:"rendered_json"`
|
||||
Digest string `json:"digest"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Nodes []map[string]interface{} `json:"nodes"`
|
||||
Meta struct {
|
||||
Digest string `json:"digest"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
type capturedEmail struct {
|
||||
To []string
|
||||
Subject string
|
||||
@ -168,6 +182,51 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder) apiResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
func newAuthenticatedSyncHarness(t *testing.T, opts ...Option) (*gin.Engine, *store.User, string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
st := store.NewMemoryStore()
|
||||
user := &store.User{
|
||||
Name: "Sync User",
|
||||
Email: "sync@example.com",
|
||||
EmailVerified: true,
|
||||
Role: store.RoleUser,
|
||||
Level: store.LevelUser,
|
||||
Active: true,
|
||||
}
|
||||
if err := st.CreateUser(ctx, user); err != nil {
|
||||
t.Fatalf("create sync user: %v", err)
|
||||
}
|
||||
|
||||
token := "sync-session-token"
|
||||
if err := st.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("create sync session: %v", err)
|
||||
}
|
||||
|
||||
freshUser, err := st.GetUserByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload sync user: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
baseOpts := []Option{
|
||||
WithStore(st),
|
||||
WithEmailVerification(false),
|
||||
}
|
||||
RegisterRoutes(router, append(baseOpts, opts...)...)
|
||||
return router, freshUser, token
|
||||
}
|
||||
|
||||
func decodeSyncConfigResponse(t *testing.T, rr *httptest.ResponseRecorder) syncConfigResponse {
|
||||
t.Helper()
|
||||
var resp syncConfigResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode sync response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAgentServerUsers_DefaultSyncIncludesSandboxAndRegularUsers(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@ -474,6 +533,116 @@ func TestOAuthCallbackIssuesOneTimeExchangeCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigSnapshotReturnsRenderedJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router, user, token := newAuthenticatedSyncHarness(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/sync/config?since_version=0", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected sync config success, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
resp := decodeSyncConfigResponse(t, rr)
|
||||
if !resp.Changed {
|
||||
t.Fatalf("expected changed=true for initial sync")
|
||||
}
|
||||
if resp.Version != deriveSyncVersion(user) {
|
||||
t.Fatalf("expected sync version %d, got %d", deriveSyncVersion(user), resp.Version)
|
||||
}
|
||||
if strings.TrimSpace(resp.RenderedJSON) == "" {
|
||||
t.Fatalf("expected rendered_json to be returned")
|
||||
}
|
||||
if len(resp.Nodes) == 0 {
|
||||
t.Fatalf("expected sync response to include nodes")
|
||||
}
|
||||
if strings.TrimSpace(resp.Digest) == "" {
|
||||
t.Fatalf("expected digest to be populated")
|
||||
}
|
||||
if resp.Meta.Digest != resp.Digest {
|
||||
t.Fatalf("expected top-level digest and meta digest to match, got %q and %q", resp.Digest, resp.Meta.Digest)
|
||||
}
|
||||
if len(resp.Warnings) != 0 || len(resp.Meta.Warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got top=%v meta=%v", resp.Warnings, resp.Meta.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigSnapshotSkipsRenderingWhenVersionUnchanged(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
renderCalls := 0
|
||||
router, user, token := newAuthenticatedSyncHarness(t, WithXrayConfigRenderer(func(*store.User) (string, string, []string, error) {
|
||||
renderCalls++
|
||||
return `{"outbounds":[{"tag":"proxy","protocol":"vless"}]}`, "digest", nil, nil
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/sync/config?since_version="+strconv.FormatInt(deriveSyncVersion(user), 10), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected unchanged sync config success, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
resp := decodeSyncConfigResponse(t, rr)
|
||||
if resp.Changed {
|
||||
t.Fatalf("expected changed=false when since_version matches current version")
|
||||
}
|
||||
if renderCalls != 0 {
|
||||
t.Fatalf("expected renderer to be skipped when config version is unchanged, got %d call(s)", renderCalls)
|
||||
}
|
||||
if strings.TrimSpace(resp.RenderedJSON) != "" {
|
||||
t.Fatalf("expected no rendered_json when sync payload is unchanged, got %q", resp.RenderedJSON)
|
||||
}
|
||||
if len(resp.Nodes) != 0 {
|
||||
t.Fatalf("expected unchanged sync response to omit nodes, got %d", len(resp.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigSnapshotFallsBackWhenRenderFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router, _, token := newAuthenticatedSyncHarness(t, WithXrayConfigRenderer(func(*store.User) (string, string, []string, error) {
|
||||
return "", "", nil, errors.New("boom")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/sync/config?since_version=0", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected sync config to degrade gracefully, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
resp := decodeSyncConfigResponse(t, rr)
|
||||
if !resp.Changed {
|
||||
t.Fatalf("expected changed=true for fallback sync payload")
|
||||
}
|
||||
if strings.TrimSpace(resp.RenderedJSON) != "" {
|
||||
t.Fatalf("expected rendered_json to be omitted on render failure, got %q", resp.RenderedJSON)
|
||||
}
|
||||
if len(resp.Nodes) == 0 {
|
||||
t.Fatalf("expected fallback sync response to include nodes")
|
||||
}
|
||||
if len(resp.Meta.Warnings) == 0 {
|
||||
t.Fatalf("expected fallback warning, got none")
|
||||
}
|
||||
if got := strings.TrimSpace(resp.Meta.Warnings[0]); !strings.Contains(got, "falling back to node metadata") {
|
||||
t.Fatalf("expected fallback warning, got %v", resp.Meta.Warnings)
|
||||
}
|
||||
if len(resp.Warnings) != len(resp.Meta.Warnings) || resp.Warnings[0] != resp.Meta.Warnings[0] {
|
||||
t.Fatalf("expected top-level warnings to mirror meta warnings, got top=%v meta=%v", resp.Warnings, resp.Meta.Warnings)
|
||||
}
|
||||
if vlessURI, ok := resp.Nodes[0]["vless_uri"].(string); !ok || strings.TrimSpace(vlessURI) == "" {
|
||||
t.Fatalf("expected fallback node payload to include vless_uri, got %v", resp.Nodes[0]["vless_uri"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendVerificationEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -52,14 +53,26 @@ func (h *handler) respondSyncConfigSnapshot(c *gin.Context) {
|
||||
updatedAt = user.UpdatedAt.UTC()
|
||||
}
|
||||
|
||||
renderedJSON, digest, warnings, err := h.renderUserXrayConfig(user)
|
||||
_ = renderedJSON // server-side config, not sent to clients
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "config_render_failed", "failed to render xray config")
|
||||
return
|
||||
changed := sinceVersion < version
|
||||
renderedJSON := ""
|
||||
digest := ""
|
||||
warnings := []string{}
|
||||
if changed {
|
||||
var err error
|
||||
renderedJSON, digest, warnings, err = h.renderUserXrayConfig(user)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"desktop sync config render failed; continuing with node metadata only",
|
||||
"user_id", strings.TrimSpace(user.ID),
|
||||
"user_email", strings.TrimSpace(user.Email),
|
||||
"error", err,
|
||||
)
|
||||
renderedJSON = ""
|
||||
digest = ""
|
||||
warnings = append(warnings, "rendered xray config unavailable; falling back to node metadata")
|
||||
}
|
||||
}
|
||||
|
||||
changed := sinceVersion < version
|
||||
profiles := []gin.H{}
|
||||
nodes := []gin.H{}
|
||||
if changed {
|
||||
@ -128,6 +141,7 @@ func (h *handler) respondSyncConfigSnapshot(c *gin.Context) {
|
||||
"updated_at": updatedAt,
|
||||
"profiles": profiles,
|
||||
"nodes": nodes,
|
||||
"rendered_json": renderedJSON,
|
||||
"routes": []gin.H{},
|
||||
"dns": gin.H{
|
||||
"mode": "secure_tunnel",
|
||||
@ -190,6 +204,10 @@ func deriveSyncVersion(user *store.User) int64 {
|
||||
}
|
||||
|
||||
func (h *handler) renderUserXrayConfig(user *store.User) (string, string, []string, error) {
|
||||
if h.xrayConfigRenderer != nil {
|
||||
return h.xrayConfigRenderer(user)
|
||||
}
|
||||
|
||||
domain := extractHostFromPublicURL(h.publicURL)
|
||||
if domain == "" {
|
||||
domain = "accounts.svc.plus"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user