accounts/api/config_sync.go
Haitao Pan ea3d6aec06 fix(sync): use XHTTP VLESS URIs, include all agent nodes, remove server-side rendered_json
- Sync now generates XHTTP VLESS URIs (port 443, type=xhttp) instead of TCP
- Node list includes all registered agent hosts, not just publicURL
- Removed rendered_json (server-side inbound config) from sync response
- Client generates proper outbound config from VLESS URI
- Node names use full domain (e.g. jp-xhttp.svc.plus) with country code
2026-03-02 20:28:49 +08:00

231 lines
5.7 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"account/internal/store"
"account/internal/xrayconfig"
)
type syncConfigAckRequest struct {
Version int64 `json:"version"`
DeviceID string `json:"device_id"`
AppliedAt string `json:"applied_at"`
}
func (h *handler) syncConfigSnapshot(c *gin.Context) {
h.respondSyncConfigSnapshot(c)
}
func (h *handler) syncConfig(c *gin.Context) {
// Backward-compatible endpoint: old clients call POST /api/auth/config/sync.
h.respondSyncConfigSnapshot(c)
}
func (h *handler) respondSyncConfigSnapshot(c *gin.Context) {
user, ok := h.requireAuthenticatedUser(c)
if !ok {
return
}
sinceVersion := int64(0)
if raw := strings.TrimSpace(c.Query("since_version")); raw != "" {
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
respondError(c, http.StatusBadRequest, "invalid_since_version", "since_version must be a non-negative integer")
return
}
sinceVersion = v
}
version := deriveSyncVersion(user)
updatedAt := time.Now().UTC()
if !user.UpdatedAt.IsZero() {
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
profiles := []gin.H{}
nodes := []gin.H{}
if changed {
proxyUUID := strings.TrimSpace(user.ProxyUUID)
if proxyUUID == "" {
proxyUUID = strings.TrimSpace(user.ID)
}
// Collect node hosts from registered agents + publicURL fallback.
registeredHosts, registeredNames := registeredNodeMetadata(h.agentStatusReader)
hosts := parseProxyNodeHosts(h.publicURL, registeredHosts)
xhttpPath := envOrDefault("XRAY_XHTTP_PATH", defaultXHTTPPath)
xhttpMode := envOrDefault("XRAY_XHTTP_MODE", defaultXHTTPMode)
xhttpScheme := xrayconfig.VLESSXHTTPScheme()
for _, host := range hosts {
nodeName := resolveNodeName(host, registeredNames)
countryCode := countryCodeForHost(host)
vlessURI := renderVLESSURIScheme(xhttpScheme, map[string]string{
"UUID": proxyUUID,
"DOMAIN": host,
"NODE": host,
"PATH": url.QueryEscape(xhttpPath),
"MODE": url.QueryEscape(xhttpMode),
"SNI": host,
"FP": defaultTLSFP,
"TAG": url.QueryEscape(nodeName),
})
profiles = append(profiles, gin.H{
"id": strings.TrimSpace(user.ID),
"remark": nodeName,
"address": host,
"port": 443,
"uuid": proxyUUID,
"flow": "",
"transport": "xhttp",
"security": "tls",
"source": "server",
"country_code": countryCode,
"vless_uri": vlessURI,
})
nodes = append(nodes, gin.H{
"id": strings.TrimSpace(user.ID),
"name": nodeName,
"protocol": "vless",
"transport": "xhttp",
"security": "tls",
"address": host,
"port": 443,
"uuid": proxyUUID,
"flow": "",
"source": "server",
"country_code": countryCode,
"updated_at": updatedAt,
"vless_uri": vlessURI,
})
}
}
c.JSON(http.StatusOK, gin.H{
"schema_version": 1,
"changed": changed,
"version": version,
"updated_at": updatedAt,
"profiles": profiles,
"nodes": nodes,
"routes": []gin.H{},
"dns": gin.H{
"mode": "secure_tunnel",
"servers": []string{},
},
"meta": gin.H{
"digest": digest,
"warnings": warnings,
},
"digest": digest,
"warnings": warnings,
})
}
func (h *handler) syncConfigAck(c *gin.Context) {
user, ok := h.requireAuthenticatedUser(c)
if !ok {
return
}
var req syncConfigAckRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
return
}
if req.Version <= 0 {
respondError(c, http.StatusBadRequest, "invalid_version", "version must be positive")
return
}
if strings.TrimSpace(req.DeviceID) == "" {
respondError(c, http.StatusBadRequest, "device_id_required", "device_id is required")
return
}
if strings.TrimSpace(req.AppliedAt) == "" {
respondError(c, http.StatusBadRequest, "applied_at_required", "applied_at is required")
return
}
c.JSON(http.StatusOK, gin.H{
"acked": true,
"version": req.Version,
"device_id": strings.TrimSpace(req.DeviceID),
"user_id": strings.TrimSpace(user.ID),
"received_at": time.Now().UTC(),
})
}
func deriveSyncVersion(user *store.User) int64 {
if user == nil {
return time.Now().UTC().Unix()
}
if !user.UpdatedAt.IsZero() {
return user.UpdatedAt.UTC().Unix()
}
if !user.CreatedAt.IsZero() {
return user.CreatedAt.UTC().Unix()
}
return time.Now().UTC().Unix()
}
func (h *handler) renderUserXrayConfig(user *store.User) (string, string, []string, error) {
domain := extractHostFromPublicURL(h.publicURL)
if domain == "" {
domain = "accounts.svc.plus"
}
clientID := strings.TrimSpace(user.ProxyUUID)
if clientID == "" {
clientID = strings.TrimSpace(user.ID)
}
clients := []xrayconfig.Client{{
ID: clientID,
Email: strings.TrimSpace(user.Email),
Flow: xrayconfig.DefaultFlow,
}}
gen := xrayconfig.Generator{
Definition: xrayconfig.TCPDefinition(),
Domain: domain,
}
buf, err := gen.Render(clients)
if err != nil {
return "", "", nil, err
}
sum := sha256.Sum256(buf)
return string(buf), hex.EncodeToString(sum[:]), []string{}, nil
}
func extractHostFromPublicURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
u, err := url.Parse(trimmed)
if err != nil {
return ""
}
return strings.TrimSpace(u.Hostname())
}