257 lines
6.3 KiB
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())
|
|
}
|