accounts/api/config_sync.go

257 lines
6.3 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"log/slog"
"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()
}
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")
}
}
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)
nodeID := 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": nodeID,
"remark": nodeName,
"display_name": nodeName,
"host": host,
"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": nodeID,
"name": nodeName,
"display_name": nodeName,
"remark": nodeName,
"host": host,
"protocol": "vless",
"transport": "xhttp",
"security": "tls",
"address": host,
"port": 443,
"server_name": host,
"uuid": proxyUUID,
"flow": "",
"source": "server",
"country_code": countryCode,
"updated_at": updatedAt,
"vless_uri": vlessURI,
"uri_scheme_xhttp": vlessURI,
})
}
}
c.JSON(http.StatusOK, gin.H{
"schema_version": 1,
"changed": changed,
"version": version,
"updated_at": updatedAt,
"profiles": profiles,
"nodes": nodes,
"rendered_json": renderedJSON,
"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) {
if h.xrayConfigRenderer != nil {
return h.xrayConfigRenderer(user)
}
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())
}