This commit is contained in:
2026-07-13 18:01:39 -03:00
parent dab8b09f0c
commit a345e70e5a
33 changed files with 3741 additions and 589 deletions
+186 -56
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -1883,7 +1884,7 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
return
}
if err := xrayMgr.Start(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeInternalError(w, "start Xray", err)
return
}
w.WriteHeader(http.StatusOK)
@@ -1898,7 +1899,7 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
return
}
if err := xrayMgr.Stop(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeInternalError(w, "stop Xray", err)
return
}
w.WriteHeader(http.StatusOK)
@@ -1913,7 +1914,7 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
return
}
if err := xrayMgr.Restart(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeInternalError(w, "restart Xray", err)
return
}
w.WriteHeader(http.StatusOK)
@@ -1938,7 +1939,7 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
data, err := xrayMgr.GetConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeInternalError(w, "read Xray configuration", err)
return
}
w.Header().Set("Content-Type", "application/json")
@@ -1950,8 +1951,19 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var raw map[string]interface{}
if !json.Valid(body) || json.Unmarshal(body, &raw) != nil || raw == nil {
http.Error(w, "invalid Xray JSON configuration", http.StatusBadRequest)
return
}
if xrayMgr.useNativeMode() {
if err := validateNativeInboundBindings(body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if err := xrayMgr.SetConfig(body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeInternalError(w, "save Xray configuration", err)
return
}
w.WriteHeader(http.StatusOK)
@@ -1972,13 +1984,13 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
wasRunning := xrayMgr.isRunningSnapshot()
changed, err := xrayMgr.EnsureStatsAPIConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeInternalError(w, "repair Xray statistics configuration", err)
return
}
restarted := false
if wasRunning {
if err := xrayMgr.Restart(); err != nil {
http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError)
writeInternalError(w, "restart Xray after repairing statistics", err)
return
}
restarted = true
@@ -2293,7 +2305,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
}
inbounds, err := xrayMgr.ListInbounds()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
writeInternalError(w, "list Xray inbounds", err)
return
}
@@ -2446,22 +2458,40 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.InboundTag == "" || req.UUID == "" {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
req.InboundTag = strings.TrimSpace(req.InboundTag)
req.UUID = strings.TrimSpace(req.UUID)
req.Email = strings.TrimSpace(req.Email)
req.Name = strings.TrimSpace(req.Name)
req.OwnerUsername = strings.TrimSpace(req.OwnerUsername)
if err := validateXrayClientFields(req.UUID, req.InboundTag, req.Email, req.Name, req.ExpiresAt, req.MaxConnections, true); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.OwnerUsername != "" {
if err := validateAdminUsername(req.OwnerUsername); err != nil {
http.Error(w, "invalid owner username", http.StatusBadRequest)
return
}
}
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
http.Error(w, "invalid server id", http.StatusBadRequest)
return
}
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeManagedServerSelectionError(w, err)
return
} else if remote {
if !ms.EnableXray {
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
return
}
chargedCredits, creditCost, creditOwner := false, 0, ""
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
if ownerErr != nil {
@@ -2472,25 +2502,44 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "UUID already exists", http.StatusConflict)
return
}
owner, ok := adminUsers.get(sess.Username)
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
if quotaErr != nil {
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
quotaUnlock := lockResellerQuota(sess.Username)
defer quotaUnlock()
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
if ownerErr != nil {
writeResellerProvisionError(w, ownerErr)
return
}
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
creditOwner = sess.Username
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
req.ExpiresAt = expiry
}
req.OwnerUsername = sess.Username
}
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
if syncErr := syncOwnerChainToManagedServer(r.Context(), ms, sess.Username); syncErr != nil {
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
}
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
return
}
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
}
writeBadGatewayError(w, "create Xray account on managed server", err)
return
}
if status < 200 || status >= 300 {
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
}
}
writeProxyResponse(w, status, data, ct)
return
}
@@ -2504,26 +2553,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
ownerUsername := ""
chargedCredits, creditCost := false, 0
if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username
if statsStore == nil {
http.Error(w, "storage not available", http.StatusInternalServerError)
return
}
owner, ok := adminUsers.get(sess.Username)
if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
if err := adminAccountChainActive(sess.Username); err != nil {
http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
return
}
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
if quotaErr != nil {
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
return
}
if owner.MaxUsers > 0 && used >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
ownerUsername = strings.TrimSpace(req.OwnerUsername)
}
@@ -2533,12 +2573,35 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "UUID already exists in database", http.StatusBadRequest)
return
} else if err != sql.ErrNoRows {
http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError)
writeInternalError(w, "check Xray client metadata", err)
return
}
}
if sess != nil && sess.Role == RoleReseller {
quotaUnlock := lockResellerQuota(sess.Username)
defer quotaUnlock()
var quotaErr error
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
if quotaErr != nil {
writeResellerProvisionError(w, quotaErr)
return
}
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
req.ExpiresAt = expiry
}
}
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
}
lowerErr := strings.ToLower(err.Error())
if strings.Contains(lowerErr, "already exists") {
http.Error(w, "UUID already exists", http.StatusConflict)
} else if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
http.Error(w, "inbound not found", http.StatusBadRequest)
} else {
writeInternalError(w, "add Xray client", err)
}
return
}
if statsStore != nil {
@@ -2564,7 +2627,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
}
}
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
_ = xrayMgr.RemoveXrayClient(req.InboundTag, req.UUID)
if chargedCredits {
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
}
http.Error(w, "could not save Xray client", http.StatusInternalServerError)
return
}
}
xrayMgr.restartIfExternalRunning()
@@ -2579,34 +2647,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Email string `json:"email"`
ExpiresAt string `json:"expires_at"`
MaxConnections int `json:"max_connections"`
ServerID string `json:"server_id,omitempty"`
UUID string `json:"uuid"`
Name string `json:"name"`
Email string `json:"email"`
ExpiresAt string `json:"expires_at"`
MaxConnections int `json:"max_connections"`
ServerID string `json:"server_id,omitempty"`
PreserveExpires bool `json:"preserve_expires,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.UUID == "" {
http.Error(w, "uuid required", http.StatusBadRequest)
req.UUID = strings.TrimSpace(req.UUID)
req.Email = strings.TrimSpace(req.Email)
req.Name = strings.TrimSpace(req.Name)
if err := validateXrayClientFields(req.UUID, "", req.Email, req.Name, req.ExpiresAt, req.MaxConnections, false); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
http.Error(w, "invalid server id", http.StatusBadRequest)
return
}
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeManagedServerSelectionError(w, err)
return
} else if remote {
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
quotaUnlock := lockResellerQuota(sess.Username)
defer quotaUnlock()
row, exists, infoErr := remoteXrayClientInfo(r.Context(), ms, req.UUID)
if infoErr != nil {
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
return
}
if !exists {
http.Error(w, "Xray account not found", http.StatusNotFound)
return
}
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
oldMaxConnections := jsonInt(row["max_conns"])
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
if strings.TrimSpace(req.ExpiresAt) != "" {
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
return
}
req.PreserveExpires = true
req.MaxConnections = oldMaxConnections
}
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, oldMaxConnections, req.MaxConnections); quotaErr != nil {
writeResellerProvisionError(w, quotaErr)
return
}
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
writeBadGatewayError(w, "update Xray account on managed server", err)
return
}
writeProxyResponse(w, status, data, ct)
@@ -2617,16 +2721,34 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
return
}
sess := sessionFromCtx(r.Context())
if sess != nil && sess.Role == RoleReseller {
quotaUnlock := lockResellerQuota(sess.Username)
defer quotaUnlock()
}
existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
if err != nil {
http.Error(w, "client metadata not found", http.StatusNotFound)
return
}
sess := sessionFromCtx(r.Context())
if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if sess != nil && sess.Role == RoleReseller {
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
if strings.TrimSpace(req.ExpiresAt) != "" && resellerTimeExtended(existing.ExpiresAt, req.ExpiresAt) {
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
return
}
req.PreserveExpires = true
req.MaxConnections = existing.MaxConns
}
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, existing.MaxConns, req.MaxConnections); quotaErr != nil {
writeResellerProvisionError(w, quotaErr)
return
}
}
meta := XrayClientMeta{
UUID: req.UUID,
@@ -2636,6 +2758,9 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
OwnerUsername: existing.OwnerUsername,
MaxConns: req.MaxConnections,
}
if req.PreserveExpires {
meta.ExpiresAt = existing.ExpiresAt
}
if req.ExpiresAt != "" {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
if t, err := time.Parse(layout, req.ExpiresAt); err == nil {
@@ -2645,7 +2770,7 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
}
}
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
writeInternalError(w, "update Xray client metadata", err)
return
}
if req.Email != "" {
@@ -2664,13 +2789,13 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
return
}
inboundTag := r.URL.Query().Get("inbound_tag")
uuid := r.URL.Query().Get("uuid")
if inboundTag == "" || uuid == "" {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
uuid := strings.TrimSpace(r.URL.Query().Get("uuid"))
if err := validateXrayClientFields(uuid, inboundTag, "", "", "", 0, true); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeManagedServerSelectionError(w, err)
return
} else if remote {
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) {
@@ -2680,7 +2805,7 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid)
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
writeBadGatewayError(w, "delete Xray account from managed server", err)
return
}
writeProxyResponse(w, status, data, ct)
@@ -2704,7 +2829,12 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
}
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
lowerErr := strings.ToLower(err.Error())
if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
http.Error(w, "inbound not found", http.StatusBadRequest)
} else {
writeInternalError(w, "remove Xray client", err)
}
return
}
if statsStore != nil {