diff --git a/api/api.go b/api/api.go index 08f464a..1402468 100644 --- a/api/api.go +++ b/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) { diff --git a/api/api_test.go b/api/api_test.go index 6f724e0..f07bd2d 100644 --- a/api/api_test.go +++ b/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) diff --git a/api/config_sync.go b/api/config_sync.go index 31d4bb7..f8a392f 100644 --- a/api/config_sync.go +++ b/api/config_sync.go @@ -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"