Compare commits
58
Commits
c1bb3c7a97
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a345e70e5a | ||
|
|
dab8b09f0c | ||
|
|
635f190630 | ||
|
|
64b1fc5cb3 | ||
|
|
047e4be207 | ||
|
|
7d90568869 | ||
|
|
92c5c2ace6 | ||
|
|
9001b47204 | ||
|
|
ba5b581aaf | ||
|
|
2776eba034 | ||
|
|
09c1f57a34 | ||
|
|
a4798f8db6 | ||
|
|
cc72b92932 | ||
|
|
aea27916e8 | ||
|
|
6f7fa2fad1 | ||
|
|
4e3c99650e | ||
|
|
cf49340b9a | ||
|
|
0b80a69192 | ||
|
|
1e9cef9e02 | ||
|
|
4a0383dce1 | ||
|
|
d4046526c9 | ||
|
|
e779d2486a | ||
|
|
8f088f7cca | ||
|
|
7200dbd236 | ||
|
|
f87023ebff | ||
|
|
e77dc6f62f | ||
|
|
4b9f6c123a | ||
|
|
aa676eb081 | ||
|
|
ea15f1bfa1 | ||
|
|
4866f0cf10 | ||
|
|
4f3b961fa3 | ||
|
|
0b5679bfeb | ||
|
|
ffe7964330 | ||
|
|
0eaa48ffd0 | ||
|
|
6cd9626db9 | ||
|
|
1479e6ac73 | ||
|
|
f64f7fdc4d | ||
|
|
15859dc7f3 | ||
|
|
60cb2e3cdb | ||
|
|
f1a587e00d | ||
|
|
1ad8b868ab | ||
|
|
67d56b2a76 | ||
|
|
b66d194fa7 | ||
|
|
391db7708f | ||
|
|
603ae906a1 | ||
|
|
4a04ff79f0 | ||
|
|
e00a7bd93c | ||
|
|
77a722d4ed | ||
|
|
03c43debf4 | ||
|
|
51aedfd3c7 | ||
|
|
3c7b02b8db | ||
|
|
3ddd934d9a | ||
|
|
c74f6e2282 | ||
|
|
43482c88fa | ||
|
|
09f3959aa2 | ||
|
|
9b5f436a6e | ||
|
|
d01fb919aa | ||
|
|
41aca3b7f3 |
+3
-27
@@ -1,27 +1,3 @@
|
||||
# Build output
|
||||
sshpanel
|
||||
sshpanel.bak
|
||||
*.bak
|
||||
|
||||
# Runtime/generated config
|
||||
.env
|
||||
config.json
|
||||
xray_config.json
|
||||
banner.txt
|
||||
|
||||
# Secrets / keys / certificates
|
||||
keys/
|
||||
certs/
|
||||
*.pem
|
||||
*.key
|
||||
ssh_host_*_key
|
||||
ssh_host_*_key.pub
|
||||
|
||||
# Logs / runtime data
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Local/editor
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
/shell2.exe
|
||||
/BOT_PLAN.md
|
||||
/SECURITY_REVIEW.md
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accountRenewPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Days int `json:"days,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func renewalDays(owner string, requested int) int {
|
||||
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
||||
return 31
|
||||
}
|
||||
if requested == 0 {
|
||||
return 30
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func renewalExpiry(existing *time.Time, days int) time.Time {
|
||||
base := time.Now()
|
||||
if existing != nil && existing.After(base) {
|
||||
base = *existing
|
||||
}
|
||||
return base.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
func jsonInt(value interface{}) int {
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
result, _ := strconv.Atoi(value.String())
|
||||
return result
|
||||
default:
|
||||
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
return int(result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := userMgr.Get(p.Username)
|
||||
if !ok {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cfg := state.Cfg
|
||||
existingExpiry := state.ExpiresAt
|
||||
state.mu.Unlock()
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if cfg.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(cfg.MaxConnections)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(existingExpiry, p.Days)
|
||||
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
reloadUsersFromDB(ctx, store)
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
||||
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.UUID = strings.TrimSpace(p.UUID)
|
||||
if _, err := parseUUID(p.UUID); err != nil {
|
||||
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if meta.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(meta.MaxConns)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
||||
meta.ExpiresAt = &next
|
||||
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runtimeWarning := ""
|
||||
if meta.OwnerUsername != "" {
|
||||
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
||||
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
||||
}
|
||||
}
|
||||
|
||||
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Tag != meta.InboundTag {
|
||||
continue
|
||||
}
|
||||
for _, client := range inbound.Clients {
|
||||
if client.UUID == meta.UUID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(meta.Email)
|
||||
if email == "" {
|
||||
email = meta.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`)
|
||||
|
||||
func validateAccountUsername(username string) error {
|
||||
if !accountUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAccountControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0
|
||||
}
|
||||
|
||||
func validateOptionalAccountExpiry(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
|
||||
func validateSSHUserPayload(p *UserPayload) error {
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.OwnerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Password != nil && len(*p.Password) > 4096 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
if p.MaxConnections < 0 || p.MaxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 {
|
||||
return fmt.Errorf("speed limits must be between 0 and 100000 Mbps")
|
||||
}
|
||||
if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(p.OwnerUsername); err != nil {
|
||||
return fmt.Errorf("invalid owner username")
|
||||
}
|
||||
}
|
||||
if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) {
|
||||
return fmt.Errorf("invalid server id")
|
||||
}
|
||||
if p.TOTPSecret != "" {
|
||||
if len(p.TOTPSecret) > 256 {
|
||||
return fmt.Errorf("TOTP secret is too long")
|
||||
}
|
||||
if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil {
|
||||
return fmt.Errorf("invalid TOTP secret")
|
||||
}
|
||||
}
|
||||
if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) {
|
||||
return fmt.Errorf("TOTP period must be between 15 and 300 seconds")
|
||||
}
|
||||
if p.TOTPWindow < 0 || p.TOTPWindow > 10 {
|
||||
return fmt.Errorf("TOTP window must be between 0 and 10")
|
||||
}
|
||||
if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) {
|
||||
return fmt.Errorf("TOTP digits must be between 6 and 8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if _, err := parseUUID(uuid); err != nil {
|
||||
return fmt.Errorf("invalid UUID")
|
||||
}
|
||||
if requireInbound && strings.TrimSpace(inboundTag) == "" {
|
||||
return fmt.Errorf("inbound_tag required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"inbound_tag": inboundTag,
|
||||
"email": email,
|
||||
"name": name,
|
||||
} {
|
||||
limit := 256
|
||||
if field == "inbound_tag" {
|
||||
limit = 128
|
||||
}
|
||||
if len(value) > limit || hasAccountControlCharacters(value) {
|
||||
return fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
}
|
||||
if maxConnections < 0 || maxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
return validateOptionalAccountExpiry(expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateSSHUserPayloadBounds(t *testing.T) {
|
||||
valid := &UserPayload{
|
||||
Username: "client-01",
|
||||
MaxConnections: 2,
|
||||
TOTPPeriod: 60,
|
||||
TOTPWindow: 1,
|
||||
TOTPDigits: 6,
|
||||
}
|
||||
if err := validateSSHUserPayload(valid); err != nil {
|
||||
t.Fatalf("valid SSH payload rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.MaxConnections = -1
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("negative max_connections was accepted")
|
||||
}
|
||||
|
||||
invalid = *valid
|
||||
invalid.TOTPSecret = "not base32!"
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("invalid TOTP secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateXrayClientFields(t *testing.T) {
|
||||
const id = "d9428888-122b-11e1-b85c-61cd3cbb3210"
|
||||
if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil {
|
||||
t.Fatalf("valid Xray client rejected: %v", err)
|
||||
}
|
||||
if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil {
|
||||
t.Fatal("invalid Xray UUID was accepted")
|
||||
}
|
||||
if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil {
|
||||
t.Fatal("excessive Xray connection limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) {
|
||||
username := "credit-limit-test"
|
||||
adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true})
|
||||
defer adminUsers.delete(username)
|
||||
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable {
|
||||
t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable)
|
||||
}
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil {
|
||||
t.Fatalf("unchanged credit limit rejected: %v", err)
|
||||
}
|
||||
if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit {
|
||||
t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
/* DragonCore Command - original black-only admin panel */
|
||||
:root{
|
||||
color-scheme:dark;
|
||||
--bg:#020305;
|
||||
--bg-2:#07090d;
|
||||
--panel:#0a0d12;
|
||||
--panel-2:#0d1118;
|
||||
--card:#0c1017;
|
||||
--card-bg:#0c1017;
|
||||
--card-2:#101620;
|
||||
--card-3:#121a25;
|
||||
--input-bg:#070b11;
|
||||
--line:#1b2636;
|
||||
--line-2:#27364b;
|
||||
--border:rgba(148,163,184,.14);
|
||||
--text:#f3f7ff;
|
||||
--text-2:#d6dfec;
|
||||
--muted:#8390a3;
|
||||
--muted-2:#657386;
|
||||
--accent:#22d3ee;
|
||||
--accent-2:#8b5cf6;
|
||||
--accent-3:#14f195;
|
||||
--accent-soft:rgba(34,211,238,.13);
|
||||
--success:#31d67b;
|
||||
--danger:#ff5b69;
|
||||
--warn:#ffc857;
|
||||
--radius-xl:24px;
|
||||
--radius-lg:18px;
|
||||
--radius-md:14px;
|
||||
--shadow:0 22px 70px rgba(0,0,0,.48);
|
||||
--glow:0 0 0 1px rgba(34,211,238,.05),0 0 42px rgba(34,211,238,.10);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
html,body{width:100%;min-height:100%;overflow-x:hidden;background:var(--bg);}
|
||||
body{
|
||||
font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
|
||||
color:var(--text);
|
||||
min-height:100vh;
|
||||
background:
|
||||
radial-gradient(circle at 18% -10%,rgba(34,211,238,.16),transparent 34%),
|
||||
radial-gradient(circle at 92% 12%,rgba(139,92,246,.18),transparent 34%),
|
||||
linear-gradient(180deg,#020305 0%,#05070b 46%,#020305 100%);
|
||||
}
|
||||
body::before{
|
||||
content:"";
|
||||
position:fixed;
|
||||
inset:0;
|
||||
pointer-events:none;
|
||||
opacity:.28;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.032) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(255,255,255,.032) 1px,transparent 1px);
|
||||
background-size:54px 54px;
|
||||
mask-image:linear-gradient(to bottom,rgba(0,0,0,.8),transparent 80%);
|
||||
}
|
||||
button,input,select,textarea{font:inherit;}
|
||||
button{appearance:none;}
|
||||
a{color:inherit;}
|
||||
.hidden{display:none!important;}
|
||||
.i18n-pending body{visibility:hidden;}
|
||||
|
||||
.app{min-height:100vh;padding:0;background:transparent;}
|
||||
.shell{min-height:100vh;width:100%;max-width:none;margin:0;padding:0;background:transparent;border:0;box-shadow:none;}
|
||||
.panel-layout{min-height:100vh;display:block;background:transparent;}
|
||||
@supports (min-height:100dvh){.app,.shell,.panel-layout{min-height:100dvh;}}
|
||||
|
||||
/* Desktop shell alignment: keep sidebar and content aligned so the brand panel does not look clipped */
|
||||
@media(min-width:901px){
|
||||
.panel-layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:18px;padding:18px;}
|
||||
.sidebar{position:sticky;left:auto;top:18px;bottom:auto;width:300px;height:calc(100vh - 36px);max-height:calc(100vh - 36px);}
|
||||
@supports (height:100dvh){.sidebar{height:calc(100dvh - 36px);max-height:calc(100dvh - 36px);}}
|
||||
.workspace{margin-left:0;min-height:calc(100vh - 36px);border:1px solid rgba(148,163,184,.10);border-radius:28px;overflow:hidden;background:linear-gradient(180deg,rgba(6,9,14,.52),rgba(2,3,5,.18));box-shadow:var(--shadow);}
|
||||
@supports (min-height:100dvh){.workspace{min-height:calc(100dvh - 36px);}}
|
||||
}
|
||||
|
||||
|
||||
/* Login */
|
||||
.overlay{
|
||||
position:fixed;inset:0;z-index:50;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
padding:22px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%,rgba(34,211,238,.18),transparent 38%),
|
||||
radial-gradient(circle at 12% 86%,rgba(139,92,246,.18),transparent 35%),
|
||||
rgba(2,3,5,.96);
|
||||
}
|
||||
.overlay-inner{
|
||||
width:min(100%,390px);
|
||||
position:relative;
|
||||
padding:28px;
|
||||
border-radius:28px;
|
||||
border:1px solid rgba(148,163,184,.14);
|
||||
background:linear-gradient(180deg,rgba(14,20,30,.96),rgba(6,9,14,.98));
|
||||
box-shadow:0 32px 90px rgba(0,0,0,.72),0 0 80px rgba(34,211,238,.08);
|
||||
overflow:hidden;
|
||||
}
|
||||
.overlay-inner::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:3px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2),var(--accent-3));
|
||||
}
|
||||
.ov-title{font-size:1.28rem;line-height:1.1;font-weight:850;letter-spacing:.01em;margin-bottom:8px;}
|
||||
.ov-sub{font-size:.88rem;line-height:1.5;color:var(--muted);margin-bottom:20px;}
|
||||
.ov-field,
|
||||
.field input,.field select,.field textarea,.code-area{
|
||||
width:100%;min-width:0;outline:0;color:var(--text);
|
||||
border:1px solid var(--line);
|
||||
background:linear-gradient(180deg,var(--input-bg),#06090f);
|
||||
border-radius:14px;
|
||||
padding:11px 13px;
|
||||
transition:border-color .16s ease,box-shadow .16s ease,background .16s ease;
|
||||
}
|
||||
.ov-field{margin:7px 0;}
|
||||
.ov-field::placeholder,input::placeholder,textarea::placeholder{color:#526073;}
|
||||
.ov-field:focus,
|
||||
.field input:focus,.field select:focus,.field textarea:focus,.code-area:focus{
|
||||
border-color:rgba(34,211,238,.62);
|
||||
box-shadow:0 0 0 3px rgba(34,211,238,.10),0 0 32px rgba(34,211,238,.08);
|
||||
}
|
||||
input[type="datetime-local"],input[type="date"],input[type="time"]{color-scheme:dark;}
|
||||
input[type="checkbox"]{accent-color:var(--accent);}
|
||||
select{color-scheme:dark;}
|
||||
|
||||
/* Shell */
|
||||
.sidebar{
|
||||
position:fixed;left:18px;top:18px;bottom:18px;z-index:25;
|
||||
width:284px;display:flex;flex-direction:column;overflow:hidden;
|
||||
border:1px solid rgba(148,163,184,.12);
|
||||
border-radius:28px;
|
||||
background:linear-gradient(180deg,rgba(12,16,23,.94),rgba(5,8,13,.96));
|
||||
box-shadow:var(--shadow),var(--glow);
|
||||
backdrop-filter:blur(18px);
|
||||
}
|
||||
.brand-block{height:116px;display:flex;align-items:center;gap:14px;padding:22px 28px 22px 22px;border-bottom:1px solid rgba(148,163,184,.10);}
|
||||
.brand-mark{
|
||||
width:58px;height:58px;display:grid;place-items:center;border-radius:20px;
|
||||
color:#061015;font-size:1rem;font-weight:950;letter-spacing:-.05em;
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-3));
|
||||
box-shadow:0 16px 44px rgba(34,211,238,.18),inset 0 1px 0 rgba(255,255,255,.45);
|
||||
}
|
||||
.brand-copy{display:flex;flex-direction:column;gap:5px;min-width:0;}
|
||||
.brand-copy strong{font-size:1.08rem;font-weight:900;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.brand-copy span{font-size:.7rem;text-transform:uppercase;letter-spacing:.22em;color:var(--muted);white-space:nowrap;}
|
||||
.side-nav{flex:1;display:flex;flex-direction:column;gap:7px;padding:18px 14px 20px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--line-2) transparent;}
|
||||
.nav-group-label{margin:18px 12px 5px;color:var(--muted-2);font-size:.68rem;text-transform:uppercase;letter-spacing:.2em;font-weight:800;}
|
||||
.tab-btn{
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:10px;
|
||||
border:1px solid transparent;border-radius:999px;background:transparent;color:var(--muted);
|
||||
padding:8px 13px;font-size:.82rem;font-weight:760;cursor:pointer;
|
||||
transition:background .16s ease,border-color .16s ease,color .16s ease,transform .16s ease,box-shadow .16s ease;
|
||||
}
|
||||
.tab-btn:hover{color:var(--text);border-color:rgba(148,163,184,.16);background:rgba(255,255,255,.035);}
|
||||
.side-nav .tab-btn{width:100%;justify-content:flex-start;border-radius:18px;padding:12px 13px;color:var(--text-2);font-size:.92rem;}
|
||||
.side-nav .tab-btn.active{
|
||||
color:#fff;border-color:rgba(34,211,238,.28);
|
||||
background:
|
||||
linear-gradient(135deg,rgba(34,211,238,.18),rgba(139,92,246,.13)),
|
||||
rgba(255,255,255,.045);
|
||||
box-shadow:inset 3px 0 0 var(--accent),0 14px 28px rgba(0,0,0,.22);
|
||||
}
|
||||
.nav-icon{width:26px;height:26px;display:grid;place-items:center;border-radius:10px;background:rgba(255,255,255,.05);font-size:.95rem;}
|
||||
.side-nav .tab-btn.active .nav-icon{background:rgba(34,211,238,.15);color:var(--accent);}
|
||||
|
||||
.workspace{margin-left:320px;min-height:100vh;display:flex;flex-direction:column;min-width:0;}
|
||||
@supports (min-height:100dvh){.workspace{min-height:100dvh;}}
|
||||
.topbar{
|
||||
position:sticky;top:0;z-index:18;
|
||||
height:92px;margin:0;padding:18px 30px;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:18px;
|
||||
border-bottom:1px solid rgba(148,163,184,.10);
|
||||
background:linear-gradient(180deg,rgba(2,3,5,.88),rgba(2,3,5,.66));
|
||||
backdrop-filter:blur(18px);
|
||||
}
|
||||
.topbar-left,.topbar-actions{display:flex;align-items:center;gap:12px;min-width:0;}
|
||||
.topbar-title{display:flex;flex-direction:column;gap:4px;min-width:0;}
|
||||
.topbar-title span{font-size:.68rem;line-height:1;text-transform:uppercase;letter-spacing:.22em;color:var(--accent);font-weight:850;}
|
||||
.topbar-title strong{font-size:1.18rem;line-height:1.15;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.icon-btn,
|
||||
.language-select,
|
||||
.user-pill{
|
||||
min-height:42px;border:1px solid rgba(148,163,184,.14);border-radius:15px;
|
||||
background:rgba(255,255,255,.045);color:var(--text);
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
|
||||
}
|
||||
.icon-btn{width:42px;display:none;align-items:center;justify-content:center;cursor:pointer;}
|
||||
.language-select{padding:0 12px;font-size:.8rem;font-weight:800;outline:0;}
|
||||
.user-pill{display:flex;align-items:center;gap:8px;padding:0 12px;max-width:230px;}
|
||||
.user-pill strong{font-size:.84rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-main{width:100%;min-width:0;padding:30px;}
|
||||
|
||||
/* Cards and dashboard */
|
||||
.tab-pane{display:none;animation:fadeIn .2s ease both;}
|
||||
.tab-pane.active{display:block;}
|
||||
@keyframes fadeIn{from{opacity:.35;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||
.card{
|
||||
min-width:0;position:relative;overflow:hidden;
|
||||
border:1px solid rgba(148,163,184,.12);
|
||||
border-radius:var(--radius-xl);
|
||||
background:linear-gradient(180deg,rgba(16,22,32,.94),rgba(9,13,19,.96));
|
||||
box-shadow:0 20px 58px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.025);
|
||||
padding:18px;
|
||||
}
|
||||
.card::before{
|
||||
content:"";position:absolute;left:0;right:0;top:0;height:1px;
|
||||
background:linear-gradient(90deg,transparent,rgba(34,211,238,.28),transparent);
|
||||
pointer-events:none;
|
||||
}
|
||||
.card+.card{margin-top:18px;}
|
||||
.card-hdr{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px;min-width:0;}
|
||||
.card-title{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;font-size:1rem;font-weight:900;letter-spacing:.005em;}
|
||||
.card-actions,.form-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
|
||||
.grid2{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.62fr);gap:18px;align-items:start;}
|
||||
.dashboard-lower{margin-top:18px;}
|
||||
.dash-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;}
|
||||
.dash-card{
|
||||
position:relative;min-height:154px;overflow:hidden;
|
||||
border:1px solid rgba(148,163,184,.12);
|
||||
border-radius:28px;padding:20px;
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%,rgba(255,255,255,.08),transparent 34%),
|
||||
linear-gradient(180deg,rgba(16,22,32,.95),rgba(8,12,18,.98));
|
||||
box-shadow:0 20px 60px rgba(0,0,0,.28);
|
||||
}
|
||||
.dash-card::after{content:"";position:absolute;inset:auto -35px -52px auto;width:140px;height:140px;border-radius:999px;background:var(--accent-soft);filter:blur(2px);}
|
||||
.dash-card-main{position:relative;z-index:1;display:flex;flex-direction:column;gap:8px;}
|
||||
.dash-label{color:var(--muted);font-size:.74rem;text-transform:uppercase;letter-spacing:.14em;font-weight:850;}
|
||||
.dash-card strong{font-size:2rem;letter-spacing:-.05em;line-height:1.05;}
|
||||
.dash-card small{font-size:.78rem;line-height:1.35;color:var(--muted);}
|
||||
.dash-icon{
|
||||
position:absolute;right:17px;top:17px;width:44px;height:44px;border-radius:17px;
|
||||
display:grid;place-items:center;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.075);
|
||||
color:var(--accent);font-size:1.15rem;
|
||||
}
|
||||
.accent-blue{--accent-soft:rgba(34,211,238,.13);}
|
||||
.accent-green{--accent-soft:rgba(20,241,149,.12);}
|
||||
.accent-purple{--accent-soft:rgba(139,92,246,.14);}
|
||||
.accent-orange{--accent-soft:rgba(255,200,87,.13);}
|
||||
.quick-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}
|
||||
.quick-action{
|
||||
text-align:left;border:1px solid rgba(148,163,184,.12);border-radius:18px;
|
||||
background:rgba(255,255,255,.035);color:var(--text);padding:14px;cursor:pointer;
|
||||
transition:transform .16s ease,border-color .16s ease,background .16s ease;
|
||||
}
|
||||
.quick-action:hover{transform:translateY(-1px);border-color:rgba(34,211,238,.28);background:rgba(34,211,238,.06);}
|
||||
.quick-action strong{display:block;font-size:.9rem;margin-bottom:5px;}
|
||||
.quick-action span{display:block;color:var(--muted);font-size:.77rem;line-height:1.35;}
|
||||
|
||||
/* UI pieces */
|
||||
.btn{
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;
|
||||
border:1px solid rgba(34,211,238,.22);border-radius:14px;
|
||||
padding:9px 14px;cursor:pointer;
|
||||
color:#031014;font-weight:900;font-size:.82rem;
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-3));
|
||||
box-shadow:0 12px 30px rgba(34,211,238,.16);
|
||||
transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease,background .15s ease,color .15s ease;
|
||||
}
|
||||
.btn:hover{transform:translateY(-1px);box-shadow:0 16px 36px rgba(34,211,238,.22);}
|
||||
.btn-sm{min-height:34px;padding:7px 11px;font-size:.75rem;border-radius:12px;}
|
||||
.btn-ghost{color:var(--text-2);background:rgba(255,255,255,.045);border-color:rgba(148,163,184,.14);box-shadow:none;}
|
||||
.btn-ghost:hover{color:var(--text);background:rgba(34,211,238,.075);border-color:rgba(34,211,238,.28);box-shadow:none;}
|
||||
.btn-danger{color:#ffdce1;background:rgba(255,91,105,.12);border-color:rgba(255,91,105,.34);box-shadow:none;}
|
||||
.btn-danger:hover{background:rgba(255,91,105,.18);box-shadow:none;}
|
||||
.btn-warn{color:#fff3cf;background:rgba(255,200,87,.12);border-color:rgba(255,200,87,.34);box-shadow:none;}
|
||||
.btn-light,.btn-soft{color:var(--text);background:rgba(255,255,255,.07);border-color:rgba(148,163,184,.16);box-shadow:none;}
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:5px;
|
||||
border:1px solid rgba(148,163,184,.14);border-radius:999px;
|
||||
padding:4px 9px;background:rgba(255,255,255,.045);color:var(--text-2);
|
||||
font-size:.69rem;font-weight:900;letter-spacing:.02em;white-space:nowrap;
|
||||
}
|
||||
.chip.green{color:#9ff4bf;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.10);}
|
||||
.chip.warn{color:#ffe3a1;border-color:rgba(255,200,87,.28);background:rgba(255,200,87,.10);}
|
||||
.chip.red{color:#ffc6cc;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.10);}
|
||||
.hint{font-size:.76rem;line-height:1.45;color:var(--muted);}
|
||||
.statusbar{margin-top:10px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;color:var(--muted);font-size:.76rem;}
|
||||
.badge-on,.badge-off{display:inline-flex;align-items:center;gap:6px;font-size:.73rem;font-weight:900;}
|
||||
.badge-on{color:var(--success);}
|
||||
.badge-off{color:var(--muted);}
|
||||
.badge-on::before,.badge-off::before{content:"";width:7px;height:7px;border-radius:999px;background:currentColor;box-shadow:0 0 14px currentColor;}
|
||||
|
||||
/* Metrics */
|
||||
.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;}
|
||||
.metric{min-width:0;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.035);padding:14px;}
|
||||
.m-label{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.14em;font-weight:850;}
|
||||
.m-val{margin-top:7px;font-size:1.14rem;line-height:1.15;font-weight:950;color:var(--text);word-break:break-word;}
|
||||
|
||||
/* Forms */
|
||||
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px;}
|
||||
.field{display:flex;flex-direction:column;gap:6px;min-width:0;}
|
||||
.field label{color:var(--text-2);font-size:.75rem;font-weight:850;letter-spacing:.01em;}
|
||||
.field-row{display:flex;align-items:center;gap:8px;min-width:0;}
|
||||
.field-row input{flex:1 1 auto;}
|
||||
.form-actions{margin-top:13px;}
|
||||
.collapsible.collapsed{display:none;}
|
||||
textarea{resize:vertical;}
|
||||
.code-area{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:.75rem;line-height:1.45;min-height:160px;}
|
||||
pre.log-box,.log-box{
|
||||
display:block;width:100%;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;
|
||||
color:#b7c3d4;background:#05080d;border:1px solid rgba(148,163,184,.13);border-radius:18px;
|
||||
padding:14px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;font-size:.73rem;line-height:1.5;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.tbl-wrap{width:100%;overflow:auto;border:1px solid rgba(148,163,184,.12);border-radius:20px;background:rgba(3,6,10,.55);}
|
||||
table{width:100%;border-collapse:separate;border-spacing:0;min-width:760px;font-size:.8rem;}
|
||||
th,td{padding:11px 12px;text-align:left;vertical-align:middle;border-bottom:1px solid rgba(148,163,184,.09);}
|
||||
th{position:sticky;top:0;z-index:1;background:#080c12;color:var(--muted);font-size:.69rem;text-transform:uppercase;letter-spacing:.12em;font-weight:950;}
|
||||
tbody tr{transition:background .14s ease;}
|
||||
tbody tr:hover{background:rgba(34,211,238,.045);}
|
||||
tbody tr:last-child td{border-bottom:0;}
|
||||
td{color:var(--text-2);}
|
||||
td .btn+ .btn{margin-left:6px;}
|
||||
.table-meter,.mini-meter,.quota-meter,.bar{position:relative;display:block;overflow:hidden;background:rgba(148,163,184,.12);border-radius:999px;}
|
||||
.mini-meter{height:7px;margin-top:8px;}
|
||||
.quota-meter{height:12px;margin:14px 0 9px;}
|
||||
.table-meter{height:6px;margin-top:6px;max-width:170px;}
|
||||
.bar{height:8px;margin-top:8px;}
|
||||
.table-meter span,.mini-meter span,.quota-meter span,.bar-inner{display:block;height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-3));box-shadow:0 0 18px rgba(34,211,238,.24);transition:width .25s ease;}
|
||||
|
||||
/* Save/config helpers */
|
||||
.save-bar{
|
||||
position:sticky;bottom:18px;z-index:10;
|
||||
margin-top:18px;padding:14px 16px;
|
||||
display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;
|
||||
border:1px solid rgba(148,163,184,.14);border-radius:22px;
|
||||
background:linear-gradient(180deg,rgba(14,20,30,.92),rgba(7,10,16,.94));
|
||||
box-shadow:0 22px 60px rgba(0,0,0,.42);
|
||||
backdrop-filter:blur(16px);
|
||||
}
|
||||
.save-bar-actions{margin:0;}
|
||||
.mini-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px;}
|
||||
.mini-summary span{display:flex;flex-direction:column;gap:5px;border:1px solid rgba(148,163,184,.12);border-radius:16px;padding:12px;background:rgba(255,255,255,.035);}
|
||||
.mini-summary strong{font-size:1rem;}
|
||||
.mini-summary small{color:var(--muted);font-size:.72rem;}
|
||||
.reseller-helper-card{margin-bottom:18px;}
|
||||
hr{border:0;border-top:1px solid rgba(148,163,184,.12);margin:14px 0;}
|
||||
|
||||
|
||||
|
||||
/* Desktop shell alignment override */
|
||||
@media(min-width:901px){
|
||||
.panel-layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:18px;padding:18px;}
|
||||
.sidebar{position:sticky;left:auto;top:18px;bottom:auto;width:300px;height:calc(100vh - 36px);max-height:calc(100vh - 36px);}
|
||||
@supports (height:100dvh){.sidebar{height:calc(100dvh - 36px);max-height:calc(100dvh - 36px);}}
|
||||
.workspace{margin-left:0;min-height:calc(100vh - 36px);border:1px solid rgba(148,163,184,.10);border-radius:28px;overflow:hidden;background:linear-gradient(180deg,rgba(6,9,14,.52),rgba(2,3,5,.18));box-shadow:var(--shadow);}
|
||||
@supports (min-height:100dvh){.workspace{min-height:calc(100dvh - 36px);}}
|
||||
}
|
||||
|
||||
/* Layout stability fixes: keep wide pages from leaving broken empty columns */
|
||||
@media(min-width:1321px){
|
||||
.dash-grid{grid-template-columns:repeat(12,minmax(0,1fr));}
|
||||
.dash-grid>.dash-card{grid-column:span 3;}
|
||||
.dash-grid>.dash-resource{grid-column:span 4;}
|
||||
#mainApp.role-superadmin .dashboard-lower{grid-template-columns:1fr;}
|
||||
#mainApp.role-superadmin .dashboard-lower>.card:not(.hidden){grid-column:1/-1;}
|
||||
#mainApp.role-superadmin .dashboard-lower .quick-actions{grid-template-columns:repeat(4,minmax(0,1fr));}
|
||||
#mainApp.role-reseller .dashboard-lower{grid-template-columns:minmax(0,1fr) minmax(360px,.62fr);}
|
||||
}
|
||||
@supports selector(:has(*)){
|
||||
.dashboard-lower:has(#dashboardQuotaCard.hidden){grid-template-columns:1fr;}
|
||||
.dashboard-lower:has(#dashboardQuotaCard.hidden)>.card:not(.hidden){grid-column:1/-1;}
|
||||
}
|
||||
|
||||
/* Mobile drawer */
|
||||
.drawer-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:22;backdrop-filter:blur(2px);}
|
||||
body.drawer-open .drawer-backdrop,body.sidebar-open .drawer-backdrop{display:block;}
|
||||
|
||||
@media(max-width:1320px){
|
||||
.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.grid2{grid-template-columns:1fr;}
|
||||
.metrics{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
}
|
||||
@media(max-width:900px){
|
||||
.panel-layout{display:block;padding:0;}
|
||||
.workspace{border:0;border-radius:0;overflow:visible;background:transparent;box-shadow:none;}
|
||||
.sidebar{left:12px;top:12px;bottom:12px;transform:translateX(calc(-100% - 24px));transition:transform .2s ease;width:min(86vw,310px);}
|
||||
body.drawer-open .sidebar,body.sidebar-open .sidebar,.sidebar.open{transform:translateX(0);}
|
||||
.workspace{margin-left:0;}
|
||||
.icon-btn{display:inline-flex;}
|
||||
.topbar{height:auto;min-height:78px;padding:16px;align-items:flex-start;}
|
||||
.topbar-actions{margin-left:auto;gap:8px;flex-wrap:wrap;justify-content:flex-end;}
|
||||
.workspace-main{padding:18px 14px 26px;}
|
||||
.dash-grid{grid-template-columns:1fr;gap:12px;}
|
||||
.quick-actions{grid-template-columns:1fr;}
|
||||
.language-select,.user-pill{min-height:38px;}
|
||||
}
|
||||
@media(max-width:640px){
|
||||
.topbar{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||
.topbar-left,.topbar-actions{width:100%;}
|
||||
.topbar-actions{justify-content:flex-start;}
|
||||
.topbar-title strong{font-size:1.02rem;}
|
||||
.user-pill{max-width:100%;}
|
||||
.form-grid,.metrics,.mini-summary{grid-template-columns:1fr!important;}
|
||||
.card{border-radius:20px;padding:14px;}
|
||||
.dash-card{min-height:132px;border-radius:22px;padding:17px;}
|
||||
.dash-card strong{font-size:1.65rem;}
|
||||
.card-hdr{align-items:flex-start;flex-direction:column;}
|
||||
.field-row{flex-wrap:wrap;}
|
||||
.field-row .btn{flex:0 0 auto;}
|
||||
.save-bar{bottom:10px;border-radius:18px;}
|
||||
table{font-size:.76rem;}
|
||||
th,td{padding:9px 10px;}
|
||||
}
|
||||
|
||||
/* --- UI polish fixes for servers page / sidebar / language selector --- */
|
||||
@media(min-width:901px){
|
||||
.panel-layout{align-items:start;}
|
||||
.sidebar{align-self:start; position:sticky; top:18px;}
|
||||
}
|
||||
|
||||
/* Keep the sidebar visible while long pages scroll */
|
||||
.sidebar{
|
||||
overflow:hidden;
|
||||
}
|
||||
.side-nav{
|
||||
overscroll-behavior:contain;
|
||||
}
|
||||
|
||||
/* Better top alignment for paired cards */
|
||||
.grid2,
|
||||
.servers-grid{
|
||||
align-items:start;
|
||||
}
|
||||
.grid2 > .card,
|
||||
.grid2 > div,
|
||||
.servers-grid > .card,
|
||||
.servers-grid > div{
|
||||
align-self:start;
|
||||
margin-top:0 !important;
|
||||
}
|
||||
|
||||
/* Server form checkbox rows should align visually with the input fields */
|
||||
.server-form-grid{
|
||||
align-items:start;
|
||||
}
|
||||
.server-form-grid > .toggle-field{
|
||||
min-height:44px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:0 12px;
|
||||
border:1px solid var(--line);
|
||||
border-radius:14px;
|
||||
background:linear-gradient(180deg,var(--input-bg),#06090f);
|
||||
color:var(--text-2);
|
||||
font-size:.8rem;
|
||||
font-weight:800;
|
||||
cursor:pointer;
|
||||
}
|
||||
.server-form-grid > .toggle-field input{
|
||||
flex:0 0 auto;
|
||||
}
|
||||
|
||||
/* Language selector dark theme fix */
|
||||
.language-select{
|
||||
color:var(--text);
|
||||
background:linear-gradient(180deg,rgba(17,23,32,.94),rgba(10,14,21,.98));
|
||||
border-color:rgba(148,163,184,.18);
|
||||
}
|
||||
.language-select:hover,
|
||||
.language-select:focus{
|
||||
border-color:rgba(34,211,238,.42);
|
||||
box-shadow:0 0 0 3px rgba(34,211,238,.10), 0 0 22px rgba(34,211,238,.08);
|
||||
}
|
||||
.language-select option,
|
||||
.language-select optgroup{
|
||||
background:#0d1118;
|
||||
color:#f3f7ff;
|
||||
}
|
||||
|
||||
/* Small visual consistency improvements */
|
||||
.topbar-actions{
|
||||
align-items:center;
|
||||
}
|
||||
.card-hdr{
|
||||
align-items:flex-start;
|
||||
}
|
||||
.card-hdr > .card-actions{
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
|
||||
/* --- sidebar follow-scroll fix --- */
|
||||
@media(min-width:901px){
|
||||
.panel-layout{
|
||||
display:block;
|
||||
padding:18px;
|
||||
}
|
||||
.sidebar{
|
||||
position:fixed !important;
|
||||
top:18px !important;
|
||||
left:18px !important;
|
||||
bottom:auto !important;
|
||||
width:300px !important;
|
||||
height:calc(100vh - 36px) !important;
|
||||
max-height:calc(100vh - 36px) !important;
|
||||
z-index:30;
|
||||
}
|
||||
@supports (height:100dvh){
|
||||
.sidebar{
|
||||
height:calc(100dvh - 36px) !important;
|
||||
max-height:calc(100dvh - 36px) !important;
|
||||
}
|
||||
}
|
||||
.workspace{
|
||||
margin-left:336px !important;
|
||||
min-height:calc(100vh - 36px);
|
||||
}
|
||||
@supports (min-height:100dvh){
|
||||
.workspace{min-height:calc(100dvh - 36px);}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --- Servers status page --- */
|
||||
.servers-status-toolbar{margin-bottom:16px;}
|
||||
.servers-status-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit,minmax(330px,1fr));
|
||||
gap:16px;
|
||||
align-items:start;
|
||||
}
|
||||
.server-status-card{
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
border:1px solid rgba(148,163,184,.12);
|
||||
border-radius:24px;
|
||||
padding:16px;
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%,rgba(255,255,255,.08),transparent 34%),
|
||||
linear-gradient(180deg,rgba(16,22,32,.95),rgba(8,12,18,.98));
|
||||
box-shadow:0 20px 58px rgba(0,0,0,.26),inset 0 1px 0 rgba(255,255,255,.025);
|
||||
}
|
||||
.server-status-card::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
right:-38px;
|
||||
bottom:-58px;
|
||||
width:150px;
|
||||
height:150px;
|
||||
border-radius:999px;
|
||||
background:rgba(34,211,238,.12);
|
||||
pointer-events:none;
|
||||
}
|
||||
.server-status-offline{opacity:.72;}
|
||||
.server-status-offline::after{background:rgba(255,91,105,.11);}
|
||||
.server-status-head{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
margin-bottom:12px;
|
||||
}
|
||||
.server-status-title{font-size:1rem;font-weight:950;color:var(--text);line-height:1.1;}
|
||||
.server-status-url{margin-top:5px;color:var(--muted);font-size:.72rem;font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;word-break:break-all;}
|
||||
.server-status-badges{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;}
|
||||
.server-status-error{position:relative;z-index:1;margin-bottom:10px;color:#ffc6cc;font-size:.76rem;}
|
||||
.server-mini-grid{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:10px;
|
||||
}
|
||||
.server-mini-metric{
|
||||
min-width:0;
|
||||
border:1px solid rgba(148,163,184,.11);
|
||||
border-radius:18px;
|
||||
padding:12px;
|
||||
background:rgba(255,255,255,.035);
|
||||
}
|
||||
.server-mini-label{color:var(--muted);font-size:.66rem;text-transform:uppercase;letter-spacing:.14em;font-weight:900;}
|
||||
.server-mini-value{margin-top:6px;font-size:1.28rem;line-height:1.05;font-weight:950;color:var(--text);letter-spacing:-.04em;}
|
||||
.server-mini-note{margin-top:5px;min-height:15px;color:var(--muted);font-size:.7rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.server-mini-bar{height:7px;margin-top:9px;border-radius:999px;background:rgba(148,163,184,.12);overflow:hidden;}
|
||||
.server-mini-bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-3));box-shadow:0 0 18px rgba(34,211,238,.24);transition:width .25s ease;}
|
||||
.server-status-footer{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
display:grid;
|
||||
gap:5px;
|
||||
margin-top:12px;
|
||||
color:var(--muted);
|
||||
font-size:.72rem;
|
||||
line-height:1.35;
|
||||
}
|
||||
@media(max-width:640px){
|
||||
.servers-status-grid{grid-template-columns:1fr;}
|
||||
.server-mini-grid{grid-template-columns:1fr;}
|
||||
}
|
||||
|
||||
|
||||
/* --- Xray full config and select color fixes --- */
|
||||
.field select,
|
||||
select,
|
||||
#xrayServerSelect,
|
||||
#wzLogLevel,
|
||||
#wzProtocol,
|
||||
#wzNetwork,
|
||||
#wzXHTTPMode,
|
||||
#wzTLS,
|
||||
#wzSSMethod {
|
||||
color:#f3f7ff !important;
|
||||
background:#070b12 !important;
|
||||
border-color:rgba(34,211,238,.26) !important;
|
||||
color-scheme:dark;
|
||||
}
|
||||
.field select option,
|
||||
select option,
|
||||
.field select optgroup,
|
||||
select optgroup {
|
||||
color:#f3f7ff !important;
|
||||
background:#0b111a !important;
|
||||
}
|
||||
#xrayServerHint.hidden,
|
||||
#sshServerHint.hidden { display:none !important; }
|
||||
|
||||
select option:checked,
|
||||
.field select option:checked {
|
||||
background:#1f2a3a !important;
|
||||
color:#f8fafc !important;
|
||||
}
|
||||
select:disabled {
|
||||
color:#94a3b8 !important;
|
||||
background:#070b12 !important;
|
||||
}
|
||||
|
||||
/* Xray runtime mode selector */
|
||||
.input-sm{
|
||||
min-height:30px;
|
||||
padding:4px 8px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(34,211,238,.26);
|
||||
background:#070b12;
|
||||
color:#f3f7ff;
|
||||
font-size:.78rem;
|
||||
}
|
||||
|
||||
/* Git update status card */
|
||||
.update-commit{
|
||||
font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
||||
font-size:1rem!important;
|
||||
letter-spacing:.01em!important;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
|
||||
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
|
||||
|
||||
/* --- Shared visual language for every workspace tab --- */
|
||||
.page-hero{
|
||||
--hero-accent:34,211,238;
|
||||
position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:22px;
|
||||
min-height:150px;margin-bottom:18px;padding:25px 27px;overflow:hidden;
|
||||
border:1px solid rgba(var(--hero-accent),.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 88% 8%,rgba(var(--hero-accent),.24),transparent 33%),
|
||||
radial-gradient(circle at 8% 115%,rgba(139,92,246,.12),transparent 40%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.32),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.page-hero::after{content:"";position:absolute;right:-65px;top:-100px;width:250px;height:250px;border:1px solid rgba(255,255,255,.055);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.017),0 0 0 68px rgba(255,255,255,.011);pointer-events:none;}
|
||||
.page-hero[data-tone="green"]{--hero-accent:49,214,123}.page-hero[data-tone="purple"]{--hero-accent:139,92,246}.page-hero[data-tone="amber"]{--hero-accent:255,200,87}.page-hero[data-tone="blue"]{--hero-accent:80,145,255}
|
||||
.page-hero-copy,.page-hero-pills,.page-hero-mark{position:relative;z-index:1}.page-hero-copy{max-width:700px}.page-kicker{display:block;color:rgb(var(--hero-accent));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.page-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em}.page-hero p{max-width:650px;margin-top:9px;color:var(--muted);font-size:.82rem;line-height:1.55}.page-hero-pills{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;max-width:42%}.page-hero-pills span{padding:7px 10px;border:1px solid rgba(var(--hero-accent),.18);border-radius:999px;background:rgba(var(--hero-accent),.075);color:var(--text-2);font-size:.69rem;font-weight:850;letter-spacing:.04em}.page-hero-mark{display:grid;place-items:center;width:64px;height:64px;border:1px solid rgba(var(--hero-accent),.25);border-radius:22px;background:rgba(var(--hero-accent),.10);color:rgb(var(--hero-accent));font-size:1.05rem;font-weight:950;box-shadow:0 18px 44px rgba(0,0,0,.24)}
|
||||
|
||||
/* Bot-style live workspace heroes shared by SSH, Xray, and infrastructure. */
|
||||
.page-hero.status-hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;min-height:0}.status-hero .page-hero-copy,.workspace-hero-actions,.workspace-overview-grid,.workspace-hero-toolbar{position:relative;z-index:1}.workspace-hero-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:620px}.workspace-live-status{display:inline-flex;align-items:center;gap:7px;max-width:360px;min-height:34px;padding:7px 11px;overflow:hidden;border:1px solid rgba(160,174,192,.14);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.7rem;font-weight:850;text-overflow:ellipsis;white-space:nowrap}.workspace-live-status::before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor}.workspace-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08)}.workspace-live-status.is-warn{color:#ffd36d;border-color:rgba(255,200,87,.25);background:rgba(255,200,87,.08)}.workspace-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08)}.workspace-live-status.is-loading{color:#71dff0;border-color:rgba(34,211,238,.23);background:rgba(34,211,238,.075)}
|
||||
.workspace-overview-grid{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;width:100%;margin-top:23px}.workspace-overview-grid.five{grid-template-columns:repeat(5,minmax(0,1fr))}.workspace-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid rgba(160,174,192,.14);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px)}.workspace-overview-card>div{display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.workspace-overview-card small{color:var(--muted);font-size:.64rem;font-weight:800;letter-spacing:.095em;text-transform:uppercase}.workspace-overview-card strong{overflow:hidden;color:var(--text);font-size:1rem;text-overflow:ellipsis;white-space:nowrap}.workspace-card-note{overflow:hidden;color:var(--muted);font-size:.61rem;text-overflow:ellipsis;white-space:nowrap}.workspace-overview-icon{display:grid;place-items:center;flex:0 0 auto;width:34px;height:34px;border:1px solid rgba(34,211,238,.17);border-radius:12px;background:rgba(34,211,238,.12);color:#4de0ef;font-size:.68rem;font-weight:950}.workspace-overview-icon.green{color:#72e6a4;border-color:rgba(49,214,123,.18);background:rgba(49,214,123,.11)}.workspace-overview-icon.purple{color:#b19cff;border-color:rgba(139,92,246,.2);background:rgba(139,92,246,.13)}.workspace-overview-icon.amber{color:#ffd36d;border-color:rgba(255,200,87,.18);background:rgba(255,200,87,.11)}.workspace-overview-icon.blue{color:#84b3ff;border-color:rgba(80,145,255,.2);background:rgba(80,145,255,.12)}.workspace-overview-icon.red{color:#ff8f99;border-color:rgba(255,91,105,.2);background:rgba(255,91,105,.11)}.workspace-mini-meter{height:3px;margin-top:2px;overflow:hidden;border-radius:999px;background:rgba(148,163,184,.1)}.workspace-mini-meter span{display:block;width:0;height:100%;border-radius:inherit;background:linear-gradient(90deg,rgb(var(--hero-accent)),#74edb0);transition:width .25s ease}
|
||||
.workspace-hero-toolbar{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:13px;padding-top:13px;border-top:1px solid rgba(148,163,184,.1)}.workspace-toolbar-status{min-width:0;color:var(--muted);font-size:.7rem;line-height:1.45}.workspace-toolbar-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}.workspace-toolbar-actions .input-sm{max-width:220px}
|
||||
|
||||
/* Four infrastructure screens share one compact section switcher. */
|
||||
.infra-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.infra-section-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px}.infra-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.infra-section-nav button span{margin-right:6px;color:#80abff}.infra-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.infra-section-nav button.active{color:#fff;border-color:rgba(80,145,255,.3);background:linear-gradient(135deg,rgba(80,145,255,.2),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.infra-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(80,145,255,.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}
|
||||
|
||||
/* Bot-style sub-navigation shared by SSH, Xray, resellers, and settings. */
|
||||
#tab-ssh{--section-accent:49,214,123}#tab-xray{--section-accent:139,92,246}#tab-resellers{--section-accent:255,200,87}#tab-server{--section-accent:80,145,255}
|
||||
.workspace-nav-shell{--section-accent:80,145,255;position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.9);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.workspace-nav-shell[data-tone="green"]{--section-accent:49,214,123}.workspace-nav-shell[data-tone="purple"]{--section-accent:139,92,246}.workspace-nav-shell[data-tone="amber"]{--section-accent:255,200,87}.workspace-nav-shell[data-tone="blue"]{--section-accent:80,145,255}.workspace-section-nav{display:grid;grid-template-columns:repeat(var(--workspace-nav-columns,2),minmax(0,1fr));gap:5px}.workspace-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.workspace-section-nav button span{margin-right:6px;color:rgb(var(--section-accent));font-size:.72rem}.workspace-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.workspace-section-nav button.active{color:#fff;border-color:rgba(var(--section-accent),.3);background:linear-gradient(135deg,rgba(var(--section-accent),.19),rgba(34,211,238,.07));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.workspace-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(var(--section-accent),.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}.workspace-section{display:none;min-width:0}.workspace-section.active{display:block}.workspace-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px}.workspace-section-heading>div>span{display:block;color:rgb(var(--section-accent,80,145,255));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.workspace-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em}.workspace-section-heading p{color:var(--muted);font-size:.79rem;line-height:1.5}.workspace-form-card{width:min(100%,920px)}.workspace-section-status{margin-top:16px}.settings-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.settings-panel-grid>.card{margin-top:0!important}.settings-panel-grid>.settings-span-all{grid-column:1/-1}.settings-workspace>.workspace-section>.card{width:min(100%,1040px);margin-top:0!important}
|
||||
.role-reseller #xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
|
||||
/* Shared panel-native confirmations and non-blocking notifications. */
|
||||
.panel-dialog{position:fixed;inset:0;z-index:120;display:grid;place-items:center;padding:20px}.panel-dialog.hidden{display:none!important}.panel-dialog-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(8px)}.panel-dialog-card{--hero-accent:80,145,255;position:relative;width:min(100%,510px);padding:22px;border:1px solid rgba(var(--hero-accent),.28);border-radius:25px;background:radial-gradient(circle at 100% 0,rgba(var(--hero-accent),.13),transparent 34%),linear-gradient(180deg,#121925,#080c13);box-shadow:0 36px 110px rgba(0,0,0,.66);animation:fadeIn .16s ease both}.panel-dialog-card.is-danger{--hero-accent:255,91,105}.panel-dialog-card.is-success{--hero-accent:49,214,123}.panel-dialog-head{display:flex;align-items:center;gap:13px}.panel-dialog-head h3{margin-top:5px;font-size:1.15rem;letter-spacing:-.025em}.panel-dialog-icon{display:grid;place-items:center;flex:0 0 auto;width:45px;height:45px;border:1px solid rgba(var(--hero-accent),.28);border-radius:15px;background:rgba(var(--hero-accent),.11);color:rgb(var(--hero-accent));font-size:.76rem;font-weight:950}.panel-dialog-card>p{margin-top:16px;color:var(--text-2);font-size:.79rem;line-height:1.58}.panel-dialog-detail{margin-top:12px;padding:12px 13px;border:1px solid rgba(148,163,184,.11);border-radius:14px;background:rgba(255,255,255,.035);color:var(--muted);font-size:.7rem;line-height:1.5;white-space:pre-line}.panel-dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:19px;padding-top:15px;border-top:1px solid rgba(148,163,184,.1)}.panel-dialog-open{overflow:hidden}.panel-toast-stack{position:fixed;right:20px;bottom:20px;z-index:130;display:flex;flex-direction:column-reverse;gap:9px;width:min(390px,calc(100vw - 40px));pointer-events:none}.panel-toast{--toast-accent:80,145,255;display:flex;align-items:flex-start;gap:10px;padding:12px 13px;border:1px solid rgba(var(--toast-accent),.28);border-radius:17px;background:rgba(10,15,24,.96);box-shadow:0 18px 55px rgba(0,0,0,.48);backdrop-filter:blur(15px);animation:toastIn .18s ease both;pointer-events:auto}.panel-toast.success{--toast-accent:49,214,123}.panel-toast.warning{--toast-accent:255,200,87}.panel-toast.error{--toast-accent:255,91,105}.panel-toast-icon{display:grid;place-items:center;flex:0 0 auto;width:28px;height:28px;border-radius:10px;background:rgba(var(--toast-accent),.12);color:rgb(var(--toast-accent));font-size:.68rem;font-weight:950}.panel-toast-copy{min-width:0;flex:1}.panel-toast-copy strong{display:block;color:var(--text);font-size:.75rem}.panel-toast-copy p{margin-top:3px;color:var(--muted);font-size:.69rem;line-height:1.42}.panel-toast-close{padding:2px;border:0;background:transparent;color:var(--muted);font-size:.85rem;cursor:pointer}@keyframes toastIn{from{opacity:0;transform:translateY(9px) scale(.98)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Bring legacy screens up to the Bot workspace's information density. */
|
||||
.tab-pane:not(#tab-bot)>.grid2,.tab-pane:not(#tab-bot)>#serversListView>.grid2{gap:16px}.tab-pane:not(#tab-bot) .card-hdr{padding-bottom:12px;border-bottom:1px solid rgba(148,163,184,.09)}.tab-pane:not(#tab-bot) .card-title{font-size:.96rem}.tab-pane:not(#tab-bot) .statusbar{margin-top:13px;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
|
||||
/* Xray visual configuration studio */
|
||||
.shared-endpoint-card{--hero-accent:139,92,246;position:relative;margin-bottom:18px;padding:20px;overflow:hidden;border:1px solid rgba(139,92,246,.22);border-radius:22px;background:radial-gradient(circle at 96% 0,rgba(139,92,246,.17),transparent 32%),rgba(8,12,20,.82)}
|
||||
.shared-endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.shared-endpoint-head h3,.visual-editor-heading h3{margin-top:5px;font-size:1.12rem;letter-spacing:-.02em}.shared-endpoint-head p{margin-top:5px;color:var(--muted);font-size:.75rem;line-height:1.45}.shared-endpoint-head code,.shared-route-preview code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#c8bbff}
|
||||
.shared-route-preview{display:grid;grid-template-columns:1fr 48px 1fr;align-items:center;gap:8px;margin:17px 0;padding:10px;border:1px solid rgba(148,163,184,.11);border-radius:17px;background:rgba(255,255,255,.025)}.shared-route-preview span{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border:1px solid rgba(139,92,246,.16);border-radius:13px;background:rgba(139,92,246,.07)}.shared-route-preview strong{font-size:.78rem}.shared-route-preview code{font-size:.77rem;font-weight:900}.shared-route-preview i{height:1px;background:linear-gradient(90deg,rgba(139,92,246,.2),rgba(34,211,238,.7),rgba(139,92,246,.2));position:relative}.shared-route-preview i::after{content:"";position:absolute;right:0;top:-3px;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}
|
||||
.shared-endpoint-grid{grid-template-columns:repeat(3,minmax(0,1fr));}.shared-endpoint-actions{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:15px;padding-top:14px;border-top:1px solid rgba(148,163,184,.1)}.shared-endpoint-actions .hint{max-width:650px}
|
||||
.legacy-xhttp-migration{display:flex;align-items:center;gap:13px;margin:-3px 0 15px;padding:13px 15px;border:1px solid rgba(49,214,123,.19);border-radius:18px;background:linear-gradient(135deg,rgba(49,214,123,.075),rgba(34,211,238,.035));color:var(--text-2)}.legacy-xhttp-icon{display:grid;place-items:center;flex:0 0 auto;width:42px;height:42px;border:1px solid rgba(49,214,123,.25);border-radius:14px;background:rgba(49,214,123,.11);color:#72e6a4;font-size:.68rem;font-weight:950;letter-spacing:.035em}.legacy-xhttp-migration strong{display:block;color:var(--text);font-size:.8rem}.legacy-xhttp-migration p{margin-top:3px;color:var(--muted);font-size:.71rem;line-height:1.5}
|
||||
.visual-config-toolbar{display:grid;grid-template-columns:180px minmax(0,1fr) auto;align-items:end;gap:14px;margin-bottom:13px;padding:13px 15px;border:1px solid rgba(148,163,184,.1);border-radius:18px;background:rgba(255,255,255,.025)}.visual-config-toolbar-copy{display:flex;flex-direction:column;gap:4px;padding-bottom:4px}.visual-config-toolbar-copy strong{font-size:.84rem}.visual-config-toolbar-copy span{color:var(--muted);font-size:.71rem}
|
||||
.visual-inbound-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:14px}.visual-inbound-card{position:relative;display:flex;flex-direction:column;gap:13px;min-width:0;padding:15px;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.027);transition:.15s ease}.visual-inbound-card:hover{border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.045);transform:translateY(-1px)}.visual-inbound-card-head,.visual-inbound-meta,.visual-inbound-actions{display:flex;align-items:center;gap:8px}.visual-inbound-card-head{justify-content:space-between}.visual-inbound-name{min-width:0}.visual-inbound-name strong{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--text);font-size:.84rem;white-space:nowrap}.visual-inbound-name small{display:block;margin-top:4px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.67rem}.visual-inbound-meta{flex-wrap:wrap}.visual-inbound-meta span{padding:4px 7px;border-radius:8px;background:rgba(148,163,184,.07);color:var(--muted);font-size:.67rem}.visual-inbound-actions{justify-content:flex-end;margin-top:auto;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
.legacy-ssh-btn{margin-right:auto;border-color:rgba(49,214,123,.3)!important;background:linear-gradient(135deg,rgba(49,214,123,.18),rgba(34,211,238,.09))!important;color:#8af0b5!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.legacy-ssh-btn:hover:not(:disabled){border-color:rgba(49,214,123,.52)!important;transform:translateY(-1px)}.legacy-ssh-btn.is-enabled:disabled{opacity:1;border-color:rgba(49,214,123,.16)!important;background:rgba(49,214,123,.07)!important;color:#72b98e!important;cursor:default}
|
||||
.visual-inbound-editor{margin:14px 0;padding:18px;border:1px solid rgba(34,211,238,.2);border-radius:22px;background:radial-gradient(circle at 100% 0,rgba(34,211,238,.09),transparent 28%),rgba(6,10,16,.86)}.visual-editor-heading{--hero-accent:34,211,238;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:15px;padding-bottom:13px;border-bottom:1px solid rgba(148,163,184,.1)}.visual-save-bar{position:sticky;bottom:14px;z-index:8;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:16px;padding:13px 15px;border:1px solid rgba(139,92,246,.2);border-radius:19px;background:rgba(8,12,20,.9);box-shadow:0 18px 48px rgba(0,0,0,.35);backdrop-filter:blur(16px)}
|
||||
|
||||
@media(max-width:1180px){.workspace-overview-grid.five{grid-template-columns:repeat(3,minmax(0,1fr))}.infra-nav-shell,.workspace-nav-shell{top:78px}}
|
||||
@media(max-width:1100px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}#configSectionNav{grid-template-columns:repeat(3,minmax(0,1fr))}#xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}.shared-endpoint-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.visual-inbound-list{grid-template-columns:1fr}}
|
||||
@media(max-width:760px){.page-hero{min-height:0;padding:20px;border-radius:22px;align-items:flex-start;flex-direction:column}.page-hero.status-hero{display:grid;grid-template-columns:1fr}.page-hero h2{font-size:1.55rem}.page-hero-pills{max-width:none;justify-content:flex-start}.page-hero-mark{width:50px;height:50px;border-radius:17px}.workspace-hero-actions{justify-content:flex-start;max-width:none;margin-top:14px}.workspace-live-status{max-width:100%}.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:18px}.workspace-hero-toolbar{align-items:stretch;flex-direction:column}.workspace-toolbar-actions{justify-content:flex-start}.workspace-toolbar-actions .input-sm{width:100%;max-width:none}.infra-section-nav,.workspace-section-nav{display:none}.infra-section-select,.workspace-section-select{display:block}.infra-nav-shell,.workspace-nav-shell{top:76px}.workspace-section-heading{align-items:flex-start;flex-direction:column}.workspace-section-heading>.btn,.workspace-section-heading>.card-actions{width:100%}.workspace-section-heading>.btn{justify-content:center}.settings-panel-grid{grid-template-columns:1fr}.settings-panel-grid>.settings-span-all{grid-column:auto}.shared-endpoint-card{padding:15px}.shared-endpoint-head,.shared-endpoint-actions,.visual-save-bar{align-items:flex-start;flex-direction:column}.shared-endpoint-actions .btn,.visual-save-bar .btn{width:100%}.shared-route-preview{grid-template-columns:1fr}.shared-route-preview i{width:1px;height:22px;justify-self:center}.shared-route-preview i::after{right:-3px;top:auto;bottom:0}.shared-endpoint-grid{grid-template-columns:1fr!important}.legacy-xhttp-migration{align-items:flex-start}.visual-config-toolbar{grid-template-columns:1fr;align-items:stretch}.visual-config-toolbar .btn{width:100%}.visual-inbound-actions{align-items:stretch;flex-wrap:wrap}.legacy-ssh-btn{flex:1 0 100%;margin-right:0}.panel-dialog{padding:14px}.panel-dialog-card{padding:19px}.panel-dialog-actions .btn{flex:1}.panel-toast-stack{right:14px;bottom:14px;width:calc(100vw - 28px)}}
|
||||
@media(max-width:460px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:1fr}.workspace-hero-actions .btn{flex:1}.workspace-toolbar-actions .btn{flex:1}.workspace-overview-card{padding:11px 12px}}
|
||||
|
||||
/* --- Bot sales workspace --- */
|
||||
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
|
||||
.bot-hero{
|
||||
position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%),
|
||||
radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;}
|
||||
.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;}
|
||||
.bot-hero-copy{max-width:620px;}
|
||||
.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;}
|
||||
.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;}
|
||||
.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;}
|
||||
.bot-hero-copy p{margin-top:8px;}
|
||||
.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;}
|
||||
.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;}
|
||||
.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;}
|
||||
.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);}
|
||||
.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);}
|
||||
.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;}
|
||||
.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);}
|
||||
.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);}
|
||||
|
||||
.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);}
|
||||
.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;}
|
||||
.bot-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);}
|
||||
.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;}
|
||||
.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;}
|
||||
.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;}
|
||||
|
||||
.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;}
|
||||
.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);}
|
||||
.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);}
|
||||
.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;}
|
||||
.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;}
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
|
||||
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
|
||||
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
|
||||
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
|
||||
@@ -0,0 +1,737 @@
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || "";
|
||||
if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let currentQuotaMode = "slots";
|
||||
let currentCreditBalance = 0;
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
let tlsForwardersState = [];
|
||||
let managedTlsForwardersState = [];
|
||||
let editingXrayClientId = null;
|
||||
let wzInbounds = [];
|
||||
let wzLoadedFullConfig = null;
|
||||
let wzLoadedConfigText = "";
|
||||
let wzLoadedServerID = null;
|
||||
let wzDirty = false;
|
||||
let wzEditingIndex = -1;
|
||||
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
|
||||
let currentTab = "dashboard";
|
||||
let inboundsRefreshInFlight = false;
|
||||
let lastInboundsStructure = "";
|
||||
let serversCache = [];
|
||||
let selectedSSHServerID = localStorage.getItem("SSH_SERVER_ID") || "local";
|
||||
let selectedXrayServerID = localStorage.getItem("XRAY_SERVER_ID") || "local";
|
||||
let configuringServerID = "";
|
||||
|
||||
|
||||
// ─── Language / i18n ─────────────────────────────────────────────────────────
|
||||
const SUPPORTED_LANGS = ["pt-BR", "en-US"];
|
||||
const LANG_STORAGE_KEY = "PANEL_LANG";
|
||||
const I18N_TEXT = {
|
||||
"en-US": {
|
||||
"Dashboard":"Dashboard","Overview":"Overview","Accounts":"Accounts","Administration":"Administration","Server":"Server","System":"System","Settings":"Settings","Traffic":"Traffic","Monitoring":"Monitoring",
|
||||
"SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Xray Users","Resellers":"Resellers","Logs":"Logs","VnStat":"VnStat","VPN Control":"VPN Control","DragonCore":"DragonCore",
|
||||
"SSH Panel":"SSH Panel","Sign in with your admin or reseller credentials.":"Sign in with your admin or reseller credentials.","Username":"Username","Password":"Password","Sign in":"Sign in","Logout":"Logout","Open menu":"Open menu","Toggle theme":"Toggle theme","Language":"Language",
|
||||
"Total accounts":"Total accounts","active":"active","expired":"expired","available limit":"Available limit","Loading quota…":"Loading quota…","Active connections":"Active connections","SSH + Xray online now":"SSH + Xray online now","Ready for resellers":"Ready for resellers","Server monitoring in real time":"real-time monitoring","CPU":"CPU","RAM":"RAM","Network":"Network","Processor load":"Processor load","Memory used":"Memory used","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s",
|
||||
"Quick actions":"Quick actions","simple":"simple","Create SSH":"Create SSH","Create Xray":"Create Xray","New reseller":"New reseller","Configure services":"Configure services","User, password, expiry and limit.":"User, password, expiry and limit.","UUID, label, expiry and connections.":"UUID, label, expiry and connections.","Plan, expiry and account limit.":"Plan, expiry and account limit.","Ports, DNSTT, UDPGW and TLS.":"Ports, DNSTT, UDPGW and TLS.","My quota":"My quota","Loading…":"Loading…","Loading...":"Loading...",
|
||||
"My Account":"My Account","Users (used / max)":"Users (used / max)","Users (used/max)":"Users (used/max)","Expires":"Expires","Status":"Status","Users":"Users","User":"User","Auth":"Auth","Conn":"Conn","Max":"Max","Up":"Up","Dn":"Dn","Owner":"Owner","Actions":"Actions","Create / update user":"Create / update user","Create / edit user form":"Create / edit user form","Show form":"Show form","Hide form":"Hide form","TOTP Secret":"TOTP Secret","TOTP Period (s)":"TOTP Period (s)","TOTP Window":"TOTP Window","TOTP Digits":"TOTP Digits","Allow static password too":"Allow static password too","Max connections":"Max connections","Expires at":"Expires at","Max Upload (Mb/s)":"Max Upload (Mb/s)","Max Download (Mb/s)":"Max Download (Mb/s)","Save user":"Save user","Cancel":"Cancel","Gen":"Gen","Copy":"Copy","Edit":"Edit","Del":"Del","Reload":"Reload","Refresh":"Refresh","+ New":"+ New","+ Add":"+ Add","Add":"Add","Remove":"Remove",
|
||||
"Running":"Running","Stopped":"Stopped","running":"running","stopped":"stopped","disabled":"disabled","Counters API":"Counters API","Repair counters":"Repair counters","Start":"Start","Stop":"Stop","Restart":"Restart","Inbounds & Clients":"Inbounds & Clients","Inbounds & clients":"Inbounds & clients","Xray Config":"Xray Config","Visual":"Visual","JSON":"JSON","Config editor":"Config editor","Load JSON":"Load JSON","Save & Restart":"Save & Restart","System Logs":"System Logs","last 200 lines":"last 200 lines","Xray clients":"Xray clients","Xray Core":"Xray Core","Enabled":"Enabled","Online":"Online","PID":"PID","Uptime":"Uptime","Counters API ready.":"Counters API ready.","Counters API ready at {server}.":"Counters API ready at {server}.","Online counters need Stats API repair.":"Online counters need Stats API repair.","Online counters: {error}":"Online counters: {error}","Needs repair":"Needs repair","OK":"OK",
|
||||
"UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Display Name","Expiry Date":"Expiry Date","Max Connections":"Max Connections","(0 = unlimited)":"(0 = unlimited)","auto-generate":"auto-generate","Name":"Name","Expiry":"Expiry","Online":"Online","Traffic":"Traffic","No clients.":"No clients.","No VLESS/VMess/Trojan inbounds found.":"No VLESS/VMess/Trojan inbounds found.","Add Client":"Add Client","+ Add Client":"+ Add Client","Copied client ID.":"Copied client ID.","UUID required.":"UUID required.","Client {id}… added. Restarting Xray…":"Client {id}… added. Restarting Xray…","Client removed. Restarting Xray…":"Client removed. Restarting Xray…","Remove client {id}… from {tag}?":"Remove client {id}… from {tag}?","New client data is available; editing was preserved.":"New client data is available; editing was preserved.","Config loaded.":"Config loaded.","Invalid JSON: {error}":"Invalid JSON: {error}","Saved. Restarting Xray…":"Saved. Restarting Xray…","Saved.":"Saved.","Saving…":"Saving…","Error: {error}":"Error: {error}","Error loading inbounds.":"Error loading inbounds.",
|
||||
"Active":"Active","Suspended":"Suspended","Expired":"Expired","Unlimited":"Unlimited","No expiration":"No expiration","Idle":"idle","online":"online","offline":"offline","idle":"idle","ago":"ago","Active ({days}d)":"Active ({days}d)","No limit set by admin":"No limit set by admin","{remaining} accounts available · {pct}% used":"{remaining} accounts available · {pct}% used","{used} used · unlimited":"{used} used · unlimited","{used}/{max} used · {pct}% of plan":"{used}/{max} used · {pct}% of plan","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} active · {expired} expired · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} active · {online} online",
|
||||
"New user.":"New user.","TOTP secret generated.":"TOTP secret generated.","Loaded.":"Loaded.","Last reload: {time}":"Last reload: {time}","Error loading users.":"Error loading users.","Editing {name}":"Editing {name}","Deleting {name}…":"Deleting {name}…","Deleted.":"Deleted.","Error deleting.":"Error deleting.","Delete user \"{name}\"?":"Delete user \"{name}\"?","Invalid credentials.":"Invalid credentials.","Account suspended or expired.":"Account suspended or expired.","Login failed.":"Login failed.","Network error.":"Network error.","Session expired — please sign in again.":"Session expired — please sign in again.",
|
||||
"Create Reseller":"Create Reseller","Create / edit reseller form":"Create / edit reseller form","Save reseller":"Save reseller","New reseller.":"New reseller.","Edit: {name}":"Edit: {name}","Editing {name}.":"Editing {name}.","Deleting {name}…":"Deleting {name}…","Error loading.":"Error loading.","Resellers list":"Resellers list","Max SSH users (0 = unlimited)":"Max SSH users (0 = unlimited)",
|
||||
"Server Load":"Server Load","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Updated: {time}","Error loading stats.":"Error loading stats.","Normal load":"Normal load","Moderate load":"Moderate load","High load":"High load","Cleaning interface totals…":"Cleaning interface totals…","Interface totals cleaned. Auto-clean remains every 30 days.":"Interface totals cleaned. Auto-clean remains every 30 days.","Error cleaning totals: {error}":"Error cleaning totals: {error}",
|
||||
"VnStat Usage":"VnStat Usage","Today total":"Today total","This month total":"This month total","Interfaces tracked":"Interfaces tracked","daily / monthly":"daily / monthly","Daily usage":"Daily usage","Monthly usage":"Monthly usage","Day":"Day","Month":"Month","Clean usage":"Clean usage","Clean VnStat history":"Clean VnStat history","VnStat history does not auto-clean. Use the button when you want to reset it.":"VnStat history does not auto-clean. Use the button when you want to reset it.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.","Loading VnStat usage…":"Loading VnStat usage…","VnStat history cleaned.":"VnStat history cleaned.","Error loading VnStat usage: {error}":"Error loading VnStat usage: {error}","Error cleaning VnStat history: {error}":"Error cleaning VnStat history: {error}",
|
||||
"Panel / system":"Panel / system","Select a log source and click Refresh.":"Select a log source and click Refresh.","Clean panel log":"Clean panel log","No log lines yet.":"No log lines yet.","Panel log cleaned · {path} · max {max}":"Panel log cleaned · {path} · max {max}","Cleaning panel log…":"Cleaning panel log…",
|
||||
"Network":"Network","Main Listen (SSH / HTTP)":"Main Listen (SSH / HTTP)","Extra Listen Addresses":"Extra Listen Addresses","(one per line, e.g. 0.0.0.0:8080)":"(one per line, e.g. 0.0.0.0:8080)","SSH & General":"SSH & General","Default Upload Limit (Mbps)":"Default Upload Limit (Mbps)","Default Download Limit (Mbps)":"Default Download Limit (Mbps)","Quiet Logs":"Quiet Logs","User Count Display":"User Count Display","SSH Banner":"SSH Banner","Banner Text":"Banner Text","(shown to connecting SSH clients)":"(shown to connecting SSH clients)","DNSTT Tunnel":"DNSTT Tunnel","Domain":"Domain","UDP Listen":"UDP Listen","Auto Restart Interval":"Auto Restart Interval","Restart Grace Delay":"Restart Grace Delay","0s/off disables":"0s/off disables","Private Key":"Private Key","Public Key":"Public Key","Disable Stats Log":"Disable Stats Log","Disable Console Log":"Disable Console Log","UDP Gateway":"UDP Gateway","Listen":"Listen","Idle Timeout":"Idle Timeout","Map TTL":"Map TTL","Debug Logging":"Debug Logging","TLS Forwarders":"TLS Forwarders","Listen Address":"Listen Address","Certificate":"Certificate","Generate Self-Signed":"Generate Self-Signed","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Paste PEM text","Custom file paths":"Custom file paths","Cert File":"Cert File","Key File":"Key File","Certificate PEM":"Certificate PEM","Private Key PEM":"Private Key PEM","Add Forwarder":"Add Forwarder","Save Config":"Save Config","All service changes apply live.":"All service changes apply live.","Saved and applied live.":"Saved and applied live.","Saved live with warnings: {warnings}":"Saved live with warnings: {warnings}","Processing…":"Processing…","Listen address required.":"Listen address required.","Domain required.":"Domain required.","Domain and email required.":"Domain and email required.","Cert and key paths required.":"Cert and key paths required.","Added. Save config to apply.":"Added. Save config to apply.","Generating…":"Generating…","Generated ✓ paths set.":"Generated ✓ paths set.","Generating key…":"Generating key…","Key generated. Save config to apply.":"Key generated. Save config to apply.","Loading public key…":"Loading public key…","Self-signed cert generated.":"Self-signed cert generated.","Let's Encrypt cert issued.":"Let's Encrypt cert issued.","PEM saved.":"PEM saved.","Saved ✓ paths set.":"Saved ✓ paths set.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Name, cert, and key required.":"Name, cert, and key required.","Name, cert PEM, and key PEM required.":"Name, cert PEM, and key PEM required.","Save Changes":"Save Changes"
|
||||
},
|
||||
"pt-BR": {
|
||||
"Dashboard":"Painel","Overview":"Visão geral","Accounts":"Contas","Administration":"Administração","Server":"Servidor","System":"Sistema","Settings":"Configurações","Traffic":"Tráfego","Monitoring":"Monitoramento",
|
||||
"SSH / SlowDNS":"SSH / SlowDNS","Xray Users":"Usuários Xray","Resellers":"Revendedores","Logs":"Logs","VnStat":"VnStat","VPN Control":"Controle VPN","DragonCore":"DragonCore",
|
||||
"SSH Panel":"Painel SSH","Sign in with your admin or reseller credentials.":"Entre com suas credenciais de admin ou revendedor.","Username":"Usuário","Password":"Senha","Sign in":"Entrar","Logout":"Sair","Open menu":"Abrir menu","Toggle theme":"Alternar tema","Language":"Idioma",
|
||||
"Total accounts":"Total de contas","active":"ativas","expired":"expiradas","available limit":"Limite disponível","Loading quota…":"Carregando cota…","Active connections":"Conexões ativas","SSH + Xray online now":"SSH + Xray online agora","Ready for resellers":"Pronto para revendedores","Server monitoring in real time":"monitoramento em tempo real","CPU":"CPU","RAM":"RAM","Network":"Rede","Processor load":"Carga do processador","Memory used":"Memória usada","Total":"Total","Total --":"Total --","RX -- · TX -- Mb/s":"RX -- · TX -- Mb/s",
|
||||
"Quick actions":"Ações rápidas","simple":"simples","Create SSH":"Criar SSH","Create Xray":"Criar Xray","New reseller":"Novo revendedor","Configure services":"Configurar serviços","User, password, expiry and limit.":"Usuário, senha, validade e limite.","UUID, label, expiry and connections.":"UUID, label, validade e conexões.","Plan, expiry and account limit.":"Plano, validade e limite de contas.","Ports, DNSTT, UDPGW and TLS.":"Portas, DNSTT, UDPGW e TLS.","My quota":"Minha cota","Loading…":"Carregando…","Loading...":"Carregando...",
|
||||
"My Account":"Minha conta","Users (used / max)":"Usuários (usado / máximo)","Users (used/max)":"Usuários (usado/máximo)","Expires":"Vence em","Status":"Status","Users":"Usuários","User":"Usuário","Auth":"Autenticação","Conn":"Conexões","Max":"Máximo","Up":"Upload","Dn":"Download","Owner":"Dono","Actions":"Ações","Create / update user":"Criar / atualizar usuário","Create / edit user form":"Formulário de criar / editar usuário","Show form":"Mostrar formulário","Hide form":"Ocultar formulário","TOTP Secret":"Segredo TOTP","TOTP Period (s)":"Período TOTP (s)","TOTP Window":"Janela TOTP","TOTP Digits":"Dígitos TOTP","Allow static password too":"Permitir senha estática também","Max connections":"Máx. conexões","Expires at":"Vence em","Max Upload (Mb/s)":"Upload máx. (Mb/s)","Max Download (Mb/s)":"Download máx. (Mb/s)","Save user":"Salvar usuário","Cancel":"Cancelar","Gen":"Gerar","Copy":"Copiar","Edit":"Editar","Del":"Excluir","Reload":"Recarregar","Refresh":"Atualizar","+ New":"+ Novo","+ Add":"+ Adicionar","Add":"Adicionar","Remove":"Remover",
|
||||
"Running":"Rodando","Stopped":"Parado","running":"rodando","stopped":"parado","disabled":"desativado","Counters API":"API de contadores","Repair counters":"Reparar contadores","Start":"Iniciar","Stop":"Parar","Restart":"Reiniciar","Inbounds & Clients":"Inbounds e clientes","Inbounds & clients":"Inbounds e clientes","Xray Config":"Configuração Xray","Visual":"Visual","JSON":"JSON","Config editor":"Editor de configuração","Load JSON":"Carregar JSON","Save & Restart":"Salvar e reiniciar","System Logs":"Logs do sistema","last 200 lines":"últimas 200 linhas","Xray clients":"Clientes Xray","Xray Core":"Núcleo Xray","Enabled":"Ativado","Online":"Online","PID":"PID","Uptime":"Tempo ativo","Counters API ready.":"API de contadores pronta.","Counters API ready at {server}.":"API de contadores pronta em {server}.","Online counters need Stats API repair.":"Contadores online precisam de reparo da Stats API.","Online counters: {error}":"Contadores online: {error}","Needs repair":"Precisa de reparo","OK":"OK",
|
||||
"UUID":"UUID","Email":"Email","Email / label":"Email / label","Display Name":"Nome de exibição","Expiry Date":"Data de vencimento","Max Connections":"Máx. conexões","(0 = unlimited)":"(0 = ilimitado)","auto-generate":"gerar automaticamente","Name":"Nome","Expiry":"Vencimento","Online":"Online","Traffic":"Tráfego","No clients.":"Nenhum cliente.","No VLESS/VMess/Trojan inbounds found.":"Nenhum inbound VLESS/VMess/Trojan encontrado.","Add Client":"Adicionar cliente","+ Add Client":"+ Adicionar cliente","Copied client ID.":"ID do cliente copiado.","UUID required.":"UUID obrigatório.","Client {id}… added. Restarting Xray…":"Cliente {id}… adicionado. Reiniciando Xray…","Client removed. Restarting Xray…":"Cliente removido. Reiniciando Xray…","Remove client {id}… from {tag}?":"Remover cliente {id}… de {tag}?","New client data is available; editing was preserved.":"Novos dados de cliente disponíveis; sua edição foi preservada.","Config loaded.":"Configuração carregada.","Invalid JSON: {error}":"JSON inválido: {error}","Saved. Restarting Xray…":"Salvo. Reiniciando Xray…","Saved.":"Salvo.","Saving…":"Salvando…","Error: {error}":"Erro: {error}","Error loading inbounds.":"Erro ao carregar inbounds.",
|
||||
"Active":"Ativo","Suspended":"Suspenso","Expired":"Expirado","Unlimited":"Ilimitado","No expiration":"Sem vencimento","Idle":"ocioso","online":"online","offline":"offline","idle":"ocioso","ago":"atrás","Active ({days}d)":"Ativo ({days}d)","No limit set by admin":"Sem limite definido pelo admin","{remaining} accounts available · {pct}% used":"{remaining} contas disponíveis · {pct}% usado","{used} used · unlimited":"{used} usadas · sem limite","{used}/{max} used · {pct}% of plan":"{used}/{max} usadas · {pct}% do plano","SSH {ssh} · Xray {xray}":"SSH {ssh} · Xray {xray}","{ssh} SSH · {xray} Xray online":"{ssh} SSH · {xray} Xray online","{online} online · {active} active · {expired} expired · Core: {core}":"{online} online · {active} ativos · {expired} expirados · Core: {core}","{count} online":"{count} online","{count} total · {active} active · {online} online":"{count} total · {active} ativas · {online} online",
|
||||
"New user.":"Novo usuário.","TOTP secret generated.":"Segredo TOTP gerado.","Loaded.":"Carregado.","Last reload: {time}":"Último reload: {time}","Error loading users.":"Erro ao carregar usuários.","Editing {name}":"Editando {name}","Deleting {name}…":"Excluindo {name}…","Deleted.":"Excluído.","Error deleting.":"Erro ao excluir.","Delete user \"{name}\"?":"Excluir usuário \"{name}\"?","Invalid credentials.":"Credenciais inválidas.","Account suspended or expired.":"Conta suspensa ou expirada.","Login failed.":"Falha no login.","Network error.":"Erro de rede.","Session expired — please sign in again.":"Sessão expirada — faça login novamente.",
|
||||
"Create Reseller":"Criar revendedor","Create / edit reseller form":"Formulário de criar / editar revendedor","Save reseller":"Salvar revendedor","New reseller.":"Novo revendedor.","Edit: {name}":"Editar: {name}","Editing {name}.":"Editando {name}.","Deleting {name}…":"Excluindo {name}…","Error loading.":"Erro ao carregar.","Resellers list":"Lista de revendedores","Max SSH users (0 = unlimited)":"Máximo de usuários SSH (0 = ilimitado)",
|
||||
"Server Load":"Carga do servidor","Interfaces":"Interfaces","Interface":"Interface","Rx Mbps":"Rx Mbps","Tx Mbps":"Tx Mbps","Rx Total":"Rx Total","Tx Total":"Tx Total","Updated: {time}":"Atualizado: {time}","Error loading stats.":"Erro ao carregar stats.","Normal load":"Carga normal","Moderate load":"Carga moderada","High load":"Carga alta","Cleaning interface totals…":"Limpando totais das interfaces…","Interface totals cleaned. Auto-clean remains every 30 days.":"Totais das interfaces limpos. A limpeza automática continua a cada 30 dias.","Error cleaning totals: {error}":"Erro ao limpar totais: {error}",
|
||||
"VnStat Usage":"Uso do VnStat","Today total":"Total hoje","This month total":"Total este mês","Interfaces tracked":"Interfaces monitoradas","daily / monthly":"diário / mensal","Daily usage":"Uso diário","Monthly usage":"Uso mensal","Day":"Dia","Month":"Mês","Clean usage":"Limpar uso","Clean VnStat history":"Limpar histórico VnStat","VnStat history does not auto-clean. Use the button when you want to reset it.":"O histórico VnStat não é limpo automaticamente. Use o botão quando quiser resetar.","Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.":"Os totais podem ser limpos aqui e têm limpeza automática a cada 30 dias. O histórico VnStat é separado.","Loading VnStat usage…":"Carregando uso do VnStat…","VnStat history cleaned.":"Histórico VnStat limpo.","Error loading VnStat usage: {error}":"Erro ao carregar uso do VnStat: {error}","Error cleaning VnStat history: {error}":"Erro ao limpar histórico VnStat: {error}",
|
||||
"Panel / system":"Painel / sistema","Select a log source and click Refresh.":"Selecione uma fonte de log e clique em Atualizar.","Clean panel log":"Limpar log do painel","No log lines yet.":"Ainda não há linhas de log.","Panel log cleaned · {path} · max {max}":"Log do painel limpo · {path} · máx {max}","Cleaning panel log…":"Limpando log do painel…",
|
||||
"Network":"Rede","Main Listen (SSH / HTTP)":"Listen principal (SSH / HTTP)","Extra Listen Addresses":"Endereços extras de listen","Proxy Auto Restart":"Reinício automático do proxy","Proxy Auto Restart Interval":"Intervalo de reinício automático do proxy","Proxy Restart Grace Delay":"Atraso para reiniciar proxy","(one per line, e.g. 0.0.0.0:8080)":"(um por linha, ex. 0.0.0.0:8080)","SSH & General":"SSH e geral","Default Upload Limit (Mbps)":"Limite padrão de upload (Mbps)","Default Download Limit (Mbps)":"Limite padrão de download (Mbps)","Quiet Logs":"Logs silenciosos","User Count Display":"Exibir contagem de usuários","SSH Banner":"Banner SSH","Banner Text":"Texto do banner","(shown to connecting SSH clients)":"(mostrado aos clientes SSH ao conectar)","DNSTT Tunnel":"Túnel DNSTT","Domain":"Domínio","UDP Listen":"Listen UDP","Auto Restart Interval":"Intervalo de reinício automático","Restart Grace Delay":"Atraso para reiniciar","0s/off disables":"0s/off desativa","Private Key":"Chave privada","Public Key":"Chave pública","Disable Stats Log":"Desativar log de stats","Disable Console Log":"Desativar log do console","UDP Gateway":"Gateway UDP","Listen":"Listen","Idle Timeout":"Timeout ocioso","Map TTL":"TTL do mapa","Debug Logging":"Log de debug","TLS Forwarders":"Encaminhadores TLS","Listen Address":"Endereço de listen","Certificate":"Certificado","Generate Self-Signed":"Gerar autoassinado","Let's Encrypt (certbot)":"Let's Encrypt (certbot)","Paste PEM text":"Colar texto PEM","Custom file paths":"Caminhos personalizados","Cert File":"Arquivo cert","Key File":"Arquivo key","Certificate PEM":"Certificado PEM","Private Key PEM":"Chave privada PEM","Add Forwarder":"Adicionar forwarder","Save Config":"Salvar config","All service changes apply live.":"Todas as mudanças de serviço aplicam ao vivo.","Saved and applied live.":"Salvo e aplicado ao vivo.","Saved live with warnings: {warnings}":"Salvo ao vivo com avisos: {warnings}","Processing…":"Processando…","Listen address required.":"Endereço de listen obrigatório.","Domain required.":"Domínio obrigatório.","Domain and email required.":"Domínio e email obrigatórios.","Cert and key paths required.":"Caminhos do certificado e da chave obrigatórios.","Added. Save config to apply.":"Adicionado. Salve a config para aplicar.","Generating…":"Gerando…","Generated ✓ paths set.":"Gerado ✓ caminhos definidos.","Generating key…":"Gerando chave…","Key generated. Save config to apply.":"Chave gerada. Salve a config para aplicar.","Loading public key…":"Carregando chave pública…","Self-signed cert generated.":"Certificado autoassinado gerado.","Let's Encrypt cert issued.":"Certificado Let's Encrypt emitido.","PEM saved.":"PEM salvo.","Saved ✓ paths set.":"Salvo ✓ caminhos definidos.","Name, cert PEM, and key PEM required.":"Nome, cert PEM e chave PEM obrigatórios.","Name, cert, and key required.":"Nome, cert e chave obrigatórios.","Save Changes":"Salvar alterações"
|
||||
}
|
||||
};
|
||||
const I18N_ALIASES = {
|
||||
"Painel":"Dashboard","Visão geral":"Overview","Contas":"Accounts","Administração":"Administration","Servidor":"Server","Sistema":"System","Configurações":"Settings","Tráfego":"Traffic","Revendedores":"Resellers","Usuários Xray":"Xray Users","Controle VPN":"VPN Control","Sair":"Logout",
|
||||
"Total de contas":"Total accounts","ativas":"active","expiradas":"expired","Limite disponível":"available limit","Carregando cota…":"Loading quota…","Conexões ativas":"Active connections","SSH + Xray online agora":"SSH + Xray online now","Pronto para revendedores":"Ready for resellers","monitoramento em tempo real":"Server monitoring in real time","Carga do processador":"Processor load","Memória usada":"Memory used",
|
||||
"Ações rápidas":"Quick actions","Criar SSH":"Create SSH","Criar Xray":"Create Xray","Novo revendedor":"New reseller","Configurar serviços":"Configure services","Usuário, senha, validade e limite.":"User, password, expiry and limit.","UUID, label, validade e conexões.":"UUID, label, expiry and connections.","Plano, validade e limite de contas.":"Plan, expiry and account limit.","Portas, DNSTT, UDPGW e TLS.":"Ports, DNSTT, UDPGW and TLS.","Minha cota":"My quota","Carregando…":"Loading…",
|
||||
"Minha conta":"My Account","Usuários":"Users","Usuário":"User","Autenticação":"Auth","Conexões":"Conn","Máximo":"Max","Dono":"Owner","Ações":"Actions","Criar / atualizar usuário":"Create / update user","Mostrar formulário":"Show form","Ocultar formulário":"Hide form","Salvar usuário":"Save user","Cancelar":"Cancel","Gerar":"Gen","Copiar":"Copy","Editar":"Edit","Excluir":"Del","Recarregar":"Reload","Atualizar":"Refresh",
|
||||
"Rodando":"Running","Parado":"Stopped","rodando":"running","parado":"stopped","desativado":"disabled","API de contadores":"Counters API","Reparar contadores":"Repair counters","Iniciar":"Start","Parar":"Stop","Reiniciar":"Restart","Inbounds e clientes":"Inbounds & Clients","Configuração Xray":"Xray Config","Editor de configuração":"Config editor","Carregar JSON":"Load JSON","Salvar e reiniciar":"Save & Restart","Logs do sistema":"System Logs","últimas 200 linhas":"last 200 lines","Clientes Xray":"Xray clients","Núcleo Xray":"Xray Core","Ativado":"Enabled","Tempo ativo":"Uptime","Precisa de reparo":"Needs repair",
|
||||
"Nome":"Name","Nome de exibição":"Display Name","Data de vencimento":"Expiry Date","Máx. conexões":"Max Connections","Ilimitado":"Unlimited","Ativo":"Active","Suspenso":"Suspended","Expirado":"Expired","Sem vencimento":"No expiration","ocioso":"idle","Nenhum cliente.":"No clients.","Adicionar cliente":"Add Client","Novo usuário.":"New user.","Carregado.":"Loaded.","Salvo.":"Saved.","Salvando…":"Saving…","Erro ao carregar usuários.":"Error loading users.","Erro ao excluir.":"Error deleting.","Credenciais inválidas.":"Invalid credentials.","Conta suspensa ou expirada.":"Account suspended or expired.","Falha no login.":"Login failed.","Erro de rede.":"Network error.","Sessão expirada — faça login novamente.":"Session expired — please sign in again.",
|
||||
"Rede":"Network","Listen principal (SSH / HTTP)":"Main Listen (SSH / HTTP)","Endereços extras de listen":"Extra Listen Addresses","Reinício automático do proxy":"Proxy Auto Restart","Intervalo de reinício automático do proxy":"Proxy Auto Restart Interval","Atraso para reiniciar proxy":"Proxy Restart Grace Delay","SSH e geral":"SSH & General","Limite padrão de upload (Mbps)":"Default Upload Limit (Mbps)","Limite padrão de download (Mbps)":"Default Download Limit (Mbps)","Logs silenciosos":"Quiet Logs","Exibir contagem de usuários":"User Count Display","Banner SSH":"SSH Banner","Texto do banner":"Banner Text","Túnel DNSTT":"DNSTT Tunnel","Domínio":"Domain","Chave privada":"Private Key","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Gateway UDP":"UDP Gateway","Endereço de listen":"Listen Address","Certificado":"Certificate","Gerar autoassinado":"Generate Self-Signed","Colar texto PEM":"Paste PEM text","Caminhos personalizados":"Custom file paths","Arquivo cert":"Cert File","Arquivo key":"Key File","Adicionar forwarder":"Add Forwarder","Salvar config":"Save Config","Todas as mudanças de serviço aplicam ao vivo.":"All service changes apply live."
|
||||
};
|
||||
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Servers":"Servers","Reseller area":"Reseller area","shared quota":"shared quota","available":"available","used":"used","breakdown":"breakdown",
|
||||
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
|
||||
"Loading inbounds…":"Loading inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"active ·","expired":"expired",
|
||||
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Public Key — share with DNSTT clients","auto-saved to /opt/sshpanel/dnstt.key":"auto-saved to /opt/sshpanel/dnstt.key",
|
||||
"Max UDP Sessions Per Client":"Max UDP Sessions Per Client","(not total server users)":"(not total server users)","Service Name":"Service Name","Mode":"Mode","Protocol":"Protocol","Port":"Port","Tag":"Tag","Listen IP":"Listen IP","Method":"Method","Host":"Host","Path":"Path","Dest":"Dest","Short ID":"Short ID","Server Name":"Server Name","Cert File Path":"Cert File Path","Key File Path":"Key File Path","Certificate source:":"Certificate source:","Self-Signed":"Self-Signed","Paste PEM":"Paste PEM","File Path":"File Path","Save PEM":"Save PEM","Generate":"Generate","Public Key":"Public Key","Debug Logging":"Debug Logging","Name":"Name","Private Key PEM":"Private Key PEM","Certificate PEM":"Certificate PEM","Domain Name":"Domain Name"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Servers":"Servidores","Reseller area":"Área do revendedor","shared quota":"cota única","available":"disponíveis","used":"usadas","breakdown":"divisão",
|
||||
"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.":"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.",
|
||||
"Loading inbounds…":"Carregando inbounds…","SSH -- · Xray --":"SSH -- · Xray --","active ·":"ativas ·","expired":"expiradas",
|
||||
"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085":"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Chave pública — compartilhe com clientes DNSTT","auto-saved to /opt/sshpanel/dnstt.key":"salva automaticamente em /opt/sshpanel/dnstt.key",
|
||||
"Max UDP Sessions Per Client":"Máx. sessões UDP por cliente","(not total server users)":"(não é o total de usuários do servidor)","Service Name":"Nome do serviço","Mode":"Modo","Protocol":"Protocolo","Port":"Porta","Tag":"Tag","Listen IP":"IP de listen","Method":"Método","Host":"Host","Path":"Caminho","Dest":"Destino","Short ID":"ID curto","Server Name":"Nome do servidor","Cert File Path":"Caminho do arquivo cert","Key File Path":"Caminho do arquivo key","Certificate source:":"Fonte do certificado:","Self-Signed":"Autoassinado","Paste PEM":"Colar PEM","File Path":"Caminho do arquivo","Save PEM":"Salvar PEM","Generate":"Gerar","Public Key":"Chave pública","Debug Logging":"Log de debug","Name":"Nome","Private Key PEM":"Chave privada PEM","Certificate PEM":"Certificado PEM","Domain Name":"Nome do domínio"
|
||||
});
|
||||
Object.assign(I18N_ALIASES, {
|
||||
"Servidores":"Servers","Área do revendedor":"Reseller area","cota única":"shared quota","disponíveis":"available","usadas":"used","divisão":"breakdown",
|
||||
"Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.":"Create Xray clients with the same experience as the main panel. Each Xray client uses the same limit shared with SSH accounts.",
|
||||
"Carregando inbounds…":"Loading inbounds…","Loading inbounds…":"Loading inbounds…","ativas ·":"active ·","expiradas":"expired",
|
||||
"Binário: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Contadores online usam a Xray Stats API em 127.0.0.1:10085":"Binary: /opt/sshpanel/xray · Config: DB-backed /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085",
|
||||
"Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients",
|
||||
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Panel Updates":"Panel Updates","Checking…":"Checking…","Open Git":"Open Git","Check now":"Check now","Installed version":"Installed version","Latest Git version":"Latest Git version","Branch":"Branch","Last checked":"Last checked",
|
||||
"Comparing the installed version with the Git repository.":"Comparing the installed version with the Git repository.","To update:":"To update:","Copy command":"Copy command","Up to date":"Up to date","Update available":"Update available","Local changes":"Local changes","Unknown":"Unknown",
|
||||
"The installed version matches the latest commit on {branch}.":"The installed version matches the latest commit on {branch}.","A newer commit is available on {branch}.":"A newer commit is available on {branch}.","This build contains local changes, so it cannot be compared safely.":"This build contains local changes, so it cannot be compared safely.",
|
||||
"Could not check for updates: {error}":"Could not check for updates: {error}","The update status could not be determined.":"The update status could not be determined.","Checking repository…":"Checking repository…","Update check timed out.":"Update check timed out.","Could not reach the Git repository.":"Could not reach the Git repository.","Current build commit is unavailable.":"Current build commit is unavailable.","Unknown update-check error.":"Unknown update-check error.","Copied":"Copied"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Panel Updates":"Atualizações do painel","Checking…":"Verificando…","Open Git":"Abrir Git","Check now":"Verificar agora","Installed version":"Versão instalada","Latest Git version":"Última versão no Git","Branch":"Branch","Last checked":"Última verificação",
|
||||
"Comparing the installed version with the Git repository.":"Comparando a versão instalada com o repositório Git.","To update:":"Para atualizar:","Copy command":"Copiar comando","Up to date":"Atualizado","Update available":"Atualização disponível","Local changes":"Alterações locais","Unknown":"Desconhecido",
|
||||
"The installed version matches the latest commit on {branch}.":"A versão instalada corresponde ao commit mais recente da branch {branch}.","A newer commit is available on {branch}.":"Existe um commit mais recente disponível na branch {branch}.","This build contains local changes, so it cannot be compared safely.":"Esta compilação contém alterações locais e não pode ser comparada com segurança.",
|
||||
"Could not check for updates: {error}":"Não foi possível verificar atualizações: {error}","The update status could not be determined.":"Não foi possível determinar o status da atualização.","Checking repository…":"Verificando o repositório…","Update check timed out.":"A verificação de atualização excedeu o tempo limite.","Could not reach the Git repository.":"Não foi possível acessar o repositório Git.","Current build commit is unavailable.":"O commit da compilação atual não está disponível.","Unknown update-check error.":"Erro desconhecido ao verificar atualização.","Copied":"Copiado"
|
||||
});
|
||||
Object.assign(I18N_ALIASES, {
|
||||
"Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked",
|
||||
"Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Command center":"Command center","Overview command copy":"Accounts, connections, and infrastructure at a glance.","Access workspace":"Access workspace","SSH accounts":"SSH accounts","SSH accounts copy":"Create, limit, and monitor SSH and SlowDNS access securely.",
|
||||
"Proxy studio":"Proxy studio","Xray visual":"Visual Xray","Xray visual copy":"Manage clients, inbounds, and a shared XHTTP endpoint without editing JSON.","Partner operations":"Partner operations","Partner operations copy":"Control partner quotas, expiration, and access in one place.",
|
||||
"Fleet control":"Fleet control","Fleet control copy":"Add nodes, test credentials, and configure remote infrastructure.","Live fleet":"Live fleet","Server status copy":"Health, load, and active sessions for every managed node.","Observability":"Observability","Monitoring copy":"Server resources, interfaces, and capacity in real time.",
|
||||
"Traffic intelligence":"Traffic intelligence","Network traffic":"Network traffic","Network traffic copy":"Daily and monthly history for understanding infrastructure usage.","Diagnostics":"Diagnostics","System logs copy":"Investigate the panel, DNSTT, and Xray in a focused view.","System studio":"System studio","Settings copy":"Network, SSH, tunnels, and TLS organized visually and applied live.",
|
||||
"One domain and port":"One domain and port","Shared endpoint copy":"The selected protocol uses /; SSH uses /ssh. Available in native Xray mode.","Protocol on /":"Protocol on /","Shared port":"Shared port","Listen IP":"Listen IP","HTTP host":"HTTP host","optional":"optional","XHTTP mode":"XHTTP mode","Security":"Security","No TLS":"No TLS","Certificate file":"Certificate file","Key file":"Key file","Create / update endpoint":"Create / update endpoint",
|
||||
"Configured inbounds":"Configured inbounds","Visual inbound help":"Edit any card visually or use JSON for advanced fields.","New inbound":"New inbound","Visual editor":"Visual editor","Add inbound":"Add inbound","Save changes":"Save changes","Duplicate":"Duplicate","Remove":"Remove","Save config and restart":"Save config and restart",
|
||||
"Old XHTTP configuration?":"Old XHTTP configuration?","Legacy XHTTP migration copy":"Use “Enable SSH /ssh” on the existing inbound card. The panel preserves the inbound, clients, and every current option; it only adds the SSH route and restarts Xray.","Enable SSH /ssh":"Enable SSH /ssh","SSH /ssh enabled":"SSH /ssh enabled","Legacy XHTTP migration":"Legacy XHTTP migration"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Command center":"Central de comando","Overview command copy":"Contas, conexões e infraestrutura em uma leitura rápida.","Access workspace":"Área de acessos","SSH accounts":"Contas SSH","SSH accounts copy":"Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.",
|
||||
"Proxy studio":"Estúdio de proxy","Xray visual":"Xray visual","Xray visual copy":"Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.","Partner operations":"Operação de parceiros","Partner operations copy":"Controle cotas, validade e acesso dos parceiros em um só lugar.",
|
||||
"Fleet control":"Controle da frota","Fleet control copy":"Adicione nós, teste credenciais e configure a infraestrutura remota.","Live fleet":"Frota ao vivo","Server status copy":"Saúde, carga e sessões ativas de cada nó gerenciado.","Observability":"Observabilidade","Monitoring copy":"Recursos, interfaces e capacidade do servidor em tempo real.",
|
||||
"Traffic intelligence":"Inteligência de tráfego","Network traffic":"Tráfego de rede","Network traffic copy":"Histórico diário e mensal para entender o consumo da infraestrutura.","Diagnostics":"Diagnóstico","System logs copy":"Investigue painel, DNSTT e Xray com uma visualização focada.","System studio":"Estúdio do sistema","Settings copy":"Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.",
|
||||
"One domain and port":"Um domínio e uma porta","Shared endpoint copy":"O protocolo selecionado usa /; SSH usa /ssh. Disponível no modo Xray nativo.","Protocol on /":"Protocolo em /","Shared port":"Porta compartilhada","Listen IP":"IP de listen","HTTP host":"Host HTTP","optional":"opcional","XHTTP mode":"Modo XHTTP","Security":"Segurança","No TLS":"Sem TLS","Certificate file":"Arquivo do certificado","Key file":"Arquivo da chave","Create / update endpoint":"Criar / atualizar endpoint",
|
||||
"Configured inbounds":"Inbounds configurados","Visual inbound help":"Edite qualquer cartão visualmente ou use JSON para campos avançados.","New inbound":"Novo inbound","Visual editor":"Editor visual","Add inbound":"Adicionar inbound","Save changes":"Salvar alterações","Duplicate":"Duplicar","Remove":"Remover","Save config and restart":"Salvar configuração e reiniciar",
|
||||
"Old XHTTP configuration?":"Configuração XHTTP antiga?","Legacy XHTTP migration copy":"Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.","Enable SSH /ssh":"Ativar SSH /ssh","SSH /ssh enabled":"SSH /ssh ativado","Legacy XHTTP migration":"Migração de configuração XHTTP antiga"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Infrastructure":"Infrastructure","Infrastructure areas":"Infrastructure areas","Infrastructure area":"Infrastructure area","Waiting for data":"Waiting for data","Operation":"Operation","Accounts":"Accounts","Active accounts":"Active","Online now":"Online now","Counters":"Counters","Save mode":"Save mode",
|
||||
"Nodes":"Nodes","Active nodes":"Active","SSH enabled":"SSH enabled","Xray enabled":"Xray enabled","Sessions":"Sessions","Processor":"Processor","Memory":"Memory","Network now":"Network now","Interfaces":"Interfaces","Today":"Today","This month":"This month","Recent period":"Recent period","Update":"Refresh","Clear history":"Clear history",
|
||||
"Infrastructure workspace copy":"Manage nodes, monitor system health, and inspect traffic from one workspace.","Confirmation":"Confirmation","Confirm action":"Confirm action","Confirm":"Confirm","Completed":"Completed","Action failed":"Action failed","Attention":"Attention","Information":"Information","Close":"Close",
|
||||
"Loading SSH status…":"Loading SSH status…","Loading":"Loading","Could not load SSH status":"Could not load SSH status","Error":"Error","Online":"Online","SSH data updated at {time}":"SSH data updated at {time}","Delete SSH account":"Delete SSH account","The active SSH sessions for this account will be disconnected.":"The active SSH sessions for this account will be disconnected.","Delete account":"Delete account",
|
||||
"Loading Xray status…":"Loading Xray status…","Could not load Xray status":"Could not load Xray status","Remove Xray client":"Remove Xray client","The client will lose access immediately after the configuration reload.":"The client will lose access immediately after the configuration reload.","Remove client":"Remove client","Client removed successfully.":"Client removed successfully.","Xray client":"Xray client",
|
||||
"Loading infrastructure…":"Loading infrastructure…","Infrastructure loaded with fallback data":"Infrastructure loaded with fallback data","{count} active nodes · updated {time}":"{count} active nodes · updated {time}","Delete managed server":"Delete managed server","Delete server \"{name}\"?":"Delete server \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.","Delete server":"Delete server",
|
||||
"Updating live status…":"Updating live status…","Live · updated {time}":"Live · updated {time}","Error loading server status":"Error loading server status","Clean live interface totals":"Clean live interface totals","Clean the live Interface totals now?":"Clean the live Interface totals now?","VnStat daily and monthly history will be preserved.":"VnStat daily and monthly history will be preserved.","Clean totals":"Clean totals","Live interface totals were cleaned.":"Live interface totals were cleaned.","Traffic counters":"Traffic counters","Clean VnStat history":"Clean VnStat history","Clean all daily and monthly traffic history?":"Clean all daily and monthly traffic history?","Live interface totals are separate and will not be reset.":"Live interface totals are separate and will not be reset.","Clean history":"Clean history","VnStat history was cleaned.":"VnStat history was cleaned.","Traffic history":"Traffic history","Clean panel log":"Clean panel log","Clean the current panel log now?":"Clean the current panel log now?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"This only clears the panel log file. Automatic size-based cleanup remains enabled.","Clean log":"Clean log",
|
||||
"Delete reseller":"Delete reseller","Delete reseller \"{name}\"?":"Delete reseller \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Their owned access will be removed and active SSH sessions will be disconnected.","Remove inbound":"Remove inbound","Remove inbound {name}?":"Remove inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clients attached only to this inbound will stop connecting after the configuration is saved.",
|
||||
"This endpoint already has an SSH /ssh route.":"This endpoint already has an SSH /ssh route.","Add SSH /ssh without rebuilding this inbound.":"Add SSH /ssh without rebuilding this inbound.","SSH migration attention":"SSH migration attention","Could not enable SSH /ssh":"Could not enable SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Select a VLESS/VMess inbound using XHTTP.","Load the selected server configuration before enabling SSH.":"Load the selected server configuration before enabling SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH is already enabled on /ssh by inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"Path /ssh is already used by inbound {name}. Edit that path first.","This inbound uses TLS but has no reusable certificate and key file paths.":"This inbound uses TLS but has no reusable certificate and key file paths.","Safe XHTTP migration":"Safe XHTTP migration","Enable SSH on /ssh":"Enable SSH on /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Add SSH to the same endpoint without rebuilding {name}?","Listener":"Listener","Existing path preserved":"Existing path preserved","New SSH path":"New SSH path","Clients preserved":"Clients preserved","Enabling SSH…":"Enabling SSH…","Could not enable SSH: {error}":"Could not enable SSH: {error}","Migration was cancelled because it would alter the old inbound.":"Migration was cancelled because it would alter the old inbound.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh added to the draft without changing {name}.","Enabling SSH /ssh without changing {name}…":"Enabling SSH /ssh without changing {name}…","The SSH route could not be saved. The old inbound was not changed.":"The SSH route could not be saved. The old inbound was not changed.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh is active. The old inbound and all clients were preserved.","Configuration for this server was not loaded.":"Configuration for this server was not loaded.","Invalid visual config: {error}":"Invalid visual config: {error}","Xray could not restart.":"Xray could not restart.","Could not save configuration: {error}":"Could not save configuration: {error}","selected":"selected","this inbound":"this inbound","the old inbound":"the old inbound","untagged":"untagged"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Infrastructure":"Infraestrutura","Infrastructure areas":"Áreas da infraestrutura","Infrastructure area":"Área da infraestrutura","Waiting for data":"Aguardando dados","Operation":"Operação","Accounts":"Contas","Active accounts":"Ativas","Online now":"Online agora","Counters":"Contadores","Save mode":"Salvar modo",
|
||||
"Nodes":"Nós","Active nodes":"Ativos","SSH enabled":"SSH habilitado","Xray enabled":"Xray habilitado","Sessions":"Sessões","Processor":"Processador","Memory":"Memória","Network now":"Rede agora","Interfaces":"Interfaces","Today":"Hoje","This month":"Este mês","Recent period":"Período recente","Update":"Atualizar","Clear history":"Limpar histórico",
|
||||
"Infrastructure workspace copy":"Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.","Confirmation":"Confirmação","Confirm action":"Confirmar ação","Confirm":"Confirmar","Completed":"Concluído","Action failed":"Ação não concluída","Attention":"Atenção","Information":"Informação","Close":"Fechar",
|
||||
"Loading SSH status…":"Carregando status SSH…","Loading":"Carregando","Could not load SSH status":"Não foi possível carregar o status SSH","Error":"Erro","Online":"Online","SSH data updated at {time}":"Dados SSH atualizados às {time}","Delete SSH account":"Excluir conta SSH","The active SSH sessions for this account will be disconnected.":"As sessões SSH ativas desta conta serão desconectadas.","Delete account":"Excluir conta",
|
||||
"Loading Xray status…":"Carregando status do Xray…","Could not load Xray status":"Não foi possível carregar o status do Xray","Remove Xray client":"Remover cliente Xray","The client will lose access immediately after the configuration reload.":"O cliente perderá o acesso imediatamente após recarregar a configuração.","Remove client":"Remover cliente","Client removed successfully.":"Cliente removido com sucesso.","Xray client":"Cliente Xray",
|
||||
"Loading infrastructure…":"Carregando infraestrutura…","Infrastructure loaded with fallback data":"Infraestrutura carregada com dados locais de segurança","{count} active nodes · updated {time}":"{count} nós ativos · atualizado às {time}","Delete managed server":"Excluir servidor gerenciado","Delete server \"{name}\"?":"Excluir o servidor \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"O nó remoto não será apagado, mas será removido deste painel e deixará de receber ações gerenciadas.","Delete server":"Excluir servidor",
|
||||
"Updating live status…":"Atualizando status ao vivo…","Live · updated {time}":"Ao vivo · atualizado às {time}","Error loading server status":"Erro ao carregar status do servidor","Clean live interface totals":"Limpar totais ao vivo das interfaces","Clean the live Interface totals now?":"Limpar agora os totais ao vivo das interfaces?","VnStat daily and monthly history will be preserved.":"O histórico diário e mensal do VnStat será preservado.","Clean totals":"Limpar totais","Live interface totals were cleaned.":"Os totais ao vivo das interfaces foram limpos.","Traffic counters":"Contadores de tráfego","Clean VnStat history":"Limpar histórico VnStat","Clean all daily and monthly traffic history?":"Limpar todo o histórico diário e mensal de tráfego?","Live interface totals are separate and will not be reset.":"Os totais ao vivo das interfaces são separados e não serão zerados.","Clean history":"Limpar histórico","VnStat history was cleaned.":"O histórico VnStat foi limpo.","Traffic history":"Histórico de tráfego","Clean panel log":"Limpar log do painel","Clean the current panel log now?":"Limpar agora o log atual do painel?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"Isso limpa somente o arquivo de log do painel. A limpeza automática por tamanho continuará ativa.","Clean log":"Limpar log",
|
||||
"Delete reseller":"Excluir revendedor","Delete reseller \"{name}\"?":"Excluir o revendedor \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Os acessos pertencentes a ele serão removidos e as sessões SSH ativas serão desconectadas.","Remove inbound":"Remover inbound","Remove inbound {name}?":"Remover o inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clientes vinculados somente a este inbound deixarão de conectar após salvar a configuração.",
|
||||
"This endpoint already has an SSH /ssh route.":"Este endpoint já possui uma rota SSH /ssh.","Add SSH /ssh without rebuilding this inbound.":"Adicione SSH /ssh sem recriar este inbound.","SSH migration attention":"Atenção na migração SSH","Could not enable SSH /ssh":"Não foi possível ativar SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Selecione um inbound VLESS/VMess usando XHTTP.","Load the selected server configuration before enabling SSH.":"Carregue a configuração do servidor selecionado antes de ativar SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"O SSH compartilhado exige o modo Xray nativo. Selecione Internal native emulator e salve o modo primeiro.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"O inbound {name} usa {security}, que o SSH XHTTP nativo não suporta. Use TLS ou sem TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH já está ativado em /ssh pelo inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"O caminho /ssh já é usado pelo inbound {name}. Edite esse caminho primeiro.","This inbound uses TLS but has no reusable certificate and key file paths.":"Este inbound usa TLS, mas não possui caminhos reutilizáveis para certificado e chave.","Safe XHTTP migration":"Migração XHTTP segura","Enable SSH on /ssh":"Ativar SSH em /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Adicionar SSH ao mesmo endpoint sem recriar {name}?","Listener":"Listener","Existing path preserved":"Path existente preservado","New SSH path":"Novo path SSH","Clients preserved":"Clientes preservados","Enabling SSH…":"Ativando SSH…","Could not enable SSH: {error}":"Não foi possível ativar SSH: {error}","Migration was cancelled because it would alter the old inbound.":"A migração foi cancelada porque alteraria o inbound antigo.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh foi adicionado ao rascunho sem alterar {name}.","Enabling SSH /ssh without changing {name}…":"Ativando SSH /ssh sem alterar {name}…","The SSH route could not be saved. The old inbound was not changed.":"A rota SSH não pôde ser salva. O inbound antigo não foi alterado.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh foi salvo, mas o Xray não conseguiu reiniciar. Verifique o log e use o botão Reiniciar.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh está ativo. O inbound antigo e todos os clientes foram preservados.","Configuration for this server was not loaded.":"A configuração deste servidor não foi carregada.","Invalid visual config: {error}":"Configuração visual inválida: {error}","Xray could not restart.":"O Xray não conseguiu reiniciar.","Could not save configuration: {error}":"Não foi possível salvar a configuração: {error}","selected":"selecionado","this inbound":"este inbound","the old inbound":"o inbound antigo","untagged":"sem tag"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Create user":"Create user","Edit user":"Edit user","Create SSH user":"Create SSH user","SSH user saved successfully.":"SSH user saved successfully.","SSH and SlowDNS areas":"SSH and SlowDNS areas","SSH and SlowDNS area":"SSH and SlowDNS area",
|
||||
"01 · Management":"01 · Management","02 · Registration":"02 · Registration","SSH and SlowDNS users":"SSH and SlowDNS users","SSH users section copy":"Review connections, limits, expiration, and actions for every account.","SSH create section copy":"Set authentication, expiration, connections, and speed in a dedicated screen.",
|
||||
"Configuration":"Configuration","Xray areas":"Xray areas","Xray area":"Xray area","Xray users section copy":"Review clients, connections, expiration, and traffic by inbound.","Create Xray user":"Create Xray user","Xray create section copy":"Choose the inbound and register the client without opening the users table.","New Xray client":"New Xray client","Loading inbounds…":"Loading inbounds…","Select where the client will be added.":"Select where the client will be added.","Generated automatically":"Generated automatically","Generate":"Generate","Display name":"Display name","Email / identifier":"Email / identifier","Expires on":"Expires on","Maximum connections":"Maximum connections","0 = unlimited":"0 = unlimited","Back to users":"Back to users","Fill in the new client details.":"Fill in the new client details.",
|
||||
"No compatible inbound found":"No compatible inbound found","No inbound":"No inbound","The client will be added to {tag} on port {port}.":"The client will be added to {tag} on port {port}.","Create or enable a compatible inbound before adding a client.":"Create or enable a compatible inbound before adding a client.","Ready to create a new Xray client.":"Ready to create a new Xray client.","Waiting for a compatible inbound.":"Waiting for a compatible inbound.","Select a compatible inbound.":"Select a compatible inbound.","Creating Xray client…":"Creating Xray client…","Xray user created successfully.":"Xray user created successfully.","Xray user":"Xray user","Could not create the Xray user: {error}":"Could not create the Xray user: {error}",
|
||||
"03 · Service":"03 · Service","04 · Diagnostics":"04 · Diagnostics","Xray configuration section copy":"Edit the endpoint, inbounds, TLS, and advanced options visually or as JSON.","Xray logs section copy":"Review the latest service messages in a focused screen.",
|
||||
"Reseller areas":"Reseller areas","Reseller area":"Reseller area","Create reseller":"Create reseller","Edit reseller":"Edit reseller","Registered resellers":"Registered resellers","Reseller list section copy":"Review quotas, shared usage, expiration, and status for every partner.","Reseller create section copy":"Set login, shared limit, expiration, and access in a dedicated screen.","Reseller saved successfully.":"Reseller saved successfully.",
|
||||
"Configuration areas":"Configuration areas","Configuration area":"Configuration area","Network and SSH":"Network and SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"TLS forwarders","01 · Base":"01 · Base","02 · DNS tunnel":"02 · DNS tunnel","03 · UDP":"03 · UDP","04 · Security":"04 · Security","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, default limits, idle timeout, and the connection banner.","SlowDNS section copy":"Manage domains, local DNS, capacity, queues, and controlled restarts.","UDP section copy":"Set the listener, capacity, map expiration, and service restart.","TLS section copy":"Create TLS listeners with automatic, pasted, or file-based certificates.","Xray core section copy":"Enable the core, choose the runtime, and apply safe native tuning."
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Create user":"Criar usuário","Edit user":"Editar usuário","Create SSH user":"Criar usuário SSH","SSH user saved successfully.":"Usuário SSH salvo com sucesso.","SSH and SlowDNS areas":"Áreas SSH e SlowDNS","SSH and SlowDNS area":"Área SSH e SlowDNS",
|
||||
"01 · Management":"01 · Gestão","02 · Registration":"02 · Cadastro","SSH and SlowDNS users":"Usuários SSH e SlowDNS","SSH users section copy":"Consulte conexões, limites, validade e ações de cada conta.","SSH create section copy":"Defina autenticação, validade, conexões e velocidade em uma tela dedicada.",
|
||||
"Configuration":"Configuração","Xray areas":"Áreas do Xray","Xray area":"Área do Xray","Xray users section copy":"Consulte clientes, conexões, validade e tráfego separados por inbound.","Create Xray user":"Criar usuário Xray","Xray create section copy":"Escolha o inbound e cadastre o cliente sem abrir a tabela de usuários.","New Xray client":"Novo cliente Xray","Loading inbounds…":"Carregando inbounds…","Select where the client will be added.":"Selecione onde o cliente será adicionado.","Generated automatically":"Gerado automaticamente","Generate":"Gerar","Display name":"Nome de exibição","Email / identifier":"Email / identificação","Expires on":"Expira em","Maximum connections":"Máximo de conexões","0 = unlimited":"0 = ilimitado","Back to users":"Voltar aos usuários","Fill in the new client details.":"Preencha os dados do novo cliente.",
|
||||
"No compatible inbound found":"Nenhum inbound compatível encontrado","No inbound":"Sem inbound","The client will be added to {tag} on port {port}.":"O cliente será adicionado em {tag} na porta {port}.","Create or enable a compatible inbound before adding a client.":"Crie ou ative um inbound compatível antes de adicionar um cliente.","Ready to create a new Xray client.":"Pronto para criar um novo cliente Xray.","Waiting for a compatible inbound.":"Aguardando um inbound compatível.","Select a compatible inbound.":"Selecione um inbound compatível.","Creating Xray client…":"Criando cliente Xray…","Xray user created successfully.":"Usuário Xray criado com sucesso.","Xray user":"Usuário Xray","Could not create the Xray user: {error}":"Não foi possível criar o usuário Xray: {error}",
|
||||
"03 · Service":"03 · Serviço","04 · Diagnostics":"04 · Diagnóstico","Xray configuration section copy":"Edite endpoint, inbounds, TLS e opções avançadas visualmente ou em JSON.","Xray logs section copy":"Acompanhe as últimas mensagens do serviço em uma tela focada.",
|
||||
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
|
||||
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
|
||||
});
|
||||
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
let i18nTranslating = false;
|
||||
let i18nQueued = false;
|
||||
|
||||
function detectInitialLanguage() {
|
||||
const saved = localStorage.getItem(LANG_STORAGE_KEY);
|
||||
if (SUPPORTED_LANGS.includes(saved)) return saved;
|
||||
const langs = (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""]).join(" ").toLowerCase();
|
||||
return langs.includes("pt") ? "pt-BR" : "en-US";
|
||||
}
|
||||
function normalizeI18nText(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); }
|
||||
function i18nCanonicalKey(value) {
|
||||
const text = normalizeI18nText(value);
|
||||
if (!text) return "";
|
||||
if (Object.prototype.hasOwnProperty.call(I18N_TEXT["en-US"], text)) return text;
|
||||
if (I18N_ALIASES[text]) return I18N_ALIASES[text];
|
||||
for (const lang of SUPPORTED_LANGS) {
|
||||
if (I18N_REVERSE[lang][text]) return I18N_REVERSE[lang][text];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function t(key, vars = {}) {
|
||||
const dict = I18N_TEXT[currentLang] || I18N_TEXT["en-US"];
|
||||
let out = dict[key] || I18N_TEXT["en-US"][key] || key;
|
||||
return out.replace(/\{(\w+)\}/g, (_, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : "");
|
||||
}
|
||||
function shouldSkipI18n(el) {
|
||||
return !el || !el.closest || !!el.closest("script,style,textarea,pre,code,[data-no-i18n]");
|
||||
}
|
||||
function translateTextNode(node) {
|
||||
const raw = node.nodeValue || "";
|
||||
const key = i18nCanonicalKey(raw);
|
||||
if (!key) return;
|
||||
const translated = t(key);
|
||||
const lead = raw.match(/^\s*/)?.[0] || "";
|
||||
const tail = raw.match(/\s*$/)?.[0] || "";
|
||||
const next = lead + translated + tail;
|
||||
if (node.nodeValue !== next) node.nodeValue = next;
|
||||
}
|
||||
function translateStatic(root = document.body) {
|
||||
if (!root) return;
|
||||
i18nTranslating = true;
|
||||
try {
|
||||
if (root.nodeType === Node.TEXT_NODE) translateTextNode(root);
|
||||
const base = root.nodeType === Node.ELEMENT_NODE ? root : document.body;
|
||||
if (!base) return;
|
||||
const walker = document.createTreeWalker(base, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
return shouldSkipI18n(node.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
nodes.forEach(translateTextNode);
|
||||
base.querySelectorAll?.("[placeholder],[title],[aria-label]").forEach(el => {
|
||||
if (shouldSkipI18n(el)) return;
|
||||
["placeholder", "title", "aria-label"].forEach(attr => {
|
||||
const value = el.getAttribute(attr);
|
||||
const key = i18nCanonicalKey(value);
|
||||
if (key) el.setAttribute(attr, t(key));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
i18nTranslating = false;
|
||||
}
|
||||
}
|
||||
function queueI18nRefresh() {
|
||||
if (i18nTranslating || i18nQueued) return;
|
||||
i18nQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
i18nQueued = false;
|
||||
translateStatic(document.body);
|
||||
});
|
||||
}
|
||||
function startI18nObserver() {
|
||||
if (!document.body || window.__dragonI18nObserver) return;
|
||||
window.__dragonI18nObserver = new MutationObserver(() => queueI18nRefresh());
|
||||
window.__dragonI18nObserver.observe(document.body, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["placeholder", "title", "aria-label"] });
|
||||
}
|
||||
function applyLanguage(lang, options = {}) {
|
||||
currentLang = SUPPORTED_LANGS.includes(lang) ? lang : "en-US";
|
||||
if (options.persist !== false) localStorage.setItem(LANG_STORAGE_KEY, currentLang);
|
||||
document.documentElement.lang = currentLang.toLowerCase();
|
||||
if (languageSelect) languageSelect.value = currentLang;
|
||||
updatePageHeading();
|
||||
translateStatic(document.body);
|
||||
document.documentElement.classList.remove("i18n-pending");
|
||||
}
|
||||
|
||||
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
const loginOverlay = document.getElementById("loginOverlay");
|
||||
const loginUser = document.getElementById("loginUser");
|
||||
const loginPass = document.getElementById("loginPass");
|
||||
const loginBtn = document.getElementById("loginBtn");
|
||||
const loginErr = document.getElementById("loginErr");
|
||||
const mainApp = document.getElementById("mainApp");
|
||||
const meUsername = document.getElementById("meUsername");
|
||||
const roleChip = document.getElementById("roleChip");
|
||||
const logoutBtn = document.getElementById("logoutBtn");
|
||||
const menuToggle = document.getElementById("menuToggle");
|
||||
const drawerBackdrop = document.getElementById("drawerBackdrop");
|
||||
const languageSelect = document.getElementById("languageSelect");
|
||||
const pageTitle = document.getElementById("pageTitle");
|
||||
const pageEyebrow = document.getElementById("pageEyebrow");
|
||||
const dashTotalUsers = document.getElementById("dashTotalUsers");
|
||||
const dashActiveUsers = document.getElementById("dashActiveUsers");
|
||||
const dashExpiredUsers = document.getElementById("dashExpiredUsers");
|
||||
const dashAccountBreakdown = document.getElementById("dashAccountBreakdown");
|
||||
const dashConnections = document.getElementById("dashConnections");
|
||||
const dashConnectionsText = document.getElementById("dashConnectionsText");
|
||||
const dashServers = document.getElementById("dashServers");
|
||||
const dashServerStatus = document.getElementById("dashServerStatus");
|
||||
const dashXrayClients = document.getElementById("dashXrayClients");
|
||||
const dashXrayStatus = document.getElementById("dashXrayStatus");
|
||||
const dashCpuVal = document.getElementById("dashCpuVal");
|
||||
const dashCpuText = document.getElementById("dashCpuText");
|
||||
const dashCpuBar = document.getElementById("dashCpuBar");
|
||||
const dashRamVal = document.getElementById("dashRamVal");
|
||||
const dashRamText = document.getElementById("dashRamText");
|
||||
const dashRamBar = document.getElementById("dashRamBar");
|
||||
const dashNetVal = document.getElementById("dashNetVal");
|
||||
const dashNetText = document.getElementById("dashNetText");
|
||||
const dashNetTotal = document.getElementById("dashNetTotal");
|
||||
const dashQuotaChip = document.getElementById("dashQuotaChip");
|
||||
const dashQuotaBar = document.getElementById("dashQuotaBar");
|
||||
const dashQuotaText = document.getElementById("dashQuotaText");
|
||||
const dashQuotaBreakdown = document.getElementById("dashQuotaBreakdown");
|
||||
const dashQuotaRemaining = document.getElementById("dashQuotaRemaining");
|
||||
const dashQuotaSummaryText = document.getElementById("dashQuotaSummaryText");
|
||||
const dashQuotaMiniBar = document.getElementById("dashQuotaMiniBar");
|
||||
const xrayResellerQuotaUsed = document.getElementById("xrayResellerQuotaUsed");
|
||||
const xrayResellerQuotaRemaining = document.getElementById("xrayResellerQuotaRemaining");
|
||||
const xrayResellerQuotaMix = document.getElementById("xrayResellerQuotaMix");
|
||||
const dashboardQuotaCard = document.getElementById("dashboardQuotaCard");
|
||||
|
||||
// Users
|
||||
const usersBody = document.getElementById("usersBody");
|
||||
const userCountChip = document.getElementById("userCountChip");
|
||||
const userStatus = document.getElementById("userStatus");
|
||||
const lastReload = document.getElementById("lastReload");
|
||||
const ownerColHead = document.getElementById("ownerColHead");
|
||||
const resellerInfoCard = document.getElementById("resellerInfoCard");
|
||||
const rUsedMax = document.getElementById("rUsedMax");
|
||||
const rExpiry = document.getElementById("rExpiry");
|
||||
const rStatus = document.getElementById("rStatus");
|
||||
|
||||
// User form
|
||||
const userForm = document.getElementById("userForm");
|
||||
const cancelUserBtn = document.getElementById("cancelUserBtn");
|
||||
const newUserBtn = document.getElementById("newUserBtn");
|
||||
const saveUserBtn = document.getElementById("saveUserBtn");
|
||||
const fUsername = document.getElementById("fUsername");
|
||||
const fPassword = document.getElementById("fPassword");
|
||||
const fTotpSecret = document.getElementById("fTotpSecret");
|
||||
const fTotpPeriod = document.getElementById("fTotpPeriod");
|
||||
const fTotpWindow = document.getElementById("fTotpWindow");
|
||||
const fTotpDigits = document.getElementById("fTotpDigits");
|
||||
const fAllowStatic = document.getElementById("fAllowStatic");
|
||||
const fMaxConn = document.getElementById("fMaxConn");
|
||||
const fExpires = document.getElementById("fExpires");
|
||||
const fUp = document.getElementById("fUp");
|
||||
const fDown = document.getElementById("fDown");
|
||||
const sshLiveStatus = document.getElementById("sshLiveStatus");
|
||||
const sshMetricState = document.getElementById("sshMetricState");
|
||||
const sshMetricTotal = document.getElementById("sshMetricTotal");
|
||||
const sshMetricActive = document.getElementById("sshMetricActive");
|
||||
const sshMetricOnline = document.getElementById("sshMetricOnline");
|
||||
|
||||
// Xray
|
||||
const xrayChip = document.getElementById("xrayChip");
|
||||
const xRunning = document.getElementById("xRunning");
|
||||
const xPID = document.getElementById("xPID");
|
||||
const xUptime = document.getElementById("xUptime");
|
||||
const xStatus = document.getElementById("xStatus");
|
||||
const xOnlineUsers = document.getElementById("xOnlineUsers");
|
||||
const xCoreMode = document.getElementById("xCoreMode");
|
||||
const xSaveModeBtn = document.getElementById("xSaveModeBtn");
|
||||
const xCfgEditor = document.getElementById("xCfgEditor");
|
||||
const xCfgStatus = document.getElementById("xCfgStatus");
|
||||
const xLogsBox = document.getElementById("xLogsBox");
|
||||
const inboundsContainer = document.getElementById("inboundsContainer");
|
||||
const sshServerPickerCard = document.getElementById("sshServerPickerCard");
|
||||
const xrayServerPickerCard = document.getElementById("xrayServerPickerCard");
|
||||
const sshServerSelect = document.getElementById("sshServerSelect");
|
||||
const xrayServerSelect = document.getElementById("xrayServerSelect");
|
||||
const sshServerHint = document.getElementById("sshServerHint");
|
||||
const xrayServerHint = document.getElementById("xrayServerHint");
|
||||
|
||||
// Resellers
|
||||
const resellersBody = document.getElementById("resellersBody");
|
||||
const resellerCountChip = document.getElementById("resellerCountChip");
|
||||
const resellerStatus = document.getElementById("resellerStatus");
|
||||
const resellerFormTitle = document.getElementById("resellerFormTitle");
|
||||
const resellerForm = document.getElementById("resellerForm");
|
||||
const rUsername = document.getElementById("rUsername");
|
||||
const rPassword = document.getElementById("rPassword");
|
||||
const rParent = document.getElementById("rParent");
|
||||
const rQuotaMode = document.getElementById("rQuotaMode");
|
||||
const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rCredits = document.getElementById("rCredits");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rWhatsApp = document.getElementById("rWhatsApp");
|
||||
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
const serversBody = document.getElementById("serversBody");
|
||||
const serversCountChip = document.getElementById("serversCountChip");
|
||||
const serversStatus = document.getElementById("serversStatus");
|
||||
const serverForm = document.getElementById("serverForm");
|
||||
const serverFormTitle = document.getElementById("serverFormTitle");
|
||||
const srvID = document.getElementById("srvID");
|
||||
const srvName = document.getElementById("srvName");
|
||||
const srvBaseURL = document.getElementById("srvBaseURL");
|
||||
const srvAdminUser = document.getElementById("srvAdminUser");
|
||||
const srvAdminKey = document.getElementById("srvAdminKey");
|
||||
const srvEnableSSH = document.getElementById("srvEnableSSH");
|
||||
const srvEnableXray = document.getElementById("srvEnableXray");
|
||||
const srvIsActive = document.getElementById("srvIsActive");
|
||||
const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
const serversStatusGrid = document.getElementById("serversStatusGrid");
|
||||
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
|
||||
const serversStatusCountChip = document.getElementById("serversStatusCountChip");
|
||||
const fleetLiveStatus = document.getElementById("fleetLiveStatus");
|
||||
const fleetMetricNodes = document.getElementById("fleetMetricNodes");
|
||||
const fleetMetricActive = document.getElementById("fleetMetricActive");
|
||||
const fleetMetricSSH = document.getElementById("fleetMetricSSH");
|
||||
const fleetMetricXray = document.getElementById("fleetMetricXray");
|
||||
const fleetStatusOnline = document.getElementById("fleetStatusOnline");
|
||||
const fleetStatusOffline = document.getElementById("fleetStatusOffline");
|
||||
const fleetStatusSessions = document.getElementById("fleetStatusSessions");
|
||||
|
||||
// Stats
|
||||
const cpuVal = document.getElementById("cpuVal");
|
||||
const cpuBar = document.getElementById("cpuBar");
|
||||
const memVal = document.getElementById("memVal");
|
||||
const memBar = document.getElementById("memBar");
|
||||
const memDetail = document.getElementById("memDetail");
|
||||
const ifaceBody = document.getElementById("ifaceBody");
|
||||
const ifaceSummary = document.getElementById("ifaceSummary");
|
||||
const statsUpdated = document.getElementById("statsUpdated");
|
||||
const statsNetVal = document.getElementById("statsNetVal");
|
||||
const statsIfaceVal = document.getElementById("statsIfaceVal");
|
||||
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
|
||||
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
|
||||
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
|
||||
const dnsttActiveSessions = document.getElementById("dnsttActiveSessions");
|
||||
const dnsttActiveStreams = document.getElementById("dnsttActiveStreams");
|
||||
const dnsttDNSRx = document.getElementById("dnsttDNSRx");
|
||||
const dnsttQueueLen = document.getElementById("dnsttQueueLen");
|
||||
const dnsttHealthBody = document.getElementById("dnsttHealthBody");
|
||||
const dnsttHealthSummary = document.getElementById("dnsttHealthSummary");
|
||||
|
||||
// VnStat
|
||||
const vnstatDailyBody = document.getElementById("vnstatDailyBody");
|
||||
const vnstatMonthlyBody = document.getElementById("vnstatMonthlyBody");
|
||||
const vnstatStatus = document.getElementById("vnstatStatus");
|
||||
const vnTodayTotal = document.getElementById("vnTodayTotal");
|
||||
const vnMonthTotal = document.getElementById("vnMonthTotal");
|
||||
const vnIfaceCount = document.getElementById("vnIfaceCount");
|
||||
const vnLatestPeriod = document.getElementById("vnLatestPeriod");
|
||||
const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
|
||||
const resetVnstatBtn = document.getElementById("resetVnstatBtn");
|
||||
|
||||
// Shared panel-native dialog and toast surfaces. Destructive and migration
|
||||
// actions use these instead of browser confirm/alert prompts.
|
||||
const panelConfirmDialog = document.getElementById("panelConfirmDialog");
|
||||
const panelDialogCard = panelConfirmDialog?.querySelector(".panel-dialog-card");
|
||||
const panelDialogIcon = document.getElementById("panelDialogIcon");
|
||||
const panelDialogEyebrow = document.getElementById("panelDialogEyebrow");
|
||||
const panelDialogTitle = document.getElementById("panelDialogTitle");
|
||||
const panelDialogMessage = document.getElementById("panelDialogMessage");
|
||||
const panelDialogDetail = document.getElementById("panelDialogDetail");
|
||||
const panelDialogCancelBtn = document.getElementById("panelDialogCancelBtn");
|
||||
const panelDialogConfirmBtn = document.getElementById("panelDialogConfirmBtn");
|
||||
const panelToastStack = document.getElementById("panelToastStack");
|
||||
let panelDialogResolver = null;
|
||||
let panelDialogLastFocus = null;
|
||||
|
||||
function closePanelConfirm(accepted = false) {
|
||||
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
|
||||
panelConfirmDialog.classList.add("hidden");
|
||||
panelConfirmDialog.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("panel-dialog-open");
|
||||
const resolver = panelDialogResolver;
|
||||
panelDialogResolver = null;
|
||||
resolver?.(!!accepted);
|
||||
panelDialogLastFocus?.focus?.();
|
||||
panelDialogLastFocus = null;
|
||||
}
|
||||
|
||||
function panelConfirm(options = {}) {
|
||||
const opts = typeof options === "string" ? { message: options } : options;
|
||||
if (!panelConfirmDialog || !panelDialogConfirmBtn) return Promise.resolve(false);
|
||||
if (panelDialogResolver) closePanelConfirm(false);
|
||||
panelDialogLastFocus = document.activeElement;
|
||||
const tone = opts.tone || (opts.danger ? "danger" : "default");
|
||||
panelDialogCard?.classList.toggle("is-danger", tone === "danger");
|
||||
panelDialogCard?.classList.toggle("is-success", tone === "success");
|
||||
if (panelDialogIcon) panelDialogIcon.textContent = opts.icon || (tone === "danger" ? "!" : tone === "success" ? "✓" : "?");
|
||||
if (panelDialogEyebrow) panelDialogEyebrow.textContent = opts.eyebrow || t("Confirmation");
|
||||
if (panelDialogTitle) panelDialogTitle.textContent = opts.title || t("Confirm action");
|
||||
if (panelDialogMessage) panelDialogMessage.textContent = opts.message || "";
|
||||
if (panelDialogDetail) {
|
||||
panelDialogDetail.textContent = opts.detail || "";
|
||||
panelDialogDetail.classList.toggle("hidden", !opts.detail);
|
||||
}
|
||||
if (panelDialogCancelBtn) panelDialogCancelBtn.textContent = opts.cancelLabel || t("Cancel");
|
||||
panelDialogConfirmBtn.textContent = opts.confirmLabel || t("Confirm");
|
||||
panelDialogConfirmBtn.className = tone === "danger" ? "btn btn-danger" : "btn";
|
||||
panelConfirmDialog.classList.remove("hidden");
|
||||
panelConfirmDialog.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("panel-dialog-open");
|
||||
setTimeout(() => panelDialogConfirmBtn.focus(), 0);
|
||||
return new Promise(resolve => { panelDialogResolver = resolve; });
|
||||
}
|
||||
|
||||
function showPanelToast(message, tone = "info", title = "", duration = 4800) {
|
||||
if (!panelToastStack || !message) return null;
|
||||
const toast = document.createElement("article");
|
||||
toast.className = `panel-toast ${tone}`;
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "panel-toast-icon";
|
||||
icon.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : tone === "warning" ? "i" : "•";
|
||||
const copy = document.createElement("div");
|
||||
copy.className = "panel-toast-copy";
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = title || (tone === "success" ? t("Completed") : tone === "error" ? t("Action failed") : tone === "warning" ? t("Attention") : t("Information"));
|
||||
const body = document.createElement("p");
|
||||
body.textContent = message;
|
||||
copy.append(heading, body);
|
||||
const close = document.createElement("button");
|
||||
close.className = "panel-toast-close";
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", t("Close"));
|
||||
close.textContent = "×";
|
||||
const remove = () => toast.remove();
|
||||
close.addEventListener("click", remove);
|
||||
toast.append(icon, copy, close);
|
||||
panelToastStack.appendChild(toast);
|
||||
if (duration > 0) setTimeout(remove, duration);
|
||||
return toast;
|
||||
}
|
||||
|
||||
panelDialogCancelBtn?.addEventListener("click", () => closePanelConfirm(false));
|
||||
panelDialogConfirmBtn?.addEventListener("click", () => closePanelConfirm(true));
|
||||
panelConfirmDialog?.querySelector("[data-panel-dialog-close]")?.addEventListener("click", () => closePanelConfirm(false));
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
|
||||
if (event.key === "Escape") closePanelConfirm(false);
|
||||
if (event.key === "Enter" && document.activeElement !== panelDialogCancelBtn) closePanelConfirm(true);
|
||||
});
|
||||
|
||||
// ─── API helper ───────────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const o = Object.assign({ headers: {} }, opts);
|
||||
o.headers = Object.assign({}, o.headers, {
|
||||
"Content-Type": "application/json",
|
||||
"X-Session-Token": sessionToken,
|
||||
});
|
||||
const res = await fetch(path, o);
|
||||
// A 403 is an in-session permission or quota error; only 401 means the
|
||||
// session is no longer valid and should return to the login screen.
|
||||
if (res.status === 401) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
serverID = serverID || "local";
|
||||
if (!serverID || serverID === "local") return path;
|
||||
return path + (path.includes("?") ? "&" : "?") + "server_id=" + encodeURIComponent(serverID);
|
||||
}
|
||||
function selectedSSHServer() { return sshServerSelect?.value || selectedSSHServerID || "local"; }
|
||||
function selectedXrayServer() { return xrayServerSelect?.value || selectedXrayServerID || "local"; }
|
||||
function serverByID(id) { return serversCache.find(s => String(s.id) === String(id)); }
|
||||
function selectedXrayServerLabel() {
|
||||
const id = selectedXrayServer();
|
||||
const srv = serverByID(id);
|
||||
if (srv) return srv.name || srv.base_url || id;
|
||||
return id === "local" ? "Master node" : id;
|
||||
}
|
||||
function xrayModeFromConfig(x) {
|
||||
const mode = String(x?.mode || "").toLowerCase();
|
||||
return mode === "external" ? "external" : "native";
|
||||
}
|
||||
|
||||
function applyXrayModeToConfig(cfg, mode) {
|
||||
mode = mode === "external" ? "external" : "native";
|
||||
cfg.xray = cfg.xray && typeof cfg.xray === "object" ? cfg.xray : {};
|
||||
cfg.xray.mode = mode;
|
||||
cfg.xray.native = mode === "native";
|
||||
cfg.xray.bin_path = cfg.xray.bin_path || "/opt/sshpanel/xray";
|
||||
cfg.xray.config_file = cfg.xray.config_file || "/opt/sshpanel/xray_config.json";
|
||||
cfg.xray.native_config_file = cfg.xray.native_config_file || "/opt/sshpanel/xray_native_config.json";
|
||||
cfg.xray.api_server = cfg.xray.api_server || "127.0.0.1:10085";
|
||||
cfg.xray.online_window_seconds = cfg.xray.online_window_seconds || 90;
|
||||
cfg.xray.stats_poll_seconds = cfg.xray.stats_poll_seconds || 15;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function reloadXrayConfigForSelectedServer() {
|
||||
const wizPane = document.getElementById("xrayWizardPane");
|
||||
const jsonPane = document.getElementById("xrayCfgPaneJson");
|
||||
if (jsonPane && !jsonPane.classList.contains("hidden")) return loadXrayCfg();
|
||||
if (wizPane && !wizPane.classList.contains("hidden")) return loadWizardFromConfig();
|
||||
}
|
||||
|
||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||
const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%";
|
||||
const fmtMbps = n => (n == null || isNaN(n)) ? "--" : n.toFixed(2);
|
||||
function fmtBytes(n) {
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
if (n<1024) return n+" B";
|
||||
const k=n/1024; if(k<1024) return k.toFixed(1)+" KiB";
|
||||
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
|
||||
return (m/1024).toFixed(1)+" GiB";
|
||||
}
|
||||
function fmtInt(n) {
|
||||
const v = Number(n);
|
||||
return Number.isFinite(v) ? v.toLocaleString() : "--";
|
||||
}
|
||||
function fmtDnsttTimestamp(ts) {
|
||||
if (!ts) return "Waiting for DNSTT stats…";
|
||||
const d = new Date(ts);
|
||||
if (!Number.isFinite(d.getTime()) || d.getFullYear() < 2020) return "Waiting for DNSTT stats…";
|
||||
return "Updated: " + d.toLocaleTimeString();
|
||||
}
|
||||
function localDateKey(d = new Date()) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString()+" "+d.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
|
||||
}
|
||||
function isoFromLocal(v) {
|
||||
if (!v) return "";
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? "" : d.toISOString();
|
||||
}
|
||||
function localFromISO(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const pad = n => String(n).padStart(2,"0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
function genBase32(len=20) {
|
||||
const alpha="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const bytes=new Uint8Array(len);
|
||||
crypto.getRandomValues(bytes);
|
||||
let bits=0,val=0,out="";
|
||||
for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){out+=alpha[(val>>>(bits-5))&31];bits-=5;}}
|
||||
if(bits>0) out+=alpha[(val<<(5-bits))&31];
|
||||
return out;
|
||||
}
|
||||
function genUUID() {
|
||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
|
||||
}
|
||||
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "").replace(/[&<>'"]/g, ch => ({
|
||||
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
return !!el && !el.classList.contains("hidden") && getComputedStyle(el).display !== "none";
|
||||
}
|
||||
|
||||
function isXrayClientEditorActive() {
|
||||
const active = document.activeElement;
|
||||
if (active && active.closest && (active.closest("#inboundsContainer") || active.closest("#editXrayClientPanel"))) return true;
|
||||
if (isVisible(document.getElementById("editXrayClientPanel"))) return true;
|
||||
return Array.from(document.querySelectorAll('#inboundsContainer [id^="add-form-"]')).some(isVisible);
|
||||
}
|
||||
|
||||
function inboundStructure(inbounds = []) {
|
||||
return JSON.stringify((inbounds || []).map(ib => ({
|
||||
tag: ib.tag || "",
|
||||
protocol: ib.protocol || "",
|
||||
port: ib.port ?? "",
|
||||
clients: (ib.clients || []).map(c => c.id || ""),
|
||||
})));
|
||||
}
|
||||
|
||||
function clientStatusHTML(c) {
|
||||
const exp = c.expires_at ? new Date(c.expires_at) : null;
|
||||
const daysLeft = c.expiration_days;
|
||||
if (c.expired) return `<span style="color:var(--danger);font-size:.68rem;">${t("Expired")}</span>`;
|
||||
if (daysLeft === -1 || !exp) return `<span style="color:var(--success);font-size:.68rem;">${t("Active")}</span>`;
|
||||
return `<span style="color:var(--success);font-size:.68rem;">${t("Active ({days}d)", {days: escapeHTML(daysLeft)})}</span>`;
|
||||
}
|
||||
|
||||
function clientExpiryLabel(c) {
|
||||
const exp = c.expires_at ? new Date(c.expires_at) : null;
|
||||
return exp ? exp.toLocaleDateString() : t("Unlimited");
|
||||
}
|
||||
|
||||
function clientOnlineHTML(c) {
|
||||
return `${c.online ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("offline")}</span>`}<div class="hint">${escapeHTML(formatLastActive(c.last_active))}</div>`;
|
||||
}
|
||||
|
||||
function clientTrafficHTML(c) {
|
||||
const up = Number(c.uplink_bytes || 0);
|
||||
const down = Number(c.downlink_bytes || 0);
|
||||
const total = Number(c.total_bytes || (up + down) || 0);
|
||||
return `${escapeHTML(formatBytes(total))}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
}
|
||||
|
||||
function updateCell(row, name, html) {
|
||||
const cell = row?.querySelector?.(`[data-cell="${name}"]`);
|
||||
if (cell && cell.innerHTML !== html) cell.innerHTML = html;
|
||||
}
|
||||
|
||||
function patchRenderedInbounds(inbounds) {
|
||||
const sections = Array.from(inboundsContainer.querySelectorAll("[data-inbound-tag]"));
|
||||
if (sections.length !== inbounds.length) return false;
|
||||
|
||||
for (const ib of inbounds) {
|
||||
const tag = String(ib.tag || "");
|
||||
const section = sections.find(el => el.dataset.inboundTag === tag);
|
||||
if (!section) return false;
|
||||
if (section.dataset.inboundProtocol !== String(ib.protocol || "") || section.dataset.inboundPort !== String(ib.port ?? "")) return false;
|
||||
|
||||
const clients = ib.clients || [];
|
||||
const rows = Array.from(section.querySelectorAll("tr[data-client-id]"));
|
||||
if (rows.length !== clients.length) return false;
|
||||
|
||||
const onlineCount = clients.filter(c => !!c.online).length;
|
||||
const chip = section.querySelector('[data-role="inbound-online-chip"]');
|
||||
if (chip) {
|
||||
chip.textContent = t("{count} online", {count: onlineCount});
|
||||
chip.classList.toggle("green", onlineCount > 0);
|
||||
}
|
||||
|
||||
for (const c of clients) {
|
||||
const row = rows.find(el => el.dataset.clientId === String(c.id || ""));
|
||||
if (!row) return false;
|
||||
updateCell(row, "name", escapeHTML(c.name || "—"));
|
||||
updateCell(row, "uuid", escapeHTML(c.id || "—"));
|
||||
updateCell(row, "email", escapeHTML(c.email || "—"));
|
||||
updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
|
||||
updateCell(row, "status", clientStatusHTML(c));
|
||||
updateCell(row, "online", clientOnlineHTML(c));
|
||||
updateCell(row, "traffic", clientTrafficHTML(c));
|
||||
updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// ─── Navigation / shell ──────────────────────────────────────────────────────
|
||||
const tabTitles = {
|
||||
dashboard: ["Dashboard", "Overview"],
|
||||
ssh: ["Accounts", "SSH / SlowDNS"],
|
||||
xray: ["Accounts", "Xray Users"],
|
||||
resellers: ["Administration", "Resellers"],
|
||||
servers: ["Infrastructure", "Servers"],
|
||||
"servers-status": ["Infrastructure", "Servers Status"],
|
||||
stats: ["Infrastructure", "Monitoring"],
|
||||
vnstat: ["Infrastructure", "Traffic"],
|
||||
logs: ["System", "Logs"],
|
||||
bot: ["Vendas", "Bot / Telegram"],
|
||||
server: ["System", "Settings"],
|
||||
};
|
||||
const infrastructureTabs = ["servers", "servers-status", "stats", "vnstat"];
|
||||
const infrastructureNavItems = [
|
||||
{ tab:"servers", icon:"▣", label:"Servers" },
|
||||
{ tab:"servers-status", icon:"●", label:"Status" },
|
||||
{ tab:"stats", icon:"◴", label:"Server" },
|
||||
{ tab:"vnstat", icon:"⇅", label:"Traffic" },
|
||||
];
|
||||
|
||||
function syncInfrastructureNavigation(tab = currentTab) {
|
||||
if (!infrastructureTabs.includes(tab)) return;
|
||||
document.querySelectorAll("[data-infra-tab]").forEach(button => button.classList.toggle("active", button.dataset.infraTab === tab));
|
||||
document.querySelectorAll(".infra-section-select").forEach(select => { select.value = tab; });
|
||||
}
|
||||
|
||||
function mountInfrastructureNavigation() {
|
||||
document.querySelectorAll(".infra-nav-mount").forEach(mount => {
|
||||
const shell = document.createElement("div");
|
||||
shell.className = "infra-nav-shell";
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "infra-section-nav";
|
||||
nav.setAttribute("aria-label", t("Infrastructure areas"));
|
||||
const select = document.createElement("select");
|
||||
select.className = "infra-section-select";
|
||||
select.setAttribute("aria-label", t("Infrastructure area"));
|
||||
infrastructureNavItems.forEach(item => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset.infraTab = item.tab;
|
||||
const icon = document.createElement("span");
|
||||
icon.textContent = item.icon;
|
||||
button.append(icon, document.createTextNode(" " + t(item.label)));
|
||||
button.addEventListener("click", () => selectTab(item.tab));
|
||||
nav.appendChild(button);
|
||||
const option = document.createElement("option");
|
||||
option.value = item.tab;
|
||||
option.textContent = t(item.label);
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener("change", () => selectTab(select.value));
|
||||
shell.append(nav, select);
|
||||
mount.replaceChildren(shell);
|
||||
});
|
||||
syncInfrastructureNavigation();
|
||||
}
|
||||
|
||||
const workspaceSectionDefaults = {
|
||||
ssh: "users",
|
||||
xray: "users",
|
||||
resellers: "users",
|
||||
config: "general",
|
||||
};
|
||||
|
||||
function workspaceSectionRoot(workspace) {
|
||||
const tab = workspace === "config" ? "server" : workspace;
|
||||
return document.getElementById(`tab-${tab}`);
|
||||
}
|
||||
|
||||
function activeWorkspaceSection(workspace) {
|
||||
const root = workspaceSectionRoot(workspace);
|
||||
return root?.querySelector(`[data-workspace-panel="${workspace}"].active`)?.dataset.workspaceSectionPanel
|
||||
|| workspaceSectionDefaults[workspace]
|
||||
|| "";
|
||||
}
|
||||
|
||||
function setWorkspaceSection(workspace, section, options = {}) {
|
||||
const root = workspaceSectionRoot(workspace);
|
||||
if (!root) return false;
|
||||
const panels = Array.from(root.querySelectorAll(`[data-workspace-panel="${workspace}"]`));
|
||||
const targets = panels.filter(panel => panel.dataset.workspaceSectionPanel === section && !panel.classList.contains("hidden"));
|
||||
if (!targets.length) {
|
||||
section = workspaceSectionDefaults[workspace] || panels.find(panel => !panel.classList.contains("hidden"))?.dataset.workspaceSectionPanel || "";
|
||||
}
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.workspaceSectionPanel === section));
|
||||
root.querySelectorAll(`[data-workspace="${workspace}"][data-workspace-section]`).forEach(button => {
|
||||
const active = button.dataset.workspaceSection === section;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
const select = root.querySelector(`[data-workspace-select="${workspace}"]`);
|
||||
if (select) select.value = section;
|
||||
|
||||
if (!options.silent) {
|
||||
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
|
||||
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function prepareWorkspaceSection(workspace, section) {
|
||||
if (section !== "create") return;
|
||||
if (workspace === "ssh" && typeof prepareNewSSHUser === "function") prepareNewSSHUser();
|
||||
if (workspace === "xray" && typeof prepareXrayClientCreator === "function") prepareXrayClientCreator();
|
||||
if (workspace === "resellers" && typeof prepareNewReseller === "function") prepareNewReseller();
|
||||
}
|
||||
|
||||
function navigateWorkspaceSection(workspace, section) {
|
||||
prepareWorkspaceSection(workspace, section);
|
||||
return setWorkspaceSection(workspace, section);
|
||||
}
|
||||
|
||||
function mountWorkspaceSectionNavigation() {
|
||||
document.querySelectorAll("[data-workspace][data-workspace-section]").forEach(button => {
|
||||
button.setAttribute("role", "tab");
|
||||
button.addEventListener("click", () => navigateWorkspaceSection(button.dataset.workspace, button.dataset.workspaceSection));
|
||||
});
|
||||
document.querySelectorAll("[data-workspace-select]").forEach(select => {
|
||||
select.addEventListener("change", () => navigateWorkspaceSection(select.dataset.workspaceSelect, select.value));
|
||||
});
|
||||
Object.entries(workspaceSectionDefaults).forEach(([workspace, section]) => setWorkspaceSection(workspace, section, { silent:true }));
|
||||
}
|
||||
|
||||
function updatePageHeading() {
|
||||
const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab];
|
||||
if (pageEyebrow) pageEyebrow.textContent = t(eyebrow);
|
||||
if (pageTitle) pageTitle.textContent = t(title);
|
||||
}
|
||||
|
||||
function selectTab(tab) {
|
||||
currentTab = tab;
|
||||
const pane = document.getElementById("tab-" + tab);
|
||||
const navTab = infrastructureTabs.includes(tab) ? "servers" : tab;
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${navTab}"]`);
|
||||
if (!pane) return;
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
btn?.classList.add("active");
|
||||
pane.classList.add("active");
|
||||
syncInfrastructureNavigation(tab);
|
||||
updatePageHeading();
|
||||
document.body.classList.remove("sidebar-open");
|
||||
|
||||
if (tab === "dashboard") refreshDashboard();
|
||||
if (tab === "xray") {
|
||||
loadXrayStatus();
|
||||
loadInbounds({ silent: true });
|
||||
if (currentRole === "superadmin" && activeWorkspaceSection("xray") === "config") loadWizardFromConfig();
|
||||
}
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
|
||||
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
|
||||
if (tab === "resellers") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
|
||||
}
|
||||
|
||||
mountInfrastructureNavigation();
|
||||
mountWorkspaceSectionNavigation();
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
|
||||
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||||
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||||
languageSelect?.addEventListener("change", () => { applyLanguage(languageSelect.value); renderDashboardCounters(); });
|
||||
applyLanguage(currentLang, { persist: false });
|
||||
startI18nObserver();
|
||||
|
||||
// ─── Login / Logout ───────────────────────────────────────────────────────────
|
||||
loginBtn.addEventListener("click", doLogin);
|
||||
loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await api("/api/auth/logout", { method: "POST" }); } catch {}
|
||||
sessionToken = "";
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
loginErr.textContent = "";
|
||||
loginUser.value = loginPass.value = "";
|
||||
});
|
||||
|
||||
async function doLogin() {
|
||||
loginErr.textContent = "";
|
||||
loginBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
loginErr.textContent = res.status === 401 ? t("Invalid credentials.") :
|
||||
res.status === 403 ? t("Account suspended or expired.") :
|
||||
t("Login failed.");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
sessionToken = data.token;
|
||||
currentRole = data.role;
|
||||
currentUser = data.username;
|
||||
sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
loginOverlay.classList.add("hidden");
|
||||
mainApp.classList.remove("hidden");
|
||||
initAfterLogin();
|
||||
} catch (e) {
|
||||
loginErr.textContent = t("Network error.");
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init after login ─────────────────────────────────────────────────────────
|
||||
function clearTimers() {
|
||||
[statsTimer, usersTimer, xrayTimer].forEach(t => t && clearInterval(t));
|
||||
statsTimer = usersTimer = xrayTimer = null;
|
||||
}
|
||||
|
||||
function initAfterLogin() {
|
||||
if (currentRole === "superadmin") {
|
||||
currentQuotaMode = "slots";
|
||||
currentCreditBalance = 0;
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (input) { input.disabled = false; input.title = ""; }
|
||||
});
|
||||
}
|
||||
meUsername.textContent = currentUser;
|
||||
mainApp.classList.remove("role-superadmin", "role-reseller");
|
||||
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
|
||||
roleChip.innerHTML = currentRole === "superadmin"
|
||||
? `<span class="chip green">superadmin</span>`
|
||||
: `<span class="chip warn">reseller</span>`;
|
||||
|
||||
document.querySelectorAll(".superadmin-only").forEach(el => {
|
||||
el.classList.toggle("hidden", currentRole !== "superadmin");
|
||||
});
|
||||
document.querySelectorAll(".reseller-only").forEach(el => {
|
||||
el.classList.toggle("hidden", currentRole !== "reseller");
|
||||
});
|
||||
document.querySelectorAll(".xray-admin-only").forEach(el => {
|
||||
el.classList.toggle("hidden", currentRole !== "superadmin");
|
||||
});
|
||||
document.querySelectorAll("option.xray-admin-only").forEach(option => {
|
||||
option.hidden = currentRole !== "superadmin";
|
||||
option.disabled = currentRole !== "superadmin";
|
||||
});
|
||||
if (currentRole !== "superadmin" && ["config", "logs"].includes(activeWorkspaceSection("xray"))) {
|
||||
setWorkspaceSection("xray", "users", { silent:true });
|
||||
}
|
||||
|
||||
resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
|
||||
dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
|
||||
|
||||
selectTab("dashboard");
|
||||
loadServers();
|
||||
|
||||
if (currentRole === "superadmin") {
|
||||
loadDashboardStats();
|
||||
if (typeof loadUpdateStatus === "function") loadUpdateStatus();
|
||||
statsTimer = setInterval(() => {
|
||||
loadDashboardStats();
|
||||
if (currentTab === "stats") loadStats();
|
||||
if (currentTab === "servers-status") loadServersStatus({ silent: true });
|
||||
}, 2000);
|
||||
} else {
|
||||
loadMe();
|
||||
}
|
||||
xrayTimer = setInterval(() => {
|
||||
loadXrayStatus();
|
||||
if (currentTab === "xray") loadInbounds({ silent: true });
|
||||
}, 7000);
|
||||
|
||||
loadUsers();
|
||||
loadXrayStatus();
|
||||
loadInbounds({ silent: true });
|
||||
usersTimer = setInterval(() => loadUsersSilent(), 3000);
|
||||
}
|
||||
|
||||
// ─── Me (reseller info) ───────────────────────────────────────────────────────
|
||||
async function loadMe() {
|
||||
try {
|
||||
const res = await api("/api/auth/me");
|
||||
const d = await res.json();
|
||||
dashboardCache.me = d;
|
||||
currentQuotaMode = d.quota_mode || "slots";
|
||||
currentCreditBalance = d.credit_balance || 0;
|
||||
const creditPlan = currentQuotaMode === "credits";
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (!input) return;
|
||||
input.disabled = creditPlan;
|
||||
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
|
||||
});
|
||||
const used = d.used_users ?? 0;
|
||||
const max = d.max_users || 0;
|
||||
rUsedMax.textContent = currentQuotaMode === "credits"
|
||||
? `${currentCreditBalance} créditos`
|
||||
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
|
||||
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
|
||||
const effectiveActive = d.effective_active ?? d.is_active;
|
||||
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
|
||||
if (currentQuotaMode === "credits") {
|
||||
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
}
|
||||
renderDashboardCounters();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function quotaToneClass(pct, remaining) {
|
||||
if (remaining === 0 || pct >= 90) return "quota-danger";
|
||||
if (pct >= 75) return "quota-warn";
|
||||
return "quota-good";
|
||||
}
|
||||
|
||||
function setQuotaTone(el, tone) {
|
||||
if (!el) return;
|
||||
el.classList.remove("quota-good", "quota-warn", "quota-danger");
|
||||
el.classList.add(tone);
|
||||
}
|
||||
|
||||
function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
const unlimited = !max;
|
||||
const remaining = unlimited ? "∞" : Math.max(0, max - used);
|
||||
const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100));
|
||||
const tone = quotaToneClass(pct, remaining === "∞" ? 999999 : remaining);
|
||||
const labelMax = unlimited ? "∞" : max;
|
||||
|
||||
dashQuotaChip.textContent = `${used} / ${labelMax}`;
|
||||
dashQuotaChip.className = `chip ${pct >= 90 ? "red" : pct >= 75 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = unlimited
|
||||
? t("No limit set by admin")
|
||||
: t("{remaining} accounts available · {pct}% used", {remaining, pct});
|
||||
dashQuotaBreakdown.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
|
||||
dashQuotaBar.style.width = `${pct}%`;
|
||||
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(remaining);
|
||||
setQuotaTone(dashQuotaRemaining, tone);
|
||||
}
|
||||
if (dashQuotaSummaryText) {
|
||||
dashQuotaSummaryText.textContent = unlimited
|
||||
? t("{used} used · unlimited", {used})
|
||||
: t("{used}/{max} used · {pct}% of plan", {used, max, pct});
|
||||
}
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = `${pct}%`;
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${used}/${labelMax}`;
|
||||
if (xrayResellerQuotaRemaining) {
|
||||
xrayResellerQuotaRemaining.textContent = String(remaining);
|
||||
setQuotaTone(xrayResellerQuotaRemaining, tone);
|
||||
}
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
|
||||
}
|
||||
|
||||
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
dashQuotaChip.textContent = `${balance} Cr`;
|
||||
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = `${balance} créditos disponíveis`;
|
||||
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
|
||||
dashQuotaBar.style.width = "100%";
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(balance);
|
||||
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
|
||||
}
|
||||
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
|
||||
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
|
||||
}
|
||||
|
||||
function flattenXrayClients(inbounds = []) {
|
||||
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
|
||||
}
|
||||
|
||||
function isExpiredDate(value) {
|
||||
return !!value && new Date(value) < new Date();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (!n) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = n, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatLastActive(value) {
|
||||
if (!value) return "--";
|
||||
const diff = Math.max(0, Date.now() - new Date(value).getTime());
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s ${t("ago")}`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ${t("ago")}`;
|
||||
const hrs = Math.floor(min / 60);
|
||||
if (hrs < 24) return `${hrs}h ${t("ago")}`;
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function renderDashboardCounters() {
|
||||
if (!dashTotalUsers) return;
|
||||
const sshUsers = dashboardCache.sshUsers || [];
|
||||
const xrayClients = flattenXrayClients(dashboardCache.xrayInbounds || []);
|
||||
const sshExpired = sshUsers.filter(u => isExpiredDate(u.expires_at)).length;
|
||||
const xrayExpired = xrayClients.filter(c => c.expired || isExpiredDate(c.expires_at)).length;
|
||||
const sshActive = Math.max(0, sshUsers.length - sshExpired);
|
||||
const xrayActive = Math.max(0, xrayClients.length - xrayExpired);
|
||||
const total = sshUsers.length + xrayClients.length;
|
||||
const active = sshActive + xrayActive;
|
||||
const expired = sshExpired + xrayExpired;
|
||||
const sshConns = sshUsers.reduce((sum, u) => sum + Number(u.active_conns || 0), 0);
|
||||
const xrayOnline = xrayClients.filter(c => !!c.online).length;
|
||||
const liveTotal = sshConns + xrayOnline;
|
||||
|
||||
dashTotalUsers.textContent = total;
|
||||
dashActiveUsers.textContent = active;
|
||||
dashExpiredUsers.textContent = expired;
|
||||
if (dashAccountBreakdown) dashAccountBreakdown.textContent = `SSH ${sshUsers.length} · Xray ${xrayClients.length}`;
|
||||
dashConnections.textContent = liveTotal;
|
||||
if (dashConnectionsText) dashConnectionsText.textContent = t("{ssh} SSH · {xray} Xray online", {ssh: sshConns, xray: xrayOnline});
|
||||
if (dashXrayClients) dashXrayClients.textContent = xrayClients.length;
|
||||
if (dashXrayStatus) {
|
||||
const running = xrayChip?.textContent || "--";
|
||||
dashXrayStatus.textContent = t("{online} online · {active} active · {expired} expired · Core: {core}", {online: xrayOnline, active: xrayActive, expired: xrayExpired, core: running});
|
||||
}
|
||||
|
||||
const me = dashboardCache.me;
|
||||
if (currentRole === "reseller" && me) {
|
||||
if ((me.quota_mode || "slots") === "credits") {
|
||||
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboardFromUsers(users = []) {
|
||||
dashboardCache.sshUsers = users || [];
|
||||
renderDashboardCounters();
|
||||
}
|
||||
|
||||
function updateDashboardXray(inbounds = []) {
|
||||
dashboardCache.xrayInbounds = inbounds || [];
|
||||
renderDashboardCounters();
|
||||
}
|
||||
|
||||
function refreshDashboard() {
|
||||
loadUsersSilent();
|
||||
loadInbounds({ silent: true });
|
||||
loadXrayStatus();
|
||||
if (currentRole === "superadmin") loadStats();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
||||
let editingSSHUser = "";
|
||||
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
||||
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
|
||||
cancelUserBtn.addEventListener("click", () => {
|
||||
prepareNewSSHUser();
|
||||
setWorkspaceSection("ssh", "users");
|
||||
});
|
||||
function prepareNewSSHUser() {
|
||||
editingSSHUser = "";
|
||||
userForm.reset();
|
||||
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
||||
fMaxConn.disabled = false;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
if (title) title.textContent = t("Create SSH user");
|
||||
userStatus.textContent = t("New user.");
|
||||
requestAnimationFrame(() => fUsername.focus());
|
||||
}
|
||||
document.getElementById("genTotpBtn").addEventListener("click", () => {
|
||||
fTotpSecret.value = genBase32();
|
||||
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
|
||||
if (!fTotpWindow.value) fTotpWindow.value = 1;
|
||||
if (!fTotpDigits.value) fTotpDigits.value = 6;
|
||||
userStatus.textContent = t("TOTP secret generated.");
|
||||
});
|
||||
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
|
||||
|
||||
async function loadUsers() {
|
||||
userStatus.textContent = t("Loading…");
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("Loading SSH status…");
|
||||
sshLiveStatus.className = "workspace-live-status is-loading";
|
||||
}
|
||||
if (sshMetricState) sshMetricState.textContent = t("Loading");
|
||||
try {
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
renderUsers(data || []);
|
||||
userStatus.textContent = t("Loaded.");
|
||||
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
|
||||
} catch (e) {
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("Could not load SSH status");
|
||||
sshLiveStatus.className = "workspace-live-status is-error";
|
||||
}
|
||||
if (sshMetricState) sshMetricState.textContent = t("Error");
|
||||
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
|
||||
}
|
||||
}
|
||||
async function loadUsersSilent() {
|
||||
try {
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
renderUsers(data || []);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
updateDashboardFromUsers(users);
|
||||
const isSA = currentRole === "superadmin";
|
||||
userCountChip.textContent = users.length;
|
||||
if (isSA) ownerColHead.classList.remove("hidden");
|
||||
usersBody.innerHTML = "";
|
||||
let online = 0;
|
||||
let expiredCount = 0;
|
||||
users.forEach(u => {
|
||||
const on = (u.active_conns || 0) > 0;
|
||||
if (on) online++;
|
||||
if (isExpiredDate(u.expires_at)) expiredCount++;
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [
|
||||
u.username,
|
||||
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
|
||||
u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password",
|
||||
u.active_conns ?? 0,
|
||||
u.max_connections || 0,
|
||||
u.limit_mbps_up || 0,
|
||||
u.limit_mbps_down || 0,
|
||||
u.expires_at ? fmtDate(u.expires_at) : "—",
|
||||
];
|
||||
if (isSA) cells.push(u.owner_username || "—");
|
||||
cells.forEach((c, i) => {
|
||||
const td = document.createElement("td");
|
||||
if (i === 1) td.innerHTML = c; else td.textContent = c;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
const renewBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:"+30d",
|
||||
onclick: () => renewSSHUser(u),
|
||||
});
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.className = "bot-row-actions";
|
||||
tdA.append(renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
const activeCount = Math.max(0, users.length - expiredCount);
|
||||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
||||
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
|
||||
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
|
||||
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
|
||||
if (sshMetricState) sshMetricState.textContent = t("Online");
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("SSH data updated at {time}", {time:new Date().toLocaleTimeString()});
|
||||
sshLiveStatus.className = "workspace-live-status is-ok";
|
||||
}
|
||||
}
|
||||
|
||||
function fillUserForm(u) {
|
||||
editingSSHUser = u.username || "";
|
||||
setWorkspaceSection("ssh", "create");
|
||||
fUsername.value = u.username || "";
|
||||
fPassword.value = "";
|
||||
fTotpSecret.value = u.totp_secret || "";
|
||||
fTotpPeriod.value = u.totp_period || 60;
|
||||
fTotpWindow.value = u.totp_window ?? 1;
|
||||
fTotpDigits.value = u.totp_digits || 6;
|
||||
fAllowStatic.checked = !!u.allow_static_password;
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
fMaxConn.disabled = creditLocked;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
fDown.value = u.limit_mbps_down || "";
|
||||
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Edit user");
|
||||
if (title) title.textContent = t("Editing {name}", {name:u.username});
|
||||
userStatus.textContent = t("Editing {name}", {name: u.username});
|
||||
}
|
||||
|
||||
userForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
saveUserBtn.disabled = true;
|
||||
userStatus.textContent = t("Saving…");
|
||||
const payload = {
|
||||
username: fUsername.value.trim(),
|
||||
password: fPassword.value || undefined,
|
||||
totp_secret: fTotpSecret.value.trim(),
|
||||
totp_period: parseInt(fTotpPeriod.value||"60",10),
|
||||
totp_window: parseInt(fTotpWindow.value||"1",10),
|
||||
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
||||
allow_static_password: !!fAllowStatic.checked,
|
||||
max_connections: parseInt(fMaxConn.value||"0",10),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
|
||||
? ""
|
||||
: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
server_id: selectedSSHServer(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
userStatus.textContent = t("Saved.");
|
||||
fPassword.value = "";
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
|
||||
setWorkspaceSection("ssh", "users");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else userStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
} finally {
|
||||
saveUserBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteUser(username) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete SSH account"),
|
||||
message:t("Delete user \"{name}\"?", {name: username}),
|
||||
detail:t("The active SSH sessions for this account will be disconnected."),
|
||||
confirmLabel:t("Delete account"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = t("Deleting {name}…", {name: username});
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("delete failed");
|
||||
userStatus.textContent = t("Deleted.");
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else userStatus.textContent = t("Error deleting.");
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSSHUser(user) {
|
||||
const creditCost = Math.max(1, Number(user.max_connections || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = `Renovando ${user.username}…`;
|
||||
try {
|
||||
const res = await api("/api/users/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS");
|
||||
await loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar SSH");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// ─── Xray ─────────────────────────────────────────────────────────────────────
|
||||
document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("start"));
|
||||
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
|
||||
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
|
||||
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
|
||||
xSaveModeBtn?.addEventListener("click", saveXrayCoreMode);
|
||||
document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); });
|
||||
document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true }));
|
||||
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
||||
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
||||
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
|
||||
document.getElementById("xrayOpenCreateBtn")?.addEventListener("click", () => navigateWorkspaceSection("xray", "create"));
|
||||
document.getElementById("xCreateCancelBtn")?.addEventListener("click", () => setWorkspaceSection("xray", "users"));
|
||||
document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => {
|
||||
const field = document.getElementById("xCreateUUID");
|
||||
if (field) field.value = genUUID();
|
||||
});
|
||||
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
|
||||
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
|
||||
|
||||
|
||||
async function loadXrayStatus() {
|
||||
if (xrayChip) {
|
||||
xrayChip.textContent = t("Loading Xray status…");
|
||||
xrayChip.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/status", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
|
||||
const s = await res.json();
|
||||
const run = !!s.running;
|
||||
xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled"));
|
||||
xrayChip.className = "workspace-live-status " + (run ? "is-ok" : (s.enabled ? "is-warn" : "is-error"));
|
||||
xRunning.textContent = run ? t("Running") : t("Stopped");
|
||||
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
|
||||
xPID.textContent = s.pid || (s.native ? "internal" : "--");
|
||||
xUptime.textContent = s.uptime || "--";
|
||||
if (xCoreMode) xCoreMode.value = String(s.mode || (s.native ? "native" : "external")).toLowerCase() === "external" ? "external" : "native";
|
||||
const statsCfgEl = document.getElementById("xStatsConfig");
|
||||
const repairBtn = document.getElementById("xRepairStatsBtn");
|
||||
if (statsCfgEl) {
|
||||
statsCfgEl.textContent = s.stats_configured ? t("OK") : t("Needs repair");
|
||||
statsCfgEl.style.color = s.stats_configured ? "var(--success)" : "var(--warning)";
|
||||
}
|
||||
if (repairBtn) repairBtn.style.display = s.stats_configured ? "none" : "";
|
||||
if (xOnlineUsers) xOnlineUsers.textContent = String(s.online_users ?? 0);
|
||||
if (!s.stats_configured && xStatus) {
|
||||
const missing = Array.isArray(s.stats_missing) && s.stats_missing.length ? ` Missing: ${s.stats_missing.join(", ")}.` : "";
|
||||
xStatus.textContent = t("Online counters need Stats API repair.") + missing;
|
||||
} else if (s.stats_error && xStatus) {
|
||||
xStatus.textContent = t("Online counters: {error}", {error: s.stats_error});
|
||||
} else if (xStatus) {
|
||||
xStatus.textContent = s.api_server ? t("Counters API ready at {server}.", {server: s.api_server}) : t("Counters API ready.");
|
||||
}
|
||||
if (dashServers) dashServers.textContent = String((serversCache || []).filter(n => n.is_active !== false).length || (s.enabled ? 1 : 0));
|
||||
if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled")));
|
||||
renderDashboardCounters();
|
||||
if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error});
|
||||
} catch (e) {
|
||||
if (xrayChip) {
|
||||
xrayChip.textContent = t("Could not load Xray status");
|
||||
xrayChip.className = "workspace-live-status is-error";
|
||||
}
|
||||
if (xRunning) { xRunning.textContent = t("Error"); xRunning.style.color = "var(--danger)"; }
|
||||
if (xStatus && e.message !== "auth") xStatus.textContent = t("Error: {error}", {error:e.message});
|
||||
if (e.message==="auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveXrayCoreMode() {
|
||||
const mode = xCoreMode?.value === "external" ? "external" : "native";
|
||||
const target = selectedXrayServerLabel();
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (xStatus) xStatus.textContent = `Saving Xray mode on ${target}...`;
|
||||
try {
|
||||
const getRes = await api(withServerParam("/api/servers/config", selectedID));
|
||||
if (!getRes.ok) throw new Error(await getRes.text());
|
||||
const cfg = await getRes.json();
|
||||
applyXrayModeToConfig(cfg, mode);
|
||||
const postRes = await api(withServerParam("/api/servers/config", selectedID), { method:"POST", body: JSON.stringify(cfg) });
|
||||
if (!postRes.ok) throw new Error(await postRes.text());
|
||||
if (xStatus) xStatus.textContent = mode === "native"
|
||||
? `Saved on ${target}: using internal native emulator.`
|
||||
: `Saved on ${target}: using external Xray binary.`;
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (xStatus) xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
}
|
||||
}
|
||||
|
||||
async function repairXrayStats() {
|
||||
const btn = document.getElementById("xRepairStatsBtn");
|
||||
if (btn) btn.disabled = true;
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Verificando e reparando a API de contadores do Xray…" : "Checking and repairing Xray counters API…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/stats/repair", selectedXrayServer()), { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json().catch(() => ({}));
|
||||
xStatus.textContent = d.changed
|
||||
? (d.restarted ? (currentLang === "pt-BR" ? "API de contadores reparada e Xray reiniciado." : "Counters API repaired and Xray restarted.") : (currentLang === "pt-BR" ? "API de contadores reparada. Reinicie o Xray para aplicar." : "Counters API repaired. Restart Xray to apply it."))
|
||||
: (currentLang === "pt-BR" ? "A API de contadores já parece correta." : "Counters API already looks correct.");
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = (currentLang === "pt-BR" ? "Erro ao reparar contadores: " : "Error repairing counters: ")+e.message;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function xrayCtrl(action) {
|
||||
xStatus.textContent = (currentLang === "pt-BR" ? "Processando Xray…" : action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…");
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/xray/${action}`, selectedXrayServer()), { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInbounds(options = {}) {
|
||||
const { silent = false, force = false } = options || {};
|
||||
if (inboundsRefreshInFlight) return;
|
||||
inboundsRefreshInFlight = true;
|
||||
if (!silent) inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("Loading…")}</div>`;
|
||||
else inboundsContainer.classList.add("xray-refreshing");
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/inbounds", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const inbounds = await res.json();
|
||||
renderInbounds(inbounds || [], { silent, force });
|
||||
} catch (e) {
|
||||
if (!silent) inboundsContainer.textContent = t("Error loading inbounds.");
|
||||
if (e.message==="auth") doAuthError();
|
||||
} finally {
|
||||
inboundsRefreshInFlight = false;
|
||||
inboundsContainer.classList.remove("xray-refreshing");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { return document.execCommand("copy"); }
|
||||
finally { document.body.removeChild(ta); }
|
||||
}
|
||||
|
||||
function renderInbounds(inbounds, options = {}) {
|
||||
const { silent = false, force = false } = options || {};
|
||||
updateDashboardXray(inbounds);
|
||||
syncXrayCreatorInbounds(inbounds);
|
||||
const nextStructure = inboundStructure(inbounds);
|
||||
|
||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
|
||||
if (silent && !force && isXrayClientEditorActive()) {
|
||||
patchRenderedInbounds(inbounds);
|
||||
if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inbounds.length) {
|
||||
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No VLESS/VMess/Trojan inbounds found.")}</div>`;
|
||||
lastInboundsStructure = nextStructure;
|
||||
return;
|
||||
}
|
||||
inboundsContainer.innerHTML = "";
|
||||
lastInboundsStructure = nextStructure;
|
||||
inbounds.forEach(ib => {
|
||||
const section = document.createElement("div");
|
||||
section.dataset.inboundTag = String(ib.tag || "");
|
||||
section.dataset.inboundProtocol = String(ib.protocol || "");
|
||||
section.dataset.inboundPort = String(ib.port ?? "");
|
||||
section.style = "margin-bottom:14px;";
|
||||
|
||||
const hdr = document.createElement("div");
|
||||
hdr.className = "card-hdr";
|
||||
hdr.style = "margin-bottom:6px;";
|
||||
const clients = ib.clients || [];
|
||||
const onlineCount = clients.filter(c => !!c.online).length;
|
||||
hdr.innerHTML = `
|
||||
<div class="card-title" style="font-size:.8rem;">
|
||||
<span class="chip">${escapeHTML(ib.protocol)}</span>
|
||||
${escapeHTML(ib.tag || "untagged")}
|
||||
<span class="hint">:${escapeHTML(ib.port ?? "?")}</span>
|
||||
<span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span>
|
||||
</div>`;
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "btn btn-sm";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = t("Create user");
|
||||
openButton.addEventListener("click", () => openAddClient(ib.tag));
|
||||
hdr.appendChild(openButton);
|
||||
section.appendChild(hdr);
|
||||
|
||||
// Clients table
|
||||
const tblWrap = document.createElement("div");
|
||||
tblWrap.className = "tbl-wrap";
|
||||
if (!clients.length) {
|
||||
tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`;
|
||||
} else {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.innerHTML = `<thead><tr><th>${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th>${t("Expiry")}</th><th>${t("Status")}</th><th>${t("Online")}</th><th>${t("Traffic")}</th><th>${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
clients.forEach(c => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.clientId = String(c.id || "");
|
||||
tr.innerHTML = `
|
||||
<td data-cell="name">${escapeHTML(c.name || "—")}</td>
|
||||
<td data-cell="uuid" style="font-family:monospace;font-size:.65rem;">${escapeHTML(c.id || "—")}</td>
|
||||
<td data-cell="email">${escapeHTML(c.email || "—")}</td>
|
||||
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
const actTd = document.createElement("td");
|
||||
actTd.style.whiteSpace = "nowrap";
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "btn btn-ghost btn-sm";
|
||||
copyBtn.textContent = t("Copy");
|
||||
copyBtn.onclick = async () => { await copyText(c.id); xStatus.textContent = t("Copied client ID."); };
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-warn btn-sm";
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
||||
const renewBtn = document.createElement("button");
|
||||
renewBtn.className = "btn btn-ghost btn-sm";
|
||||
renewBtn.style.marginLeft = "4px";
|
||||
renewBtn.textContent = "+30d";
|
||||
renewBtn.onclick = () => renewXrayClient(c);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbl.appendChild(tbody);
|
||||
tblWrap.appendChild(tbl);
|
||||
}
|
||||
section.appendChild(tblWrap);
|
||||
|
||||
const divider = document.createElement("hr");
|
||||
divider.style = "border:none;border-top:1px solid var(--border);margin-top:10px;";
|
||||
section.appendChild(divider);
|
||||
|
||||
inboundsContainer.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
let xrayCreatorInbounds = [];
|
||||
let xrayCreatorInboundSignature = "";
|
||||
|
||||
function syncXrayCreatorInbounds(inbounds = []) {
|
||||
const select = document.getElementById("xCreateInbound");
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
const nextInbounds = (inbounds || []).filter(ib => ib?.tag).map(ib => ({
|
||||
tag: String(ib.tag),
|
||||
protocol: String(ib.protocol || "xray").toUpperCase(),
|
||||
port: ib.port ?? "?",
|
||||
}));
|
||||
const nextSignature = JSON.stringify(nextInbounds);
|
||||
xrayCreatorInbounds = nextInbounds;
|
||||
if (nextSignature === xrayCreatorInboundSignature) {
|
||||
updateXrayCreatorInboundLabel();
|
||||
return;
|
||||
}
|
||||
xrayCreatorInboundSignature = nextSignature;
|
||||
select.replaceChildren();
|
||||
if (!xrayCreatorInbounds.length) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = t("No compatible inbound found");
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
} else {
|
||||
xrayCreatorInbounds.forEach(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = inbound.tag;
|
||||
option.textContent = `${inbound.protocol} · ${inbound.tag} · :${inbound.port}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
select.value = xrayCreatorInbounds.some(inbound => inbound.tag === previous) ? previous : xrayCreatorInbounds[0].tag;
|
||||
}
|
||||
updateXrayCreatorInboundLabel();
|
||||
}
|
||||
|
||||
function updateXrayCreatorInboundLabel() {
|
||||
const selected = document.getElementById("xCreateInbound")?.value || "";
|
||||
const inbound = xrayCreatorInbounds.find(item => item.tag === selected);
|
||||
const chip = document.getElementById("xCreateProtocolChip");
|
||||
const hint = document.getElementById("xCreateInboundHint");
|
||||
if (chip) chip.textContent = inbound ? inbound.protocol : t("No inbound");
|
||||
if (hint) hint.textContent = inbound
|
||||
? t("The client will be added to {tag} on port {port}.", {tag:inbound.tag, port:inbound.port})
|
||||
: t("Create or enable a compatible inbound before adding a client.");
|
||||
}
|
||||
|
||||
function prepareXrayClientCreator(preferredTag = "") {
|
||||
const form = document.getElementById("xCreateClientForm");
|
||||
form?.reset();
|
||||
const inbound = document.getElementById("xCreateInbound");
|
||||
if (inbound && preferredTag && xrayCreatorInbounds.some(item => item.tag === preferredTag)) inbound.value = preferredTag;
|
||||
const uuid = document.getElementById("xCreateUUID");
|
||||
if (uuid) uuid.value = genUUID();
|
||||
const maxConns = document.getElementById("xCreateMaxConns");
|
||||
if (maxConns) {
|
||||
maxConns.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxConns.value = currentRole === "reseller" ? "1" : "0";
|
||||
}
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
|
||||
updateXrayCreatorInboundLabel();
|
||||
requestAnimationFrame(() => document.getElementById("xCreateName")?.focus());
|
||||
}
|
||||
|
||||
function openAddClient(tag) {
|
||||
setWorkspaceSection("xray", "create");
|
||||
prepareXrayClientCreator(tag);
|
||||
}
|
||||
|
||||
async function submitXrayClientCreator(event) {
|
||||
event?.preventDefault?.();
|
||||
const tag = document.getElementById("xCreateInbound")?.value || "";
|
||||
const uuid = (document.getElementById("xCreateUUID")?.value || "").trim();
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
const button = document.getElementById("xCreateClientBtn");
|
||||
if (!tag) { if (status) status.textContent = t("Select a compatible inbound."); return false; }
|
||||
if (!uuid) { if (status) status.textContent = t("UUID required."); return false; }
|
||||
const payload = {
|
||||
inbound_tag: tag,
|
||||
uuid,
|
||||
email: (document.getElementById("xCreateEmail")?.value || "").trim(),
|
||||
name: (document.getElementById("xCreateName")?.value || "").trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
|
||||
max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0,
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = t("Creating Xray client…");
|
||||
try {
|
||||
const res = await api("/api/xray/clients/add", { method:"POST", body:JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const success = t("Client {id}… added. Native mode hot-reloads without restart.", {id:uuid.slice(0,8)});
|
||||
if (status) status.textContent = success;
|
||||
xStatus.textContent = success;
|
||||
showPanelToast(t("Xray user created successfully."), "success", t("Xray user"));
|
||||
setTimeout(() => { loadInbounds({ force:true }); if (currentRole === "reseller") loadMe(); }, 700);
|
||||
const name = document.getElementById("xCreateName");
|
||||
const email = document.getElementById("xCreateEmail");
|
||||
const expiry = document.getElementById("xCreateExpiry");
|
||||
if (name) name.value = "";
|
||||
if (email) email.value = "";
|
||||
if (expiry) expiry.value = "";
|
||||
const nextUUID = document.getElementById("xCreateUUID");
|
||||
if (nextUUID) nextUUID.value = genUUID();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
if (status) status.textContent = t("Error: {error}", {error:e.message});
|
||||
xStatus.textContent = t("Error: {error}", {error:e.message});
|
||||
showPanelToast(t("Could not create the Xray user: {error}", {error:e.message}), "error", t("Xray user"));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClient(tag, uuid) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove Xray client"),
|
||||
message:t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}),
|
||||
detail:t("The client will lose access immediately after the configuration reload."),
|
||||
confirmLabel:t("Remove client"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
|
||||
showPanelToast(t("Client removed successfully."), "success", t("Xray client"));
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
}
|
||||
}
|
||||
|
||||
async function renewXrayClient(client) {
|
||||
const creditCost = Math.max(1, Number(client.max_conns || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
xStatus.textContent = "Renovando cliente Xray…";
|
||||
try {
|
||||
const res = await api("/api/xray/clients/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
|
||||
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
|
||||
await loadInbounds({ force:true });
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar Xray");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXrayCfg() {
|
||||
if (!xCfgEditor) return;
|
||||
const target = selectedXrayServerLabel();
|
||||
if (xCfgStatus) xCfgStatus.textContent = `Loading config from ${target}…`;
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/config", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const text = await res.text();
|
||||
try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch { xCfgEditor.value = text; }
|
||||
if (xCfgStatus) xCfgStatus.textContent = `Config loaded from ${target}.`;
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveXrayCfg() {
|
||||
const text = (xCfgEditor?.value || "").trim();
|
||||
const target = selectedXrayServerLabel();
|
||||
try { JSON.parse(text); } catch(e) { if (xCfgStatus) xCfgStatus.textContent = t("Invalid JSON: {error}", {error: e.message}); return; }
|
||||
if (xCfgStatus) xCfgStatus.textContent = `Saving config to ${target}…`;
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/config", selectedXrayServer()), { method:"POST", body: text });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (xCfgStatus) xCfgStatus.textContent = `Saved on ${target}. Restarting Xray…`;
|
||||
await xrayCtrl("restart");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (xCfgStatus) xCfgStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXrayLogs() {
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/logs", selectedXrayServer()));
|
||||
const data = await res.json();
|
||||
xLogsBox.textContent = (data.lines||[]).join("\n");
|
||||
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// ─── Hierarchical resellers ───────────────────────────────────────────────────
|
||||
let resellersCache = [];
|
||||
let editingReseller = "";
|
||||
|
||||
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
navigateWorkspaceSection("resellers", "create");
|
||||
});
|
||||
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
});
|
||||
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
|
||||
el.addEventListener("click", loadResellerAudit);
|
||||
});
|
||||
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
|
||||
if (e.target.value === "audit") loadResellerAudit();
|
||||
});
|
||||
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
|
||||
|
||||
function toggleResellerPlanFields() {
|
||||
const credit = rQuotaMode.value === "credits";
|
||||
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
|
||||
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
|
||||
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
|
||||
if (credit) rExpires.value = "";
|
||||
}
|
||||
|
||||
function prepareNewReseller() {
|
||||
editingReseller = "";
|
||||
resellerFormTitle.textContent = t("Create Reseller");
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Create reseller");
|
||||
resellerForm.reset();
|
||||
rUsername.disabled = false;
|
||||
rParent.disabled = false;
|
||||
rQuotaMode.disabled = currentRole === "reseller";
|
||||
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
|
||||
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
|
||||
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
|
||||
rCredits.value = "1";
|
||||
rActive.checked = true;
|
||||
populateResellerParents();
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("New reseller.");
|
||||
requestAnimationFrame(() => rUsername.focus());
|
||||
}
|
||||
|
||||
async function loadResellers() {
|
||||
resellerStatus.textContent = t("Loading…");
|
||||
setResellerLiveStatus("Carregando revendedores…", "is-loading");
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellersCache = await res.json() || [];
|
||||
renderResellers(resellersCache);
|
||||
populateResellerParents();
|
||||
resellerStatus.textContent = t("Loaded.");
|
||||
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
|
||||
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setResellerLiveStatus(message, tone) {
|
||||
const el = document.getElementById("resellerLiveStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.className = `workspace-live-status ${tone || ""}`.trim();
|
||||
}
|
||||
|
||||
function renderResellerMetrics(list) {
|
||||
const active = list.filter(r => r.effective_active).length;
|
||||
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
|
||||
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
|
||||
document.getElementById("resellerMetricTotal").textContent = String(list.length);
|
||||
document.getElementById("resellerMetricActive").textContent = String(active);
|
||||
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
|
||||
document.getElementById("resellerMetricCredits").textContent = String(credits);
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
renderResellerMetrics(list);
|
||||
resellersBody.innerHTML = "";
|
||||
if (!list.length) {
|
||||
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
list.forEach(r => {
|
||||
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const effective = !!r.effective_active && !expired;
|
||||
const maxUsers = Number(r.max_users || 0);
|
||||
const directUsed = Number(r.used_users || 0);
|
||||
const childAllocation = Number(r.child_allocation || 0);
|
||||
const committed = directUsed + childAllocation;
|
||||
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
|
||||
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
|
||||
const isCredit = r.quota_mode === "credits";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
|
||||
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
|
||||
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
|
||||
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
|
||||
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
|
||||
</td>
|
||||
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
|
||||
<td></td>`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "bot-row-actions reseller-row-actions";
|
||||
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
|
||||
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
|
||||
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
|
||||
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
|
||||
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
|
||||
tr.lastElementChild.appendChild(actions);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resellerActionButton(label, className, onclick) {
|
||||
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
|
||||
}
|
||||
|
||||
function populateResellerParents() {
|
||||
if (!rParent) return;
|
||||
const selected = rParent.value;
|
||||
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
|
||||
resellersCache
|
||||
.filter(r => r.username !== editingReseller && r.effective_active)
|
||||
.forEach(r => {
|
||||
const option = document.createElement("option");
|
||||
option.value = r.username;
|
||||
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
|
||||
rParent.appendChild(option);
|
||||
});
|
||||
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
editingReseller = r.username;
|
||||
setWorkspaceSection("resellers", "create");
|
||||
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Edit reseller");
|
||||
rUsername.value = r.username;
|
||||
rUsername.disabled = true;
|
||||
rPassword.value = "";
|
||||
populateResellerParents();
|
||||
rParent.value = r.parent_username || "";
|
||||
rParent.disabled = true;
|
||||
rQuotaMode.value = r.quota_mode || "slots";
|
||||
rQuotaMode.disabled = true;
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rCredits.value = r.credit_balance || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rWhatsApp.value = r.whatsapp || "";
|
||||
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
|
||||
rActive.checked = !!r.is_active;
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = t("Saving…");
|
||||
const mode = rQuotaMode.value || "slots";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
|
||||
quota_mode: mode,
|
||||
max_users: parseInt(rMaxUsers.value || "0", 10),
|
||||
credits: parseInt(rCredits.value || "0", 10),
|
||||
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
|
||||
whatsapp: rWhatsApp.value.trim(),
|
||||
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
|
||||
prepareNewReseller();
|
||||
await loadResellers();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
|
||||
showPanelToast(e.message, "error", t("Resellers"));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function runResellerAction(reseller, action) {
|
||||
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
|
||||
const descriptions = {
|
||||
renew: "A validade será estendida a partir da data atual ou da validade existente.",
|
||||
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
|
||||
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
|
||||
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
|
||||
};
|
||||
const accepted = await panelConfirm({
|
||||
tone: action === "suspend" ? "danger" : "default",
|
||||
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
|
||||
title: labels[action],
|
||||
message: `${labels[action]} “${reseller.username}”?`,
|
||||
detail: descriptions[action],
|
||||
confirmLabel: labels[action],
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `${labels[action]}…`;
|
||||
try {
|
||||
const res = await api("/api/resellers/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
|
||||
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
|
||||
await loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", labels[action]);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReseller(reseller) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: "danger", icon: "×", title: t("Delete reseller"),
|
||||
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
|
||||
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
|
||||
confirmLabel: t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
|
||||
await loadResellers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResellerAudit() {
|
||||
const body = document.getElementById("resellerAuditBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
|
||||
try {
|
||||
const res = await api("/api/resellers/audit");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const rows = await res.json() || [];
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows.forEach(item => {
|
||||
const tr = document.createElement("tr");
|
||||
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
// ─── Managed Servers ─────────────────────────────────────────────────────────
|
||||
sshServerSelect?.addEventListener("change", () => {
|
||||
selectedSSHServerID = selectedSSHServer();
|
||||
localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID);
|
||||
loadUsers();
|
||||
});
|
||||
xrayServerSelect?.addEventListener("change", () => {
|
||||
selectedXrayServerID = selectedXrayServer();
|
||||
localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID);
|
||||
lastInboundsStructure = "";
|
||||
closeEditXrayClient?.();
|
||||
if (xStatus) xStatus.textContent = `Switched Xray target to ${selectedXrayServerLabel()}.`;
|
||||
loadXrayStatus();
|
||||
loadInbounds({ force: true });
|
||||
reloadXrayConfigForSelectedServer();
|
||||
loadXrayLogs();
|
||||
});
|
||||
document.getElementById("reloadServersBtn")?.addEventListener("click", loadServers);
|
||||
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus());
|
||||
document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm);
|
||||
document.getElementById("testServerBtn")?.addEventListener("click", testServerForm);
|
||||
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
|
||||
document.getElementById("loadManagedConfigBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID));
|
||||
document.getElementById("saveManagedConfigBtn")?.addEventListener("click", saveManagedServerConfig);
|
||||
document.getElementById("saveManagedConfigBottomBtn")?.addEventListener("click", saveManagedServerConfig);
|
||||
document.getElementById("reloadManagedConfigBottomBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID));
|
||||
serverForm?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
await saveServerForm();
|
||||
});
|
||||
|
||||
async function loadServers() {
|
||||
let loadError = null;
|
||||
if (fleetLiveStatus) {
|
||||
fleetLiveStatus.textContent = t("Loading infrastructure…");
|
||||
fleetLiveStatus.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/servers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serversCache = await res.json() || [];
|
||||
} catch (e) {
|
||||
loadError = e;
|
||||
serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }];
|
||||
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
|
||||
if (e.message === "auth") doAuthError();
|
||||
}
|
||||
renderServerSelectors();
|
||||
renderServersTable();
|
||||
updateFleetOverview(loadError);
|
||||
}
|
||||
|
||||
function updateFleetOverview(error = null) {
|
||||
const rows = Array.isArray(serversCache) ? serversCache.filter(Boolean) : [];
|
||||
const active = rows.filter(server => server.is_active !== false);
|
||||
if (fleetMetricNodes) fleetMetricNodes.textContent = String(rows.length);
|
||||
if (fleetMetricActive) fleetMetricActive.textContent = String(active.length);
|
||||
if (fleetMetricSSH) fleetMetricSSH.textContent = String(active.filter(server => server.enable_ssh || server.is_local).length);
|
||||
if (fleetMetricXray) fleetMetricXray.textContent = String(active.filter(server => server.enable_xray || server.is_local).length);
|
||||
if (fleetLiveStatus) {
|
||||
fleetLiveStatus.textContent = error
|
||||
? t("Infrastructure loaded with fallback data")
|
||||
: t("{count} active nodes · updated {time}", {count:active.length, time:new Date().toLocaleTimeString()});
|
||||
fleetLiveStatus.className = `workspace-live-status ${error ? "is-warn" : "is-ok"}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadServersStatus(options = {}) {
|
||||
const silent = !!options.silent;
|
||||
if (!serversStatusGrid) return;
|
||||
try {
|
||||
if (!Array.isArray(serversCache) || serversCache.length === 0) await loadServers();
|
||||
const nodes = (serversCache || []).filter(Boolean);
|
||||
if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length);
|
||||
if (!silent) {
|
||||
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers...");
|
||||
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-loading";
|
||||
serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`;
|
||||
}
|
||||
const rows = await Promise.all(nodes.map(loadSingleServerStatus));
|
||||
renderServersStatusCards(rows);
|
||||
if (serversStatusPageStatus) {
|
||||
const online = rows.filter(r => r.ok).length;
|
||||
serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`;
|
||||
serversStatusPageStatus.className = `workspace-live-status ${online === rows.length ? "is-ok" : online > 0 ? "is-warn" : "is-error"}`;
|
||||
}
|
||||
const online = rows.filter(row => row.ok).length;
|
||||
const sessions = rows.reduce((total, row) => {
|
||||
const ssh = (Array.isArray(row.users) ? row.users : []).reduce((sum, user) => sum + Number(user.active_conns || 0), 0);
|
||||
let xray = 0;
|
||||
(Array.isArray(row.inbounds) ? row.inbounds : []).forEach(inbound => { xray += (inbound.clients || []).filter(client => !!client.online).length; });
|
||||
return total + ssh + xray;
|
||||
}, 0);
|
||||
if (fleetStatusOnline) fleetStatusOnline.textContent = String(online);
|
||||
if (fleetStatusOffline) fleetStatusOffline.textContent = String(Math.max(0, rows.length - online));
|
||||
if (fleetStatusSessions) fleetStatusSessions.textContent = String(sessions);
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message);
|
||||
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-error";
|
||||
if (!silent) serversStatusGrid.innerHTML = `<div class="hint">Error loading server status.</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJSONForServer(path, serverID) {
|
||||
const res = await api(withServerParam(path, serverID));
|
||||
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function loadSingleServerStatus(server) {
|
||||
const id = String(server.id || "local");
|
||||
const out = { server, ok: true, error: "", stats: null, users: [], inbounds: [], xray: null };
|
||||
if (server.is_active === false) { out.ok = false; out.error = "disabled"; return out; }
|
||||
try {
|
||||
out.stats = await fetchJSONForServer("/api/stats", id);
|
||||
} catch (e) {
|
||||
out.ok = false;
|
||||
out.error = e.message || "stats failed";
|
||||
}
|
||||
if (server.enable_ssh || server.is_local) {
|
||||
try { out.users = await fetchJSONForServer("/api/users", id) || []; }
|
||||
catch (e) { out.usersError = e.message || "users failed"; }
|
||||
}
|
||||
if (server.enable_xray || server.is_local) {
|
||||
try { out.xray = await fetchJSONForServer("/api/xray/status", id); }
|
||||
catch (e) { out.xrayError = e.message || "xray status failed"; }
|
||||
try { out.inbounds = await fetchJSONForServer("/api/xray/inbounds", id) || []; }
|
||||
catch (e) { out.inboundsError = e.message || "xray clients failed"; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderServersStatusCards(rows = []) {
|
||||
if (!serversStatusGrid) return;
|
||||
if (!rows.length) {
|
||||
serversStatusGrid.innerHTML = `<div class="hint">No active servers configured.</div>`;
|
||||
return;
|
||||
}
|
||||
serversStatusGrid.innerHTML = rows.map(serverStatusCardHTML).join("");
|
||||
}
|
||||
|
||||
function serverStatusCardHTML(row) {
|
||||
const s = row.server || {};
|
||||
const stats = row.stats || {};
|
||||
const ifaces = Array.isArray(stats.interfaces) ? stats.interfaces : [];
|
||||
let rx = 0, tx = 0, rxTotal = 0, txTotal = 0;
|
||||
ifaces.forEach(it => {
|
||||
rx += Number(it.rx_mbps || 0);
|
||||
tx += Number(it.tx_mbps || 0);
|
||||
rxTotal += Number(it.rx_bytes || 0);
|
||||
txTotal += Number(it.tx_bytes || 0);
|
||||
});
|
||||
const cpu = Number(stats.cpu_percent || 0);
|
||||
const mem = stats.mem_percent == null ? 0 : Number(stats.mem_percent || 0);
|
||||
const users = Array.isArray(row.users) ? row.users : [];
|
||||
const now = Date.now();
|
||||
const sshActive = users.filter(u => !u.expires_at || new Date(u.expires_at).getTime() > now).length;
|
||||
const sshExpired = Math.max(0, users.length - sshActive);
|
||||
const sshConns = users.reduce((sum, u) => sum + Number(u.active_conns || 0), 0);
|
||||
const clients = [];
|
||||
(Array.isArray(row.inbounds) ? row.inbounds : []).forEach(ib => (ib.clients || []).forEach(c => clients.push(c)));
|
||||
const xrayOnline = clients.filter(c => !!c.online).length;
|
||||
const xrayActive = clients.filter(c => !c.expired && (!c.expires_at || new Date(c.expires_at).getTime() > now)).length;
|
||||
const xrayExpired = Math.max(0, clients.length - xrayActive);
|
||||
const netNow = rx + tx;
|
||||
const running = row.xray ? !!row.xray.running : false;
|
||||
const nodeStatus = row.ok ? `<span class="badge-on">online</span>` : `<span class="badge-off">offline</span>`;
|
||||
const options = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled";
|
||||
const err = row.ok ? "" : `<div class="server-status-error">${escapeHTML(row.error || "connection failed")}</div>`;
|
||||
return `
|
||||
<article class="server-status-card ${row.ok ? "" : "server-status-offline"}">
|
||||
<div class="server-status-head">
|
||||
<div>
|
||||
<div class="server-status-title">${escapeHTML(s.name || "Server")}</div>
|
||||
<div class="server-status-url">${escapeHTML(s.base_url || "local")}</div>
|
||||
</div>
|
||||
<div class="server-status-badges">
|
||||
${nodeStatus}
|
||||
<span class="chip">${escapeHTML(options)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${err}
|
||||
<div class="server-mini-grid">
|
||||
${miniMetricHTML("CPU", fmtPct(cpu), cpu, cpu >= 85 ? "High load" : cpu >= 60 ? "Moderate load" : "Normal load")}
|
||||
${miniMetricHTML("RAM", fmtPct(mem), mem, stats.mem_used_bytes && stats.mem_total_bytes ? `${fmtBytes(stats.mem_used_bytes)} / ${fmtBytes(stats.mem_total_bytes)}` : "Memory used")}
|
||||
${miniMetricHTML("Network", `${fmtMbps(netNow)} Mb/s`, Math.min(100, netNow / 20), `RX ${fmtMbps(rx)} - TX ${fmtMbps(tx)}`)}
|
||||
${miniMetricHTML("Accounts", String(users.length + clients.length), Math.min(100, (users.length + clients.length) * 3), `SSH ${users.length} - Xray ${clients.length}`)}
|
||||
</div>
|
||||
<div class="server-status-footer">
|
||||
<span>SSH: ${sshConns} online - ${sshActive} active - ${sshExpired} expired</span>
|
||||
<span>Xray: ${xrayOnline} online - ${xrayActive} active - ${xrayExpired} expired - Core ${running ? "running" : "stopped"}</span>
|
||||
<span>Total traffic: ${fmtBytes(rxTotal + txTotal)}</span>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function miniMetricHTML(label, value, pct, note) {
|
||||
const width = Math.min(100, Math.max(0, Number(pct) || 0));
|
||||
return `<div class="server-mini-metric">
|
||||
<div class="server-mini-label">${escapeHTML(label)}</div>
|
||||
<div class="server-mini-value">${escapeHTML(value)}</div>
|
||||
<div class="server-mini-note">${escapeHTML(note || "")}</div>
|
||||
<div class="server-mini-bar"><span style="width:${width}%"></span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderServerSelectors() {
|
||||
const active = serversCache.filter(s => s.is_active !== false);
|
||||
const sshServers = active.filter(s => s.enable_ssh || s.is_local);
|
||||
const xrayServers = active.filter(s => s.enable_xray || s.is_local);
|
||||
populateServerSelect(sshServerSelect, sshServers, selectedSSHServerID, "ssh");
|
||||
populateServerSelect(xrayServerSelect, xrayServers, selectedXrayServerID, "xray");
|
||||
const hasMultiSSH = sshServers.length > 1;
|
||||
const hasMultiXray = xrayServers.length > 1;
|
||||
sshServerPickerCard?.classList.toggle("hidden", !hasMultiSSH);
|
||||
xrayServerPickerCard?.classList.toggle("hidden", !hasMultiXray);
|
||||
if (sshServerHint) { sshServerHint.textContent = ""; sshServerHint.classList.add("hidden"); }
|
||||
if (xrayServerHint) { xrayServerHint.textContent = ""; xrayServerHint.classList.add("hidden"); }
|
||||
if (dashServers) dashServers.textContent = String(active.length || 1);
|
||||
if (dashServerStatus) dashServerStatus.textContent = active.length > 1 ? `${active.length} nodes configured` : "master only";
|
||||
}
|
||||
|
||||
function populateServerSelect(select, list, selected, kind) {
|
||||
if (!select) return;
|
||||
const current = String(selected || select.value || "local");
|
||||
select.innerHTML = "";
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(s.id);
|
||||
opt.textContent = `${s.name || s.base_url || s.id}${s.is_local ? " (master)" : ""}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
const allowed = list.some(s => String(s.id) === current);
|
||||
select.value = allowed ? current : "local";
|
||||
if (kind === "ssh") {
|
||||
selectedSSHServerID = select.value || "local";
|
||||
localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID);
|
||||
} else {
|
||||
selectedXrayServerID = select.value || "local";
|
||||
localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID);
|
||||
}
|
||||
}
|
||||
|
||||
function renderServersTable() {
|
||||
if (!serversBody) return;
|
||||
const rows = serversCache || [];
|
||||
serversCountChip && (serversCountChip.textContent = String(Math.max(0, rows.length - 1)));
|
||||
serversBody.innerHTML = "";
|
||||
rows.forEach(s => {
|
||||
const tr = document.createElement("tr");
|
||||
const opts = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled";
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHTML(s.name || "—")}${s.is_local ? ' <span class="chip">master</span>' : ""}</td>
|
||||
<td style="font-family:monospace;font-size:.68rem;">${escapeHTML(s.base_url || "local")}</td>
|
||||
<td>${escapeHTML(opts)}</td>
|
||||
<td>${s.is_active ? '<span class="badge-on">active</span>' : '<span class="badge-off">disabled</span>'}</td>`;
|
||||
const td = document.createElement("td");
|
||||
td.style.whiteSpace = "nowrap";
|
||||
const cfgBtn = document.createElement("button");
|
||||
cfgBtn.className = "btn btn-ghost btn-sm";
|
||||
cfgBtn.textContent = "Configure";
|
||||
cfgBtn.onclick = () => openManagedServerConfig(String(s.id));
|
||||
td.appendChild(cfgBtn);
|
||||
if (!s.is_local) {
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-warn btn-sm";
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.onclick = () => fillServerForm(s);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = "Del";
|
||||
delBtn.onclick = () => deleteServer(s);
|
||||
td.append(editBtn, delBtn);
|
||||
}
|
||||
tr.appendChild(td);
|
||||
serversBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function clearServerForm() {
|
||||
if (!serverForm) return;
|
||||
srvID.value = "";
|
||||
srvName.value = "";
|
||||
srvBaseURL.value = "";
|
||||
srvAdminUser.value = "admin";
|
||||
srvAdminKey.value = "";
|
||||
srvEnableSSH.checked = true;
|
||||
srvEnableXray.checked = true;
|
||||
srvIsActive.checked = true;
|
||||
if (serverFormTitle) serverFormTitle.textContent = "Add / edit server";
|
||||
if (serverFormStatus) serverFormStatus.textContent = "";
|
||||
}
|
||||
|
||||
function fillServerForm(s) {
|
||||
srvID.value = s.id || "";
|
||||
srvName.value = s.name || "";
|
||||
srvBaseURL.value = s.base_url || "";
|
||||
srvAdminUser.value = s.admin_username || "admin";
|
||||
srvAdminKey.value = "";
|
||||
srvEnableSSH.checked = !!s.enable_ssh;
|
||||
srvEnableXray.checked = !!s.enable_xray;
|
||||
srvIsActive.checked = s.is_active !== false;
|
||||
if (serverFormTitle) serverFormTitle.textContent = "Edit: " + (s.name || s.base_url);
|
||||
if (serverFormStatus) serverFormStatus.textContent = "Leave admin key blank to keep the saved key.";
|
||||
}
|
||||
|
||||
function serverPayloadFromForm() {
|
||||
return {
|
||||
id: srvID?.value || "",
|
||||
name: srvName?.value.trim() || "",
|
||||
base_url: srvBaseURL?.value.trim() || "",
|
||||
admin_username: srvAdminUser?.value.trim() || "admin",
|
||||
admin_key: srvAdminKey?.value || "",
|
||||
enable_ssh: !!srvEnableSSH?.checked,
|
||||
enable_xray: !!srvEnableXray?.checked,
|
||||
is_active: !!srvIsActive?.checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveServerForm() {
|
||||
if (!serverFormStatus) return;
|
||||
serverFormStatus.textContent = "Saving…";
|
||||
try {
|
||||
const res = await api("/api/servers", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serverFormStatus.textContent = "Saved.";
|
||||
clearServerForm();
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serverFormStatus.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testServerForm() {
|
||||
if (!serverFormStatus) return;
|
||||
serverFormStatus.textContent = "Testing remote login…";
|
||||
try {
|
||||
const res = await api("/api/servers/test", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serverFormStatus.textContent = "Connection OK.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serverFormStatus.textContent = "Test failed: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(s) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete managed server"),
|
||||
message:t("Delete server \"{name}\"?", {name:s.name || s.base_url}),
|
||||
detail:t("The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions."),
|
||||
confirmLabel:t("Delete server"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serversStatus && (serversStatus.textContent = "Delete failed: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showServerListView() {
|
||||
serversListView?.classList.remove("hidden");
|
||||
serverConfigSubpage?.classList.add("hidden");
|
||||
configuringServerID = "";
|
||||
}
|
||||
|
||||
function openManagedServerConfig(id) {
|
||||
configuringServerID = id || "local";
|
||||
const srv = serverByID(configuringServerID) || { name: "Master node" };
|
||||
if (cfgServerName) cfgServerName.textContent = srv.name || srv.base_url || configuringServerID;
|
||||
serversListView?.classList.add("hidden");
|
||||
serverConfigSubpage?.classList.remove("hidden");
|
||||
loadManagedServerConfig(configuringServerID);
|
||||
}
|
||||
|
||||
function toggleManagedDnsttFields(on) {
|
||||
const el = document.getElementById("managedDnsttFields");
|
||||
if (!el) return;
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
el.style.pointerEvents = on ? "" : "none";
|
||||
}
|
||||
function toggleManagedUdpgwFields(on) {
|
||||
const el = document.getElementById("managedUdpgwFields");
|
||||
if (!el) return;
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
el.style.pointerEvents = on ? "" : "none";
|
||||
}
|
||||
|
||||
async function loadManagedServerConfig(id) {
|
||||
if (!id) return;
|
||||
const st = document.getElementById("managedConfigStatus");
|
||||
if (st) st.textContent = "Loading config…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/servers/config", id));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const c = await res.json();
|
||||
|
||||
document.getElementById("managedCfgListen").value = c.listen || "";
|
||||
document.getElementById("managedCfgExtraListen").value = (c.extra_listen || []).join("\n");
|
||||
document.getElementById("managedCfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
|
||||
document.getElementById("managedCfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
|
||||
|
||||
document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0;
|
||||
document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("managedCfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("managedCfgUserCount").checked = !!c.user_count;
|
||||
document.getElementById("managedCfgBanner").value = c.banner || "";
|
||||
|
||||
const hasDnstt = !!c.dnstt;
|
||||
document.getElementById("managedCfgDnsttEnabled").checked = hasDnstt;
|
||||
toggleManagedDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("managedCfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("managedCfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("managedCfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("managedCfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("managedCfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
document.getElementById("managedCfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
|
||||
document.getElementById("managedCfgDnsttRespWorkers").value = d.dns_response_workers || 0;
|
||||
document.getElementById("managedCfgDnsttAutoRestart").value = d.auto_restart_interval || "";
|
||||
document.getElementById("managedCfgDnsttRestartGrace").value = d.auto_restart_grace || "";
|
||||
document.getElementById("managedCfgDnsttMaxSessions").value = d.max_sessions || 0;
|
||||
document.getElementById("managedCfgDnsttMaxStreams").value = d.max_streams || 0;
|
||||
document.getElementById("managedCfgDnsttPendingResponses").value = d.pending_responses || 0;
|
||||
document.getElementById("managedCfgDnsttStreamBuffer").value = d.stream_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
|
||||
document.getElementById("managedCfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
document.getElementById("managedCfgDnsttNoStats").checked = !!d.disable_stats_log;
|
||||
document.getElementById("managedCfgDnsttNoConsole").checked = !!d.disable_console_log;
|
||||
document.getElementById("managedCfgDnsttLogConnections").checked = !!d.log_connections;
|
||||
|
||||
const hasUdpgw = !!c.udpgw;
|
||||
document.getElementById("managedCfgUdpgwEnabled").checked = hasUdpgw;
|
||||
toggleManagedUdpgwFields(hasUdpgw);
|
||||
const u = c.udpgw || {};
|
||||
document.getElementById("managedCfgUdpgwListen").value = u.listen || "";
|
||||
document.getElementById("managedCfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("managedCfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("managedCfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
document.getElementById("managedCfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
document.getElementById("managedCfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
|
||||
document.getElementById("managedCfgUdpgwDebug").checked = !!u.debug;
|
||||
|
||||
managedTlsForwardersState = c.tls_forwarders || [];
|
||||
renderManagedTLSForwarders();
|
||||
|
||||
const x = c.xray || {};
|
||||
document.getElementById("managedCfgXrayEnabled").checked = !!x.enabled;
|
||||
document.getElementById("managedCfgXrayMode").value = xrayModeFromConfig(x);
|
||||
|
||||
document.getElementById("managedDnsttPubkeyWrap")?.classList.add("hidden");
|
||||
if (st) st.textContent = "Config loaded.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function managedConfigFromForm() {
|
||||
const extraLines = document.getElementById("managedCfgExtraListen").value
|
||||
.split("\n").map(s => s.trim()).filter(Boolean);
|
||||
const dnsttDomains = readDnsttDomains("managedCfgDnsttDomains");
|
||||
return {
|
||||
listen: document.getElementById("managedCfgListen").value.trim(),
|
||||
extra_listen: extraLines,
|
||||
proxy_auto_restart_interval: document.getElementById("managedCfgProxyAutoRestart").value.trim(),
|
||||
proxy_auto_restart_grace: document.getElementById("managedCfgProxyRestartGrace").value.trim(),
|
||||
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10),
|
||||
default_limit_mbps_down: parseInt(document.getElementById("managedCfgLimitDown").value || "0", 10),
|
||||
ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("managedCfgQuiet").checked,
|
||||
user_count: document.getElementById("managedCfgUserCount").checked,
|
||||
banner: document.getElementById("managedCfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("managedCfgDnsttUDP").value.trim(),
|
||||
fake_dns_enabled: document.getElementById("managedCfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("managedCfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("managedCfgDnsttFakeDomain").value.trim(),
|
||||
fake_dns_workers: parseInt(document.getElementById("managedCfgDnsttFakeWorkers").value || "0", 10),
|
||||
dns_response_workers: parseInt(document.getElementById("managedCfgDnsttRespWorkers").value || "0", 10),
|
||||
auto_restart_interval: document.getElementById("managedCfgDnsttAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("managedCfgDnsttRestartGrace").value.trim(),
|
||||
max_sessions: parseInt(document.getElementById("managedCfgDnsttMaxSessions").value || "0", 10),
|
||||
max_streams: parseInt(document.getElementById("managedCfgDnsttMaxStreams").value || "0", 10),
|
||||
pending_responses: parseInt(document.getElementById("managedCfgDnsttPendingResponses").value || "0", 10),
|
||||
stream_buffer: parseInt(document.getElementById("managedCfgDnsttStreamBuffer").value || "0", 10),
|
||||
udp_read_buffer: parseInt(document.getElementById("managedCfgDnsttUDPReadBuffer").value || "0", 10),
|
||||
udp_write_buffer: parseInt(document.getElementById("managedCfgDnsttUDPWriteBuffer").value || "0", 10),
|
||||
privkey_file: document.getElementById("managedCfgDnsttKey").value.trim(),
|
||||
disable_stats_log: document.getElementById("managedCfgDnsttNoStats").checked,
|
||||
disable_console_log: document.getElementById("managedCfgDnsttNoConsole").checked,
|
||||
log_connections: document.getElementById("managedCfgDnsttLogConnections").checked,
|
||||
} : null,
|
||||
udpgw: document.getElementById("managedCfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("managedCfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("managedCfgUdpgwMaxConns").value || "0", 10),
|
||||
idle_timeout: document.getElementById("managedCfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("managedCfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("managedCfgUdpgwAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("managedCfgUdpgwRestartGrace").value.trim(),
|
||||
debug: document.getElementById("managedCfgUdpgwDebug").checked,
|
||||
} : null,
|
||||
tls_forwarders: managedTlsForwardersState,
|
||||
xray: {
|
||||
enabled: document.getElementById("managedCfgXrayEnabled").checked,
|
||||
mode: document.getElementById("managedCfgXrayMode").value === "external" ? "external" : "native",
|
||||
native: document.getElementById("managedCfgXrayMode").value !== "external",
|
||||
bin_path: "/opt/sshpanel/xray",
|
||||
config_file: "/opt/sshpanel/xray_config.json",
|
||||
native_config_file: "/opt/sshpanel/xray_native_config.json",
|
||||
api_server: "127.0.0.1:10085",
|
||||
online_window_seconds: 90,
|
||||
stats_poll_seconds: 15,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveManagedServerConfig() {
|
||||
if (!configuringServerID) return;
|
||||
const st = document.getElementById("managedConfigStatus");
|
||||
if (st) st.textContent = "Saving config…";
|
||||
try {
|
||||
const cfg = managedConfigFromForm();
|
||||
const res = await api(withServerParam("/api/servers/config", configuringServerID), { method:"POST", body: JSON.stringify(cfg) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const report = await res.json().catch(() => null);
|
||||
const warnings = report?.warnings || [];
|
||||
const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running);
|
||||
if (warnings.length || bad.length) {
|
||||
const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | ");
|
||||
if (st) st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | ");
|
||||
} else if (st) {
|
||||
st.textContent = "Saved and applied live.";
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderManagedTLSForwarders() {
|
||||
const list = document.getElementById("managedTlsForwardersList");
|
||||
const chip = document.getElementById("managedTlsCountChip");
|
||||
if (!list) return;
|
||||
if (chip) chip.textContent = managedTlsForwardersState.length;
|
||||
if (!managedTlsForwardersState.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:4px 0;">No TLS forwarders configured.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
managedTlsForwardersState.forEach((fw, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
||||
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.onclick = () => { managedTlsForwardersState.splice(i,1); renderManagedTLSForwarders(); };
|
||||
row.appendChild(delBtn);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleManagedAddTLSForm() {
|
||||
const panel = document.getElementById("managedAddTLSPanel");
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("hidden");
|
||||
if (!panel.classList.contains("hidden")) {
|
||||
document.getElementById("managedTlsAddStatus").textContent = "";
|
||||
document.getElementById("managedTlsListenAddr").value = "";
|
||||
document.getElementById("managedTlsSSLDomain").value = "";
|
||||
document.getElementById("managedTlsCertType").value = "selfsigned";
|
||||
onManagedTLSTypeChange("selfsigned");
|
||||
}
|
||||
}
|
||||
|
||||
function onManagedTLSTypeChange(val) {
|
||||
const setVisible = (id, on, display = "") => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle("hidden", !on);
|
||||
el.style.display = on ? display : "none";
|
||||
};
|
||||
setVisible("managedTlsSSFields", val === "selfsigned", "");
|
||||
setVisible("managedTlsLEFields", val === "letsencrypt", "grid");
|
||||
setVisible("managedTlsPasteFields", val === "paste", "");
|
||||
setVisible("managedTlsCustomFields", val === "custom", "grid");
|
||||
}
|
||||
|
||||
async function addManagedTLSForwarder() {
|
||||
const st = document.getElementById("managedTlsAddStatus");
|
||||
const listen = document.getElementById("managedTlsListenAddr").value.trim();
|
||||
const certType = document.getElementById("managedTlsCertType").value;
|
||||
if (!listen) { st.textContent = "Listen address required."; return; }
|
||||
let certFile = "", keyFile = "";
|
||||
st.textContent = "Processing…";
|
||||
if (certType === "selfsigned") {
|
||||
const domain = document.getElementById("managedTlsSSLDomain").value.trim();
|
||||
if (!domain) { st.textContent = "Domain required."; return; }
|
||||
try {
|
||||
const res = await api(withServerParam("/api/tls/generate-selfsigned", configuringServerID), { method:"POST", body: JSON.stringify({ domain }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "Self-signed cert generated.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Cert error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else if (certType === "letsencrypt") {
|
||||
const domain = document.getElementById("managedTlsLEDomain").value.trim();
|
||||
const email = document.getElementById("managedTlsLEEmail").value.trim();
|
||||
if (!domain || !email) { st.textContent = "Domain and email required."; return; }
|
||||
st.textContent = "Running certbot… (may take ~30s)";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/tls/letsencrypt", configuringServerID), { method:"POST", body: JSON.stringify({ domain, email }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "Let's Encrypt cert issued.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "certbot error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else if (certType === "paste") {
|
||||
const name = document.getElementById("managedTlsPasteName").value.trim();
|
||||
const cert = document.getElementById("managedTlsPasteCert").value.trim();
|
||||
const key = document.getElementById("managedTlsPasteKey").value.trim();
|
||||
if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; }
|
||||
try {
|
||||
const res = await api(withServerParam("/api/tls/upload-pem", configuringServerID), { method:"POST", body: JSON.stringify({ name, cert, key }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "PEM saved.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Upload error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
certFile = document.getElementById("managedTlsCustomCert").value.trim();
|
||||
keyFile = document.getElementById("managedTlsCustomKey").value.trim();
|
||||
if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; }
|
||||
}
|
||||
managedTlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile });
|
||||
renderManagedTLSForwarders();
|
||||
document.getElementById("managedAddTLSPanel").classList.add("hidden");
|
||||
st.textContent = "Added. Save config to apply.";
|
||||
}
|
||||
|
||||
async function generateManagedDnsttKey() {
|
||||
const st = document.getElementById("managedDnsttKeyStatus");
|
||||
if (st) st.textContent = "Generating key…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/dnstt/genkey", configuringServerID), { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
document.getElementById("managedCfgDnsttKey").value = data.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
if (st) st.textContent = "Key generated. Save config to apply.";
|
||||
await loadManagedDnsttPubkey();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagedDnsttPubkey() {
|
||||
const st = document.getElementById("managedDnsttKeyStatus");
|
||||
if (st) st.textContent = "Loading public key…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/dnstt/pubkey", configuringServerID));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
const val = data.public_key || data.pubkey || "";
|
||||
document.getElementById("managedDnsttPubkeyVal").value = val;
|
||||
document.getElementById("managedDnsttPubkeyWrap")?.classList.remove("hidden");
|
||||
if (st) st.textContent = val ? "Public key loaded." : "No public key returned.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
document.getElementById("refreshStatsBtn")?.addEventListener("click", loadStats);
|
||||
|
||||
async function loadDashboardStats() {
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const s = await res.json();
|
||||
updateDashboardStats(s);
|
||||
await loadDnsttHealth();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
if (dashCpuVal) dashCpuVal.textContent = "erro";
|
||||
if (dashRamVal) dashRamVal.textContent = "erro";
|
||||
if (dashNetVal) dashNetVal.textContent = "erro";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboardStats(s) {
|
||||
if (!s) return;
|
||||
const cpu = Number(s.cpu_percent ?? 0);
|
||||
const mem = s.mem_percent == null ? null : Number(s.mem_percent);
|
||||
if (dashCpuVal) dashCpuVal.textContent = fmtPct(cpu);
|
||||
if (dashCpuBar) dashCpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
||||
if (dashCpuText) dashCpuText.textContent = cpu >= 85 ? "Carga alta" : cpu >= 60 ? "Carga moderada" : "Carga normal";
|
||||
if (dashRamVal) dashRamVal.textContent = mem == null ? "--%" : fmtPct(mem);
|
||||
if (dashRamBar) dashRamBar.style.width = mem == null ? "0%" : Math.min(100, Math.max(0, mem)) + "%";
|
||||
if (dashRamText) {
|
||||
const used = s.mem_used_bytes, total = s.mem_total_bytes;
|
||||
dashRamText.textContent = used != null && total != null ? `${fmtBytes(used)} / ${fmtBytes(total)}` : "Memória usada";
|
||||
}
|
||||
const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
|
||||
let rx = 0, tx = 0, rxTotal = 0, txTotal = 0;
|
||||
ifaces.forEach(it => {
|
||||
rx += Number(it.rx_mbps || 0);
|
||||
tx += Number(it.tx_mbps || 0);
|
||||
rxTotal += Number(it.rx_bytes || 0);
|
||||
txTotal += Number(it.tx_bytes || 0);
|
||||
});
|
||||
if (dashNetVal) dashNetVal.textContent = `${fmtMbps(rx + tx)} Mb/s`;
|
||||
if (dashNetText) dashNetText.textContent = `RX ${fmtMbps(rx)} · TX ${fmtMbps(tx)} Mb/s`;
|
||||
if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`;
|
||||
}
|
||||
|
||||
async function loadDnsttHealth() {
|
||||
if (!dnsttDashboardCard && !dnsttHealthBody && !dnsttActiveSessions) return;
|
||||
if (currentRole !== "superadmin") {
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/dnstt");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json();
|
||||
const enabled = d.enabled !== false;
|
||||
if (dnsttDashboardCard) dnsttDashboardCard.classList.toggle("hidden", !enabled);
|
||||
if (!enabled) return;
|
||||
|
||||
if (dnsttActiveSessions) dnsttActiveSessions.textContent = fmtInt(d.active_sessions);
|
||||
if (dnsttActiveStreams) dnsttActiveStreams.textContent = fmtInt(d.active_streams);
|
||||
if (dnsttDNSRx) dnsttDNSRx.textContent = fmtInt(d.dns_rx);
|
||||
if (dnsttQueueLen) dnsttQueueLen.textContent = fmtInt(d.ch_len);
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = d.running === false ? "DNSTT stopped" : fmtDnsttTimestamp(d.timestamp);
|
||||
|
||||
const rows = [
|
||||
["Session rejected", d.sess_rejected],
|
||||
["Stream rejected", d.stream_rejected],
|
||||
["DNS parse errors", d.parse_err],
|
||||
["No EDNS", d.no_edns],
|
||||
["EDNS limit 512", d.limit512],
|
||||
["Local DNS workers", d.fake_dns_workers],
|
||||
["Response workers", d.dns_response_workers],
|
||||
["Responses queued", d.rec_queued],
|
||||
["Responses dropped", d.rec_dropped],
|
||||
["Responses sent", d.resp_sent],
|
||||
["Response bytes", d.resp_bytes],
|
||||
["Empty responses", d.resp_empty],
|
||||
["Data responses", d.resp_data],
|
||||
["Oversize responses", d.resp_oversize],
|
||||
["KCP sessions new", d.kcp_new],
|
||||
["KCP sessions ended", d.kcp_end],
|
||||
["SMUX streams new", d.smux_new],
|
||||
["SMUX streams ended", d.smux_end],
|
||||
["Panic recovered", d.panic_recovered],
|
||||
];
|
||||
if (dnsttHealthBody) {
|
||||
dnsttHealthBody.innerHTML = "";
|
||||
for (let i = 0; i < rows.length; i += 2) {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${escapeHTML(a[0])}</td><td>${escapeHTML(fmtInt(a[1]))}</td><td>${escapeHTML(b[0])}</td><td>${b[0] ? escapeHTML(fmtInt(b[1])) : ""}</td>`;
|
||||
dnsttHealthBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
const bad = Number(d.sess_rejected || 0) + Number(d.stream_rejected || 0) + Number(d.rec_dropped || 0) + Number(d.panic_recovered || 0);
|
||||
if (dnsttHealthSummary) {
|
||||
if (d.running === false) {
|
||||
dnsttHealthSummary.textContent = "DNSTT is enabled but not running. Check key/domain/listen config or recent logs.";
|
||||
} else {
|
||||
dnsttHealthSummary.textContent = bad > 0
|
||||
? `Attention: ${fmtInt(bad)} overload/recovery events in the last DNSTT stats window.`
|
||||
: "DNSTT health OK: no rejects, drops, or recovered panics in the last stats window.";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") throw e;
|
||||
dnsttDashboardCard?.classList.add("hidden");
|
||||
if (dnsttHealthUpdated) dnsttHealthUpdated.textContent = "Error loading DNSTT stats.";
|
||||
if (dnsttHealthSummary) dnsttHealthSummary.textContent = e.message || "DNSTT stats unavailable.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadStats() {
|
||||
if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Updating live status…");
|
||||
statsUpdated.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const s = await res.json();
|
||||
updateDashboardStats(s);
|
||||
const cpu = Number(s?.cpu_percent ?? 0);
|
||||
if (cpuVal) cpuVal.textContent = fmtPct(cpu);
|
||||
if (cpuBar) cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
||||
const mp = s?.mem_percent == null ? null : Number(s.mem_percent);
|
||||
if (memVal) memVal.textContent = mp == null ? "--%" : fmtPct(mp);
|
||||
if (memBar) memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%";
|
||||
const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes;
|
||||
if (memDetail) memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : "";
|
||||
const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
|
||||
if (ifaceBody) ifaceBody.innerHTML = "";
|
||||
let totRx = 0, totTx = 0;
|
||||
ifaces.forEach(it => {
|
||||
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
|
||||
if (!ifaceBody) return;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${escapeHTML(it.name)}</td><td>${escapeHTML(fmtMbps(it.rx_mbps))}</td><td>${escapeHTML(fmtMbps(it.tx_mbps))}</td><td>${escapeHTML(fmtBytes(it.rx_bytes))}</td><td>${escapeHTML(fmtBytes(it.tx_bytes))}</td>`;
|
||||
ifaceBody.appendChild(tr);
|
||||
});
|
||||
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
||||
const currentNetwork = ifaces.reduce((sum, item) => sum + Number(item.rx_mbps || 0) + Number(item.tx_mbps || 0), 0);
|
||||
if (statsNetVal) statsNetVal.textContent = `${fmtMbps(currentNetwork)} Mb/s`;
|
||||
if (statsIfaceVal) statsIfaceVal.textContent = String(ifaces.length);
|
||||
if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Live · updated {time}", {time:new Date().toLocaleTimeString()});
|
||||
statsUpdated.className = "workspace-live-status is-ok";
|
||||
}
|
||||
await loadDnsttHealth();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Error loading server status");
|
||||
statsUpdated.className = "workspace-live-status is-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
|
||||
|
||||
async function resetInterfaceStats() {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"⇅", title:t("Clean live interface totals"),
|
||||
message:t("Clean the live Interface totals now?"),
|
||||
detail:t("VnStat daily and monthly history will be preserved."),
|
||||
confirmLabel:t("Clean totals"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resetIfaceStatsBtn.disabled = true;
|
||||
ifaceSummary.textContent = "Cleaning interface totals…";
|
||||
try {
|
||||
const res = await api("/api/stats/interfaces/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
|
||||
showPanelToast(t("Live interface totals were cleaned."), "success", t("Traffic counters"));
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else ifaceSummary.textContent = "Error cleaning totals: " + e.message;
|
||||
} finally {
|
||||
resetIfaceStatsBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VnStat ───────────────────────────────────────────────────────────────────
|
||||
reloadVnstatBtn?.addEventListener("click", loadVnstat);
|
||||
resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
|
||||
|
||||
function renderVnstatRows(body, rows, emptyLabel) {
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${escapeHTML(emptyLabel)}</td>`;
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${escapeHTML(r.period || "--")}</td><td>${escapeHTML(r.iface || "--")}</td><td>${escapeHTML(fmtBytes(r.rx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.tx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))}</td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVnstat() {
|
||||
vnstatStatus.textContent = "Loading VnStat usage…";
|
||||
vnstatStatus.className = "workspace-live-status is-loading";
|
||||
try {
|
||||
const res = await api("/api/vnstat?days=31&months=12");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
const daily = Array.isArray(data.daily) ? data.daily : [];
|
||||
const monthly = Array.isArray(data.monthly) ? data.monthly : [];
|
||||
renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet.");
|
||||
renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet.");
|
||||
|
||||
// Use the server/database periods when available. Falling back to the
|
||||
// newest row avoids browser UTC/local-time mismatches that can make
|
||||
// "Today total" show 0 while the daily table has data.
|
||||
const today = data.today_period || daily[0]?.period || localDateKey();
|
||||
const month = data.month_period || today.slice(0,7);
|
||||
const todayTotal = data.today_total_bytes ?? daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0);
|
||||
const monthTotal = data.month_total_bytes ?? monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0);
|
||||
const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean));
|
||||
vnTodayTotal.textContent = fmtBytes(todayTotal);
|
||||
vnMonthTotal.textContent = fmtBytes(monthTotal);
|
||||
vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
|
||||
if (vnLatestPeriod) vnLatestPeriod.textContent = daily[0]?.period || monthly[0]?.period || "--";
|
||||
vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
|
||||
vnstatStatus.className = "workspace-live-status is-ok";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
|
||||
vnstatStatus.className = "workspace-live-status is-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resetVnstatHistory() {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Clean VnStat history"),
|
||||
message:t("Clean all daily and monthly traffic history?"),
|
||||
detail:t("Live interface totals are separate and will not be reset."),
|
||||
confirmLabel:t("Clean history"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resetVnstatBtn.disabled = true;
|
||||
vnstatStatus.textContent = "Cleaning VnStat history…";
|
||||
try {
|
||||
const res = await api("/api/vnstat/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
vnstatStatus.textContent = "VnStat history cleaned.";
|
||||
showPanelToast(t("VnStat history was cleaned."), "success", t("Traffic history"));
|
||||
loadVnstat();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else vnstatStatus.textContent = "Error cleaning VnStat history: " + e.message;
|
||||
} finally {
|
||||
resetVnstatBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Logs ─────────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs);
|
||||
document.getElementById("logSource")?.addEventListener("change", loadSystemLogs);
|
||||
document.getElementById("clearPanelLogBtn")?.addEventListener("click", clearPanelLog);
|
||||
|
||||
async function loadSystemLogs() {
|
||||
const box = document.getElementById("systemLogBox");
|
||||
const st = document.getElementById("systemLogStatus");
|
||||
const source = document.getElementById("logSource")?.value || "panel";
|
||||
const clearBtn = document.getElementById("clearPanelLogBtn");
|
||||
if (clearBtn) clearBtn.disabled = source !== "panel";
|
||||
st.textContent = "Loading…";
|
||||
try {
|
||||
const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
const lines = Array.isArray(data.lines) ? data.lines : [];
|
||||
box.textContent = lines.length ? lines.join("\n") : "No log lines yet.";
|
||||
box.scrollTop = box.scrollHeight;
|
||||
st.textContent = `${data.source || source} logs${data.path ? " · " + data.path : ""} · ${lines.length} lines · ` + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPanelLog() {
|
||||
const st = document.getElementById("systemLogStatus");
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Clean panel log"),
|
||||
message:t("Clean the current panel log now?"),
|
||||
detail:t("This only clears the panel log file. Automatic size-based cleanup remains enabled."),
|
||||
confirmLabel:t("Clean log"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
st.textContent = "Cleaning panel log…";
|
||||
try {
|
||||
const res = await api("/api/system/logs/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
st.textContent = `Panel log cleaned · ${data.path || "panel.log"} · max ${fmtBytes(data.max_bytes || 1048576)}`;
|
||||
await loadSystemLogs();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error cleaning panel log: " + e.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// ─── Server Config ────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
|
||||
|
||||
|
||||
function dnsttDomainsText(d) {
|
||||
const domains = Array.isArray(d?.domains) && d.domains.length ? d.domains : (d?.domain ? [d.domain] : []);
|
||||
return domains.join("\n");
|
||||
}
|
||||
function readDnsttDomains(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const seen = new Set();
|
||||
return el.value.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\.$/, "").toLowerCase()).filter(s => {
|
||||
if (seen.has(s)) return false;
|
||||
seen.add(s);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDnsttFields(on) {
|
||||
const el = document.getElementById("dnsttFields");
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
el.style.pointerEvents = on ? "" : "none";
|
||||
}
|
||||
function toggleUdpgwFields(on) {
|
||||
const el = document.getElementById("udpgwFields");
|
||||
el.style.opacity = on ? "1" : ".4";
|
||||
el.style.pointerEvents = on ? "" : "none";
|
||||
}
|
||||
|
||||
|
||||
// Only operator-safe knobs remain. Transport buffers (HTTP/2 flow control, XHTTP
|
||||
// reorder buffer, mux/UDP buffers) are fixed to xray-core defaults in the backend
|
||||
// and are no longer exposed here, so they cannot be misconfigured.
|
||||
const XRAY_NATIVE_TUNING_DEFAULTS = {
|
||||
safe: {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 8192,
|
||||
trace_packets: false,
|
||||
},
|
||||
"2k": {
|
||||
runtime_gomaxprocs: 0,
|
||||
mux_global_sessions: 32768,
|
||||
trace_packets: false,
|
||||
},
|
||||
};
|
||||
|
||||
const XRAY_NATIVE_TUNING_FIELDS = {
|
||||
runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs",
|
||||
mux_global_sessions: "cfgXrayMuxGlobalSessions",
|
||||
};
|
||||
|
||||
function setXrayNativeTuningDefaults(profile = "2k") {
|
||||
const t = XRAY_NATIVE_TUNING_DEFAULTS[profile] || XRAY_NATIVE_TUNING_DEFAULTS.safe;
|
||||
writeXrayNativeTuning(t);
|
||||
}
|
||||
|
||||
function writeXrayNativeTuning(t = {}) {
|
||||
const defaults = XRAY_NATIVE_TUNING_DEFAULTS.safe;
|
||||
Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Number(t[key] || defaults[key] || 0);
|
||||
});
|
||||
const trace = document.getElementById("cfgXrayTracePackets");
|
||||
if (trace) trace.checked = !!t.trace_packets;
|
||||
}
|
||||
|
||||
function readXrayNativeTuning() {
|
||||
const out = {};
|
||||
Object.entries(XRAY_NATIVE_TUNING_FIELDS).forEach(([key, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
out[key] = parseInt(el?.value || "0", 10) || 0;
|
||||
});
|
||||
out.trace_packets = !!document.getElementById("cfgXrayTracePackets")?.checked;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Inline onclick handlers in index.html need this exposed on window explicitly.
|
||||
window.setXrayNativeTuningDefaults = setXrayNativeTuningDefaults;
|
||||
|
||||
async function loadServerConfig() {
|
||||
const st = document.getElementById("srvCfgStatus");
|
||||
st.textContent = "Loading…";
|
||||
try {
|
||||
const res = await api("/api/server/config");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const c = await res.json();
|
||||
|
||||
// Network
|
||||
document.getElementById("cfgListen").value = c.listen || "";
|
||||
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
|
||||
document.getElementById("cfgProxyAutoRestart").value = c.proxy_auto_restart_interval || "";
|
||||
document.getElementById("cfgProxyRestartGrace").value = c.proxy_auto_restart_grace || "";
|
||||
|
||||
// SSH / general
|
||||
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0;
|
||||
document.getElementById("cfgMaxTotalConns").value = c.max_total_connections || 0;
|
||||
document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("cfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("cfgUserCount").checked = !!c.user_count;
|
||||
|
||||
// Banner
|
||||
document.getElementById("cfgBanner").value = c.banner || "";
|
||||
|
||||
// DNSTT
|
||||
const hasDnstt = !!c.dnstt;
|
||||
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
|
||||
toggleDnsttFields(hasDnstt);
|
||||
const d = c.dnstt || {};
|
||||
document.getElementById("cfgDnsttDomains").value = dnsttDomainsText(d);
|
||||
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
||||
document.getElementById("cfgDnsttFakeEnabled").checked = !!d.fake_dns_enabled;
|
||||
document.getElementById("cfgDnsttFakeListen").value = d.fake_dns_listen || "";
|
||||
document.getElementById("cfgDnsttFakeDomain").value = d.fake_dns_domain || "t.local.lan";
|
||||
document.getElementById("cfgDnsttFakeWorkers").value = d.fake_dns_workers || 0;
|
||||
document.getElementById("cfgDnsttRespWorkers").value = d.dns_response_workers || 0;
|
||||
document.getElementById("cfgDnsttAutoRestart").value = d.auto_restart_interval || "";
|
||||
document.getElementById("cfgDnsttRestartGrace").value = d.auto_restart_grace || "";
|
||||
document.getElementById("cfgDnsttMaxSessions").value = d.max_sessions || 0;
|
||||
document.getElementById("cfgDnsttMaxStreams").value = d.max_streams || 0;
|
||||
document.getElementById("cfgDnsttPendingResponses").value = d.pending_responses || 0;
|
||||
document.getElementById("cfgDnsttStreamBuffer").value = d.stream_buffer || 0;
|
||||
document.getElementById("cfgDnsttUDPReadBuffer").value = d.udp_read_buffer || 0;
|
||||
document.getElementById("cfgDnsttUDPWriteBuffer").value = d.udp_write_buffer || 0;
|
||||
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
||||
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
|
||||
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
|
||||
document.getElementById("cfgDnsttLogConnections").checked = !!d.log_connections;
|
||||
|
||||
// UDPGW
|
||||
const hasUdpgw = !!c.udpgw;
|
||||
document.getElementById("cfgUdpgwEnabled").checked = hasUdpgw;
|
||||
toggleUdpgwFields(hasUdpgw);
|
||||
const u = c.udpgw || {};
|
||||
document.getElementById("cfgUdpgwListen").value = u.listen || "";
|
||||
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("cfgUdpgwMaxClients").value = u.max_clients || 0;
|
||||
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
document.getElementById("cfgUdpgwRestartGrace").value = u.auto_restart_grace || "";
|
||||
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
|
||||
|
||||
// TLS forwarders
|
||||
tlsForwardersState = c.tls_forwarders || [];
|
||||
renderTLSForwarders();
|
||||
|
||||
// Xray
|
||||
const x = c.xray || {};
|
||||
document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
|
||||
document.getElementById("cfgXrayMode").value = xrayModeFromConfig(x);
|
||||
writeXrayNativeTuning(x.native_tuning || XRAY_NATIVE_TUNING_DEFAULTS.safe);
|
||||
|
||||
st.textContent = "Config loaded.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveServerConfig() {
|
||||
const st = document.getElementById("srvCfgStatus");
|
||||
st.textContent = "Saving…";
|
||||
|
||||
const tlsArr = tlsForwardersState;
|
||||
|
||||
const extraLines = document.getElementById("cfgExtraListen").value
|
||||
.split("\n").map(s => s.trim()).filter(Boolean);
|
||||
const dnsttDomains = readDnsttDomains("cfgDnsttDomains");
|
||||
|
||||
const cfg = {
|
||||
listen: document.getElementById("cfgListen").value.trim(),
|
||||
extra_listen: extraLines,
|
||||
proxy_auto_restart_interval: document.getElementById("cfgProxyAutoRestart").value.trim(),
|
||||
proxy_auto_restart_grace: document.getElementById("cfgProxyRestartGrace").value.trim(),
|
||||
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
|
||||
default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10),
|
||||
max_total_connections: parseInt(document.getElementById("cfgMaxTotalConns").value || "0", 10),
|
||||
ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("cfgQuiet").checked,
|
||||
user_count: document.getElementById("cfgUserCount").checked,
|
||||
banner: document.getElementById("cfgBanner").value,
|
||||
banner_file: "/opt/sshpanel/banner.txt",
|
||||
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
||||
domain: dnsttDomains[0] || "",
|
||||
domains: dnsttDomains,
|
||||
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
||||
fake_dns_enabled: document.getElementById("cfgDnsttFakeEnabled").checked,
|
||||
fake_dns_listen: document.getElementById("cfgDnsttFakeListen").value.trim(),
|
||||
fake_dns_domain: document.getElementById("cfgDnsttFakeDomain").value.trim(),
|
||||
fake_dns_workers: parseInt(document.getElementById("cfgDnsttFakeWorkers").value || "0", 10),
|
||||
dns_response_workers: parseInt(document.getElementById("cfgDnsttRespWorkers").value || "0", 10),
|
||||
auto_restart_interval: document.getElementById("cfgDnsttAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("cfgDnsttRestartGrace").value.trim(),
|
||||
max_sessions: parseInt(document.getElementById("cfgDnsttMaxSessions").value || "0", 10),
|
||||
max_streams: parseInt(document.getElementById("cfgDnsttMaxStreams").value || "0", 10),
|
||||
pending_responses: parseInt(document.getElementById("cfgDnsttPendingResponses").value || "0", 10),
|
||||
stream_buffer: parseInt(document.getElementById("cfgDnsttStreamBuffer").value || "0", 10),
|
||||
udp_read_buffer: parseInt(document.getElementById("cfgDnsttUDPReadBuffer").value || "0", 10),
|
||||
udp_write_buffer: parseInt(document.getElementById("cfgDnsttUDPWriteBuffer").value || "0", 10),
|
||||
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
|
||||
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
|
||||
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
|
||||
log_connections: document.getElementById("cfgDnsttLogConnections").checked,
|
||||
} : null,
|
||||
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("cfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
|
||||
max_clients: parseInt(document.getElementById("cfgUdpgwMaxClients").value || "0", 10),
|
||||
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(),
|
||||
auto_restart_grace: document.getElementById("cfgUdpgwRestartGrace").value.trim(),
|
||||
debug: document.getElementById("cfgUdpgwDebug").checked,
|
||||
} : null,
|
||||
tls_forwarders: tlsArr,
|
||||
xray: {
|
||||
enabled: document.getElementById("cfgXrayEnabled").checked,
|
||||
mode: document.getElementById("cfgXrayMode").value === "external" ? "external" : "native",
|
||||
native: document.getElementById("cfgXrayMode").value !== "external",
|
||||
bin_path: "/opt/sshpanel/xray",
|
||||
config_file: "/opt/sshpanel/xray_config.json",
|
||||
native_config_file: "/opt/sshpanel/xray_native_config.json",
|
||||
api_server: "127.0.0.1:10085",
|
||||
online_window_seconds: 90,
|
||||
stats_poll_seconds: 15,
|
||||
native_tuning: readXrayNativeTuning(),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const report = await res.json().catch(() => null);
|
||||
const warnings = report?.warnings || [];
|
||||
const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running);
|
||||
if (warnings.length || bad.length) {
|
||||
const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | ");
|
||||
st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | ");
|
||||
} else {
|
||||
st.textContent = "Saved and applied live.";
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TLS Forwarders ────────────────────────────────────────────────────────────
|
||||
function renderTLSForwarders() {
|
||||
const list = document.getElementById("tlsForwardersList");
|
||||
const chip = document.getElementById("tlsCountChip");
|
||||
if (!list) return;
|
||||
chip.textContent = tlsForwardersState.length;
|
||||
if (!tlsForwardersState.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:4px 0;">No TLS forwarders configured.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
tlsForwardersState.forEach((fw, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
||||
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.onclick = () => { tlsForwardersState.splice(i,1); renderTLSForwarders(); };
|
||||
row.appendChild(delBtn);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAddTLSForm() {
|
||||
const panel = document.getElementById("addTLSPanel");
|
||||
panel.classList.toggle("hidden");
|
||||
if (!panel.classList.contains("hidden")) {
|
||||
document.getElementById("tlsAddStatus").textContent = "";
|
||||
document.getElementById("tlsListenAddr").value = "";
|
||||
document.getElementById("tlsSSLDomain").value = "";
|
||||
document.getElementById("tlsCertType").value = "selfsigned";
|
||||
onTLSTypeChange("selfsigned");
|
||||
}
|
||||
}
|
||||
|
||||
function onTLSTypeChange(val) {
|
||||
const setVisible = (id, on, display = "") => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle("hidden", !on);
|
||||
el.style.display = on ? display : "none";
|
||||
};
|
||||
setVisible("tlsSSFields", val === "selfsigned", "");
|
||||
setVisible("tlsLEFields", val === "letsencrypt", "grid");
|
||||
setVisible("tlsPasteFields", val === "paste", "");
|
||||
setVisible("tlsCustomFields", val === "custom", "grid");
|
||||
}
|
||||
|
||||
async function addTLSForwarder() {
|
||||
const st = document.getElementById("tlsAddStatus");
|
||||
const listen = document.getElementById("tlsListenAddr").value.trim();
|
||||
const certType = document.getElementById("tlsCertType").value;
|
||||
if (!listen) { st.textContent = "Listen address required."; return; }
|
||||
let certFile = "", keyFile = "";
|
||||
st.textContent = "Processing…";
|
||||
if (certType === "selfsigned") {
|
||||
const domain = document.getElementById("tlsSSLDomain").value.trim();
|
||||
if (!domain) { st.textContent = "Domain required."; return; }
|
||||
try {
|
||||
const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "Self-signed cert generated.";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Cert error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else if (certType === "letsencrypt") {
|
||||
const domain = document.getElementById("tlsLEDomain").value.trim();
|
||||
const email = document.getElementById("tlsLEEmail").value.trim();
|
||||
if (!domain || !email) { st.textContent = "Domain and email required."; return; }
|
||||
st.textContent = "Running certbot… (may take ~30s)";
|
||||
try {
|
||||
const res = await api("/api/tls/letsencrypt", { method:"POST", body: JSON.stringify({ domain, email }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "Let's Encrypt cert issued.";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "certbot error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else if (certType === "paste") {
|
||||
const name = document.getElementById("tlsPasteName").value.trim();
|
||||
const cert = document.getElementById("tlsPasteCert").value.trim();
|
||||
const key = document.getElementById("tlsPasteKey").value.trim();
|
||||
if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; }
|
||||
try {
|
||||
const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
certFile = data.cert_file; keyFile = data.key_file;
|
||||
st.textContent = "PEM saved.";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Upload error: " + e.message;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
certFile = document.getElementById("tlsCustomCert").value.trim();
|
||||
keyFile = document.getElementById("tlsCustomKey").value.trim();
|
||||
if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; }
|
||||
}
|
||||
tlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile });
|
||||
renderTLSForwarders();
|
||||
document.getElementById("addTLSPanel").classList.add("hidden");
|
||||
st.textContent = "Added. Save config to apply.";
|
||||
}
|
||||
|
||||
// ─── Xray wizard cert source picker ──────────────────────────────────────────
|
||||
function setWzCertSrc(mode) {
|
||||
["file","paste","gen"].forEach(m => {
|
||||
const cap = m.charAt(0).toUpperCase() + m.slice(1);
|
||||
document.getElementById("wzCertSrc"+cap).style.display = m === mode ? "" : "none";
|
||||
const btn = document.getElementById("wzCertSrc"+cap+"Btn");
|
||||
if (btn) btn.className = (m === mode ? "btn btn-sm" : "btn btn-ghost btn-sm");
|
||||
});
|
||||
document.getElementById("wzPasteCertStatus").textContent = "";
|
||||
document.getElementById("wzGenCertStatus").textContent = "";
|
||||
}
|
||||
|
||||
async function wzSavePastedCert() {
|
||||
const st = document.getElementById("wzPasteCertStatus");
|
||||
const name = document.getElementById("wzPastedName").value.trim();
|
||||
const cert = document.getElementById("wzPastedCert").value.trim();
|
||||
const key = document.getElementById("wzPastedKey").value.trim();
|
||||
if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; }
|
||||
st.textContent = "Saving…";
|
||||
try {
|
||||
const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
document.getElementById("wzTLSCert").value = data.cert_file;
|
||||
document.getElementById("wzTLSKey").value = data.key_file;
|
||||
st.textContent = "Saved ✓ paths set.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function wzGenerateCert() {
|
||||
const st = document.getElementById("wzGenCertStatus");
|
||||
const domain = document.getElementById("wzGenDomain").value.trim();
|
||||
if (!domain) { st.textContent = "Domain required."; return; }
|
||||
st.textContent = "Generating…";
|
||||
try {
|
||||
const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
document.getElementById("wzTLSCert").value = data.cert_file;
|
||||
document.getElementById("wzTLSKey").value = data.key_file;
|
||||
st.textContent = "Generated ✓ paths set.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DNSTT Key Management ─────────────────────────────────────────────────────
|
||||
async function generateDnsttKey() {
|
||||
const st = document.getElementById("dnsttKeyStatus");
|
||||
st.textContent = "Generating key…";
|
||||
try {
|
||||
const res = await api("/api/dnstt/genkey", { method: "POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
document.getElementById("cfgDnsttKey").value = data.privkey_file;
|
||||
document.getElementById("dnsttPubkeyVal").value = data.pubkey;
|
||||
document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
|
||||
st.textContent = "Key generated. Save config to apply.";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDnsttPubkey() {
|
||||
const st = document.getElementById("dnsttKeyStatus");
|
||||
st.textContent = "Loading public key…";
|
||||
try {
|
||||
const res = await api("/api/dnstt/pubkey");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
document.getElementById("dnsttPubkeyVal").value = data.pubkey;
|
||||
document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
|
||||
st.textContent = "";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
// ─── Xray Client Edit ─────────────────────────────────────────────────────────
|
||||
function openEditXrayClient(tag, client) {
|
||||
editingXrayClientId = client.id;
|
||||
document.getElementById("editClientUUID").textContent = client.id;
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
const maxInput = document.getElementById("editXrayMaxConns");
|
||||
maxInput.value = client.max_conns || 0;
|
||||
maxInput.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
function closeEditXrayClient() {
|
||||
editingXrayClientId = null;
|
||||
document.getElementById("editXrayClientPanel").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function saveEditXrayClient() {
|
||||
if (!editingXrayClientId) return;
|
||||
const st = document.getElementById("editXrayClientStatus");
|
||||
st.textContent = "Saving…";
|
||||
const payload = {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? ""
|
||||
: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
st.textContent = "Saved.";
|
||||
setTimeout(() => { closeEditXrayClient(); loadInbounds({ force: true }); }, 700);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Xray Config Wizard ────────────────────────────────────────────────────────
|
||||
function setXrayCfgMode(mode) {
|
||||
const wizPane = document.getElementById("xrayWizardPane");
|
||||
const jsonPane = document.getElementById("xrayCfgPaneJson");
|
||||
const wizBtn = document.getElementById("xrayWizardTabBtn");
|
||||
const jsonBtn = document.getElementById("xrayJsonTabBtn");
|
||||
if (mode === "wizard") {
|
||||
wizPane.classList.remove("hidden");
|
||||
jsonPane.classList.add("hidden");
|
||||
wizBtn.classList.remove("btn-ghost");
|
||||
jsonBtn.classList.add("btn-ghost");
|
||||
loadWizardFromConfig();
|
||||
} else {
|
||||
wizPane.classList.add("hidden");
|
||||
jsonPane.classList.remove("hidden");
|
||||
jsonBtn.classList.remove("btn-ghost");
|
||||
wizBtn.classList.add("btn-ghost");
|
||||
loadXrayCfg();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("wzLogLevel")?.addEventListener("change", () => { wzDirty = true; });
|
||||
|
||||
function cloneJsonSafe(obj) {
|
||||
return obj && typeof obj === "object" ? JSON.parse(JSON.stringify(obj)) : obj;
|
||||
}
|
||||
|
||||
function loadWizardFromConfig() {
|
||||
const serverID = selectedXrayServer();
|
||||
const target = selectedXrayServerLabel();
|
||||
const st = document.getElementById("wzStatus");
|
||||
wzLoadedServerID = null;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Loading config from ${target}...`;
|
||||
api(withServerParam("/api/xray/config", serverID)).then(async res => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const raw = await res.text();
|
||||
const cfg = JSON.parse(raw);
|
||||
wzLoadedServerID = serverID || "local";
|
||||
wzLoadedConfigText = raw;
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
||||
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Config loaded from ${target}.`;
|
||||
}).catch(e => {
|
||||
wzLoadedServerID = null;
|
||||
wzLoadedConfigText = "";
|
||||
wzLoadedFullConfig = null;
|
||||
wzInbounds = [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderWzInbounds() {
|
||||
const list = document.getElementById("wzInboundsList");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!wzInbounds.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "hint visual-empty-state";
|
||||
empty.textContent = "Nenhum inbound configurado. Crie um endpoint compartilhado ou adicione um inbound.";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
wzInbounds.forEach((ib, i) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "visual-inbound-card";
|
||||
const portStr = ib.port !== undefined ? `:${ib.port}` : "";
|
||||
const ss = ib.streamSettings || {};
|
||||
const net = ss.network || "";
|
||||
const sec = ss.security || "";
|
||||
const transportSettings = ss.xhttpSettings || ss.splithttpSettings || ss.wsSettings || ss.httpupgradeSettings || ss.httpSettings || ss.grpcSettings || {};
|
||||
const head = document.createElement("div");
|
||||
head.className = "visual-inbound-card-head";
|
||||
const name = document.createElement("div");
|
||||
name.className = "visual-inbound-name";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = ib.tag || "untagged";
|
||||
const address = document.createElement("small");
|
||||
address.textContent = `${ib.listen || "0.0.0.0"}${portStr}`;
|
||||
name.append(title, address);
|
||||
const protocol = document.createElement("span");
|
||||
protocol.className = "chip";
|
||||
protocol.textContent = String(ib.protocol || "unknown").toUpperCase();
|
||||
head.append(name, protocol);
|
||||
row.appendChild(head);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "visual-inbound-meta";
|
||||
[net || "default", transportSettings.path || transportSettings.serviceName || "no path", sec || "no TLS"].forEach(value => {
|
||||
const item = document.createElement("span");
|
||||
item.textContent = value;
|
||||
meta.appendChild(item);
|
||||
});
|
||||
row.appendChild(meta);
|
||||
const clients = ib.settings?.clients;
|
||||
if (Array.isArray(clients) && clients.length) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "chip green";
|
||||
badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "visual-inbound-actions";
|
||||
const xhttp = visualXHTTPSettings(ib);
|
||||
const proxyProtocol = String(ib.protocol || "").toLowerCase();
|
||||
if (xhttp && ["vless", "vmess"].includes(proxyProtocol)) {
|
||||
const sshRoute = findSSHRouteForInbound(ib);
|
||||
const sshBtn = document.createElement("button");
|
||||
sshBtn.className = sshRoute ? "btn btn-soft btn-sm legacy-ssh-btn is-enabled" : "btn btn-sm legacy-ssh-btn";
|
||||
sshBtn.type = "button";
|
||||
sshBtn.textContent = t(sshRoute ? "SSH /ssh enabled" : "Enable SSH /ssh");
|
||||
sshBtn.disabled = !!sshRoute;
|
||||
sshBtn.title = sshRoute ? t("This endpoint already has an SSH /ssh route.") : t("Add SSH /ssh without rebuilding this inbound.");
|
||||
sshBtn.onclick = () => enableSSHForWzInbound(i, true, sshBtn);
|
||||
actions.appendChild(sshBtn);
|
||||
}
|
||||
const duplicateBtn = document.createElement("button");
|
||||
duplicateBtn.className = "btn btn-ghost btn-sm";
|
||||
duplicateBtn.type = "button";
|
||||
duplicateBtn.textContent = "Duplicar";
|
||||
duplicateBtn.onclick = () => duplicateWzInbound(i);
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-ghost btn-sm";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Editar";
|
||||
editBtn.onclick = () => editWzInbound(i);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.type = "button";
|
||||
delBtn.textContent = "Remover";
|
||||
delBtn.onclick = async () => {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove inbound"),
|
||||
message:t("Remove inbound {name}?", {name:ib.tag || "untagged"}),
|
||||
detail:t("Clients attached only to this inbound will stop connecting after the configuration is saved."),
|
||||
confirmLabel:t("Remove inbound"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
wzInbounds.splice(i,1);
|
||||
if (wzEditingIndex === i) wzCancelInbound();
|
||||
else if (wzEditingIndex > i) wzEditingIndex--;
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
};
|
||||
actions.append(duplicateBtn, editBtn, delBtn);
|
||||
row.appendChild(actions);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function wzToggleAddInbound() {
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
|
||||
resetWzInboundForm();
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
function setWzValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value ?? "";
|
||||
}
|
||||
|
||||
function resetWzInboundForm() {
|
||||
wzEditingIndex = -1;
|
||||
setWzValue("wzProtocol", "vless");
|
||||
setWzValue("wzPort", "10086");
|
||||
setWzValue("wzListenIP", "0.0.0.0");
|
||||
setWzValue("wzTag", "vless-in");
|
||||
setWzValue("wzNetwork", "tcp");
|
||||
setWzValue("wzWSPath", "/ws");
|
||||
setWzValue("wzXHTTPPath", "/xhttp");
|
||||
setWzValue("wzXHTTPHost", "");
|
||||
setWzValue("wzXHTTPMode", "auto");
|
||||
setWzValue("wzHUPath", "/upgrade");
|
||||
setWzValue("wzHUHost", "");
|
||||
setWzValue("wzH2Path", "/h2");
|
||||
setWzValue("wzH2Host", "");
|
||||
setWzValue("wzGRPCService", "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = false;
|
||||
setWzValue("wzTLS", "none");
|
||||
["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, ""));
|
||||
setWzValue("wzSSMethod", "chacha20-ietf-poly1305");
|
||||
document.getElementById("wzInboundFormTitle").textContent = "Novo inbound";
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound";
|
||||
document.getElementById("wzEditingBadge").classList.add("hidden");
|
||||
onWzProtoChange("vless");
|
||||
onWzNetworkChange("tcp");
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
|
||||
function wzCancelInbound() {
|
||||
wzEditingIndex = -1;
|
||||
document.getElementById("wzAddInboundForm")?.classList.add("hidden");
|
||||
document.getElementById("wzEditingBadge")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function editWzInbound(index) {
|
||||
const ib = wzInbounds[index];
|
||||
if (!ib) return;
|
||||
resetWzInboundForm();
|
||||
wzEditingIndex = index;
|
||||
const proto = String(ib.protocol || "vless").toLowerCase();
|
||||
setWzValue("wzProtocol", proto);
|
||||
onWzProtoChange(proto);
|
||||
setWzValue("wzPort", ib.port ?? "");
|
||||
setWzValue("wzListenIP", ib.listen || (proto === "socks" ? "127.0.0.1" : "0.0.0.0"));
|
||||
setWzValue("wzTag", ib.tag || `${proto}-in`);
|
||||
|
||||
const ss = ib.streamSettings || {};
|
||||
const network = proto === "ssh" ? "xhttp" : (ss.network || "tcp");
|
||||
setWzValue("wzNetwork", network);
|
||||
onWzNetworkChange(network);
|
||||
const xh = ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
setWzValue("wzWSPath", ss.wsSettings?.path || "/ws");
|
||||
setWzValue("wzXHTTPPath", xh.path || "/xhttp");
|
||||
setWzValue("wzXHTTPHost", xh.host || "");
|
||||
setWzValue("wzXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("wzHUPath", ss.httpupgradeSettings?.path || "/upgrade");
|
||||
setWzValue("wzHUHost", ss.httpupgradeSettings?.host || "");
|
||||
setWzValue("wzH2Path", ss.httpSettings?.path || "/h2");
|
||||
setWzValue("wzH2Host", Array.isArray(ss.httpSettings?.host) ? (ss.httpSettings.host[0] || "") : (ss.httpSettings?.host || ""));
|
||||
setWzValue("wzGRPCService", ss.grpcSettings?.serviceName || "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = !!ss.grpcSettings?.multiMode;
|
||||
|
||||
const security = ss.security === "tls" ? "tls" : (ss.security === "reality" ? "reality" : "none");
|
||||
setWzValue("wzTLS", security);
|
||||
onWzTLSChange(security);
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("wzTLSCert", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKey", cert.keyFile || "");
|
||||
setWzValue("wzTLSCertPath", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKeyPath", cert.keyFile || "");
|
||||
setWzValue("wzRealityDest", ss.realitySettings?.dest || "");
|
||||
setWzValue("wzRealitySNI", ss.realitySettings?.serverNames?.[0] || "");
|
||||
setWzValue("wzRealityPriv", ss.realitySettings?.privateKey || "");
|
||||
setWzValue("wzRealityShortID", ss.realitySettings?.shortIds?.[0] || "");
|
||||
setWzValue("wzTrojanPass", ib.settings?.clients?.[0]?.password || "");
|
||||
setWzValue("wzSSPass", ib.settings?.password || "");
|
||||
setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305");
|
||||
|
||||
document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`;
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações";
|
||||
document.getElementById("wzEditingBadge").classList.remove("hidden");
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"start" });
|
||||
}
|
||||
|
||||
function duplicateWzInbound(index) {
|
||||
const source = wzInbounds[index];
|
||||
if (!source) return;
|
||||
const copy = cloneJsonSafe(source);
|
||||
const tags = new Set(wzInbounds.map(ib => ib?.tag));
|
||||
let n = 2;
|
||||
let tag = `${source.tag || source.protocol || "inbound"}-copy`;
|
||||
while (tags.has(tag)) tag = `${source.tag || source.protocol || "inbound"}-copy-${n++}`;
|
||||
copy.tag = tag;
|
||||
const port = Number(copy.port || 0);
|
||||
if (port > 0 && port < 65535) copy.port = port + 1;
|
||||
wzInbounds.push(copy);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
editWzInbound(wzInbounds.length - 1);
|
||||
}
|
||||
|
||||
function normalizeVisualPath(value) {
|
||||
let path = String(value || "/").trim().split("?", 1)[0];
|
||||
if (!path.startsWith("/")) path = `/${path}`;
|
||||
path = path.replace(/\/+$/, "") || "/";
|
||||
return path;
|
||||
}
|
||||
|
||||
function visualXHTTPSettings(ib) {
|
||||
const ss = ib?.streamSettings || {};
|
||||
if (!["xhttp", "splithttp"].includes(String(ss.network || "").toLowerCase())) return null;
|
||||
return ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
}
|
||||
|
||||
function sameVisualEndpoint(a, b) {
|
||||
return String(a?.listen || "0.0.0.0") === String(b?.listen || "0.0.0.0") && String(a?.port) === String(b?.port);
|
||||
}
|
||||
|
||||
function findSSHRouteForInbound(source) {
|
||||
return wzInbounds.find(candidate => {
|
||||
const xh = visualXHTTPSettings(candidate);
|
||||
return sameVisualEndpoint(source, candidate) && !!xh && String(candidate?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function uniqueLegacySSHTag(source) {
|
||||
const tags = new Set(wzInbounds.map(ib => String(ib?.tag || "")));
|
||||
const sourceTag = String(source?.tag || source?.port || "xhttp").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "xhttp";
|
||||
let tag = `ssh-${sourceTag}`;
|
||||
let suffix = 2;
|
||||
while (tags.has(tag)) tag = `ssh-${sourceTag}-${suffix++}`;
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Adds SSH to a legacy VLESS/VMess XHTTP listener without rebuilding or
|
||||
// modifying the original inbound. The companion inherits its listen address,
|
||||
// port, host, XHTTP mode, advanced transport options, TLS certificate, and key;
|
||||
// only its protocol, empty SSH settings, tag, and /ssh path differ.
|
||||
function reportSSHMigration(message, tone = "error") {
|
||||
const st = document.getElementById("wzStatus");
|
||||
if (st) st.textContent = message;
|
||||
if (typeof showPanelToast === "function") {
|
||||
showPanelToast(message, tone, tone === "success" ? t("SSH /ssh enabled") : tone === "warning" ? t("SSH migration attention") : t("Could not enable SSH /ssh"));
|
||||
}
|
||||
}
|
||||
|
||||
async function enableSSHForWzInbound(index, applyNow = true, trigger = null) {
|
||||
const st = document.getElementById("wzStatus");
|
||||
const source = wzInbounds[index];
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!source || !visualXHTTPSettings(source) || !["vless", "vmess"].includes(String(source.protocol || "").toLowerCase())) {
|
||||
reportSSHMigration(t("Select a VLESS/VMess inbound using XHTTP."));
|
||||
return false;
|
||||
}
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
reportSSHMigration(t("Load the selected server configuration before enabling SSH."));
|
||||
return false;
|
||||
}
|
||||
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
|
||||
reportSSHMigration(t("Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first."));
|
||||
return false;
|
||||
}
|
||||
const security = String(source.streamSettings?.security || "").toLowerCase();
|
||||
if (security && security !== "none" && security !== "tls") {
|
||||
reportSSHMigration(t("Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.", {name:source.tag || t("selected"), security}));
|
||||
return false;
|
||||
}
|
||||
const existingSSH = findSSHRouteForInbound(source);
|
||||
if (existingSSH) {
|
||||
reportSSHMigration(t("SSH is already enabled on /ssh by inbound {name}.", {name:existingSSH.tag}), "success");
|
||||
return true;
|
||||
}
|
||||
const pathConflict = wzInbounds.find(candidate => {
|
||||
const xh = visualXHTTPSettings(candidate);
|
||||
return sameVisualEndpoint(source, candidate) && !!xh && normalizeVisualPath(xh.path) === "/ssh";
|
||||
});
|
||||
if (pathConflict) {
|
||||
reportSSHMigration(t("Path /ssh is already used by inbound {name}. Edit that path first.", {name:pathConflict.tag || t("untagged")}));
|
||||
return false;
|
||||
}
|
||||
if (security === "tls") {
|
||||
const certificate = source.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
if (!certificate.certificateFile || !certificate.keyFile) {
|
||||
reportSSHMigration(t("This inbound uses TLS but has no reusable certificate and key file paths."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (applyNow) {
|
||||
const xhttp = visualXHTTPSettings(source) || {};
|
||||
const clients = Array.isArray(source.settings?.clients) ? source.settings.clients.length : 0;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"success", icon:"SSH", eyebrow:t("Safe XHTTP migration"), title:t("Enable SSH on /ssh"),
|
||||
message:t("Add SSH to the same endpoint without rebuilding {name}?", {name:source.tag || t("this inbound")}),
|
||||
detail:[
|
||||
`${t("Listener")}: ${source.listen || "0.0.0.0"}:${source.port}`,
|
||||
`${t("Existing path preserved")}: ${normalizeVisualPath(xhttp.path)}`,
|
||||
`${t("New SSH path")}: /ssh`,
|
||||
`${t("Clients preserved")}: ${clients}`,
|
||||
`${t("Security")}: ${security === "tls" ? "TLS" : t("No TLS")}`,
|
||||
].join("\n"),
|
||||
confirmLabel:t("Enable SSH /ssh"),
|
||||
});
|
||||
if (!accepted) return false;
|
||||
}
|
||||
if (trigger) {
|
||||
trigger.disabled = true;
|
||||
trigger.textContent = t("Enabling SSH…");
|
||||
}
|
||||
|
||||
const streamSettings = cloneJsonSafe(source.streamSettings || {});
|
||||
const settingsKey = streamSettings.xhttpSettings ? "xhttpSettings" : (streamSettings.splithttpSettings ? "splithttpSettings" : "xhttpSettings");
|
||||
streamSettings[settingsKey] = Object.assign({}, streamSettings[settingsKey] || {}, { path:"/ssh" });
|
||||
const sshInbound = {
|
||||
tag: uniqueLegacySSHTag(source),
|
||||
listen: source.listen || "0.0.0.0",
|
||||
port: cloneJsonSafe(source.port),
|
||||
protocol: "ssh",
|
||||
settings: {},
|
||||
streamSettings,
|
||||
};
|
||||
const previousDirty = wzDirty;
|
||||
const before = cloneJsonSafe(source);
|
||||
wzInbounds.push(sshInbound);
|
||||
try {
|
||||
validateVisualInbounds(wzInbounds);
|
||||
} catch (error) {
|
||||
wzInbounds.pop();
|
||||
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
|
||||
reportSSHMigration(t("Could not enable SSH: {error}", {error:error.message}));
|
||||
return false;
|
||||
}
|
||||
if (JSON.stringify(source) !== JSON.stringify(before)) {
|
||||
wzInbounds.pop();
|
||||
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
|
||||
reportSSHMigration(t("Migration was cancelled because it would alter the old inbound."));
|
||||
return false;
|
||||
}
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
if (!applyNow) {
|
||||
if (st) st.textContent = t("SSH /ssh added to the draft without changing {name}.", {name:source.tag || t("the old inbound")});
|
||||
return true;
|
||||
}
|
||||
if (st) st.textContent = t("Enabling SSH /ssh without changing {name}…", {name:source.tag || t("the old inbound")});
|
||||
const result = await applyWizardConfig();
|
||||
if (!result?.saved) {
|
||||
const addedIndex = wzInbounds.indexOf(sshInbound);
|
||||
if (addedIndex >= 0) wzInbounds.splice(addedIndex, 1);
|
||||
wzDirty = previousDirty;
|
||||
renderWzInbounds();
|
||||
reportSSHMigration(result?.error || t("The SSH route could not be saved. The old inbound was not changed."));
|
||||
return false;
|
||||
}
|
||||
if (!result.restarted) {
|
||||
reportSSHMigration(t("SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log."), "warning");
|
||||
return true;
|
||||
}
|
||||
reportSSHMigration(t("SSH /ssh is active. The old inbound and all clients were preserved."), "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateVisualInbounds(inbounds) {
|
||||
const tags = new Set();
|
||||
const binds = new Map();
|
||||
const nativeMode = (document.getElementById("xCoreMode")?.value || "native") === "native";
|
||||
for (const ib of inbounds || []) {
|
||||
const tag = String(ib?.tag || "").trim();
|
||||
if (!tag) throw new Error("every inbound needs a tag");
|
||||
if (tags.has(tag)) throw new Error(`duplicate inbound tag: ${tag}`);
|
||||
tags.add(tag);
|
||||
const port = Number(ib?.port || 0);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port on ${tag}`);
|
||||
const bind = `${String(ib?.listen || "0.0.0.0").trim()}:${port}`;
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
if (!binds.has(bind)) binds.set(bind, []);
|
||||
binds.get(bind).push({ ib, xh });
|
||||
if (String(ib?.protocol || "").toLowerCase() === "ssh") {
|
||||
if (!xh) throw new Error(`SSH inbound ${tag} requires XHTTP`);
|
||||
if (!nativeMode) throw new Error(`SSH inbound ${tag} requires native Xray mode`);
|
||||
}
|
||||
}
|
||||
for (const [bind, rows] of binds) {
|
||||
if (rows.length < 2) continue;
|
||||
if (!nativeMode || rows.some(row => !row.xh)) throw new Error(`multiple inbounds cannot share ${bind} unless all use native XHTTP`);
|
||||
const paths = new Set();
|
||||
const firstSecurity = String(rows[0].ib.streamSettings?.security || "none");
|
||||
const firstCert = rows[0].ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
for (const row of rows) {
|
||||
const path = normalizeVisualPath(row.xh.path);
|
||||
if (paths.has(path)) throw new Error(`duplicate XHTTP path ${path} on ${bind}`);
|
||||
paths.add(path);
|
||||
const security = String(row.ib.streamSettings?.security || "none");
|
||||
const cert = row.ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
if (security !== firstSecurity) throw new Error(`shared XHTTP inbounds on ${bind} must use the same TLS setting`);
|
||||
if (security === "tls" && (cert.certificateFile !== firstCert.certificateFile || cert.keyFile !== firstCert.keyFile)) {
|
||||
throw new Error(`shared XHTTP inbounds on ${bind} must use the same certificate`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findSharedEndpointPair() {
|
||||
const roots = wzInbounds.filter(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
});
|
||||
for (const proxy of roots) {
|
||||
const ssh = wzInbounds.find(ib => String(ib?.protocol || "").toLowerCase() === "ssh" &&
|
||||
String(ib.listen || "0.0.0.0") === String(proxy.listen || "0.0.0.0") && String(ib.port) === String(proxy.port) &&
|
||||
normalizeVisualPath(visualXHTTPSettings(ib)?.path) === "/ssh");
|
||||
if (ssh) return { proxy, ssh };
|
||||
}
|
||||
const proxy = wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || null;
|
||||
const ssh = wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || null;
|
||||
return proxy && ssh ? { proxy, ssh } : null;
|
||||
}
|
||||
|
||||
function loadSharedEndpointForm() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
if (!status) return;
|
||||
const pair = findSharedEndpointPair();
|
||||
if (!pair) {
|
||||
status.textContent = wzLoadedConfigText ? "Nenhum endpoint compartilhado detectado. Preencha os campos para criar um." : "Carregue a configuração para detectar um endpoint existente.";
|
||||
return;
|
||||
}
|
||||
const xh = visualXHTTPSettings(pair.proxy) || {};
|
||||
const ss = pair.proxy.streamSettings || {};
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("sharedXHTTPProtocol", pair.proxy.protocol || "vless");
|
||||
setWzValue("sharedXHTTPPort", pair.proxy.port || 443);
|
||||
setWzValue("sharedXHTTPListen", pair.proxy.listen || "0.0.0.0");
|
||||
setWzValue("sharedXHTTPHost", xh.host || "");
|
||||
setWzValue("sharedXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("sharedXHTTPSecurity", ss.security === "tls" ? "tls" : "none");
|
||||
setWzValue("sharedXHTTPCert", cert.certificateFile || "");
|
||||
setWzValue("sharedXHTTPKey", cert.keyFile || "");
|
||||
updateSharedEndpointControls();
|
||||
status.textContent = `Endpoint detectado em ${pair.proxy.listen || "0.0.0.0"}:${pair.proxy.port} — ${String(pair.proxy.protocol).toUpperCase()} / e SSH /ssh.`;
|
||||
}
|
||||
|
||||
function updateSharedEndpointControls() {
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol")?.value || "vless";
|
||||
const security = document.getElementById("sharedXHTTPSecurity")?.value || "none";
|
||||
document.getElementById("sharedProxyRouteLabel").textContent = protocol.toUpperCase();
|
||||
document.querySelectorAll(".shared-tls-field").forEach(el => el.classList.toggle("hidden", security !== "tls"));
|
||||
}
|
||||
|
||||
function applySharedXHTTPEndpoint() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
status.textContent = "Carregue a configuração do servidor selecionado antes de editar.";
|
||||
return;
|
||||
}
|
||||
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
|
||||
status.textContent = "O endpoint compartilhado requer o modo Xray nativo.";
|
||||
return;
|
||||
}
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol").value;
|
||||
const port = Number(document.getElementById("sharedXHTTPPort").value || 0);
|
||||
const listen = document.getElementById("sharedXHTTPListen").value.trim() || "0.0.0.0";
|
||||
const host = document.getElementById("sharedXHTTPHost").value.trim();
|
||||
const mode = document.getElementById("sharedXHTTPMode").value || "auto";
|
||||
const security = document.getElementById("sharedXHTTPSecurity").value;
|
||||
const cert = document.getElementById("sharedXHTTPCert").value.trim();
|
||||
const key = document.getElementById("sharedXHTTPKey").value.trim();
|
||||
if (!["vless", "vmess"].includes(protocol) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
status.textContent = "Escolha VLESS/VMess e uma porta válida.";
|
||||
return;
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${host}${cert}${key}`)) {
|
||||
status.textContent = "Os campos contêm caracteres de controle inválidos.";
|
||||
return;
|
||||
}
|
||||
if (security === "tls" && (!cert || !key)) {
|
||||
status.textContent = "Informe os arquivos do certificado e da chave para usar TLS.";
|
||||
return;
|
||||
}
|
||||
|
||||
const pair = findSharedEndpointPair();
|
||||
const sameEndpoint = ib => String(ib?.listen || "0.0.0.0") === listen && String(ib?.port) === String(port);
|
||||
const existingProxy = pair?.proxy || wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
}) || null;
|
||||
const existingSSH = pair?.ssh || wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && String(ib?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
|
||||
}) || null;
|
||||
const removeSet = new Set([existingProxy, existingSSH].filter(Boolean));
|
||||
const others = wzInbounds.filter(ib => !removeSet.has(ib));
|
||||
const blocking = others.find(ib => String(ib.listen || "0.0.0.0") === listen && String(ib.port) === String(port) && !visualXHTTPSettings(ib));
|
||||
if (blocking) {
|
||||
status.textContent = `A porta já é usada pelo inbound não-XHTTP ${blocking.tag || "sem tag"}. Escolha outra porta.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const buildSharedStream = (existing, path) => {
|
||||
const stream = cloneJsonSafe(existing?.streamSettings || {});
|
||||
stream.network = "xhttp";
|
||||
stream.xhttpSettings = Object.assign({}, stream.xhttpSettings || stream.splithttpSettings || {}, { path, mode });
|
||||
delete stream.splithttpSettings;
|
||||
if (host) stream.xhttpSettings.host = host;
|
||||
else delete stream.xhttpSettings.host;
|
||||
if (security === "tls") {
|
||||
stream.security = "tls";
|
||||
stream.tlsSettings = Object.assign({}, stream.tlsSettings || {}, { certificates:[{ certificateFile:cert, keyFile:key }] });
|
||||
} else {
|
||||
delete stream.security;
|
||||
delete stream.tlsSettings;
|
||||
}
|
||||
delete stream.realitySettings;
|
||||
return stream;
|
||||
};
|
||||
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
|
||||
const proxyInbound = cloneJsonSafe(existingProxy || {});
|
||||
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp";
|
||||
proxyInbound.listen = listen;
|
||||
proxyInbound.port = port;
|
||||
proxyInbound.protocol = protocol;
|
||||
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
|
||||
proxyInbound.settings.clients = previousClients;
|
||||
if (protocol === "vless") proxyInbound.settings.decryption = "none";
|
||||
else delete proxyInbound.settings.decryption;
|
||||
proxyInbound.streamSettings = buildSharedStream(existingProxy, "/");
|
||||
const sshInbound = cloneJsonSafe(existingSSH || {});
|
||||
sshInbound.tag = existingSSH?.tag || "shared-ssh-xhttp";
|
||||
sshInbound.listen = listen;
|
||||
sshInbound.port = port;
|
||||
sshInbound.protocol = "ssh";
|
||||
sshInbound.settings = {};
|
||||
sshInbound.streamSettings = buildSharedStream(existingSSH, "/ssh");
|
||||
wzInbounds = [...others, proxyInbound, sshInbound];
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar.";
|
||||
}
|
||||
|
||||
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
|
||||
updateSharedEndpointControls();
|
||||
|
||||
function onWzProtoChange(val) {
|
||||
const isSSH = val === "ssh";
|
||||
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
|
||||
// fields, but carry no proxy client list of their own.
|
||||
const usesTransportFields = val === "vless" || val === "vmess" || isSSH;
|
||||
document.getElementById("wzVlessFields").style.display = usesTransportFields ? "grid" : "none";
|
||||
document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
|
||||
document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
|
||||
|
||||
// SSH runs only over XHTTP: force the network to xhttp and lock the dropdown
|
||||
// so the wizard can only emit a valid xhttp+ssh inbound.
|
||||
const netSel = document.getElementById("wzNetwork");
|
||||
if (isSSH) {
|
||||
netSel.value = "xhttp";
|
||||
netSel.disabled = true;
|
||||
onWzNetworkChange("xhttp");
|
||||
} else {
|
||||
netSel.disabled = false;
|
||||
}
|
||||
|
||||
const tlsSel = document.getElementById("wzTLS");
|
||||
const realityOpt = document.querySelector("#wzTLS option[value='reality']");
|
||||
if (realityOpt) {
|
||||
// REALITY is not wired for the native XHTTP listener (tls/none only) and is
|
||||
// unavailable for VMess, so disable it for both.
|
||||
const noReality = val === "vmess" || isSSH;
|
||||
realityOpt.disabled = noReality;
|
||||
if (noReality && tlsSel.value === "reality") {
|
||||
tlsSel.value = "none";
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
}
|
||||
|
||||
const portMap = { vless:10086, vmess:10087, ssh:2087, trojan:8443, shadowsocks:8388, socks:10808 };
|
||||
const tagMap = { vless:"vless-in", vmess:"vmess-in", ssh:"ssh-xhttp-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
|
||||
const portEl = document.getElementById("wzPort");
|
||||
const tagEl = document.getElementById("wzTag");
|
||||
const lisEl = document.getElementById("wzListenIP");
|
||||
const knownPorts = Object.values(portMap).map(String);
|
||||
const knownTags = Object.values(tagMap);
|
||||
if (!portEl.value || knownPorts.includes(portEl.value)) portEl.value = portMap[val] || "";
|
||||
if (!tagEl.value || knownTags.includes(tagEl.value)) tagEl.value = tagMap[val] || val+"-in";
|
||||
if (!lisEl.value || lisEl.value === "0.0.0.0" || lisEl.value === "127.0.0.1") {
|
||||
lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
function onWzNetworkChange(val) {
|
||||
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
||||
// WebSocket
|
||||
show("wzWSPathField", val === "ws");
|
||||
// XHTTP
|
||||
show("wzXHTTPPathField", val === "xhttp");
|
||||
show("wzXHTTPHostField", val === "xhttp");
|
||||
show("wzXHTTPModeField", val === "xhttp");
|
||||
// HTTPUpgrade
|
||||
show("wzHUPathField", val === "httpupgrade");
|
||||
show("wzHUHostField", val === "httpupgrade");
|
||||
// H2
|
||||
show("wzH2PathField", val === "h2");
|
||||
show("wzH2HostField", val === "h2");
|
||||
// gRPC
|
||||
show("wzGRPCServiceField", val === "grpc");
|
||||
show("wzGRPCMultiField", val === "grpc");
|
||||
// Auto-select TLS defaults
|
||||
const tlsSel = document.getElementById("wzTLS");
|
||||
if ((val === "h2" || val === "grpc") && tlsSel.value === "none") {
|
||||
tlsSel.value = "tls"; onWzTLSChange("tls");
|
||||
}
|
||||
}
|
||||
|
||||
function onWzTLSChange(val) {
|
||||
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
||||
show("wzTLSCertBlock", val === "tls");
|
||||
show("wzRealityDestField", val === "reality");
|
||||
show("wzRealitySNIField", val === "reality");
|
||||
show("wzRealityPrivField", val === "reality");
|
||||
show("wzRealityShortIDField",val === "reality");
|
||||
}
|
||||
|
||||
function wzSaveInbound() {
|
||||
const proto = document.getElementById("wzProtocol").value;
|
||||
const port = parseInt(document.getElementById("wzPort").value || "0", 10);
|
||||
const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
|
||||
const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
|
||||
const st = document.getElementById("wzStatus");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) { st.textContent = "Informe uma porta válida."; return; }
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${tag}`)) { st.textContent = "Listen ou tag contém caracteres inválidos."; return; }
|
||||
if (wzInbounds.some((item, index) => index !== wzEditingIndex && item?.tag === tag)) { st.textContent = `A tag ${tag} já está em uso.`; return; }
|
||||
|
||||
const original = wzEditingIndex >= 0 ? cloneJsonSafe(wzInbounds[wzEditingIndex]) : null;
|
||||
const ib = original || {};
|
||||
const previousClients = Array.isArray(original?.settings?.clients) ? cloneJsonSafe(original.settings.clients) : [];
|
||||
ib.tag = tag;
|
||||
ib.port = port;
|
||||
ib.listen = listen;
|
||||
ib.protocol = proto;
|
||||
ib.settings = {};
|
||||
if (proto === "vless" || proto === "vmess") {
|
||||
ib.settings = original?.protocol === proto && original.settings ? cloneJsonSafe(original.settings) : {};
|
||||
ib.settings.clients = previousClients;
|
||||
if (proto === "vless") ib.settings.decryption = "none";
|
||||
else delete ib.settings.decryption;
|
||||
const net = document.getElementById("wzNetwork").value;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
const previousStream = original?.protocol === proto && original?.streamSettings?.network === net ? cloneJsonSafe(original.streamSettings) : {};
|
||||
ib.streamSettings = previousStream || {};
|
||||
ib.streamSettings.network = net;
|
||||
["wsSettings", "xhttpSettings", "splithttpSettings", "httpupgradeSettings", "httpSettings", "grpcSettings"].forEach(key => {
|
||||
if (!((net === "ws" && key === "wsSettings") || (net === "xhttp" && (key === "xhttpSettings" || key === "splithttpSettings")) || (net === "httpupgrade" && key === "httpupgradeSettings") || (net === "h2" && key === "httpSettings") || (net === "grpc" && key === "grpcSettings"))) delete ib.streamSettings[key];
|
||||
});
|
||||
// Transport-specific settings
|
||||
switch (net) {
|
||||
case "ws":
|
||||
ib.streamSettings.wsSettings = Object.assign({}, ib.streamSettings.wsSettings || {}, { path: document.getElementById("wzWSPath").value.trim() || "/" });
|
||||
break;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
break;
|
||||
case "httpupgrade":
|
||||
ib.streamSettings.httpupgradeSettings = Object.assign({}, ib.streamSettings.httpupgradeSettings || {}, {
|
||||
path: document.getElementById("wzHUPath").value.trim() || "/",
|
||||
host: document.getElementById("wzHUHost").value.trim() || undefined,
|
||||
});
|
||||
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
|
||||
break;
|
||||
case "h2":
|
||||
ib.streamSettings.httpSettings = Object.assign({}, ib.streamSettings.httpSettings || {}, {
|
||||
path: document.getElementById("wzH2Path").value.trim() || "/",
|
||||
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
||||
});
|
||||
break;
|
||||
case "grpc":
|
||||
ib.streamSettings.grpcSettings = Object.assign({}, ib.streamSettings.grpcSettings || {}, {
|
||||
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
||||
multiMode: document.getElementById("wzGRPCMulti").checked,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// TLS / Reality
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
delete ib.streamSettings.realitySettings;
|
||||
} else if (tlsVal === "reality" && proto === "vless") {
|
||||
ib.streamSettings.security = "reality";
|
||||
ib.streamSettings.realitySettings = Object.assign({}, ib.streamSettings.realitySettings || {}, {
|
||||
dest: document.getElementById("wzRealityDest").value.trim(),
|
||||
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
|
||||
privateKey: document.getElementById("wzRealityPriv").value.trim(),
|
||||
shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
|
||||
});
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
delete ib.streamSettings.realitySettings;
|
||||
}
|
||||
} else if (proto === "ssh") {
|
||||
// SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to
|
||||
// the SSH server, so authentication is an ordinary SSH account.
|
||||
ib.settings = {};
|
||||
ib.streamSettings = original?.protocol === "ssh" ? (cloneJsonSafe(original.streamSettings) || {}) : {};
|
||||
ib.streamSettings.network = "xhttp";
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
}
|
||||
} else if (proto === "trojan") {
|
||||
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
||||
ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} };
|
||||
} else if (proto === "shadowsocks") {
|
||||
ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" };
|
||||
} else if (proto === "socks") {
|
||||
ib.settings = { auth: "noauth", udp: true };
|
||||
ib.streamSettings = { network: "tcp" };
|
||||
}
|
||||
if (wzEditingIndex >= 0) wzInbounds[wzEditingIndex] = ib;
|
||||
else wzInbounds.push(ib);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
|
||||
wzCancelInbound();
|
||||
}
|
||||
|
||||
|
||||
function buildConfigFromVisualEditor() {
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
throw new Error("config for this server is not loaded yet");
|
||||
}
|
||||
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(wzLoadedConfigText);
|
||||
} catch (_) {
|
||||
cfg = cloneJsonSafe(wzLoadedFullConfig || {});
|
||||
}
|
||||
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
||||
throw new Error("loaded config is not an object");
|
||||
}
|
||||
|
||||
// Preserve the selected server's full JSON exactly as the base.
|
||||
// The visual tab is intentionally conservative: it only updates fields that
|
||||
// are visible here, so pressing Save cannot wipe routing/outbounds/policy/etc.
|
||||
cfg.log = cfg.log && typeof cfg.log === "object" ? cfg.log : {};
|
||||
cfg.log.loglevel = document.getElementById("wzLogLevel")?.value || cfg.log.loglevel || "warning";
|
||||
|
||||
const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : [];
|
||||
const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api");
|
||||
const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
validateVisualInbounds(visualInbounds);
|
||||
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function updateFullConfigFromWizard() {
|
||||
const cfg = buildConfigFromVisualEditor();
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function applyWizardConfig() {
|
||||
const st = document.getElementById("wzStatus");
|
||||
const target = selectedXrayServerLabel();
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
|
||||
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
|
||||
if (st) st.textContent = `Reloading config from ${target} before saving...`;
|
||||
loadWizardFromConfig();
|
||||
return { saved:false, restarted:false, error:t("Configuration for this server was not loaded.") };
|
||||
}
|
||||
|
||||
let cfg;
|
||||
try {
|
||||
cfg = buildConfigFromVisualEditor();
|
||||
} catch(e) {
|
||||
if (st) st.textContent = `Invalid visual config: ${e.message}`;
|
||||
return { saved:false, restarted:false, error:t("Invalid visual config: {error}", {error:e.message}) };
|
||||
}
|
||||
|
||||
if (st) st.textContent = `Saving config to ${target}...`;
|
||||
try {
|
||||
const body = JSON.stringify(cfg, null, 2);
|
||||
const res = await api(withServerParam("/api/xray/config", selectedID), { method:"POST", body });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
wzLoadedConfigText = body;
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
wzLoadedServerID = selectedID;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Saved on ${target}. Restarting Xray...`;
|
||||
const restarted = await xrayCtrl("restart");
|
||||
if (st) st.textContent = restarted
|
||||
? `Config saved on ${target} and Xray restarted.`
|
||||
: `Config saved on ${target}, but Xray could not restart. Check Xray logs before editing again.`;
|
||||
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
|
||||
return { saved:true, restarted:!!restarted, error:restarted ? "" : t("Xray could not restart.") };
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
return { saved:false, restarted:false, error:t("Could not save configuration: {error}", {error:e.message}) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// ─── Auth error ───────────────────────────────────────────────────────────────
|
||||
function doAuthError() {
|
||||
sessionToken = "";
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
loginErr.textContent = t("Session expired — please sign in again.");
|
||||
}
|
||||
|
||||
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
||||
window.addEventListener("load", () => {
|
||||
if (sessionToken) {
|
||||
// Try to validate the stored token
|
||||
api("/api/auth/me").then(async res => {
|
||||
if (!res.ok) { doAuthError(); return; }
|
||||
const d = await res.json();
|
||||
currentRole = d.role;
|
||||
currentUser = d.username;
|
||||
loginOverlay.classList.add("hidden");
|
||||
mainApp.classList.remove("hidden");
|
||||
initAfterLogin();
|
||||
}).catch(() => doAuthError());
|
||||
} else {
|
||||
loginOverlay.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// ─── Git update status ───────────────────────────────────────────────────────
|
||||
const DRAGON_UPDATE_COMMAND = "sudo bash /opt/sshpanel/update.sh";
|
||||
|
||||
function updateStatusErrorText(error) {
|
||||
switch (String(error || "")) {
|
||||
case "remote update check timed out":
|
||||
return t("Update check timed out.");
|
||||
case "could not read the remote Git branch":
|
||||
return t("Could not reach the Git repository.");
|
||||
case "current build commit is unavailable":
|
||||
return t("Current build commit is unavailable.");
|
||||
default:
|
||||
return error || t("Unknown update-check error.");
|
||||
}
|
||||
}
|
||||
|
||||
function setUpdateState(label, tone = "") {
|
||||
const chip = document.getElementById("updateStateChip");
|
||||
if (!chip) return;
|
||||
chip.className = "chip" + (tone ? ` ${tone}` : "");
|
||||
chip.textContent = label;
|
||||
}
|
||||
|
||||
function setCommitValue(elementID, shortValue, fullValue) {
|
||||
const el = document.getElementById(elementID);
|
||||
if (!el) return;
|
||||
el.textContent = shortValue || "--";
|
||||
el.title = fullValue || "";
|
||||
}
|
||||
|
||||
function renderUpdateStatus(data) {
|
||||
setCommitValue("updateCurrentCommit", data.current_commit_short, data.current_commit);
|
||||
setCommitValue("updateLatestCommit", data.latest_commit_short, data.latest_commit);
|
||||
|
||||
const branch = document.getElementById("updateBranch");
|
||||
if (branch) branch.textContent = data.branch || "main";
|
||||
|
||||
const checked = document.getElementById("updateCheckedAt");
|
||||
if (checked) {
|
||||
const date = data.checked_at ? new Date(data.checked_at) : null;
|
||||
checked.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "--";
|
||||
}
|
||||
|
||||
const repoLink = document.getElementById("updateRepoLink");
|
||||
if (repoLink && data.repo_web_url) repoLink.href = data.repo_web_url;
|
||||
|
||||
const text = document.getElementById("updateStatusText");
|
||||
const commandWrap = document.getElementById("updateCommandWrap");
|
||||
commandWrap?.classList.toggle("hidden", !data.update_available);
|
||||
|
||||
if (data.status === "up_to_date") {
|
||||
setUpdateState(t("Up to date"), "green");
|
||||
if (text) text.textContent = t("The installed version matches the latest commit on {branch}.", { branch: data.branch || "main" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "update_available") {
|
||||
setUpdateState(t("Update available"), "warn");
|
||||
if (text) text.textContent = t("A newer commit is available on {branch}.", { branch: data.branch || "main" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "local_changes") {
|
||||
setUpdateState(t("Local changes"), "warn");
|
||||
if (text) text.textContent = t("This build contains local changes, so it cannot be compared safely.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateState(t("Unknown"), "red");
|
||||
if (text) {
|
||||
text.textContent = data.error
|
||||
? t("Could not check for updates: {error}", { error: updateStatusErrorText(data.error) })
|
||||
: t("The update status could not be determined.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUpdateStatus(force = false) {
|
||||
if (currentRole !== "superadmin") return;
|
||||
|
||||
const button = document.getElementById("checkUpdateBtn");
|
||||
const text = document.getElementById("updateStatusText");
|
||||
if (button) button.disabled = true;
|
||||
setUpdateState(t("Checking…"));
|
||||
if (text) text.textContent = t("Checking repository…");
|
||||
|
||||
try {
|
||||
const path = "/api/system/update-status" + (force ? "?refresh=1" : "");
|
||||
const res = await api(path);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
renderUpdateStatus(await res.json());
|
||||
} catch (error) {
|
||||
setUpdateState(t("Unknown"), "red");
|
||||
if (text) text.textContent = t("Could not check for updates: {error}", { error: error.message || t("Network error.") });
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("checkUpdateBtn")?.addEventListener("click", () => loadUpdateStatus(true));
|
||||
document.getElementById("copyUpdateCommandBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DRAGON_UPDATE_COMMAND);
|
||||
} catch {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = DRAGON_UPDATE_COMMAND;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
const button = document.getElementById("copyUpdateCommandBtn");
|
||||
if (button) {
|
||||
const oldText = button.textContent;
|
||||
button.textContent = t("Copied");
|
||||
setTimeout(() => { button.textContent = oldText; }, 1400);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
// Bot / Vendas — safe DOM rendering and sectioned management workspace.
|
||||
|
||||
const botState = {
|
||||
config: null,
|
||||
plans: [],
|
||||
packages: [],
|
||||
users: [],
|
||||
transactions: [],
|
||||
section: sessionStorage.getItem("BOT_SECTION") || "config",
|
||||
};
|
||||
|
||||
function botStatus(id, message, ok) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
if (id === "botConfigStatus") {
|
||||
element.classList.toggle("is-ok", ok === true);
|
||||
element.classList.toggle("is-error", ok === false);
|
||||
} else {
|
||||
element.style.color = ok === false ? "var(--danger)" : "";
|
||||
}
|
||||
}
|
||||
|
||||
function botBRL(cents) {
|
||||
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100);
|
||||
}
|
||||
|
||||
function botNode(tag, options = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
if (options.className) element.className = options.className;
|
||||
if (options.text != null) element.textContent = String(options.text);
|
||||
if (options.title) element.title = options.title;
|
||||
if (options.type) element.type = options.type;
|
||||
for (const child of children) if (child) element.appendChild(child);
|
||||
return element;
|
||||
}
|
||||
|
||||
function botCell(content, className = "") {
|
||||
const cell = document.createElement("td");
|
||||
if (className) cell.className = className;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = String(content == null || content === "" ? "—" : content);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function botPrimaryCell(title, detail) {
|
||||
const wrapper = botNode("div", { className: "bot-primary-cell" });
|
||||
wrapper.appendChild(botNode("strong", { text: title || "—" }));
|
||||
if (detail) wrapper.appendChild(botNode("small", { text: detail }));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function botBadge(label, tone) {
|
||||
return botNode("span", { className: "bot-status " + tone, text: label });
|
||||
}
|
||||
|
||||
function botButton(label, handler, className = "btn btn-ghost btn-sm") {
|
||||
const button = botNode("button", { className, text: label, type: "button" });
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function botActions(buttons) {
|
||||
return botNode("div", { className: "bot-row-actions" }, buttons);
|
||||
}
|
||||
|
||||
function botEmptyRow(body, columns, message) {
|
||||
const row = botNode("tr", { className: "bot-empty-row" });
|
||||
const cell = botCell(message);
|
||||
cell.colSpan = columns;
|
||||
row.appendChild(cell);
|
||||
body.replaceChildren(row);
|
||||
}
|
||||
|
||||
async function botRequest(path, options = {}) {
|
||||
const response = await api(path, options);
|
||||
if (!response.ok) {
|
||||
const message = (await response.text()).trim();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function botHandleError(error, statusID, fallback) {
|
||||
if (error.message === "auth") {
|
||||
doAuthError();
|
||||
return;
|
||||
}
|
||||
botStatus(statusID, error.message || fallback, false);
|
||||
}
|
||||
|
||||
function botSetSection(section) {
|
||||
const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]);
|
||||
if (!allowedSections.has(section)) section = "config";
|
||||
botState.section = section;
|
||||
sessionStorage.setItem("BOT_SECTION", section);
|
||||
document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section));
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section));
|
||||
const select = document.getElementById("botSection");
|
||||
if (select) select.value = section;
|
||||
}
|
||||
|
||||
function botUpdateMetrics() {
|
||||
const config = botState.config;
|
||||
const stateMetric = document.getElementById("botMetricState");
|
||||
if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível";
|
||||
const plansMetric = document.getElementById("botMetricPlans");
|
||||
if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length);
|
||||
const usersMetric = document.getElementById("botMetricUsers");
|
||||
if (usersMetric) usersMetric.textContent = String(botState.users.length);
|
||||
const pendingMetric = document.getElementById("botMetricPending");
|
||||
if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length);
|
||||
}
|
||||
|
||||
async function loadBotTab() {
|
||||
botSetSection(botState.section);
|
||||
botStatus("botConfigStatus", "Atualizando dados…");
|
||||
await Promise.allSettled([
|
||||
loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(),
|
||||
loadBotUsers(), loadBotTxns(), loadBotSettings(),
|
||||
]);
|
||||
botUpdateMetrics();
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async function loadBotConfig() {
|
||||
try {
|
||||
const config = await botRequest("/api/bot/config");
|
||||
botState.config = config;
|
||||
const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
|
||||
setChecked("botEnabled", config.enabled);
|
||||
setValue("botMPConfirmMode", config.mp_confirm_mode);
|
||||
setValue("botMPPollInterval", config.mp_poll_interval);
|
||||
setValue("botPixExp", config.pix_expiration_minutes);
|
||||
setChecked("botTrialEnabled", config.trial_enabled);
|
||||
setValue("botTrialHours", config.trial_hours);
|
||||
setValue("botTrialMaxConns", config.trial_max_connections);
|
||||
setValue("botTrialKind", config.trial_kind);
|
||||
setValue("botTrialInbound", config.trial_inbound_tag);
|
||||
setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
|
||||
setValue("botPublicHost", config.public_host);
|
||||
setValue("botXrayPublicHost", config.xray_public_host);
|
||||
botSetSecretState("botHasTgToken", config.has_telegram_token);
|
||||
botSetSecretState("botHasMpToken", config.has_mp_access_token);
|
||||
botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
|
||||
botToggleMPWebhookBox();
|
||||
botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
|
||||
botUpdateMetrics();
|
||||
return config;
|
||||
} catch (error) {
|
||||
botState.config = null;
|
||||
botHandleError(error, "botConfigStatus", "Erro ao carregar configuração.");
|
||||
botUpdateMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
function botSetSecretState(id, configured) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = configured ? "● protegido" : "○ não configurado";
|
||||
element.classList.toggle("is-set", !!configured);
|
||||
element.classList.toggle("is-missing", !configured);
|
||||
}
|
||||
|
||||
function botToggleMPWebhookBox() {
|
||||
const mode = document.getElementById("botMPConfirmMode")?.value;
|
||||
document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
|
||||
const url = document.getElementById("botMPWebhookURL");
|
||||
if (url) url.textContent = location.origin + "/api/mp/webhook";
|
||||
}
|
||||
|
||||
async function saveBotConfig() {
|
||||
const value = id => (document.getElementById(id)?.value || "").trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const checked = id => !!document.getElementById(id)?.checked;
|
||||
const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
|
||||
const payload = {
|
||||
enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
|
||||
mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
|
||||
mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
|
||||
pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
|
||||
trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
|
||||
trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
|
||||
admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
|
||||
};
|
||||
botStatus("botConfigStatus", "Salvando e reiniciando…");
|
||||
try {
|
||||
await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
|
||||
await loadBotConfig();
|
||||
botStatus("botConfigStatus", "Configuração salva", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
|
||||
}
|
||||
}
|
||||
|
||||
async function testBot() {
|
||||
botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
|
||||
const payload = {
|
||||
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
|
||||
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
|
||||
};
|
||||
try {
|
||||
const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
|
||||
const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
|
||||
const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao testar integrações.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botCopyWebhook() {
|
||||
const value = document.getElementById("botMPWebhookURL")?.textContent || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const button = document.getElementById("botCopyWebhookBtn");
|
||||
if (button) {
|
||||
button.textContent = "Copiado";
|
||||
setTimeout(() => { button.textContent = "Copiar"; }, 1400);
|
||||
}
|
||||
} catch {
|
||||
botStatus("botConfigStatus", "Não foi possível copiar a URL.", false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBotInbounds() {
|
||||
try {
|
||||
const inbounds = await botRequest("/api/xray/inbounds");
|
||||
const datalist = document.getElementById("botInboundList");
|
||||
if (!datalist) return;
|
||||
datalist.replaceChildren(...(inbounds || []).map(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(inbound.tag || "");
|
||||
option.textContent = String(inbound.protocol || "");
|
||||
return option;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.message === "auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
// Plans
|
||||
async function loadBotPlans() {
|
||||
try {
|
||||
const plans = await botRequest("/api/bot/plans");
|
||||
botState.plans = plans || [];
|
||||
renderBotPlans(botState.plans);
|
||||
botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return plans;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotPlans(plans) {
|
||||
const body = document.getElementById("botPlansBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPlanCount").textContent = String(plans.length);
|
||||
if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado.");
|
||||
const rows = plans.map(plan => {
|
||||
const row = document.createElement("tr");
|
||||
const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH";
|
||||
const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`);
|
||||
row.append(
|
||||
botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery),
|
||||
botCell(`${plan.Days} dias`), botCell(price),
|
||||
botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPlan(plan)),
|
||||
botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function botEditPlan(plan) {
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days);
|
||||
set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown);
|
||||
set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2));
|
||||
set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder);
|
||||
document.getElementById("planActive").checked = !!plan.IsActive;
|
||||
document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`;
|
||||
document.getElementById("planName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPlanForm() {
|
||||
document.getElementById("botPlanForm")?.reset();
|
||||
document.getElementById("planId").value = "";
|
||||
document.getElementById("planActive").checked = true;
|
||||
document.getElementById("botPlanFormTitle").textContent = "Novo plano";
|
||||
}
|
||||
|
||||
async function botSavePlan(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"),
|
||||
MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"),
|
||||
XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"),
|
||||
ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"),
|
||||
};
|
||||
botStatus("botPlansStatus", "Salvando plano…");
|
||||
try {
|
||||
await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPlanForm();
|
||||
await loadBotPlans();
|
||||
botStatus("botPlansStatus", "Plano salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao salvar plano.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePlan(id) {
|
||||
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir plano", message:"Excluir este plano?", detail:"Esta ação não pode ser desfeita.", confirmLabel:"Excluir plano" });
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPlans();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao excluir plano.");
|
||||
}
|
||||
}
|
||||
|
||||
// Credit packages
|
||||
async function loadBotPkgs() {
|
||||
try {
|
||||
const packages = await botRequest("/api/bot/credit-packages");
|
||||
botState.packages = packages || [];
|
||||
renderBotPackages(botState.packages);
|
||||
botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
|
||||
return packages;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotPackages(packages) {
|
||||
const body = document.getElementById("botPkgsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPkgCount").textContent = String(packages.length);
|
||||
if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado.");
|
||||
body.replaceChildren(...packages.map(item => {
|
||||
const row = document.createElement("tr");
|
||||
row.append(
|
||||
botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)),
|
||||
botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPkg(item)),
|
||||
botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botEditPkg(item) {
|
||||
const set = (id, value) => { document.getElementById(id).value = value ?? ""; };
|
||||
set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits);
|
||||
set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder);
|
||||
document.getElementById("pkgActive").checked = !!item.IsActive;
|
||||
document.getElementById("pkgName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPkgForm() {
|
||||
document.getElementById("botPkgForm")?.reset();
|
||||
document.getElementById("pkgId").value = "";
|
||||
document.getElementById("pkgActive").checked = true;
|
||||
}
|
||||
|
||||
async function botSavePkg(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100),
|
||||
SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
|
||||
};
|
||||
botStatus("botPkgStatus", "Salvando pacote…");
|
||||
try {
|
||||
await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPkgForm();
|
||||
await loadBotPkgs();
|
||||
botStatus("botPkgStatus", "Pacote salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao salvar pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePkg(id) {
|
||||
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir pacote", message:"Excluir este pacote de créditos?", confirmLabel:"Excluir pacote" });
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPkgs();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao excluir pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
// Users
|
||||
async function loadBotUsers() {
|
||||
try {
|
||||
const users = await botRequest("/api/bot/users");
|
||||
botState.users = users || [];
|
||||
renderBotUsers(botState.users);
|
||||
botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return users;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotUsers(users) {
|
||||
const body = document.getElementById("botUsersBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botUserCount").textContent = String(users.length);
|
||||
if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda.");
|
||||
body.replaceChildren(...users.map(user => {
|
||||
const row = document.createElement("tr");
|
||||
const displayName = user.FirstName || user.Username || "Sem nome";
|
||||
const username = user.Username ? `@${user.Username}` : "Sem username";
|
||||
const isBlocked = user.Role === "blocked";
|
||||
row.append(
|
||||
botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID),
|
||||
botCell(botBadge(user.Role || "customer", user.Role || "customer")),
|
||||
botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`),
|
||||
botCell(botActions([
|
||||
botButton("Função", () => botOpenUserAction(user, "role")),
|
||||
botButton("Saldo", () => botOpenUserAction(user, "credits")),
|
||||
botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botOpenUserAction(user, mode) {
|
||||
document.getElementById("botActionTelegramID").value = String(user.TelegramID);
|
||||
document.getElementById("botActionMode").value = mode;
|
||||
document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos";
|
||||
document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`;
|
||||
document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role");
|
||||
document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits");
|
||||
document.getElementById("botActionRole").value = user.Role || "customer";
|
||||
document.getElementById("botActionLinked").value = user.LinkedAdminUsername || "";
|
||||
document.getElementById("botActionCredits").value = "";
|
||||
botToggleLinkedAdminField();
|
||||
document.getElementById("botUserActionModal").classList.remove("hidden");
|
||||
document.body.classList.add("bot-modal-open");
|
||||
setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0);
|
||||
}
|
||||
|
||||
function botCloseUserAction() {
|
||||
document.getElementById("botUserActionModal")?.classList.add("hidden");
|
||||
document.body.classList.remove("bot-modal-open");
|
||||
}
|
||||
|
||||
function botToggleLinkedAdminField() {
|
||||
const show = document.getElementById("botActionRole")?.value === "reseller";
|
||||
document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
async function botSaveUserAction(event) {
|
||||
event.preventDefault();
|
||||
const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10);
|
||||
const mode = document.getElementById("botActionMode").value;
|
||||
const payload = mode === "role" ? {
|
||||
telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value,
|
||||
linked_admin_username: document.getElementById("botActionLinked").value.trim(),
|
||||
} : {
|
||||
telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0,
|
||||
};
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
botCloseUserAction();
|
||||
await loadBotUsers();
|
||||
botStatus("botUsersStatus", "Cliente atualizado.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botToggleBlock(user) {
|
||||
const isBlocked = user.Role === "blocked";
|
||||
const accepted = await panelConfirm({
|
||||
tone:isBlocked ? "default" : "danger", icon:isBlocked ? "✓" : "!",
|
||||
title:isBlocked ? "Desbloquear cliente" : "Bloquear cliente",
|
||||
message:isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?",
|
||||
confirmLabel:isBlocked ? "Desbloquear" : "Bloquear",
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
|
||||
await loadBotUsers();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio.");
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions
|
||||
async function loadBotTxns() {
|
||||
const filter = document.getElementById("botTxnFilter")?.value || "";
|
||||
try {
|
||||
const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
|
||||
botState.transactions = transactions || [];
|
||||
renderBotTransactions(botState.transactions);
|
||||
botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
|
||||
}
|
||||
}
|
||||
|
||||
function botTransactionType(type) {
|
||||
return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—";
|
||||
}
|
||||
|
||||
function renderBotTransactions(transactions) {
|
||||
const body = document.getElementById("botTxnsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botTxnCount").textContent = String(transactions.length);
|
||||
if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro.");
|
||||
body.replaceChildren(...transactions.map(transaction => {
|
||||
const row = document.createElement("tr");
|
||||
const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—";
|
||||
const buttons = [];
|
||||
if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID)));
|
||||
if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm"));
|
||||
row.append(
|
||||
botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")),
|
||||
botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)),
|
||||
botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"),
|
||||
botCell(createdAt), botCell(botActions(buttons)),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function botReprocess(id) {
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) });
|
||||
botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true);
|
||||
setTimeout(loadBotTxns, 1400);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botRefund(id) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"!", title:"Marcar como estornado",
|
||||
message:`Marcar o pagamento #${id} como estornado no painel?`,
|
||||
detail:"Esta ação não envia um estorno financeiro ao Mercado Pago; ela altera somente o status interno.",
|
||||
confirmLabel:"Marcar estornado",
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
|
||||
await loadBotTxns();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
async function loadBotSettings() {
|
||||
try {
|
||||
const settings = await botRequest("/api/bot/settings");
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
|
||||
set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
|
||||
set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
|
||||
botStatus("botSettingsStatus", "Mensagens carregadas.", true);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBotSettings() {
|
||||
const value = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() };
|
||||
botStatus("botSettingsStatus", "Salvando mensagens…");
|
||||
try {
|
||||
await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||
botStatus("botSettingsStatus", "Mensagens salvas.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection)));
|
||||
document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value));
|
||||
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
|
||||
document.getElementById("botTestBtn")?.addEventListener("click", testBot);
|
||||
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
|
||||
document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook);
|
||||
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
|
||||
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
|
||||
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
|
||||
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
|
||||
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
|
||||
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
|
||||
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
|
||||
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
|
||||
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings);
|
||||
document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction);
|
||||
document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField);
|
||||
document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); });
|
||||
|
||||
botSetSection(botState.section);
|
||||
+1028
-1619
File diff suppressed because it is too large
Load Diff
@@ -4,33 +4,50 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
QuotaModeSlots = "slots"
|
||||
QuotaModeCredit = "credits"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
|
||||
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ParentUsername string
|
||||
QuotaMode string
|
||||
CreditBalance int
|
||||
WhatsApp string
|
||||
MonthlyPriceCents int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Session store (in-memory) ----------
|
||||
@@ -50,9 +67,11 @@ type sessionStoreT struct {
|
||||
|
||||
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)}
|
||||
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession {
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) (*AdminSession, error) {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
sess := &AdminSession{
|
||||
Token: tok,
|
||||
@@ -64,7 +83,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession
|
||||
s.mu.Lock()
|
||||
s.m[tok] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Get(token string) *AdminSession {
|
||||
@@ -86,6 +105,16 @@ func (s *sessionStoreT) Delete(token string) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) DeleteUser(userID int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token, sess := range s.m {
|
||||
if sess.UserID == userID {
|
||||
delete(s.m, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -170,6 +199,14 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
@@ -194,11 +231,112 @@ func saSession(next http.Handler) http.Handler {
|
||||
|
||||
// ---------- Password hashing ----------
|
||||
|
||||
func hashAdminPassword(pw string) string {
|
||||
func legacyAdminPasswordHash(pw string) string {
|
||||
h := sha256.Sum256([]byte(pw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func hashAdminPassword(pw string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), adminBcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// verifyAdminPassword accepts bcrypt and the legacy unsalted SHA-256 format.
|
||||
// Legacy hashes are upgraded immediately after a successful login.
|
||||
func verifyAdminPassword(storedHash, password string) (valid bool, needsUpgrade bool) {
|
||||
if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") {
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) != nil {
|
||||
return false, false
|
||||
}
|
||||
cost, err := bcrypt.Cost([]byte(storedHash))
|
||||
return true, err != nil || cost < adminBcryptCost
|
||||
}
|
||||
if len(storedHash) != sha256.Size*2 {
|
||||
return false, false
|
||||
}
|
||||
expected := legacyAdminPasswordHash(password)
|
||||
return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expected)) == 1, true
|
||||
}
|
||||
|
||||
func validateAdminPassword(password string) error {
|
||||
if len(password) < 10 {
|
||||
return fmt.Errorf("password must contain at least 10 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAdminUsername(username string) error {
|
||||
if !adminUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Login throttling ----------
|
||||
|
||||
type loginAttempt struct {
|
||||
Failures int
|
||||
FirstSeen time.Time
|
||||
BlockedTo time.Time
|
||||
}
|
||||
|
||||
type loginThrottleT struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
var loginThrottle = &loginThrottleT{attempts: make(map[string]loginAttempt)}
|
||||
|
||||
func loginAttemptKey(r *http.Request, username string) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
return host + "\x00" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) retryAfter(key string, now time.Time) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.attempts[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !a.BlockedTo.IsZero() && now.Before(a.BlockedTo) {
|
||||
return time.Until(a.BlockedTo)
|
||||
}
|
||||
if now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) fail(key string, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.attempts[key]
|
||||
if a.FirstSeen.IsZero() || now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
a = loginAttempt{FirstSeen: now}
|
||||
}
|
||||
a.Failures++
|
||||
if a.Failures >= 5 {
|
||||
a.BlockedTo = now.Add(15 * time.Minute)
|
||||
}
|
||||
l.attempts[key] = a
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) success(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- DB methods on Store ----------
|
||||
|
||||
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
@@ -213,7 +351,47 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS parent_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS quota_mode TEXT NOT NULL DEFAULT 'slots'`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS credit_balance INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS whatsapp TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS monthly_price_cents INT NOT NULL DEFAULT 0`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_users_parent ON admin_users(parent_username)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_username TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_created ON reseller_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_actor ON reseller_audit_log(actor_username, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_target ON reseller_audit_log(target_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_credit_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reseller_username TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_credit_ledger_owner ON reseller_credit_ledger(reseller_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_runtime_state (
|
||||
owner_username TEXT PRIMARY KEY,
|
||||
parent_username TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
// Older reseller-owned accounts used zero to mean "unlimited". The
|
||||
// reseller quota model charges at least one slot per account, so normalize
|
||||
// those rows once during schema setup instead of leaving a quota bypass.
|
||||
`UPDATE ssh_users SET max_connections = 1
|
||||
WHERE owner_username <> '' AND max_connections < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -223,45 +401,51 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
const adminUserSelectColumns = `id, username, password_hash, role, max_users,
|
||||
COALESCE(parent_username, ''), COALESCE(quota_mode, 'slots'), COALESCE(credit_balance, 0),
|
||||
COALESCE(whatsapp, ''), COALESCE(monthly_price_cents, 0), expires_at, is_active, created_at`
|
||||
|
||||
func scanAdminUser(scanner interface{ Scan(...interface{}) error }) (*AdminUser, error) {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users WHERE username = $1`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
|
||||
&expiresAt, &u.IsActive, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
u.QuotaMode = normalizeQuotaMode(u.QuotaMode)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u, err := scanAdminUser(s.db.QueryRowContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users WHERE username = $1`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users ORDER BY role, username`)
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -274,15 +458,26 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
}
|
||||
if u.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
|
||||
quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive,
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4,
|
||||
expires_at=$5, is_active=$6 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive)
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
|
||||
quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
|
||||
expires_at=$10, is_active=$11 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminPasswordHash(ctx context.Context, id int, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE admin_users SET password_hash=$2 WHERE id=$1`, id, passwordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -298,8 +493,7 @@ func (s *Store) SetAdminUserActive(ctx context.Context, username string, active
|
||||
|
||||
func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -311,8 +505,7 @@ func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error)
|
||||
|
||||
func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -325,15 +518,10 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
|
||||
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -352,11 +540,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
b := make([]byte, 10)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate bootstrap password: %w", err)
|
||||
}
|
||||
pw := hex.EncodeToString(b)
|
||||
passwordHash, err := hashAdminPassword(pw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &AdminUser{
|
||||
Username: "admin",
|
||||
PasswordHash: hashAdminPassword(pw),
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleSuperAdmin,
|
||||
MaxUsers: 0,
|
||||
IsActive: true,
|
||||
@@ -373,7 +567,12 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := store.ListResellerRuntimeStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
resellerRuntimeStates.replaceAll(states)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -381,20 +580,10 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
|
||||
// ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
|
||||
func ownerIsActive(ownerUsername string) error {
|
||||
if ownerUsername == "" {
|
||||
return nil
|
||||
if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
|
||||
return resellerRuntimeChainActive(ownerUsername)
|
||||
}
|
||||
u, ok := adminUsers.get(ownerUsername)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
if !u.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
return nil
|
||||
return adminAccountChainActive(ownerUsername)
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
@@ -436,28 +625,38 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
resellerLifecycleMu.Lock()
|
||||
all, listErr := store.ListAdminUsers(ctx)
|
||||
if listErr != nil {
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry hierarchy for %s: %v", u.Username, listErr)
|
||||
continue
|
||||
}
|
||||
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, u.Username)))
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
disconnectOwnerUsers(u.Username)
|
||||
sessions.DeleteUser(u.ID)
|
||||
if err := applyResellerSubtreeRuntime(ctx, store, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry runtime for %s: %v", u.Username, err)
|
||||
}
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
|
||||
renewed, err := store.ListInactiveButRenewedResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller renewal check: %v", err)
|
||||
}
|
||||
for _, u := range renewed {
|
||||
log.Printf("reseller %s renewed — reactivating", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
|
||||
log.Printf("reseller renewal: %v", err)
|
||||
continue
|
||||
// Replicated owner records on managed nodes also enforce expiration and
|
||||
// inherited parent suspension without contacting the master on each login.
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) != nil {
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
|
||||
log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -473,18 +672,33 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := loginAttemptKey(r, req.Username)
|
||||
now := time.Now()
|
||||
if retry := loginThrottle.retryAfter(key, now); retry > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds()))))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
@@ -492,20 +706,41 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u == nil || u.PasswordHash != hashAdminPassword(req.Password) {
|
||||
valid := false
|
||||
needsUpgrade := false
|
||||
if u != nil {
|
||||
valid, needsUpgrade = verifyAdminPassword(u.PasswordHash, req.Password)
|
||||
} else {
|
||||
// Keep roughly the same CPU cost for unknown users to reduce account probing.
|
||||
_, _ = hashAdminPassword(req.Password)
|
||||
}
|
||||
if !valid {
|
||||
loginThrottle.fail(key, now)
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
http.Error(w, "account suspended", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
http.Error(w, "account expired", http.StatusForbidden)
|
||||
if adminAccountChainActive(u.Username) != nil {
|
||||
http.Error(w, "account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if needsUpgrade {
|
||||
if upgradedHash, hashErr := hashAdminPassword(req.Password); hashErr == nil {
|
||||
if updateErr := store.UpdateAdminPasswordHash(r.Context(), u.ID, upgradedHash); updateErr != nil {
|
||||
log.Printf("upgrade admin password hash for %s: %v", u.Username, updateErr)
|
||||
} else {
|
||||
u.PasswordHash = upgradedHash
|
||||
adminUsers.set(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
loginThrottle.success(key)
|
||||
sess, err := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if err != nil {
|
||||
log.Printf("create admin session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": sess.Token,
|
||||
@@ -536,147 +771,35 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
resp["max_users"] = u.MaxUsers
|
||||
resp["used_users"] = countOwnedUsers(s.Username)
|
||||
childAllocation, childCount := 0, 0
|
||||
if statsStore != nil {
|
||||
childAllocation, _ = statsStore.directChildAllocation(r.Context(), s.Username, "")
|
||||
childCount = statsStore.directChildCount(r.Context(), s.Username)
|
||||
}
|
||||
resp["max_users"] = u.MaxUsers
|
||||
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
|
||||
if usageErr != nil {
|
||||
usage = resellerQuotaUsage{
|
||||
Weighted: countOwnedQuota(r.Context(), statsStore, s.Username),
|
||||
SSHAccounts: countOwnedUsers(s.Username),
|
||||
XrayAccounts: countOwnedXrayClients(r.Context(), statsStore, s.Username),
|
||||
}
|
||||
}
|
||||
resp["used_users"] = usage.Weighted
|
||||
resp["used_ssh_users"] = usage.SSHAccounts
|
||||
resp["used_xray_users"] = usage.XrayAccounts
|
||||
resp["parent_username"] = u.ParentUsername
|
||||
resp["quota_mode"] = normalizeQuotaMode(u.QuotaMode)
|
||||
resp["credit_balance"] = u.CreditBalance
|
||||
resp["child_allocation"] = childAllocation
|
||||
resp["child_count"] = childCount
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ---------- Reseller management (superadmin only) ----------
|
||||
|
||||
type ResellerDTO struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UsedUsers int `json:"used_users"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleListResellers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
users, err := store.ListAdminUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]ResellerDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, ResellerDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
MaxUsers: u.MaxUsers,
|
||||
UsedUsers: countOwnedUsers(u.Username),
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
type ResellerPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p ResellerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
existing, err := store.GetAdminUserByUsername(ctx, p.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var u *AdminUser
|
||||
if existing != nil {
|
||||
u = existing
|
||||
} else {
|
||||
if p.Password == "" {
|
||||
http.Error(w, "password required for new account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u = &AdminUser{Username: p.Username, Role: RoleReseller}
|
||||
}
|
||||
|
||||
if p.Password != "" {
|
||||
u.PasswordHash = hashAdminPassword(p.Password)
|
||||
}
|
||||
u.MaxUsers = p.MaxUsers
|
||||
u.IsActive = p.IsActive
|
||||
u.ExpiresAt = nil
|
||||
if p.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, p.ExpiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid expires_at (RFC3339 required)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.ExpiresAt = &t
|
||||
}
|
||||
|
||||
if err := store.UpsertAdminUser(ctx, u); err != nil {
|
||||
log.Printf("upsert reseller: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminUsers.set(u)
|
||||
|
||||
// If reseller was reactivated, users can reconnect automatically.
|
||||
// Reconnect of existing SSH connections happens via the expiry checker.
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := store.DeleteAdminUser(ctx, username); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
disconnectOwnerUsers(username)
|
||||
adminUsers.delete(username)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
// Reseller management handlers live in reseller_management.go.
|
||||
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
package main
|
||||
|
||||
// bot_api.go — /api/bot/* admin endpoints (superadmin).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var botSettingKeys = map[string]int{
|
||||
"welcome_text": 4096,
|
||||
"contact_text": 4096,
|
||||
"app_text": 4096,
|
||||
"app_url": 2048,
|
||||
}
|
||||
|
||||
func botHasControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool {
|
||||
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'
|
||||
}) >= 0
|
||||
}
|
||||
|
||||
func botHasAnyControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, unicode.IsControl) >= 0
|
||||
}
|
||||
|
||||
func validateBotPlan(p *BotPlan) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Kind = strings.ToLower(strings.TrimSpace(p.Kind))
|
||||
p.XrayProtocol = strings.ToLower(strings.TrimSpace(p.XrayProtocol))
|
||||
p.XrayInboundTag = strings.TrimSpace(p.XrayInboundTag)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("plan name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Kind != "ssh" && p.Kind != "xray" {
|
||||
return fmt.Errorf("plan kind must be ssh or xray")
|
||||
}
|
||||
if p.Days < 1 || p.Days > 3650 || p.MaxConnections < 0 || p.MaxConnections > 10000 {
|
||||
return fmt.Errorf("invalid plan duration or connection limit")
|
||||
}
|
||||
if p.LimitMbpsUp < 0 || p.LimitMbpsUp > 1000000 || p.LimitMbpsDown < 0 || p.LimitMbpsDown > 1000000 {
|
||||
return fmt.Errorf("invalid bandwidth limit")
|
||||
}
|
||||
if p.PriceCents < 0 || p.PriceCents > 1000000000 || p.CreditCost < 0 || p.CreditCost > 1000000000 {
|
||||
return fmt.Errorf("invalid plan price or credit cost")
|
||||
}
|
||||
if p.Kind == "xray" && p.XrayProtocol != "" && p.XrayProtocol != "vless" && p.XrayProtocol != "vmess" && p.XrayProtocol != "trojan" {
|
||||
return fmt.Errorf("invalid Xray protocol")
|
||||
}
|
||||
if len(p.XrayInboundTag) > 128 || len(p.ServerID) > 128 {
|
||||
return fmt.Errorf("inbound tag or server id is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotPackage(p *BotCreditPackage) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("package name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Credits < 1 || p.Credits > 1000000000 || p.PriceCents < 0 || p.PriceCents > 1000000000 {
|
||||
return fmt.Errorf("invalid package credits or price")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func botWriteJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func botStoreReady(w http.ResponseWriter, store *Store) bool {
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- Config ----------
|
||||
|
||||
type botConfigDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MPConfirmMode string `json:"mp_confirm_mode"`
|
||||
MPPollInterval string `json:"mp_poll_interval"`
|
||||
PixExpirationMinutes int `json:"pix_expiration_minutes"`
|
||||
TrialEnabled bool `json:"trial_enabled"`
|
||||
TrialHours int `json:"trial_hours"`
|
||||
TrialMaxConnections int `json:"trial_max_connections"`
|
||||
TrialKind string `json:"trial_kind"`
|
||||
TrialInboundTag string `json:"trial_inbound_tag"`
|
||||
AdminTelegramIDs []int64 `json:"admin_telegram_ids"`
|
||||
Currency string `json:"currency"`
|
||||
PublicHost string `json:"public_host"`
|
||||
XrayPublicHost string `json:"xray_public_host"`
|
||||
HasTelegramToken bool `json:"has_telegram_token"`
|
||||
HasMPAccessToken bool `json:"has_mp_access_token"`
|
||||
HasMPWebhookSecret bool `json:"has_mp_webhook_secret"`
|
||||
// Write-only secret fields (empty on GET; empty on POST = keep existing).
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
MPAccessToken string `json:"mp_access_token"`
|
||||
MPWebhookSecret string `json:"mp_webhook_secret"`
|
||||
}
|
||||
|
||||
func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
writeInternalError(w, "load bot configuration", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, botConfigDTO{
|
||||
Enabled: cfg.Enabled,
|
||||
MPConfirmMode: cfg.MPConfirmMode,
|
||||
MPPollInterval: cfg.MPPollInterval,
|
||||
PixExpirationMinutes: cfg.PixExpirationMinutes,
|
||||
TrialEnabled: cfg.TrialEnabled,
|
||||
TrialHours: cfg.TrialHours,
|
||||
TrialMaxConnections: cfg.TrialMaxConnections,
|
||||
TrialKind: cfg.TrialKind,
|
||||
TrialInboundTag: cfg.TrialInboundTag,
|
||||
AdminTelegramIDs: cfg.AdminTelegramIDs,
|
||||
Currency: cfg.Currency,
|
||||
PublicHost: cfg.PublicHost,
|
||||
XrayPublicHost: cfg.XrayPublicHost,
|
||||
HasTelegramToken: cfg.TelegramToken != "",
|
||||
HasMPAccessToken: cfg.MPAccessToken != "",
|
||||
HasMPWebhookSecret: cfg.MPWebhookSecret != "",
|
||||
})
|
||||
case http.MethodPost:
|
||||
var dto botConfigDTO
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.MPConfirmMode = strings.ToLower(strings.TrimSpace(dto.MPConfirmMode))
|
||||
if dto.MPConfirmMode != "polling" && dto.MPConfirmMode != "webhook" {
|
||||
http.Error(w, "confirmation mode must be polling or webhook", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollInterval, err := time.ParseDuration(strings.TrimSpace(dto.MPPollInterval))
|
||||
if err != nil || pollInterval < 5*time.Second || pollInterval > 5*time.Minute {
|
||||
http.Error(w, "poll interval must be between 5s and 5m", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dto.PixExpirationMinutes < 5 || dto.PixExpirationMinutes > 1440 || dto.TrialHours < 1 || dto.TrialHours > 720 || dto.TrialMaxConnections < 1 || dto.TrialMaxConnections > 1000 {
|
||||
http.Error(w, "invalid PIX expiration or trial limits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.TrialKind = strings.ToLower(strings.TrimSpace(dto.TrialKind))
|
||||
if dto.TrialKind != "ssh" && dto.TrialKind != "xray" {
|
||||
http.Error(w, "trial kind must be ssh or xray", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(dto.AdminTelegramIDs) > 100 {
|
||||
http.Error(w, "too many admin Telegram IDs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, id := range dto.AdminTelegramIDs {
|
||||
if id <= 0 {
|
||||
http.Error(w, "admin Telegram IDs must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, value := range []string{dto.TelegramToken, dto.MPAccessToken, dto.MPWebhookSecret, dto.PublicHost, dto.XrayPublicHost, dto.TrialInboundTag} {
|
||||
if len(value) > 2048 || botHasAnyControlCharacters(value) {
|
||||
http.Error(w, "configuration contains an invalid value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
existing, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load existing config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
effectiveTelegramToken := strings.TrimSpace(dto.TelegramToken)
|
||||
if effectiveTelegramToken == "" {
|
||||
effectiveTelegramToken = existing.TelegramToken
|
||||
}
|
||||
if dto.Enabled && effectiveTelegramToken == "" {
|
||||
http.Error(w, "Telegram token is required before enabling the bot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
effectiveWebhookSecret := strings.TrimSpace(dto.MPWebhookSecret)
|
||||
if effectiveWebhookSecret == "" {
|
||||
effectiveWebhookSecret = existing.MPWebhookSecret
|
||||
}
|
||||
if dto.MPConfirmMode == "webhook" && len(effectiveWebhookSecret) < 16 {
|
||||
http.Error(w, "a webhook secret of at least 16 characters is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := &BotConfig{
|
||||
Enabled: dto.Enabled,
|
||||
TelegramToken: strings.TrimSpace(dto.TelegramToken),
|
||||
MPAccessToken: strings.TrimSpace(dto.MPAccessToken),
|
||||
MPConfirmMode: dto.MPConfirmMode,
|
||||
MPWebhookSecret: strings.TrimSpace(dto.MPWebhookSecret),
|
||||
MPPollInterval: dto.MPPollInterval,
|
||||
PixExpirationMinutes: dto.PixExpirationMinutes,
|
||||
TrialEnabled: dto.TrialEnabled,
|
||||
TrialHours: dto.TrialHours,
|
||||
TrialMaxConnections: dto.TrialMaxConnections,
|
||||
TrialKind: dto.TrialKind,
|
||||
TrialInboundTag: strings.TrimSpace(dto.TrialInboundTag),
|
||||
AdminTelegramIDs: dto.AdminTelegramIDs,
|
||||
Currency: dto.Currency,
|
||||
PublicHost: strings.TrimSpace(dto.PublicHost),
|
||||
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
|
||||
}
|
||||
if err := SaveBotConfig(ctx, store, cfg); err != nil {
|
||||
writeInternalError(w, "save bot configuration", err)
|
||||
return
|
||||
}
|
||||
reloadBotService(store)
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Plans ----------
|
||||
|
||||
func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
plans, err := store.ListPlans(ctx, false)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, plans)
|
||||
case http.MethodPost:
|
||||
var p BotPlan
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "ssh"
|
||||
}
|
||||
if err := validateBotPlan(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
writeInternalError(w, "save bot plan", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
case http.MethodDelete:
|
||||
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeletePlan(ctx, id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Credit packages ----------
|
||||
|
||||
func handleBotCreditPackages(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
pkgs, err := store.ListCreditPackages(ctx, false)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, pkgs)
|
||||
case http.MethodPost:
|
||||
var p BotCreditPackage
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBotPackage(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertCreditPackage(ctx, &p); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
case http.MethodDelete:
|
||||
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteCreditPackage(ctx, id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bot users ----------
|
||||
|
||||
func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := store.ListBotUsers(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, users)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Action string `json:"action"`
|
||||
Role string `json:"role"`
|
||||
LinkedAdminUsername string `json:"linked_admin_username"`
|
||||
Credits int `json:"credits"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TelegramID == 0 {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "set_role":
|
||||
if req.Role == "" {
|
||||
req.Role = "customer"
|
||||
}
|
||||
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if req.Role != "customer" && req.Role != "reseller" && req.Role != "blocked" {
|
||||
http.Error(w, "role must be customer, reseller, or blocked", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.LinkedAdminUsername = strings.TrimSpace(req.LinkedAdminUsername)
|
||||
if req.Role == "reseller" {
|
||||
linked, err := store.GetAdminUserByUsername(ctx, req.LinkedAdminUsername)
|
||||
if err != nil || linked == nil || linked.Role != RoleReseller {
|
||||
http.Error(w, "linked reseller account not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.LinkedAdminUsername = ""
|
||||
}
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "block":
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, "blocked", ""); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "unblock":
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, "customer", ""); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "adjust_credits":
|
||||
if req.Credits == 0 || req.Credits < -1000000000 || req.Credits > 1000000000 {
|
||||
http.Error(w, "invalid credit adjustment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil {
|
||||
http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transactions ----------
|
||||
|
||||
func handleBotTransactions(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "pending" && status != "approved" && status != "expired" && status != "refunded" && status != "error" {
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
txns, err := store.ListTransactions(ctx, status, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, txns)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "refund":
|
||||
if err := store.SetTransactionStatus(ctx, req.ID, "refunded"); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "reprocess":
|
||||
if b := currentBot(); b != nil {
|
||||
go b.tryFulfill(req.ID)
|
||||
} else {
|
||||
http.Error(w, "bot not running", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Settings (bot texts) ----------
|
||||
|
||||
func handleBotSettings(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
all, err := store.AllSettings(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, all)
|
||||
case http.MethodPost:
|
||||
var kv map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&kv); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for k, v := range kv {
|
||||
maxLen, ok := botSettingKeys[k]
|
||||
if !ok || len(v) > maxLen || botHasControlCharacters(v) {
|
||||
http.Error(w, "invalid bot setting", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if k == "app_url" && strings.TrimSpace(v) != "" {
|
||||
u, err := url.ParseRequestURI(strings.TrimSpace(v))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
http.Error(w, "app_url must be an http or https URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.SetSetting(ctx, k, v); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Connectivity test ----------
|
||||
|
||||
func handleBotTest(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var req struct {
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
MPAccessToken string `json:"mp_access_token"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
cfg, _ := LoadBotConfig(ctx, store)
|
||||
tgToken := strings.TrimSpace(req.TelegramToken)
|
||||
mpToken := strings.TrimSpace(req.MPAccessToken)
|
||||
if cfg != nil {
|
||||
if tgToken == "" {
|
||||
tgToken = cfg.TelegramToken
|
||||
}
|
||||
if mpToken == "" {
|
||||
mpToken = cfg.MPAccessToken
|
||||
}
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
if tgToken != "" {
|
||||
name, err := newTGClient(tgToken).getMe(ctx)
|
||||
if err != nil {
|
||||
out["telegram_ok"] = false
|
||||
out["telegram_error"] = err.Error()
|
||||
} else {
|
||||
out["telegram_ok"] = true
|
||||
out["telegram_bot"] = "@" + name
|
||||
}
|
||||
} else {
|
||||
out["telegram_ok"] = false
|
||||
out["telegram_error"] = "no token configured"
|
||||
}
|
||||
if mpToken != "" {
|
||||
_, err := newMPClient(mpToken).do(ctx, http.MethodGet, "/v1/payment_methods", nil, "")
|
||||
if err != nil {
|
||||
out["mp_ok"] = false
|
||||
out["mp_error"] = err.Error()
|
||||
} else {
|
||||
out["mp_ok"] = true
|
||||
}
|
||||
} else {
|
||||
out["mp_ok"] = false
|
||||
out["mp_error"] = "no token configured"
|
||||
}
|
||||
botWriteJSON(w, out)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// bot_config.go — in-memory bot configuration loaded from the bot_config table.
|
||||
// Secrets are decrypted here and never persisted in plaintext.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var errInsufficientCredits = errors.New("insufficient credits")
|
||||
|
||||
func botItoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// BotConfig is the decrypted, ready-to-use bot configuration.
|
||||
// Telegram always uses long-polling (no webhook). The webhook/polling toggle
|
||||
// applies only to Mercado Pago payment confirmation (MPConfirmMode).
|
||||
type BotConfig struct {
|
||||
Enabled bool
|
||||
TelegramToken string
|
||||
MPAccessToken string
|
||||
MPConfirmMode string // webhook | polling
|
||||
MPWebhookSecret string
|
||||
MPPollInterval string
|
||||
PixExpirationMinutes int
|
||||
TrialEnabled bool
|
||||
TrialHours int
|
||||
TrialMaxConnections int
|
||||
TrialKind string // ssh | xray
|
||||
TrialInboundTag string
|
||||
AdminTelegramIDs []int64
|
||||
Currency string
|
||||
PublicHost string // SSH connection host shown to buyers
|
||||
XrayPublicHost string // host used to build vless/vmess links
|
||||
}
|
||||
|
||||
// LoadBotConfig reads the config row and decrypts secrets into a BotConfig.
|
||||
func LoadBotConfig(ctx context.Context, store *Store) (*BotConfig, error) {
|
||||
r, err := store.getBotConfigRow(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := decryptSecret(r.TelegramTokenEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mp, err := decryptSecret(r.MPAccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mpSec, err := decryptSecret(r.MPWebhookSecretEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &BotConfig{
|
||||
Enabled: r.Enabled,
|
||||
TelegramToken: tok,
|
||||
MPAccessToken: mp,
|
||||
MPConfirmMode: r.MPConfirmMode,
|
||||
MPWebhookSecret: mpSec,
|
||||
MPPollInterval: r.MPPollInterval,
|
||||
PixExpirationMinutes: r.PixExpirationMinutes,
|
||||
TrialEnabled: r.TrialEnabled,
|
||||
TrialHours: r.TrialHours,
|
||||
TrialMaxConnections: r.TrialMaxConnections,
|
||||
TrialKind: r.TrialKind,
|
||||
TrialInboundTag: r.TrialInboundTag,
|
||||
AdminTelegramIDs: r.AdminTelegramIDs,
|
||||
Currency: r.Currency,
|
||||
PublicHost: r.PublicHost,
|
||||
XrayPublicHost: r.XrayPublicHost,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *BotConfig) applyDefaults() {
|
||||
if c.MPConfirmMode == "" {
|
||||
c.MPConfirmMode = "polling"
|
||||
}
|
||||
if c.MPPollInterval == "" {
|
||||
c.MPPollInterval = "20s"
|
||||
}
|
||||
if c.PixExpirationMinutes <= 0 {
|
||||
c.PixExpirationMinutes = 30
|
||||
}
|
||||
if c.TrialHours <= 0 {
|
||||
c.TrialHours = 1
|
||||
}
|
||||
if c.TrialMaxConnections <= 0 {
|
||||
c.TrialMaxConnections = 1
|
||||
}
|
||||
if c.TrialKind == "" {
|
||||
c.TrialKind = "ssh"
|
||||
}
|
||||
if c.Currency == "" {
|
||||
c.Currency = "BRL"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BotConfig) isAdmin(telegramID int64) bool {
|
||||
for _, id := range c.AdminTelegramIDs {
|
||||
if id == telegramID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveBotConfig persists a BotConfig. Empty secret fields preserve the stored
|
||||
// value (nil blob → column left unchanged).
|
||||
func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error {
|
||||
cfg.applyDefaults()
|
||||
row := &botConfigRow{
|
||||
Enabled: cfg.Enabled,
|
||||
TelegramMode: "polling", // Telegram is always long-polling
|
||||
TelegramWebhookURL: "",
|
||||
MPConfirmMode: cfg.MPConfirmMode,
|
||||
MPPollInterval: cfg.MPPollInterval,
|
||||
PixExpirationMinutes: cfg.PixExpirationMinutes,
|
||||
TrialEnabled: cfg.TrialEnabled,
|
||||
TrialHours: cfg.TrialHours,
|
||||
TrialMaxConnections: cfg.TrialMaxConnections,
|
||||
TrialKind: cfg.TrialKind,
|
||||
TrialInboundTag: cfg.TrialInboundTag,
|
||||
AdminTelegramIDs: cfg.AdminTelegramIDs,
|
||||
Currency: cfg.Currency,
|
||||
PublicHost: cfg.PublicHost,
|
||||
XrayPublicHost: cfg.XrayPublicHost,
|
||||
}
|
||||
var tokEnc, mpEnc, mpSecEnc []byte
|
||||
var err error
|
||||
if cfg.TelegramToken != "" {
|
||||
if tokEnc, err = encryptSecret(cfg.TelegramToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.MPAccessToken != "" {
|
||||
if mpEnc, err = encryptSecret(cfg.MPAccessToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.MPWebhookSecret != "" {
|
||||
if mpSecEnc, err = encryptSecret(cfg.MPWebhookSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// tgSecEnc is always nil now (no Telegram webhook secret).
|
||||
return store.saveBotConfigRow(ctx, row, tokEnc, nil, mpEnc, mpSecEnc)
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
// bot_core.go — Bot lifecycle, Telegram update dispatch, and shared helpers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Global bot instance (nil when disabled). Guarded by botMgrMu.
|
||||
var (
|
||||
botMgr *Bot
|
||||
botMgrMu sync.Mutex
|
||||
)
|
||||
|
||||
func currentBot() *Bot {
|
||||
botMgrMu.Lock()
|
||||
defer botMgrMu.Unlock()
|
||||
return botMgr
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
store *Store
|
||||
cfg *BotConfig
|
||||
tg *tgClient
|
||||
mp *mpClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newBot(store *Store, cfg *BotConfig) *Bot {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b := &Bot{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
tg: newTGClient(cfg.TelegramToken),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if cfg.MPAccessToken != "" {
|
||||
b.mp = newMPClient(cfg.MPAccessToken)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ---------- Lifecycle (called from main.go boot and bot_api.go on save) ----------
|
||||
|
||||
// startBotService loads config from the DB and starts the bot if enabled.
|
||||
func startBotService(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
cfg, err := LoadBotConfig(context.Background(), store)
|
||||
if err != nil {
|
||||
log.Printf("[bot] load config: %v", err)
|
||||
return
|
||||
}
|
||||
botMgrMu.Lock()
|
||||
defer botMgrMu.Unlock()
|
||||
if botMgr != nil {
|
||||
botMgr.cancel()
|
||||
botMgr = nil
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
log.Printf("[bot] disabled")
|
||||
return
|
||||
}
|
||||
if cfg.TelegramToken == "" {
|
||||
log.Printf("[bot] enabled but no telegram token configured; not starting")
|
||||
return
|
||||
}
|
||||
b := newBot(store, cfg)
|
||||
botMgr = b
|
||||
b.start()
|
||||
}
|
||||
|
||||
// reloadBotService restarts the bot after a config change.
|
||||
func reloadBotService(store *Store) { startBotService(store) }
|
||||
|
||||
func (b *Bot) start() {
|
||||
log.Printf("[bot] starting (telegram=long-polling, mp_confirm=%s)", b.cfg.MPConfirmMode)
|
||||
// Telegram uses long-polling only. Clearing any stale webhook + polling both
|
||||
// hit the network, so run off the goroutine that holds botMgrMu.
|
||||
go func() {
|
||||
_ = b.tg.deleteWebhook(b.ctx)
|
||||
b.runPolling()
|
||||
}()
|
||||
if b.mp != nil && b.cfg.MPConfirmMode == "polling" {
|
||||
go b.runPaymentPoller()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) stop() { b.cancel() }
|
||||
|
||||
// ---------- Update polling ----------
|
||||
|
||||
func (b *Bot) runPolling() {
|
||||
var offset int64
|
||||
log.Printf("[bot] long-polling started")
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Printf("[bot] polling stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
ups, err := b.tg.getUpdates(b.ctx, offset, 50)
|
||||
if err != nil {
|
||||
if b.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[bot] getUpdates: %v", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
for i := range ups {
|
||||
u := ups[i]
|
||||
if u.UpdateID >= offset {
|
||||
offset = u.UpdateID + 1
|
||||
}
|
||||
b.handleUpdate(&u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dispatch ----------
|
||||
|
||||
func (b *Bot) handleUpdate(u *tgUpdate) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[bot] panic handling update: %v", r)
|
||||
}
|
||||
}()
|
||||
switch {
|
||||
case u.CallbackQuery != nil:
|
||||
b.handleCallback(u.CallbackQuery)
|
||||
case u.Message != nil && u.Message.From != nil:
|
||||
b.handleMessage(u.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleMessage(m *tgMessage) {
|
||||
b.touchUser(m.From)
|
||||
text := strings.TrimSpace(m.Text)
|
||||
switch {
|
||||
case text == "/start" || text == "/menu" || text == "start":
|
||||
b.showMainMenu(m.Chat.ID, m.From, 0)
|
||||
case strings.HasPrefix(text, "/stats") && b.cfg.isAdmin(m.From.ID):
|
||||
b.cmdAdminStats(m.Chat.ID)
|
||||
case strings.HasPrefix(text, "/addcredit") && b.cfg.isAdmin(m.From.ID):
|
||||
b.cmdAdminAddCredit(m.Chat.ID, text)
|
||||
default:
|
||||
b.showMainMenu(m.Chat.ID, m.From, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleCallback(cb *tgCallbackQuery) {
|
||||
b.touchUser(&cb.From)
|
||||
_ = b.tg.answerCallback(b.ctx, cb.ID, "")
|
||||
if cb.Message == nil {
|
||||
return
|
||||
}
|
||||
chatID := cb.Message.Chat.ID
|
||||
msgID := cb.Message.MessageID
|
||||
data := cb.Data
|
||||
|
||||
switch {
|
||||
case data == "menu:main":
|
||||
b.showMainMenu(chatID, &cb.From, msgID)
|
||||
case data == "buy":
|
||||
b.showPlanList(chatID, msgID, "ssh_or_xray", "buy")
|
||||
case strings.HasPrefix(data, "buy:"):
|
||||
b.startPlanPurchase(chatID, &cb.From, data[len("buy:"):], false)
|
||||
case data == "renew":
|
||||
b.showRenewList(chatID, &cb.From, msgID)
|
||||
case strings.HasPrefix(data, "renew:"):
|
||||
b.startRenew(chatID, &cb.From, msgID, data[len("renew:"):])
|
||||
case strings.HasPrefix(data, "rnw:"):
|
||||
b.startRenewPayment(chatID, &cb.From, data[len("rnw:"):])
|
||||
case data == "trial":
|
||||
b.handleTrial(chatID, &cb.From)
|
||||
case data == "purchases":
|
||||
b.showPurchases(chatID, &cb.From, msgID)
|
||||
case data == "app":
|
||||
b.showText(chatID, msgID, "app_text", "📥 App: (configure em bot_settings)")
|
||||
case data == "contact":
|
||||
b.showText(chatID, msgID, "contact_text", "👤 Contato: (configure em bot_settings)")
|
||||
case data == "res:menu":
|
||||
b.showResellerMenu(chatID, &cb.From, msgID)
|
||||
case data == "res:topup":
|
||||
b.showCreditPackages(chatID, msgID)
|
||||
case strings.HasPrefix(data, "res:topup:"):
|
||||
b.startTopup(chatID, &cb.From, data[len("res:topup:"):])
|
||||
case data == "res:create":
|
||||
b.showPlanList(chatID, msgID, "ssh_or_xray", "res:create")
|
||||
case strings.HasPrefix(data, "res:create:"):
|
||||
b.resellerCreateAccount(chatID, &cb.From, data[len("res:create:"):])
|
||||
case data == "res:clients":
|
||||
b.showResellerClients(chatID, &cb.From, msgID)
|
||||
case strings.HasPrefix(data, "pay:check:"):
|
||||
b.checkPaymentButton(chatID, &cb.From, data[len("pay:check:"):])
|
||||
default:
|
||||
// unknown — refresh menu
|
||||
b.showMainMenu(chatID, &cb.From, msgID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- User helpers ----------
|
||||
|
||||
func (b *Bot) touchUser(u *tgUser) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
_ = b.store.UpsertBotUser(b.ctx, &BotUser{
|
||||
TelegramID: u.ID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bot) botUser(telegramID int64) *BotUser {
|
||||
u, err := b.store.GetBotUser(b.ctx, telegramID)
|
||||
if err != nil {
|
||||
return &BotUser{TelegramID: telegramID, Role: "customer"}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// ---------- Message helpers ----------
|
||||
|
||||
func (b *Bot) send(chatID int64, text string, kb *tgInlineKeyboard) {
|
||||
if _, err := b.tg.sendMessage(b.ctx, chatID, text, kb); err != nil {
|
||||
log.Printf("[bot] sendMessage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendOrEdit edits an existing message if msgID>0, else sends a new one.
|
||||
func (b *Bot) sendOrEdit(chatID, msgID int64, text string, kb *tgInlineKeyboard) {
|
||||
if msgID > 0 {
|
||||
if err := b.tg.editMessageText(b.ctx, chatID, msgID, text, kb); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.send(chatID, text, kb)
|
||||
}
|
||||
|
||||
func (b *Bot) showText(chatID, msgID int64, key, def string) {
|
||||
txt := b.store.GetSetting(b.ctx, key, def)
|
||||
b.sendOrEdit(chatID, msgID, txt, backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Keyboard builders ----------
|
||||
|
||||
func kb(rows ...[]tgInlineButton) *tgInlineKeyboard {
|
||||
return &tgInlineKeyboard{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func btn(text, data string) tgInlineButton { return tgInlineButton{Text: text, CallbackData: data} }
|
||||
|
||||
func urlBtn(text, u string) tgInlineButton { return tgInlineButton{Text: text, URL: u} }
|
||||
|
||||
func backKeyboard() *tgInlineKeyboard {
|
||||
return kb([]tgInlineButton{btn("⬅️ Voltar", "menu:main")})
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// bot_crypto.go — secret encryption for the Telegram/Mercado Pago bot.
|
||||
//
|
||||
// Bot tokens (Telegram bot token, Mercado Pago access token, webhook secrets)
|
||||
// are stored in PostgreSQL encrypted with AES-256-GCM. The 32-byte master key
|
||||
// lives OUTSIDE the database, so a DB dump alone never reveals the secrets:
|
||||
// 1) env BOT_MASTER_KEY (64 hex chars), if set; otherwise
|
||||
// 2) a 0600 key file next to config.json (bot_master.key); otherwise
|
||||
// 3) generated with crypto/rand on first use and written to that key file.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
botKeyOnce sync.Once
|
||||
botKey []byte
|
||||
botKeyErr error
|
||||
)
|
||||
|
||||
// botMasterKeyPath returns the on-disk location of the AES master key.
|
||||
func botMasterKeyPath() string {
|
||||
if globalCfgPath != "" {
|
||||
return filepath.Join(filepath.Dir(globalCfgPath), "bot_master.key")
|
||||
}
|
||||
return "/opt/sshpanel/bot_master.key"
|
||||
}
|
||||
|
||||
// loadBotMasterKey resolves the 32-byte master key (env → file → generate).
|
||||
func loadBotMasterKey() ([]byte, error) {
|
||||
botKeyOnce.Do(func() {
|
||||
if env := strings.TrimSpace(os.Getenv("BOT_MASTER_KEY")); env != "" {
|
||||
k, err := hex.DecodeString(env)
|
||||
if err != nil {
|
||||
botKeyErr = fmt.Errorf("BOT_MASTER_KEY invalid hex: %w", err)
|
||||
return
|
||||
}
|
||||
if len(k) != 32 {
|
||||
botKeyErr = fmt.Errorf("BOT_MASTER_KEY must be 32 bytes (64 hex chars), got %d", len(k))
|
||||
return
|
||||
}
|
||||
botKey = k
|
||||
return
|
||||
}
|
||||
|
||||
path := botMasterKeyPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
k, derr := hex.DecodeString(strings.TrimSpace(string(data)))
|
||||
if derr == nil && len(k) == 32 {
|
||||
botKey = k
|
||||
return
|
||||
}
|
||||
// Refuse to overwrite a bad key file — overwriting would make
|
||||
// existing ciphertext undecryptable and silently lose secrets.
|
||||
botKeyErr = fmt.Errorf("bot master key file %s is invalid; refusing to overwrite", path)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
botKeyErr = fmt.Errorf("read bot master key: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
k := make([]byte, 32)
|
||||
if _, e := rand.Read(k); e != nil {
|
||||
botKeyErr = fmt.Errorf("generate bot master key: %w", e)
|
||||
return
|
||||
}
|
||||
if e := os.WriteFile(path, []byte(hex.EncodeToString(k)), 0o600); e != nil {
|
||||
botKeyErr = fmt.Errorf("write bot master key %s: %w", path, e)
|
||||
return
|
||||
}
|
||||
botKey = k
|
||||
})
|
||||
return botKey, botKeyErr
|
||||
}
|
||||
|
||||
// encryptSecret encrypts a plaintext secret. Empty input returns nil (no blob).
|
||||
func encryptSecret(plain string) ([]byte, error) {
|
||||
if plain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := loadBotMasterKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Output is nonce || ciphertext(+tag).
|
||||
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
|
||||
}
|
||||
|
||||
// decryptSecret reverses encryptSecret. Empty/nil input returns "".
|
||||
func decryptSecret(enc []byte) (string, error) {
|
||||
if len(enc) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
key, err := loadBotMasterKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(enc) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, ct := enc[:gcm.NonceSize()], enc[gcm.NonceSize():]
|
||||
pt, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt secret: %w", err)
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
// maskSecret returns a log-safe representation of a secret.
|
||||
func maskSecret(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if len(s) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
return s[:3] + "***" + s[len(s)-2:]
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
package main
|
||||
|
||||
// bot_flows.go — customer + reseller conversation flows and admin commands.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Main menu ----------
|
||||
|
||||
func (b *Bot) showMainMenu(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role == "blocked" {
|
||||
b.sendOrEdit(chatID, msgID, "🚫 Seu acesso foi bloqueado.", nil)
|
||||
return
|
||||
}
|
||||
welcome := b.store.GetSetting(b.ctx, "welcome_text", "")
|
||||
var text string
|
||||
if welcome != "" {
|
||||
text = strings.ReplaceAll(welcome, "{name}", htmlEscape(from.FirstName))
|
||||
} else {
|
||||
text = fmt.Sprintf("😉 Olá <b>%s</b>, seja bem-vindo!\n\n🚀 Aqui você encontra os melhores planos <b>SSH</b> e <b>Xray</b> Premium.\nSelecione uma das opções abaixo:", htmlEscape(from.FirstName))
|
||||
}
|
||||
|
||||
var rows [][]tgInlineButton
|
||||
if b.cfg.TrialEnabled {
|
||||
rows = append(rows, []tgInlineButton{btn("⏳ Teste Grátis", "trial"), btn("🛍️ Minhas Compras", "purchases")})
|
||||
} else {
|
||||
rows = append(rows, []tgInlineButton{btn("🛍️ Minhas Compras", "purchases")})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("💎 Comprar Premium", "buy")})
|
||||
rows = append(rows, []tgInlineButton{btn("🔄 Renovar", "renew")})
|
||||
|
||||
appURL := b.store.GetSetting(b.ctx, "app_url", "")
|
||||
appRow := []tgInlineButton{}
|
||||
if appURL != "" {
|
||||
appRow = append(appRow, urlBtn("📥 Baixar APP", appURL))
|
||||
} else {
|
||||
appRow = append(appRow, btn("📥 Baixar APP", "app"))
|
||||
}
|
||||
appRow = append(appRow, btn("👤 Contato", "contact"))
|
||||
rows = append(rows, appRow)
|
||||
|
||||
if bu.Role == "reseller" {
|
||||
rows = append(rows, []tgInlineButton{btn("👑 Área do Revendedor", "res:menu")})
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, text, kb(rows...))
|
||||
}
|
||||
|
||||
// ---------- Plan list (buy / reseller create) ----------
|
||||
|
||||
func (b *Bot) showPlanList(chatID, msgID int64, _ string, action string) {
|
||||
plans, err := b.store.ListPlans(b.ctx, true)
|
||||
if err != nil || len(plans) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum plano disponível no momento.", backKeyboard())
|
||||
return
|
||||
}
|
||||
isReseller := action == "res:create"
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range plans {
|
||||
icon := "🔒"
|
||||
if p.Kind == "xray" {
|
||||
icon = "⚡"
|
||||
}
|
||||
var label string
|
||||
if isReseller {
|
||||
label = fmt.Sprintf("%s %s — %d créd.", icon, p.Name, p.CreditCost)
|
||||
} else {
|
||||
label = fmt.Sprintf("%s %s — %s", icon, p.Name, centsToBRL(p.PriceCents))
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn(label, action+":"+botItoa(p.ID))})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", backTarget(action))})
|
||||
title := "💎 Escolha um plano:"
|
||||
if isReseller {
|
||||
title = "➕ Escolha um plano para criar (custo em créditos):"
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, title, kb(rows...))
|
||||
}
|
||||
|
||||
func backTarget(action string) string {
|
||||
if strings.HasPrefix(action, "res:") {
|
||||
return "res:menu"
|
||||
}
|
||||
return "menu:main"
|
||||
}
|
||||
|
||||
// ---------- Buy ----------
|
||||
|
||||
func (b *Bot) startPlanPurchase(chatID int64, from *tgUser, planIDStr string, _ bool) {
|
||||
id, _ := strconv.Atoi(planIDStr)
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "plan_purchase", "Compra: "+p.Name, p.PriceCents, &pid, nil, 0, "")
|
||||
}
|
||||
|
||||
// ---------- Renew ----------
|
||||
|
||||
func (b *Bot) showRenewList(chatID int64, from *tgUser, msgID int64) {
|
||||
txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 100)
|
||||
seen := map[string]bool{}
|
||||
var rows [][]tgInlineButton
|
||||
for _, t := range txns {
|
||||
if t.Status != "approved" || t.TargetUsername == "" || t.PlanID == nil {
|
||||
continue
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *t.PlanID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := p.Kind + ":" + t.TargetUsername
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
short := "s"
|
||||
if p.Kind == "xray" {
|
||||
short = "x"
|
||||
}
|
||||
disp := t.TargetUsername
|
||||
if len(disp) > 16 {
|
||||
disp = disp[:8] + "…"
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("🔄 "+disp+" ("+p.Kind+")", "renew:"+short+":"+t.TargetUsername)})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Você não tem contas para renovar.", backKeyboard())
|
||||
return
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "menu:main")})
|
||||
b.sendOrEdit(chatID, msgID, "🔄 Selecione a conta para renovar:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startRenew(chatID int64, from *tgUser, msgID int64, target string) {
|
||||
kind := "ssh"
|
||||
if strings.HasPrefix(target, "x:") {
|
||||
kind = "xray"
|
||||
}
|
||||
plans, _ := b.store.ListPlans(b.ctx, true)
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range plans {
|
||||
if p.Kind != kind {
|
||||
continue
|
||||
}
|
||||
label := fmt.Sprintf("%s — %s", p.Name, centsToBRL(p.PriceCents))
|
||||
rows = append(rows, []tgInlineButton{btn(label, "rnw:"+botItoa(p.ID)+":"+target)})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum plano de renovação disponível.", backKeyboard())
|
||||
return
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "renew")})
|
||||
b.sendOrEdit(chatID, msgID, "🔄 Escolha a duração da renovação:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startRenewPayment(chatID int64, from *tgUser, payload string) {
|
||||
parts := strings.SplitN(payload, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
b.send(chatID, "Renovação inválida.", backKeyboard())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(parts[0])
|
||||
renewTarget := parts[1] + ":" + parts[2] // "s:username" or "x:uuid"
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "plan_renewal", "Renovação: "+p.Name, p.PriceCents, &pid, nil, 0, renewTarget)
|
||||
}
|
||||
|
||||
// ---------- Trial ----------
|
||||
|
||||
func (b *Bot) handleTrial(chatID int64, from *tgUser) {
|
||||
if !b.cfg.TrialEnabled {
|
||||
b.send(chatID, "Teste grátis indisponível.", backKeyboard())
|
||||
return
|
||||
}
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.TrialUsed {
|
||||
b.send(chatID, "⚠️ Você já utilizou seu teste grátis.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(b.cfg.TrialHours) * time.Hour)
|
||||
if b.cfg.TrialKind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, b.cfg.TrialInboundTag, "", exp, b.cfg.TrialMaxConnections, "", b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
log.Printf("[bot] trial xray: %v", err)
|
||||
b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard())
|
||||
return
|
||||
}
|
||||
_ = b.store.SetBotUserTrialUsed(b.ctx, from.ID)
|
||||
b.send(chatID, b.formatXrayDelivery("Teste Grátis", uuid, link, exp), backKeyboard())
|
||||
return
|
||||
}
|
||||
user := genUsername("test")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, b.cfg.TrialMaxConnections, 0, 0, ""); err != nil {
|
||||
log.Printf("[bot] trial ssh: %v", err)
|
||||
b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard())
|
||||
return
|
||||
}
|
||||
_ = b.store.SetBotUserTrialUsed(b.ctx, from.ID)
|
||||
b.send(chatID, b.formatSSHDelivery("Teste Grátis", user, pass, exp), backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Purchases ----------
|
||||
|
||||
func (b *Bot) showPurchases(chatID int64, from *tgUser, msgID int64) {
|
||||
txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 20)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🛍️ <b>Suas Compras</b>\n\n")
|
||||
count := 0
|
||||
for _, t := range txns {
|
||||
if t.Status == "pending" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
sb.WriteString(fmt.Sprintf("• #%d %s — %s — %s\n", t.ID, txnTypeLabel(t.Type), centsToBRL(t.AmountCents), statusLabel(t.Status)))
|
||||
if t.TargetUsername != "" && t.Status == "approved" {
|
||||
sb.WriteString(" conta: <code>" + htmlEscape(t.TargetUsername) + "</code>\n")
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
sb.WriteString("Nenhuma compra ainda.")
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, sb.String(), backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Reseller ----------
|
||||
|
||||
func (b *Bot) showResellerMenu(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" {
|
||||
b.sendOrEdit(chatID, msgID, "Você não é um revendedor.", backKeyboard())
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("👑 <b>Área do Revendedor</b>\n\n💳 Saldo: <b>%d créditos</b>", bu.CreditBalance)
|
||||
rows := [][]tgInlineButton{
|
||||
{btn("💳 Recarregar Créditos", "res:topup")},
|
||||
{btn("➕ Criar Conta", "res:create")},
|
||||
{btn("👥 Meus Clientes", "res:clients")},
|
||||
{btn("⬅️ Voltar", "menu:main")},
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, text, kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) showCreditPackages(chatID, msgID int64) {
|
||||
pkgs, _ := b.store.ListCreditPackages(b.ctx, true)
|
||||
if len(pkgs) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum pacote de créditos disponível.", kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")}))
|
||||
return
|
||||
}
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range pkgs {
|
||||
rows = append(rows, []tgInlineButton{btn(fmt.Sprintf("%s — %d créd. — %s", p.Name, p.Credits, centsToBRL(p.PriceCents)), "res:topup:"+botItoa(p.ID))})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "res:menu")})
|
||||
b.sendOrEdit(chatID, msgID, "💳 Escolha um pacote de créditos:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startTopup(chatID int64, from *tgUser, pkgIDStr string) {
|
||||
id, _ := strconv.Atoi(pkgIDStr)
|
||||
p, err := b.store.GetCreditPackage(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Pacote não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "credit_topup", "Recarga: "+p.Name, p.PriceCents, nil, &pid, p.Credits, "")
|
||||
}
|
||||
|
||||
func (b *Bot) resellerCreateAccount(chatID int64, from *tgUser, planIDStr string) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" || bu.LinkedAdminUsername == "" {
|
||||
b.send(chatID, "Conta de revendedor não configurada.", backKeyboard())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(planIDStr)
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if bu.CreditBalance < p.CreditCost {
|
||||
b.send(chatID, fmt.Sprintf("❌ Saldo insuficiente. Necessário %d créditos, você tem %d.", p.CreditCost, bu.CreditBalance),
|
||||
kb([]tgInlineButton{btn("💳 Recarregar", "res:topup"), btn("⬅️ Voltar", "res:menu")}))
|
||||
return
|
||||
}
|
||||
if owner, ok := adminUsers.get(bu.LinkedAdminUsername); ok && owner.MaxUsers > 0 &&
|
||||
countOwnedQuota(b.ctx, b.store, bu.LinkedAdminUsername) >= owner.MaxUsers {
|
||||
b.send(chatID, fmt.Sprintf("❌ Limite de contas atingido (%d).", owner.MaxUsers), backKeyboard())
|
||||
return
|
||||
}
|
||||
// Debit first; refund if provisioning fails.
|
||||
if _, err := b.store.AdjustCredits(b.ctx, from.ID, -p.CreditCost, "account_create", nil); err != nil {
|
||||
b.send(chatID, "❌ Não foi possível debitar créditos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour)
|
||||
var deliver string
|
||||
if p.Kind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, bu.LinkedAdminUsername, b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
_, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil)
|
||||
b.send(chatID, "❌ Falha ao criar conta Xray. Créditos devolvidos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
deliver = b.formatXrayDelivery(p.Name, uuid, link, exp)
|
||||
} else {
|
||||
user := genUsername("r")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, bu.LinkedAdminUsername); err != nil {
|
||||
_, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil)
|
||||
b.send(chatID, "❌ Falha ao criar conta SSH. Créditos devolvidos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
deliver = b.formatSSHDelivery(p.Name, user, pass, exp)
|
||||
}
|
||||
b.send(chatID, deliver+fmt.Sprintf("\n\n💳 Saldo restante: %d créditos", bu.CreditBalance-p.CreditCost),
|
||||
kb([]tgInlineButton{btn("➕ Criar outra", "res:create"), btn("⬅️ Voltar", "res:menu")}))
|
||||
}
|
||||
|
||||
func (b *Bot) showResellerClients(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" || bu.LinkedAdminUsername == "" {
|
||||
b.sendOrEdit(chatID, msgID, "Conta de revendedor não configurada.", backKeyboard())
|
||||
return
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("👥 <b>Seus Clientes</b>\n\n")
|
||||
n := 0
|
||||
for _, u := range userMgr.List() {
|
||||
if u.Cfg.OwnerUsername == bu.LinkedAdminUsername {
|
||||
n++
|
||||
exp := "sem validade"
|
||||
if u.ExpiresAt != nil {
|
||||
exp = u.ExpiresAt.Format("02/01/2006")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• SSH <code>%s</code> — %s\n", htmlEscape(u.Cfg.Username), exp))
|
||||
if n >= 40 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
xs, _ := b.store.ListXrayClientsByOwner(b.ctx, bu.LinkedAdminUsername)
|
||||
for _, x := range xs {
|
||||
n++
|
||||
exp := "sem validade"
|
||||
if x.ExpiresAt != nil {
|
||||
exp = x.ExpiresAt.Format("02/01/2006")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• Xray <code>%s</code> — %s\n", htmlEscape(x.UUID), exp))
|
||||
if n >= 80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
sb.WriteString("Nenhum cliente ainda.")
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, sb.String(), kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")}))
|
||||
}
|
||||
|
||||
// ---------- PIX charge creation + delivery formatting ----------
|
||||
|
||||
func (b *Bot) createAndSendPix(chatID int64, from *tgUser, ttype, description string, amountCents int, planID, pkgID *int, credits int, renewTarget string) {
|
||||
if amountCents <= 0 {
|
||||
b.send(chatID, "❌ Este item não tem preço configurado. Fale com o suporte.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
b.send(chatID, "❌ Pagamento não configurado no momento. Fale com o suporte.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(b.cfg.PixExpirationMinutes) * time.Minute)
|
||||
pix, err := b.mp.CreatePixPayment(b.ctx, amountCents, description, "", strconv.FormatInt(from.ID, 10), exp, uuidV4())
|
||||
if err != nil {
|
||||
log.Printf("[bot] create pix: %v", err)
|
||||
b.send(chatID, "❌ Falha ao gerar o pagamento PIX. Tente novamente em instantes.", backKeyboard())
|
||||
return
|
||||
}
|
||||
txn := &BotTransaction{
|
||||
TelegramID: from.ID,
|
||||
Type: ttype,
|
||||
PlanID: planID,
|
||||
PackageID: pkgID,
|
||||
Credits: credits,
|
||||
AmountCents: amountCents,
|
||||
MPPaymentID: pix.PaymentID,
|
||||
MPQRCode: pix.QRCode,
|
||||
MPQRBase64: pix.QRBase64,
|
||||
Status: "pending",
|
||||
RenewTarget: renewTarget,
|
||||
ExpiresAt: &exp,
|
||||
}
|
||||
if err := b.store.CreateTransaction(b.ctx, txn); err != nil {
|
||||
log.Printf("[bot] create txn: %v", err)
|
||||
b.send(chatID, "❌ Erro interno ao registrar o pagamento.", backKeyboard())
|
||||
return
|
||||
}
|
||||
b.sendPixMessage(chatID, txn, description)
|
||||
}
|
||||
|
||||
func (b *Bot) sendPixMessage(chatID int64, txn *BotTransaction, description string) {
|
||||
caption := fmt.Sprintf("💳 <b>Pagamento PIX</b>\n%s\nValor: <b>%s</b>\n⏱ Validade: %d min\n\nEscaneie o QR acima ou use o código copia-e-cola abaixo. A liberação é automática após o pagamento.",
|
||||
htmlEscape(description), centsToBRL(txn.AmountCents), b.cfg.PixExpirationMinutes)
|
||||
kbd := kb(
|
||||
[]tgInlineButton{btn("✅ Já paguei / Verificar", "pay:check:"+botItoa(txn.ID))},
|
||||
[]tgInlineButton{btn("⬅️ Voltar", "menu:main")},
|
||||
)
|
||||
sent := false
|
||||
if txn.MPQRBase64 != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(txn.MPQRBase64); err == nil {
|
||||
if _, err := b.tg.sendPhotoBytes(b.ctx, chatID, raw, "pix.png", caption, kbd); err == nil {
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sent {
|
||||
b.send(chatID, caption, kbd)
|
||||
}
|
||||
if txn.MPQRCode != "" {
|
||||
b.send(chatID, "📋 <b>PIX Copia e Cola:</b>\n<code>"+htmlEscape(txn.MPQRCode)+"</code>", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) formatSSHDelivery(planName, user, pass string, exp time.Time) string {
|
||||
host := b.cfg.PublicHost
|
||||
if host == "" {
|
||||
host = "(configure o host no painel)"
|
||||
}
|
||||
return fmt.Sprintf("✅ <b>%s</b>\n\n🔒 <b>Conta SSH</b>\nHost: <code>%s</code>\nUsuário: <code>%s</code>\nSenha: <code>%s</code>\nValidade: %s",
|
||||
htmlEscape(planName), htmlEscape(host), htmlEscape(user), htmlEscape(pass), exp.Format("02/01/2006 15:04"))
|
||||
}
|
||||
|
||||
func (b *Bot) formatXrayDelivery(planName, uuid, link string, exp time.Time) string {
|
||||
return fmt.Sprintf("✅ <b>%s</b>\n\n⚡ <b>Conta Xray</b>\nUUID: <code>%s</code>\nValidade: %s\n\n🔗 Link de conexão:\n<code>%s</code>",
|
||||
htmlEscape(planName), htmlEscape(uuid), exp.Format("02/01/2006 15:04"), htmlEscape(link))
|
||||
}
|
||||
|
||||
// ---------- Admin commands (in-chat convenience) ----------
|
||||
|
||||
func (b *Bot) cmdAdminStats(chatID int64) {
|
||||
users, _ := b.store.ListBotUsers(b.ctx)
|
||||
pend, _ := b.store.ListPendingTransactions(b.ctx)
|
||||
b.send(chatID, fmt.Sprintf("📊 <b>Estatísticas</b>\nUsuários do bot: %d\nPagamentos pendentes: %d\nContas SSH ativas: %d",
|
||||
len(users), len(pend), len(userMgr.List())), nil)
|
||||
}
|
||||
|
||||
func (b *Bot) cmdAdminAddCredit(chatID int64, text string) {
|
||||
f := strings.Fields(text)
|
||||
if len(f) < 3 {
|
||||
b.send(chatID, "Uso: /addcredit <telegram_id> <quantidade>", nil)
|
||||
return
|
||||
}
|
||||
tid, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
amt, _ := strconv.Atoi(f[2])
|
||||
bal, err := b.store.AdjustCredits(b.ctx, tid, amt, "admin_adjust", nil)
|
||||
if err != nil {
|
||||
b.send(chatID, "Erro: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
b.send(chatID, fmt.Sprintf("✅ Ajuste aplicado. Novo saldo de %d: %d créditos", tid, bal), nil)
|
||||
b.notify(tid, fmt.Sprintf("💳 Seu saldo foi ajustado em %+d créditos. Saldo atual: %d", amt, bal))
|
||||
}
|
||||
|
||||
func (b *Bot) notify(telegramID int64, text string) {
|
||||
if _, err := b.tg.sendMessage(b.ctx, telegramID, text, nil); err != nil {
|
||||
log.Printf("[bot] notify %d: %v", telegramID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- labels ----------
|
||||
|
||||
func txnTypeLabel(t string) string {
|
||||
switch t {
|
||||
case "plan_purchase":
|
||||
return "Compra"
|
||||
case "plan_renewal":
|
||||
return "Renovação"
|
||||
case "credit_topup":
|
||||
return "Recarga"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func statusLabel(s string) string {
|
||||
switch s {
|
||||
case "approved":
|
||||
return "✅ pago"
|
||||
case "pending":
|
||||
return "⏳ pendente"
|
||||
case "expired":
|
||||
return "⌛ expirado"
|
||||
case "refunded":
|
||||
return "↩️ estornado"
|
||||
case "error":
|
||||
return "❌ erro"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
// bot_mercadopago.go — Mercado Pago PIX client + inbound webhook handler.
|
||||
// Built on net/http; no third-party dependency.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mpAPIBase = "https://api.mercadopago.com"
|
||||
|
||||
type mpClient struct {
|
||||
accessToken string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newMPClient(accessToken string) *mpClient {
|
||||
return &mpClient{accessToken: accessToken, hc: &http.Client{Timeout: 25 * time.Second}}
|
||||
}
|
||||
|
||||
// mpPixResult holds what the bot needs to show the buyer.
|
||||
type mpPixResult struct {
|
||||
PaymentID string
|
||||
QRCode string // copy-and-paste PIX string
|
||||
QRBase64 string // PNG image, base64 (no data: prefix)
|
||||
Status string
|
||||
}
|
||||
|
||||
// CreatePixPayment creates a PIX charge and returns the QR data.
|
||||
// amountCents is BRL cents; expiresAt bounds the QR validity.
|
||||
func (c *mpClient) CreatePixPayment(ctx context.Context, amountCents int, description, payerEmail, externalRef string, expiresAt time.Time, idempotencyKey string) (*mpPixResult, error) {
|
||||
if payerEmail == "" {
|
||||
payerEmail = "comprador@example.com"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"transaction_amount": float64(amountCents) / 100.0,
|
||||
"description": description,
|
||||
"payment_method_id": "pix",
|
||||
"payer": map[string]interface{}{"email": payerEmail},
|
||||
"date_of_expiration": expiresAt.Format("2006-01-02T15:04:05.000-07:00"),
|
||||
"external_reference": externalRef,
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/v1/payments", body, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID json.Number `json:"id"`
|
||||
Status string `json:"status"`
|
||||
PointOfInteraction struct {
|
||||
TransactionData struct {
|
||||
QRCode string `json:"qr_code"`
|
||||
QRCodeBase64 string `json:"qr_code_base64"`
|
||||
} `json:"transaction_data"`
|
||||
} `json:"point_of_interaction"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("mp create payment: parse: %w", err)
|
||||
}
|
||||
if resp.ID.String() == "" {
|
||||
return nil, fmt.Errorf("mp create payment: no id in response: %s", string(raw))
|
||||
}
|
||||
return &mpPixResult{
|
||||
PaymentID: resp.ID.String(),
|
||||
QRCode: resp.PointOfInteraction.TransactionData.QRCode,
|
||||
QRBase64: resp.PointOfInteraction.TransactionData.QRCodeBase64,
|
||||
Status: resp.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPaymentStatus returns the current status of a payment (e.g. "approved").
|
||||
func (c *mpClient) GetPaymentStatus(ctx context.Context, paymentID string) (string, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/v1/payments/"+paymentID, nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func (c *mpClient) do(ctx context.Context, method, path string, body interface{}, idempotencyKey string) ([]byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, mpAPIBase+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if idempotencyKey != "" {
|
||||
req.Header.Set("X-Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("mercado pago %s %s: http %d: %s", method, path, resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// verifyMPSignature validates the x-signature header per Mercado Pago's spec.
|
||||
// Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret).
|
||||
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
var ts, v1 string
|
||||
for _, part := range strings.Split(xSignature, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(kv[0]) {
|
||||
case "ts":
|
||||
ts = strings.TrimSpace(kv[1])
|
||||
case "v1":
|
||||
v1 = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
if ts == "" || v1 == "" {
|
||||
return false
|
||||
}
|
||||
if timestamp, err := strconv.ParseInt(ts, 10, 64); err != nil || timestamp <= 0 {
|
||||
return false
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if dataID != "" {
|
||||
parts = append(parts, "id:"+strings.ToLower(dataID))
|
||||
}
|
||||
if xRequestID != "" {
|
||||
parts = append(parts, "request-id:"+xRequestID)
|
||||
}
|
||||
parts = append(parts, "ts:"+ts)
|
||||
manifest := strings.Join(parts, ";") + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(manifest))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(v1))
|
||||
}
|
||||
|
||||
// handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
|
||||
// It never trusts the body: it re-fetches the payment and fulfills idempotently.
|
||||
func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := currentBot()
|
||||
if b == nil {
|
||||
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
|
||||
return
|
||||
}
|
||||
// Only honor webhooks when confirmation mode is "webhook". In polling mode
|
||||
// the poller drives fulfillment; ignore unsolicited posts to this endpoint.
|
||||
if b.cfg.MPConfirmMode != "webhook" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// Extract the payment id from body or query.
|
||||
dataID := r.URL.Query().Get("data.id")
|
||||
if dataID == "" {
|
||||
dataID = r.URL.Query().Get("id")
|
||||
}
|
||||
signatureDataID := dataID
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action"`
|
||||
Data struct {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid webhook body", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if dataID == "" {
|
||||
dataID = payload.Data.ID.String()
|
||||
}
|
||||
}
|
||||
if dataID == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if len(dataID) > 32 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, char := range dataID {
|
||||
if char < '0' || char > '9' {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), signatureDataID, b.cfg.MPWebhookSecret) {
|
||||
log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately; process in the background so MP doesn't time out.
|
||||
go b.processPaymentByMPID(dataID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// centsToBRL formats cents as "R$ 12,34".
|
||||
func centsToBRL(cents int) string {
|
||||
reais := cents / 100
|
||||
cent := cents % 100
|
||||
return "R$ " + strconv.Itoa(reais) + "," + fmt.Sprintf("%02d", cent)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
// bot_payments.go — payment polling, webhook processing, and idempotent delivery.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Polling mode ----------
|
||||
|
||||
func (b *Bot) runPaymentPoller() {
|
||||
d, err := time.ParseDuration(b.cfg.MPPollInterval)
|
||||
if err != nil || d < 5*time.Second {
|
||||
d = 20 * time.Second
|
||||
}
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
log.Printf("[bot] payment poller started (interval=%s)", d)
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Printf("[bot] payment poller stopped")
|
||||
return
|
||||
case <-t.C:
|
||||
b.pollPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) pollPending() {
|
||||
txns, err := b.store.ListPendingTransactions(b.ctx)
|
||||
if err != nil {
|
||||
log.Printf("[bot] poll list: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, txn := range txns {
|
||||
if txn.ExpiresAt != nil && now.After(*txn.ExpiresAt) {
|
||||
_ = b.store.SetTransactionStatus(b.ctx, txn.ID, "expired")
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("⌛ O PIX do pedido #%d expirou. Gere um novo pagamento se ainda quiser.", txn.ID))
|
||||
continue
|
||||
}
|
||||
if b.mp == nil {
|
||||
continue
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Webhook mode ----------
|
||||
|
||||
// processPaymentByMPID is invoked from the Mercado Pago webhook handler.
|
||||
func (b *Bot) processPaymentByMPID(mpID string) {
|
||||
txn, err := b.store.GetTransactionByMPID(b.ctx, mpID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] webhook: no txn for mp payment %s: %v", mpID, err)
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
return
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, mpID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] webhook: get status %s: %v", mpID, err)
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Idempotent fulfillment ----------
|
||||
|
||||
// tryFulfill flips the txn to approved exactly once, then delivers.
|
||||
func (b *Bot) tryFulfill(txnID int) {
|
||||
ok, err := b.store.MarkTransactionApproved(b.ctx, txnID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] mark approved %d: %v", txnID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return // already fulfilled by another path
|
||||
}
|
||||
txn, err := b.store.GetTransaction(b.ctx, txnID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill get txn %d: %v", txnID, err)
|
||||
return
|
||||
}
|
||||
b.fulfillTransaction(txn)
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillTransaction(txn *BotTransaction) {
|
||||
switch txn.Type {
|
||||
case "credit_topup":
|
||||
bal, err := b.store.AdjustCredits(b.ctx, txn.TelegramID, txn.Credits, "topup", &txn.ID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] topup credit %d: %v", txn.ID, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao creditar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ Recarga aprovada! +%d créditos.\n💳 Saldo atual: %d créditos.", txn.Credits, bal))
|
||||
case "plan_renewal":
|
||||
b.fulfillRenewal(txn)
|
||||
default:
|
||||
b.fulfillPurchase(txn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillPurchase(txn *BotTransaction) {
|
||||
if txn.PlanID == nil {
|
||||
return
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *txn.PlanID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill purchase: plan %v: %v", txn.PlanID, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas o plano não foi encontrado. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour)
|
||||
if p.Kind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, "", b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill xray: %v", err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta Xray. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, uuid)
|
||||
b.notify(txn.TelegramID, b.formatXrayDelivery(p.Name, uuid, link, exp))
|
||||
return
|
||||
}
|
||||
user := genUsername("ssh")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, ""); err != nil {
|
||||
log.Printf("[bot] fulfill ssh: %v", err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta SSH. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, user)
|
||||
b.notify(txn.TelegramID, b.formatSSHDelivery(p.Name, user, pass, exp))
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillRenewal(txn *BotTransaction) {
|
||||
if txn.PlanID == nil || txn.RenewTarget == "" {
|
||||
return
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *txn.PlanID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(txn.RenewTarget, ":", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
kind, id := parts[0], parts[1]
|
||||
base := time.Now()
|
||||
add := time.Duration(p.Days) * 24 * time.Hour
|
||||
|
||||
if kind == "x" {
|
||||
newExp := base.Add(add)
|
||||
if meta, err := b.store.GetXrayClientMeta(b.ctx, id); err == nil && meta.ExpiresAt != nil && meta.ExpiresAt.After(base) {
|
||||
newExp = meta.ExpiresAt.Add(add)
|
||||
}
|
||||
if err := renewXrayClient(b.ctx, b.store, id, newExp); err != nil {
|
||||
log.Printf("[bot] renew xray %s: %v", id, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, id)
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ <b>%s</b> renovado!\n⚡ Xray <code>%s</code>\nNova validade: %s",
|
||||
htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04")))
|
||||
return
|
||||
}
|
||||
|
||||
newExp := base.Add(add)
|
||||
if u, ok := userMgr.Get(id); ok && u.ExpiresAt != nil && u.ExpiresAt.After(base) {
|
||||
newExp = u.ExpiresAt.Add(add)
|
||||
}
|
||||
if err := renewSSHUser(b.ctx, b.store, id, newExp); err != nil {
|
||||
log.Printf("[bot] renew ssh %s: %v", id, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, id)
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ <b>%s</b> renovado!\n🔒 SSH <code>%s</code>\nNova validade: %s",
|
||||
htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04")))
|
||||
}
|
||||
|
||||
// ---------- "Verificar" button ----------
|
||||
|
||||
func (b *Bot) checkPaymentButton(chatID int64, from *tgUser, txnIDStr string) {
|
||||
id, _ := strconv.Atoi(txnIDStr)
|
||||
txn, err := b.store.GetTransaction(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Pagamento não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if txn.TelegramID != from.ID {
|
||||
b.send(chatID, "Pagamento inválido.", backKeyboard())
|
||||
return
|
||||
}
|
||||
switch txn.Status {
|
||||
case "approved":
|
||||
b.send(chatID, "✅ Pagamento já confirmado! Veja em 🛍️ Minhas Compras.", backKeyboard())
|
||||
return
|
||||
case "expired":
|
||||
b.send(chatID, "⌛ Este PIX expirou. Gere um novo pagamento.", backKeyboard())
|
||||
return
|
||||
case "refunded":
|
||||
b.send(chatID, "Este pagamento foi estornado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
b.send(chatID, "Pagamento não configurado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID)
|
||||
if err != nil {
|
||||
b.send(chatID, "Não foi possível verificar agora. Tente novamente em instantes.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID) // delivers via notify
|
||||
return
|
||||
}
|
||||
b.send(chatID, "⏱ Pagamento ainda não identificado. Assim que cair, a liberação é automática.",
|
||||
kb([]tgInlineButton{btn("🔄 Verificar novamente", "pay:check:"+botItoa(txn.ID)), btn("⬅️ Voltar", "menu:main")}))
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
// bot_provision.go — bridges the bot to the panel's account creation.
|
||||
// SSH: Store.UpsertUser. Xray: xrayMgr.AddXrayClient + UpsertXrayClientMeta.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- credential generation ----------
|
||||
|
||||
const credAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
|
||||
|
||||
func randString(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// extremely unlikely; fall back to a fixed-length timestamp-free filler
|
||||
for i := range b {
|
||||
b[i] = credAlphabet[0]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = credAlphabet[int(b[i])%len(credAlphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func genUsername(prefix string) string {
|
||||
if prefix == "" {
|
||||
prefix = "ssh"
|
||||
}
|
||||
return prefix + randString(6)
|
||||
}
|
||||
|
||||
func genPassword() string { return randString(10) }
|
||||
|
||||
func uuidV4() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// ---------- SSH ----------
|
||||
|
||||
func createSSHUser(ctx context.Context, store *Store, username, password string, expiresAt time.Time, maxConns, upMbps, downMbps int, owner string) error {
|
||||
cfg := UserConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
MaxConnections: maxConns,
|
||||
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
||||
LimitMbpsUp: upMbps,
|
||||
LimitMbpsDown: downMbps,
|
||||
OwnerUsername: owner,
|
||||
}
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
reloadUsersFromDB(ctx, store)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renewSSHUser extends an existing SSH account's expiry, preserving credentials.
|
||||
func renewSSHUser(ctx context.Context, store *Store, username string, newExpiry time.Time) error {
|
||||
u, ok := userMgr.Get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("conta SSH %q não encontrada", username)
|
||||
}
|
||||
cfg := u.Cfg
|
||||
cfg.ExpiresAt = newExpiry.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
reloadUsersFromDB(ctx, store)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Xray ----------
|
||||
|
||||
func createXrayClient(ctx context.Context, store *Store, inboundTag, protocol string, expiresAt time.Time, maxConns int, owner, publicHost string) (uuid, link string, err error) {
|
||||
if inboundTag == "" {
|
||||
return "", "", fmt.Errorf("plano Xray sem inbound configurado")
|
||||
}
|
||||
uuid = uuidV4()
|
||||
email := "bot-" + uuid[:8]
|
||||
if err = xrayMgr.AddXrayClient(inboundTag, uuid, email); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
exp := expiresAt
|
||||
meta := XrayClientMeta{
|
||||
UUID: uuid,
|
||||
Name: email,
|
||||
Email: email,
|
||||
InboundTag: inboundTag,
|
||||
OwnerUsername: owner,
|
||||
MaxConns: maxConns,
|
||||
ExpiresAt: &exp,
|
||||
}
|
||||
if e := store.UpsertXrayClientMeta(ctx, meta); e != nil {
|
||||
// The client is already live in Xray; a metadata failure must not
|
||||
// abort delivery. Log and continue.
|
||||
log.Printf("[bot] xray meta save for %s: %v", uuid, e)
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
link = buildXrayLink(inboundTag, protocol, uuid, publicHost, email)
|
||||
return uuid, link, nil
|
||||
}
|
||||
|
||||
func renewXrayClient(ctx context.Context, store *Store, uuid string, newExpiry time.Time) error {
|
||||
meta, err := store.GetXrayClientMeta(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cliente Xray não encontrado: %w", err)
|
||||
}
|
||||
exp := newExpiry
|
||||
meta.ExpiresAt = &exp
|
||||
return store.UpsertXrayClientMeta(ctx, *meta)
|
||||
}
|
||||
|
||||
// ---------- Xray connection link ----------
|
||||
|
||||
type xrayInboundDetail struct {
|
||||
Protocol string
|
||||
Port int
|
||||
Network string
|
||||
Security string
|
||||
Path string
|
||||
Host string
|
||||
SNI string
|
||||
ServiceName string
|
||||
}
|
||||
|
||||
// inboundDetail reads streamSettings for an inbound from the raw Xray config.
|
||||
func (m *XrayManager) inboundDetail(tag string) (*xrayInboundDetail, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
data, err := m.readConfigLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []json.RawMessage `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range cfg.Inbounds {
|
||||
var ib struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port json.RawMessage `json:"port"`
|
||||
StreamSettings map[string]interface{} `json:"streamSettings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ib); err != nil {
|
||||
continue
|
||||
}
|
||||
if ib.Tag != tag {
|
||||
continue
|
||||
}
|
||||
d := &xrayInboundDetail{Protocol: strings.ToLower(ib.Protocol)}
|
||||
var pnum int
|
||||
if json.Unmarshal(ib.Port, &pnum) == nil {
|
||||
d.Port = pnum
|
||||
} else {
|
||||
var pstr string
|
||||
if json.Unmarshal(ib.Port, &pstr) == nil {
|
||||
d.Port, _ = strconv.Atoi(pstr)
|
||||
}
|
||||
}
|
||||
if ss := ib.StreamSettings; ss != nil {
|
||||
d.Network, _ = ss["network"].(string)
|
||||
d.Security, _ = ss["security"].(string)
|
||||
d.Path, d.Host, d.ServiceName = extractStreamParams(ss, d.Network)
|
||||
if tls, ok := ss["tlsSettings"].(map[string]interface{}); ok {
|
||||
d.SNI, _ = tls["serverName"].(string)
|
||||
}
|
||||
if rl, ok := ss["realitySettings"].(map[string]interface{}); ok {
|
||||
if sn, _ := rl["serverNames"].([]interface{}); len(sn) > 0 {
|
||||
d.SNI, _ = sn[0].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
return nil, fmt.Errorf("inbound %q não encontrado", tag)
|
||||
}
|
||||
|
||||
func extractStreamParams(ss map[string]interface{}, network string) (path, host, serviceName string) {
|
||||
get := func(key, field string) string {
|
||||
if sub, ok := ss[key].(map[string]interface{}); ok {
|
||||
v, _ := sub[field].(string)
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch network {
|
||||
case "ws":
|
||||
return get("wsSettings", "path"), get("wsSettings", "host"), ""
|
||||
case "xhttp":
|
||||
return get("xhttpSettings", "path"), get("xhttpSettings", "host"), ""
|
||||
case "httpupgrade":
|
||||
return get("httpupgradeSettings", "path"), get("httpupgradeSettings", "host"), ""
|
||||
case "grpc":
|
||||
return "", "", get("grpcSettings", "serviceName")
|
||||
case "http", "h2":
|
||||
return get("httpSettings", "path"), get("httpSettings", "host"), ""
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// buildXrayLink assembles a shareable connection URI. Best-effort: if the config
|
||||
// can't be read it still returns a minimal link with host/port/uuid.
|
||||
func buildXrayLink(inboundTag, protocol, uuid, publicHost, label string) string {
|
||||
d, err := xrayMgr.inboundDetail(inboundTag)
|
||||
if err != nil || d == nil {
|
||||
if protocol == "" {
|
||||
protocol = "vless"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s@%s#%s", protocol, uuid, publicHost, url.QueryEscape(label))
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = d.Protocol
|
||||
}
|
||||
host := publicHost
|
||||
if host == "" {
|
||||
host = d.SNI
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, d.Port)
|
||||
|
||||
q := url.Values{}
|
||||
if d.Network != "" {
|
||||
q.Set("type", d.Network)
|
||||
}
|
||||
if d.Security != "" {
|
||||
q.Set("security", d.Security)
|
||||
}
|
||||
if d.Path != "" {
|
||||
q.Set("path", d.Path)
|
||||
}
|
||||
if d.Host != "" {
|
||||
q.Set("host", d.Host)
|
||||
}
|
||||
if d.SNI != "" {
|
||||
q.Set("sni", d.SNI)
|
||||
}
|
||||
if d.ServiceName != "" {
|
||||
q.Set("serviceName", d.ServiceName)
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
conf := map[string]interface{}{
|
||||
"v": "2", "ps": label, "add": host, "port": strconv.Itoa(d.Port),
|
||||
"id": uuid, "aid": "0", "scy": "auto", "net": d.Network,
|
||||
"type": "none", "host": d.Host, "path": d.Path, "tls": d.Security, "sni": d.SNI,
|
||||
}
|
||||
b, _ := json.Marshal(conf)
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(b)
|
||||
default: // vless, trojan
|
||||
return fmt.Sprintf("%s://%s@%s?%s#%s", protocol, uuid, addr, q.Encode(), url.QueryEscape(label))
|
||||
}
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
package main
|
||||
|
||||
// bot_store.go — PostgreSQL persistence for the Telegram sales bot.
|
||||
//
|
||||
// Tables (all created idempotently by EnsureBotSchema):
|
||||
// bot_users — Telegram customers/resellers
|
||||
// bot_plans — sellable SSH/Xray plans
|
||||
// bot_credit_packages — reseller credit top-up packages
|
||||
// bot_transactions — PIX payments (Mercado Pago)
|
||||
// bot_credits_ledger — auditable credit movements
|
||||
// bot_settings — editable bot texts (key/value)
|
||||
// bot_config — single-row bot config with encrypted secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ---------- Models ----------
|
||||
|
||||
type BotUser struct {
|
||||
TelegramID int64
|
||||
Username string
|
||||
FirstName string
|
||||
Role string // customer | reseller | blocked
|
||||
LinkedAdminUsername string
|
||||
CreditBalance int
|
||||
TrialUsed bool
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type BotPlan struct {
|
||||
ID int
|
||||
Name string
|
||||
Kind string // ssh | xray
|
||||
Days int
|
||||
MaxConnections int
|
||||
LimitMbpsUp int
|
||||
LimitMbpsDown int
|
||||
XrayInboundTag string
|
||||
XrayProtocol string
|
||||
PriceCents int
|
||||
CreditCost int
|
||||
ServerID string
|
||||
IsActive bool
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type BotCreditPackage struct {
|
||||
ID int
|
||||
Name string
|
||||
Credits int
|
||||
PriceCents int
|
||||
IsActive bool
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type BotTransaction struct {
|
||||
ID int
|
||||
TelegramID int64
|
||||
Type string // plan_purchase | plan_renewal | credit_topup
|
||||
PlanID *int
|
||||
PackageID *int
|
||||
Credits int
|
||||
AmountCents int
|
||||
MPPaymentID string
|
||||
MPQRCode string
|
||||
MPQRBase64 string
|
||||
Status string // pending | approved | expired | refunded | error
|
||||
TargetUsername string // account/uuid created or renewed
|
||||
RenewTarget string // for renewals: existing account/uuid to extend
|
||||
CreatedAt time.Time
|
||||
PaidAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type BotLedgerEntry struct {
|
||||
ID int
|
||||
TelegramID int64
|
||||
Delta int
|
||||
Reason string
|
||||
RefTxnID *int
|
||||
BalanceAfter int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Schema ----------
|
||||
|
||||
func (s *Store) EnsureBotSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS bot_users (
|
||||
telegram_id BIGINT PRIMARY KEY,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
linked_admin_username TEXT NOT NULL DEFAULT '',
|
||||
credit_balance INT NOT NULL DEFAULT 0,
|
||||
trial_used BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
days INT NOT NULL DEFAULT 30,
|
||||
max_connections INT NOT NULL DEFAULT 1,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0,
|
||||
limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
xray_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
xray_protocol TEXT NOT NULL DEFAULT '',
|
||||
price_cents INT NOT NULL DEFAULT 0,
|
||||
credit_cost INT NOT NULL DEFAULT 1,
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_credit_packages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credits INT NOT NULL DEFAULT 0,
|
||||
price_cents INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
plan_id INT,
|
||||
package_id INT,
|
||||
credits INT NOT NULL DEFAULT 0,
|
||||
amount_cents INT NOT NULL DEFAULT 0,
|
||||
mp_payment_id TEXT NOT NULL DEFAULT '',
|
||||
mp_qr_code TEXT NOT NULL DEFAULT '',
|
||||
mp_qr_base64 TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
target_username TEXT NOT NULL DEFAULT '',
|
||||
renew_target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
paid_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx
|
||||
ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> ''`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_credits_ledger (
|
||||
id SERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
ref_transaction_id INT,
|
||||
balance_after INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_config (
|
||||
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
telegram_mode TEXT NOT NULL DEFAULT 'polling',
|
||||
telegram_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
mp_confirm_mode TEXT NOT NULL DEFAULT 'polling',
|
||||
mp_poll_interval TEXT NOT NULL DEFAULT '20s',
|
||||
pix_expiration_minutes INT NOT NULL DEFAULT 30,
|
||||
trial_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
trial_hours INT NOT NULL DEFAULT 1,
|
||||
trial_max_connections INT NOT NULL DEFAULT 1,
|
||||
trial_kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
trial_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||
currency TEXT NOT NULL DEFAULT 'BRL',
|
||||
public_host TEXT NOT NULL DEFAULT '',
|
||||
xray_public_host TEXT NOT NULL DEFAULT '',
|
||||
telegram_token_enc BYTEA,
|
||||
telegram_webhook_secret_enc BYTEA,
|
||||
mp_access_token_enc BYTEA,
|
||||
mp_webhook_secret_enc BYTEA,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- bot_users ----------
|
||||
|
||||
// UpsertBotUser inserts a user or refreshes username/first_name/last_seen.
|
||||
// Role, credits, linkage and trial_used are preserved on update.
|
||||
func (s *Store) UpsertBotUser(ctx context.Context, u *BotUser) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO bot_users (telegram_id, username, first_name, last_seen_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_seen_at = NOW()`,
|
||||
u.TelegramID, u.Username, u.FirstName)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanBotUser(row interface{ Scan(...interface{}) error }) (*BotUser, error) {
|
||||
var u BotUser
|
||||
err := row.Scan(&u.TelegramID, &u.Username, &u.FirstName, &u.Role,
|
||||
&u.LinkedAdminUsername, &u.CreditBalance, &u.TrialUsed, &u.CreatedAt, &u.LastSeenAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
const botUserCols = `telegram_id, username, first_name, role, linked_admin_username, credit_balance, trial_used, created_at, last_seen_at`
|
||||
|
||||
func (s *Store) GetBotUser(ctx context.Context, telegramID int64) (*BotUser, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botUserCols+` FROM bot_users WHERE telegram_id=$1`, telegramID)
|
||||
return scanBotUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+botUserCols+` FROM bot_users ORDER BY last_seen_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotUser
|
||||
for rows.Next() {
|
||||
u, err := scanBotUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetBotUserRole updates role and reseller linkage.
|
||||
func (s *Store) SetBotUserRole(ctx context.Context, telegramID int64, role, linkedAdmin string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_users SET role=$2, linked_admin_username=$3 WHERE telegram_id=$1`,
|
||||
telegramID, role, linkedAdmin)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetBotUserTrialUsed(ctx context.Context, telegramID int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_users SET trial_used=true WHERE telegram_id=$1`, telegramID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdjustCredits changes a reseller's balance atomically and writes a ledger row.
|
||||
// Returns the resulting balance. Fails (rolls back) if the balance would go negative.
|
||||
func (s *Store) AdjustCredits(ctx context.Context, telegramID int64, delta int, reason string, refTxn *int) (int, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var balance int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`UPDATE bot_users SET credit_balance = credit_balance + $2
|
||||
WHERE telegram_id=$1 RETURNING credit_balance`,
|
||||
telegramID, delta).Scan(&balance); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if balance < 0 {
|
||||
return 0, errInsufficientCredits
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO bot_credits_ledger (telegram_id, delta, reason, ref_transaction_id, balance_after)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
telegramID, delta, reason, refTxn, balance); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLedger(ctx context.Context, telegramID int64, limit int) ([]*BotLedgerEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, telegram_id, delta, reason, ref_transaction_id, balance_after, created_at
|
||||
FROM bot_credits_ledger WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotLedgerEntry
|
||||
for rows.Next() {
|
||||
var e BotLedgerEntry
|
||||
if err := rows.Scan(&e.ID, &e.TelegramID, &e.Delta, &e.Reason, &e.RefTxnID, &e.BalanceAfter, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bot_plans ----------
|
||||
|
||||
const botPlanCols = `id, name, kind, days, max_connections, limit_mbps_up, limit_mbps_down, xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order`
|
||||
|
||||
func scanBotPlan(row interface{ Scan(...interface{}) error }) (*BotPlan, error) {
|
||||
var p BotPlan
|
||||
err := row.Scan(&p.ID, &p.Name, &p.Kind, &p.Days, &p.MaxConnections, &p.LimitMbpsUp, &p.LimitMbpsDown,
|
||||
&p.XrayInboundTag, &p.XrayProtocol, &p.PriceCents, &p.CreditCost, &p.ServerID, &p.IsActive, &p.SortOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPlans(ctx context.Context, onlyActive bool) ([]*BotPlan, error) {
|
||||
q := `SELECT ` + botPlanCols + ` FROM bot_plans`
|
||||
if onlyActive {
|
||||
q += ` WHERE is_active=true`
|
||||
}
|
||||
q += ` ORDER BY sort_order, id`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotPlan
|
||||
for rows.Next() {
|
||||
p, err := scanBotPlan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetPlan(ctx context.Context, id int) (*BotPlan, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botPlanCols+` FROM bot_plans WHERE id=$1`, id)
|
||||
return scanBotPlan(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPlan(ctx context.Context, p *BotPlan) error {
|
||||
if p.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO bot_plans (name, kind, days, max_connections, limit_mbps_up, limit_mbps_down,
|
||||
xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown,
|
||||
p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder,
|
||||
).Scan(&p.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE bot_plans SET name=$2, kind=$3, days=$4, max_connections=$5, limit_mbps_up=$6, limit_mbps_down=$7,
|
||||
xray_inbound_tag=$8, xray_protocol=$9, price_cents=$10, credit_cost=$11, server_id=$12, is_active=$13, sort_order=$14
|
||||
WHERE id=$1`,
|
||||
p.ID, p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown,
|
||||
p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeletePlan(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bot_plans WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_credit_packages ----------
|
||||
|
||||
const botPkgCols = `id, name, credits, price_cents, is_active, sort_order`
|
||||
|
||||
func scanBotPkg(row interface{ Scan(...interface{}) error }) (*BotCreditPackage, error) {
|
||||
var p BotCreditPackage
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Credits, &p.PriceCents, &p.IsActive, &p.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCreditPackages(ctx context.Context, onlyActive bool) ([]*BotCreditPackage, error) {
|
||||
q := `SELECT ` + botPkgCols + ` FROM bot_credit_packages`
|
||||
if onlyActive {
|
||||
q += ` WHERE is_active=true`
|
||||
}
|
||||
q += ` ORDER BY sort_order, id`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotCreditPackage
|
||||
for rows.Next() {
|
||||
p, err := scanBotPkg(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetCreditPackage(ctx context.Context, id int) (*BotCreditPackage, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botPkgCols+` FROM bot_credit_packages WHERE id=$1`, id)
|
||||
return scanBotPkg(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertCreditPackage(ctx context.Context, p *BotCreditPackage) error {
|
||||
if p.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO bot_credit_packages (name, credits, price_cents, is_active, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||
p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder).Scan(&p.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_credit_packages SET name=$2, credits=$3, price_cents=$4, is_active=$5, sort_order=$6 WHERE id=$1`,
|
||||
p.ID, p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCreditPackage(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bot_credit_packages WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_transactions ----------
|
||||
|
||||
const botTxnCols = `id, telegram_id, type, plan_id, package_id, credits, amount_cents, mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, created_at, paid_at, expires_at`
|
||||
|
||||
func scanBotTxn(row interface{ Scan(...interface{}) error }) (*BotTransaction, error) {
|
||||
var t BotTransaction
|
||||
err := row.Scan(&t.ID, &t.TelegramID, &t.Type, &t.PlanID, &t.PackageID, &t.Credits, &t.AmountCents,
|
||||
&t.MPPaymentID, &t.MPQRCode, &t.MPQRBase64, &t.Status, &t.TargetUsername, &t.RenewTarget,
|
||||
&t.CreatedAt, &t.PaidAt, &t.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTransaction(ctx context.Context, t *BotTransaction) error {
|
||||
return s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO bot_transactions (telegram_id, type, plan_id, package_id, credits, amount_cents,
|
||||
mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, created_at`,
|
||||
t.TelegramID, t.Type, t.PlanID, t.PackageID, t.Credits, t.AmountCents,
|
||||
t.MPPaymentID, t.MPQRCode, t.MPQRBase64, t.Status, t.TargetUsername, t.RenewTarget, t.ExpiresAt,
|
||||
).Scan(&t.ID, &t.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) GetTransaction(ctx context.Context, id int) (*BotTransaction, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE id=$1`, id)
|
||||
return scanBotTxn(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetTransactionByMPID(ctx context.Context, mpID string) (*BotTransaction, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE mp_payment_id=$1`, mpID)
|
||||
return scanBotTxn(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingTransactions(ctx context.Context) ([]*BotTransaction, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+botTxnCols+` FROM bot_transactions WHERE status='pending' AND mp_payment_id <> '' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListUserTransactions(ctx context.Context, telegramID int64, limit int) ([]*BotTransaction, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+botTxnCols+` FROM bot_transactions WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListTransactions(ctx context.Context, status string, limit int) ([]*BotTransaction, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if status != "" {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE status=$1 ORDER BY id DESC LIMIT $2`, status, limit)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions ORDER BY id DESC LIMIT $1`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkTransactionApproved atomically flips a pending txn to approved. It returns
|
||||
// true only for the caller that actually performed the transition, giving
|
||||
// idempotent delivery even if webhook and poller race.
|
||||
func (s *Store) MarkTransactionApproved(ctx context.Context, id int) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_transactions SET status='approved', paid_at=NOW() WHERE id=$1 AND status='pending'`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetTransactionStatus(ctx context.Context, id int, status string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET status=$2 WHERE id=$1`, id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetTransactionTarget(ctx context.Context, id int, target string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET target_username=$2 WHERE id=$1`, id, target)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_settings ----------
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key, def string) string {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM bot_settings WHERE key=$1`, key).Scan(&v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO bot_settings (key, value) VALUES ($1,$2)
|
||||
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM bot_settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bot_config (row) ----------
|
||||
|
||||
// botConfigRow mirrors the DB row; secrets stay encrypted here.
|
||||
type botConfigRow struct {
|
||||
Enabled bool
|
||||
TelegramMode string
|
||||
TelegramWebhookURL string
|
||||
MPConfirmMode string
|
||||
MPPollInterval string
|
||||
PixExpirationMinutes int
|
||||
TrialEnabled bool
|
||||
TrialHours int
|
||||
TrialMaxConnections int
|
||||
TrialKind string
|
||||
TrialInboundTag string
|
||||
AdminTelegramIDs []int64
|
||||
Currency string
|
||||
PublicHost string
|
||||
XrayPublicHost string
|
||||
TelegramTokenEnc []byte
|
||||
TelegramWebhookSecretEnc []byte
|
||||
MPAccessTokenEnc []byte
|
||||
MPWebhookSecretEnc []byte
|
||||
}
|
||||
|
||||
func (s *Store) getBotConfigRow(ctx context.Context) (*botConfigRow, error) {
|
||||
var r botConfigRow
|
||||
var ids pq.Int64Array
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT enabled, telegram_mode, telegram_webhook_url, mp_confirm_mode, mp_poll_interval,
|
||||
pix_expiration_minutes, trial_enabled, trial_hours, trial_max_connections, trial_kind,
|
||||
trial_inbound_tag, admin_telegram_ids, currency, public_host, xray_public_host,
|
||||
telegram_token_enc, telegram_webhook_secret_enc, mp_access_token_enc, mp_webhook_secret_enc
|
||||
FROM bot_config WHERE id=1`).Scan(
|
||||
&r.Enabled, &r.TelegramMode, &r.TelegramWebhookURL, &r.MPConfirmMode, &r.MPPollInterval,
|
||||
&r.PixExpirationMinutes, &r.TrialEnabled, &r.TrialHours, &r.TrialMaxConnections, &r.TrialKind,
|
||||
&r.TrialInboundTag, &ids, &r.Currency, &r.PublicHost, &r.XrayPublicHost,
|
||||
&r.TelegramTokenEnc, &r.TelegramWebhookSecretEnc, &r.MPAccessTokenEnc, &r.MPWebhookSecretEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.AdminTelegramIDs = []int64(ids)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// saveBotConfigRow writes the non-secret fields plus any encrypted blobs that
|
||||
// are non-nil (nil blob = keep existing secret).
|
||||
func (s *Store) saveBotConfigRow(ctx context.Context, r *botConfigRow, tokEnc, tgSecEnc, mpEnc, mpSecEnc []byte) error {
|
||||
set := `enabled=$1, telegram_mode=$2, telegram_webhook_url=$3, mp_confirm_mode=$4, mp_poll_interval=$5,
|
||||
pix_expiration_minutes=$6, trial_enabled=$7, trial_hours=$8, trial_max_connections=$9, trial_kind=$10,
|
||||
trial_inbound_tag=$11, admin_telegram_ids=$12, currency=$13, public_host=$14, xray_public_host=$15,
|
||||
updated_at=NOW()`
|
||||
args := []interface{}{
|
||||
r.Enabled, r.TelegramMode, r.TelegramWebhookURL, r.MPConfirmMode, r.MPPollInterval,
|
||||
r.PixExpirationMinutes, r.TrialEnabled, r.TrialHours, r.TrialMaxConnections, r.TrialKind,
|
||||
r.TrialInboundTag, pq.Array(r.AdminTelegramIDs), r.Currency, r.PublicHost, r.XrayPublicHost,
|
||||
}
|
||||
n := len(args)
|
||||
if tokEnc != nil {
|
||||
n++
|
||||
set += `, telegram_token_enc=$` + botItoa(n)
|
||||
args = append(args, tokEnc)
|
||||
}
|
||||
if tgSecEnc != nil {
|
||||
n++
|
||||
set += `, telegram_webhook_secret_enc=$` + botItoa(n)
|
||||
args = append(args, tgSecEnc)
|
||||
}
|
||||
if mpEnc != nil {
|
||||
n++
|
||||
set += `, mp_access_token_enc=$` + botItoa(n)
|
||||
args = append(args, mpEnc)
|
||||
}
|
||||
if mpSecEnc != nil {
|
||||
n++
|
||||
set += `, mp_webhook_secret_enc=$` + botItoa(n)
|
||||
args = append(args, mpSecEnc)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_config SET `+set+` WHERE id=1`, args...)
|
||||
return err
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
// bot_telegram.go — minimal Telegram Bot API client built on net/http.
|
||||
// No third-party dependency.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tgAPIBase = "https://api.telegram.org/bot"
|
||||
|
||||
// ---------- Wire types (subset) ----------
|
||||
|
||||
type tgUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *tgMessage `json:"message"`
|
||||
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||
}
|
||||
|
||||
type tgMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
From *tgUser `json:"from"`
|
||||
Chat tgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type tgCallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From tgUser `json:"from"`
|
||||
Message *tgMessage `json:"message"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type tgUser struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type tgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type tgInlineKeyboard struct {
|
||||
InlineKeyboard [][]tgInlineButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type tgInlineButton struct {
|
||||
Text string `json:"text"`
|
||||
CallbackData string `json:"callback_data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- Client ----------
|
||||
|
||||
type tgClient struct {
|
||||
token string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newTGClient(token string) *tgClient {
|
||||
return &tgClient{token: token, hc: &http.Client{Timeout: 65 * time.Second}}
|
||||
}
|
||||
|
||||
type tgResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
func (c *tgClient) call(ctx context.Context, method string, payload interface{}) (json.RawMessage, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/"+method, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var tr tgResponse
|
||||
if err := json.Unmarshal(data, &tr); err != nil {
|
||||
return nil, fmt.Errorf("telegram %s: bad response: %s", method, string(data))
|
||||
}
|
||||
if !tr.OK {
|
||||
return nil, fmt.Errorf("telegram %s: %s", method, tr.Description)
|
||||
}
|
||||
return tr.Result, nil
|
||||
}
|
||||
|
||||
// getUpdates long-polls. offset is the next update_id to fetch.
|
||||
func (c *tgClient) getUpdates(ctx context.Context, offset int64, timeoutSec int) ([]tgUpdate, error) {
|
||||
payload := map[string]interface{}{
|
||||
"offset": offset,
|
||||
"timeout": timeoutSec,
|
||||
"allowed_updates": []string{"message", "callback_query"},
|
||||
}
|
||||
raw, err := c.call(ctx, "getUpdates", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ups []tgUpdate
|
||||
if err := json.Unmarshal(raw, &ups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ups, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) sendMessage(ctx context.Context, chatID int64, text string, kb *tgInlineKeyboard) (int64, error) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if kb != nil {
|
||||
payload["reply_markup"] = kb
|
||||
}
|
||||
raw, err := c.call(ctx, "sendMessage", payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var m tgMessage
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
return m.MessageID, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) editMessageText(ctx context.Context, chatID, messageID int64, text string, kb *tgInlineKeyboard) error {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if kb != nil {
|
||||
payload["reply_markup"] = kb
|
||||
}
|
||||
_, err := c.call(ctx, "editMessageText", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *tgClient) answerCallback(ctx context.Context, callbackID, text string) error {
|
||||
payload := map[string]interface{}{"callback_query_id": callbackID}
|
||||
if text != "" {
|
||||
payload["text"] = text
|
||||
}
|
||||
_, err := c.call(ctx, "answerCallbackQuery", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendPhotoBytes uploads a photo (e.g. a PIX QR PNG) via multipart.
|
||||
func (c *tgClient) sendPhotoBytes(ctx context.Context, chatID int64, photo []byte, filename, caption string, kb *tgInlineKeyboard) (int64, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
|
||||
if caption != "" {
|
||||
_ = w.WriteField("caption", caption)
|
||||
_ = w.WriteField("parse_mode", "HTML")
|
||||
}
|
||||
if kb != nil {
|
||||
kbJSON, _ := json.Marshal(kb)
|
||||
_ = w.WriteField("reply_markup", string(kbJSON))
|
||||
}
|
||||
fw, err := w.CreateFormFile("photo", filename)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := fw.Write(photo); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/sendPhoto", &buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var tr tgResponse
|
||||
if err := json.Unmarshal(data, &tr); err != nil || !tr.OK {
|
||||
return 0, fmt.Errorf("telegram sendPhoto: %s", string(data))
|
||||
}
|
||||
var m tgMessage
|
||||
_ = json.Unmarshal(tr.Result, &m)
|
||||
return m.MessageID, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) deleteWebhook(ctx context.Context) error {
|
||||
_, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false})
|
||||
return err
|
||||
}
|
||||
|
||||
// getMe validates the token and returns the bot username.
|
||||
func (c *tgClient) getMe(ctx context.Context) (string, error) {
|
||||
raw, err := c.call(ctx, "getMe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var me tgUser
|
||||
_ = json.Unmarshal(raw, &me)
|
||||
return me.Username, nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/bin/bash
|
||||
# DragonCoreSSH V40 admin password recovery tool.
|
||||
# Usage:
|
||||
# sudo bash change_admin_password.sh
|
||||
# sudo bash change_admin_password.sh admin 'NewPasswordHere'
|
||||
# sudo bash change_admin_password.sh --user admin --generate
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-sshpanel}"
|
||||
ENV_FILE="${ENV_FILE:-${INSTALL_DIR}/.env}"
|
||||
ADMIN_USER=""
|
||||
NEW_PASSWORD=""
|
||||
GENERATE_PASSWORD=false
|
||||
NO_RESTART=false
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
DragonCoreSSH V40 admin password recovery
|
||||
|
||||
Usage:
|
||||
sudo bash $0
|
||||
sudo bash $0 admin 'NewPasswordHere'
|
||||
sudo bash $0 --user admin --password 'NewPasswordHere'
|
||||
sudo bash $0 --user admin --generate
|
||||
|
||||
Options:
|
||||
-u, --user USERNAME Admin username to reset. Default: admin
|
||||
-p, --password PASSWORD New password. If omitted, you will be prompted.
|
||||
-g, --generate Generate a strong random password.
|
||||
--no-restart Do not restart the sshpanel service after changing DB.
|
||||
-h, --help Show this help.
|
||||
|
||||
Environment overrides:
|
||||
INSTALL_DIR=/opt/sshpanel
|
||||
ENV_FILE=/opt/sshpanel/.env
|
||||
SERVICE_NAME=sshpanel
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-u|--user)
|
||||
[[ $# -ge 2 ]] || error "Missing value for $1"
|
||||
ADMIN_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--password)
|
||||
[[ $# -ge 2 ]] || error "Missing value for $1"
|
||||
NEW_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
-g|--generate)
|
||||
GENERATE_PASSWORD=true
|
||||
shift
|
||||
;;
|
||||
--no-restart)
|
||||
NO_RESTART=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-* )
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$ADMIN_USER" ]]; then
|
||||
ADMIN_USER="$1"
|
||||
elif [[ -z "$NEW_PASSWORD" ]]; then
|
||||
NEW_PASSWORD="$1"
|
||||
else
|
||||
error "Too many positional arguments. Use --help for usage."
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
[[ -f "$ENV_FILE" ]] || error "Environment file not found: $ENV_FILE"
|
||||
command -v psql >/dev/null 2>&1 || error "psql not found. Install PostgreSQL client first."
|
||||
|
||||
get_env_value() {
|
||||
local key="$1"
|
||||
awk -v key="$key" '
|
||||
$0 ~ "^" key "=" {
|
||||
sub("^[^=]*=", "")
|
||||
gsub(/^\"|\"$/, "")
|
||||
gsub(/^\047|\047$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
remove_legacy_env_password() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk '
|
||||
/^ADMIN_PASSWORD=/ { next }
|
||||
{ print }
|
||||
' "$ENV_FILE" > "$tmp"
|
||||
install -m 600 "$tmp" "$ENV_FILE"
|
||||
rm -f -- "$tmp"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local pw=""
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
pw="$(openssl rand -base64 24 | tr -d '\n' | tr -d '=/+' | head -c 24 || true)"
|
||||
fi
|
||||
if [[ ${#pw} -lt 20 ]]; then
|
||||
pw="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24 || true)"
|
||||
fi
|
||||
if [[ ${#pw} -lt 20 ]]; then
|
||||
pw="DragonCore$(date +%s%N)"
|
||||
fi
|
||||
printf '%s' "$pw"
|
||||
}
|
||||
|
||||
hash_password() {
|
||||
local pw="$1"
|
||||
[[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel"
|
||||
printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null
|
||||
}
|
||||
|
||||
PG_DSN="$(get_env_value PG_DSN)"
|
||||
[[ -n "$PG_DSN" ]] || error "PG_DSN not found inside $ENV_FILE"
|
||||
|
||||
if [[ -z "$ADMIN_USER" ]]; then
|
||||
read -r -p "Admin username [admin]: " ADMIN_USER
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
fi
|
||||
|
||||
[[ -n "$ADMIN_USER" ]] || error "Admin username cannot be empty."
|
||||
|
||||
if $GENERATE_PASSWORD; then
|
||||
NEW_PASSWORD="$(generate_password)"
|
||||
elif [[ -z "$NEW_PASSWORD" ]]; then
|
||||
read -r -s -p "New password: " PASS1
|
||||
echo
|
||||
read -r -s -p "Confirm password: " PASS2
|
||||
echo
|
||||
[[ "$PASS1" == "$PASS2" ]] || error "Passwords do not match."
|
||||
NEW_PASSWORD="$PASS1"
|
||||
fi
|
||||
|
||||
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
|
||||
if [[ ${#NEW_PASSWORD} -lt 10 ]]; then
|
||||
error "Password must have at least 10 characters."
|
||||
fi
|
||||
|
||||
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")"
|
||||
[[ "$PASSWORD_HASH" == \$2* ]] || error "Failed to generate a valid bcrypt password hash."
|
||||
|
||||
info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
|
||||
psql "$PG_DSN" -v ON_ERROR_STOP=1 \
|
||||
-v admin_user="$ADMIN_USER" \
|
||||
-v password_hash="$PASSWORD_HASH" <<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'reseller',
|
||||
max_users INT NOT NULL DEFAULT 30,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES (:'admin_user', :'password_hash', 'superadmin', 0, NULL, TRUE)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = 'superadmin',
|
||||
max_users = 0,
|
||||
expires_at = NULL,
|
||||
is_active = TRUE;
|
||||
SQL
|
||||
|
||||
if [[ "$ADMIN_USER" == "admin" ]]; then
|
||||
remove_legacy_env_password
|
||||
info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE"
|
||||
fi
|
||||
|
||||
if ! $NO_RESTART; then
|
||||
info "Restarting ${SERVICE_NAME} so the in-memory admin cache reloads..."
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files "${SERVICE_NAME}.service" >/dev/null 2>&1; then
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
sleep 1
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
info "${SERVICE_NAME} restarted successfully."
|
||||
else
|
||||
warn "${SERVICE_NAME} is not active after restart. Last logs:"
|
||||
journalctl -u "$SERVICE_NAME" -n 30 --no-pager 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
service "$SERVICE_NAME" restart || warn "Could not restart ${SERVICE_NAME}. Restart it manually."
|
||||
else
|
||||
warn "Could not restart ${SERVICE_NAME}. Restart it manually before logging in."
|
||||
fi
|
||||
else
|
||||
warn "Service restart skipped. Restart ${SERVICE_NAME} manually before logging in."
|
||||
fi
|
||||
|
||||
echo
|
||||
info "Admin password changed."
|
||||
echo " Username : ${ADMIN_USER}"
|
||||
echo " Password : ${NEW_PASSWORD}"
|
||||
echo
|
||||
warn "Save this password now. It is only shown here."
|
||||
+2
-1
@@ -59,7 +59,8 @@ func checkSSHUser(w http.ResponseWriter, username string) {
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
activeConns := u.ActiveConns
|
||||
activeConns := len(u.conns)
|
||||
u.ActiveConns = activeConns
|
||||
maxConns := u.Cfg.MaxConnections
|
||||
expiresAt := u.ExpiresAt
|
||||
u.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMainListen = "0.0.0.0:80"
|
||||
defaultExtraListen = "0.0.0.0:8080"
|
||||
defaultDNSTTListen = "[::]:5300"
|
||||
defaultUDPGWListen = "0.0.0.0:7400"
|
||||
)
|
||||
|
||||
func normalizeRuntimePorts(cfg *Config) []string {
|
||||
var warnings []string
|
||||
warn := func(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
warnings = append(warnings, msg)
|
||||
log.Printf("config safety: %s", msg)
|
||||
}
|
||||
|
||||
cfg.Listen = strings.TrimSpace(cfg.Listen)
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = defaultMainListen
|
||||
}
|
||||
if err := tcpAddrAvailableForPool(cfg.Listen, publicPool); err != nil {
|
||||
old := cfg.Listen
|
||||
cfg.Listen = defaultMainListen
|
||||
warn("main listener %s is unavailable (%v); using default %s", old, err, cfg.Listen)
|
||||
if err2 := tcpAddrAvailableForPool(cfg.Listen, publicPool); err2 != nil {
|
||||
warn("default main listener %s is also unavailable: %v", cfg.Listen, err2)
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{cfg.Listen: true}
|
||||
extra := make([]string, 0, len(cfg.ExtraListen))
|
||||
for _, addr := range cfg.ExtraListen {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" || seen[addr] {
|
||||
continue
|
||||
}
|
||||
if err := tcpAddrAvailableForPool(addr, publicPool); err != nil {
|
||||
warn("extra listener %s is unavailable (%v)", addr, err)
|
||||
fallback := defaultExtraListen
|
||||
if !seen[fallback] {
|
||||
if err2 := tcpAddrAvailableForPool(fallback, publicPool); err2 == nil {
|
||||
extra = append(extra, fallback)
|
||||
seen[fallback] = true
|
||||
warn("extra listener fell back to default %s", fallback)
|
||||
} else {
|
||||
warn("default extra listener %s is also unavailable: %v", fallback, err2)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
extra = append(extra, addr)
|
||||
seen[addr] = true
|
||||
}
|
||||
cfg.ExtraListen = extra
|
||||
|
||||
// DragonCore no longer uses an internal local SSH listener.
|
||||
cfg.LocalSSHListen = ""
|
||||
|
||||
cfg.ProxyAutoRestartInterval = strings.TrimSpace(cfg.ProxyAutoRestartInterval)
|
||||
if cfg.ProxyAutoRestartInterval != "" && cfg.ProxyAutoRestartInterval != "0" && cfg.ProxyAutoRestartInterval != "0s" && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "off") && !strings.EqualFold(cfg.ProxyAutoRestartInterval, "disabled") {
|
||||
if d, err := time.ParseDuration(cfg.ProxyAutoRestartInterval); err != nil {
|
||||
warn("proxy auto restart interval %q is invalid; disabling auto restart", cfg.ProxyAutoRestartInterval)
|
||||
cfg.ProxyAutoRestartInterval = ""
|
||||
} else if d < time.Minute {
|
||||
warn("proxy auto restart interval %q is below 1m; disabling auto restart", cfg.ProxyAutoRestartInterval)
|
||||
cfg.ProxyAutoRestartInterval = ""
|
||||
}
|
||||
}
|
||||
cfg.ProxyAutoRestartGrace = strings.TrimSpace(cfg.ProxyAutoRestartGrace)
|
||||
if cfg.ProxyAutoRestartGrace != "" {
|
||||
if d, err := time.ParseDuration(cfg.ProxyAutoRestartGrace); err != nil || d < 0 {
|
||||
warn("proxy auto restart grace %q is invalid; using default 2s", cfg.ProxyAutoRestartGrace)
|
||||
cfg.ProxyAutoRestartGrace = ""
|
||||
} else if d > time.Minute {
|
||||
warn("proxy auto restart grace %q is above 1m; clamping to 1m", cfg.ProxyAutoRestartGrace)
|
||||
cfg.ProxyAutoRestartGrace = "1m"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DNSTT != nil {
|
||||
cfg.DNSTT.FakeDNSDomain = strings.TrimSpace(cfg.DNSTT.FakeDNSDomain)
|
||||
if cfg.DNSTT.FakeDNSEnabled {
|
||||
if cfg.DNSTT.FakeDNSDomain == "" {
|
||||
cfg.DNSTT.FakeDNSDomain = "t.local.lan"
|
||||
}
|
||||
// Automatically add the local/fake test zone to the accepted DNSTT
|
||||
// domains so the tunnel handler can decode traffic for it.
|
||||
cfg.DNSTT.Domains = append(cfg.DNSTT.Domains, cfg.DNSTT.FakeDNSDomain)
|
||||
}
|
||||
cfg.DNSTT.Domains = normalizeDNSTTDomainList(cfg.DNSTT.Domain, cfg.DNSTT.Domains)
|
||||
if len(cfg.DNSTT.Domains) > 0 {
|
||||
cfg.DNSTT.Domain = cfg.DNSTT.Domains[0]
|
||||
} else {
|
||||
cfg.DNSTT.Domain = strings.TrimSpace(cfg.DNSTT.Domain)
|
||||
}
|
||||
if cfg.DNSTT.FakeDNSEnabled {
|
||||
localDomains := normalizeDNSTTDomainList(cfg.DNSTT.FakeDNSDomain, nil)
|
||||
if len(localDomains) > 0 {
|
||||
cfg.DNSTT.FakeDNSDomain = localDomains[0]
|
||||
}
|
||||
}
|
||||
|
||||
cfg.DNSTT.UDPListen = strings.TrimSpace(cfg.DNSTT.UDPListen)
|
||||
if cfg.DNSTT.UDPListen == "" {
|
||||
cfg.DNSTT.UDPListen = defaultDNSTTListen
|
||||
}
|
||||
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err != nil {
|
||||
old := cfg.DNSTT.UDPListen
|
||||
cfg.DNSTT.UDPListen = defaultDNSTTListen
|
||||
warn("DNSTT UDP listener %s is unavailable (%v); using default %s", old, err, cfg.DNSTT.UDPListen)
|
||||
if err2 := udpAddrAvailableForDNSTT(cfg.DNSTT.UDPListen); err2 != nil {
|
||||
warn("default DNSTT UDP listener %s is also unavailable: %v", cfg.DNSTT.UDPListen, err2)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DNSTT.FakeDNSEnabled {
|
||||
cfg.DNSTT.FakeDNSListen = strings.TrimSpace(cfg.DNSTT.FakeDNSListen)
|
||||
if cfg.DNSTT.FakeDNSListen == "" {
|
||||
cfg.DNSTT.FakeDNSListen = "[::]:53"
|
||||
}
|
||||
if !sameUDPListenAddress(cfg.DNSTT.FakeDNSListen, cfg.DNSTT.UDPListen) {
|
||||
if err := udpAddrAvailableForDNSTT(cfg.DNSTT.FakeDNSListen); err != nil {
|
||||
warn("built-in DNSTT local DNS listener %s is unavailable: %v", cfg.DNSTT.FakeDNSListen, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.DNSTT.AutoRestartInterval = strings.TrimSpace(cfg.DNSTT.AutoRestartInterval)
|
||||
if cfg.DNSTT.AutoRestartInterval != "" && cfg.DNSTT.AutoRestartInterval != "0" && cfg.DNSTT.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "off") && !strings.EqualFold(cfg.DNSTT.AutoRestartInterval, "disabled") {
|
||||
if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartInterval); err != nil {
|
||||
warn("DNSTT auto restart interval %q is invalid; disabling auto restart", cfg.DNSTT.AutoRestartInterval)
|
||||
cfg.DNSTT.AutoRestartInterval = ""
|
||||
} else if d < time.Minute {
|
||||
warn("DNSTT auto restart interval %q is below 1m; disabling auto restart", cfg.DNSTT.AutoRestartInterval)
|
||||
cfg.DNSTT.AutoRestartInterval = ""
|
||||
}
|
||||
}
|
||||
cfg.DNSTT.AutoRestartGrace = strings.TrimSpace(cfg.DNSTT.AutoRestartGrace)
|
||||
if cfg.DNSTT.AutoRestartGrace != "" {
|
||||
if d, err := time.ParseDuration(cfg.DNSTT.AutoRestartGrace); err != nil || d < 0 {
|
||||
warn("DNSTT auto restart grace %q is invalid; using default 2s", cfg.DNSTT.AutoRestartGrace)
|
||||
cfg.DNSTT.AutoRestartGrace = ""
|
||||
} else if d > time.Minute {
|
||||
warn("DNSTT auto restart grace %q is above 1m; clamping to 1m", cfg.DNSTT.AutoRestartGrace)
|
||||
cfg.DNSTT.AutoRestartGrace = "1m"
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.DNSTT.MaxSessions < -1 {
|
||||
warn("DNSTT max_sessions %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxSessions)
|
||||
cfg.DNSTT.MaxSessions = -1
|
||||
}
|
||||
if cfg.DNSTT.MaxStreams < -1 {
|
||||
warn("DNSTT max_streams %d is invalid; using unlimited (-1)", cfg.DNSTT.MaxStreams)
|
||||
cfg.DNSTT.MaxStreams = -1
|
||||
}
|
||||
if cfg.DNSTT.PendingResponses > 0 {
|
||||
if cfg.DNSTT.PendingResponses < minDNSTTPendingResponses {
|
||||
warn("DNSTT pending_responses %d is too low; clamping to %d", cfg.DNSTT.PendingResponses, minDNSTTPendingResponses)
|
||||
cfg.DNSTT.PendingResponses = minDNSTTPendingResponses
|
||||
} else if cfg.DNSTT.PendingResponses > maxDNSTTPendingResponses {
|
||||
warn("DNSTT pending_responses %d is too high; clamping to %d", cfg.DNSTT.PendingResponses, maxDNSTTPendingResponses)
|
||||
cfg.DNSTT.PendingResponses = maxDNSTTPendingResponses
|
||||
}
|
||||
}
|
||||
if cfg.DNSTT.StreamBuffer > 0 {
|
||||
if cfg.DNSTT.StreamBuffer < minDNSTTStreamBuffer {
|
||||
warn("DNSTT stream_buffer %d is too low; clamping to %d", cfg.DNSTT.StreamBuffer, minDNSTTStreamBuffer)
|
||||
cfg.DNSTT.StreamBuffer = minDNSTTStreamBuffer
|
||||
} else if cfg.DNSTT.StreamBuffer > maxDNSTTStreamBuffer {
|
||||
warn("DNSTT stream_buffer %d is too high; clamping to %d", cfg.DNSTT.StreamBuffer, maxDNSTTStreamBuffer)
|
||||
cfg.DNSTT.StreamBuffer = maxDNSTTStreamBuffer
|
||||
}
|
||||
}
|
||||
if cfg.DNSTT.UDPReadBuffer < 0 {
|
||||
warn("DNSTT udp_read_buffer %d is invalid; using default", cfg.DNSTT.UDPReadBuffer)
|
||||
cfg.DNSTT.UDPReadBuffer = 0
|
||||
}
|
||||
if cfg.DNSTT.UDPWriteBuffer < 0 {
|
||||
warn("DNSTT udp_write_buffer %d is invalid; using default", cfg.DNSTT.UDPWriteBuffer)
|
||||
cfg.DNSTT.UDPWriteBuffer = 0
|
||||
}
|
||||
if cfg.DNSTT.FakeDNSWorkers < 0 {
|
||||
warn("DNSTT fake_dns_workers %d is invalid; using automatic default", cfg.DNSTT.FakeDNSWorkers)
|
||||
cfg.DNSTT.FakeDNSWorkers = 0
|
||||
} else if cfg.DNSTT.FakeDNSWorkers > maxDNSTTFakeDNSWorkers {
|
||||
warn("DNSTT fake_dns_workers %d is too high; clamping to %d", cfg.DNSTT.FakeDNSWorkers, maxDNSTTFakeDNSWorkers)
|
||||
cfg.DNSTT.FakeDNSWorkers = maxDNSTTFakeDNSWorkers
|
||||
}
|
||||
if cfg.DNSTT.DNSResponseWorkers < 0 {
|
||||
warn("DNSTT dns_response_workers %d is invalid; using default", cfg.DNSTT.DNSResponseWorkers)
|
||||
cfg.DNSTT.DNSResponseWorkers = 0
|
||||
} else if cfg.DNSTT.DNSResponseWorkers > maxDNSTTResponseWorkers {
|
||||
warn("DNSTT dns_response_workers %d is too high; clamping to %d", cfg.DNSTT.DNSResponseWorkers, maxDNSTTResponseWorkers)
|
||||
cfg.DNSTT.DNSResponseWorkers = maxDNSTTResponseWorkers
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UDPGW != nil {
|
||||
cfg.UDPGW.Listen = strings.TrimSpace(cfg.UDPGW.Listen)
|
||||
if cfg.UDPGW.Listen == "" {
|
||||
cfg.UDPGW.Listen = defaultUDPGWListen
|
||||
}
|
||||
if err := tcpAddrAvailableForUDPGW(cfg.UDPGW.Listen); err != nil {
|
||||
old := cfg.UDPGW.Listen
|
||||
cfg.UDPGW.Listen = defaultUDPGWListen
|
||||
warn("UDPGW listener %s is unavailable (%v); using default %s", old, err, cfg.UDPGW.Listen)
|
||||
if err2 := tcpAddrAvailableForUDPGW(cfg.UDPGW.Listen); err2 != nil {
|
||||
warn("default UDPGW listener %s is also unavailable: %v", cfg.UDPGW.Listen, err2)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.UDPGW.AutoRestartInterval = strings.TrimSpace(cfg.UDPGW.AutoRestartInterval)
|
||||
if cfg.UDPGW.AutoRestartInterval != "" && cfg.UDPGW.AutoRestartInterval != "0" && cfg.UDPGW.AutoRestartInterval != "0s" && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "off") && !strings.EqualFold(cfg.UDPGW.AutoRestartInterval, "disabled") {
|
||||
if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartInterval); err != nil {
|
||||
warn("UDPGW auto restart interval %q is invalid; disabling auto restart", cfg.UDPGW.AutoRestartInterval)
|
||||
cfg.UDPGW.AutoRestartInterval = ""
|
||||
} else if d < time.Minute {
|
||||
warn("UDPGW auto restart interval %q is below 1m; disabling auto restart", cfg.UDPGW.AutoRestartInterval)
|
||||
cfg.UDPGW.AutoRestartInterval = ""
|
||||
}
|
||||
}
|
||||
cfg.UDPGW.AutoRestartGrace = strings.TrimSpace(cfg.UDPGW.AutoRestartGrace)
|
||||
if cfg.UDPGW.AutoRestartGrace != "" {
|
||||
if d, err := time.ParseDuration(cfg.UDPGW.AutoRestartGrace); err != nil || d < 0 {
|
||||
warn("UDPGW auto restart grace %q is invalid; using default 2s", cfg.UDPGW.AutoRestartGrace)
|
||||
cfg.UDPGW.AutoRestartGrace = ""
|
||||
} else if d > time.Minute {
|
||||
warn("UDPGW auto restart grace %q is above 1m; clamping to 1m", cfg.UDPGW.AutoRestartGrace)
|
||||
cfg.UDPGW.AutoRestartGrace = "1m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
func tcpAddrAvailableForPool(addr string, pool *listenerPool) error {
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
if pool != nil && pool.Has(addr) {
|
||||
return nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ln.Close()
|
||||
}
|
||||
|
||||
func tcpAddrAvailableForUDPGW(addr string) error {
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
globalCfgMu.RLock()
|
||||
current := globalCfg != nil && globalCfg.UDPGW != nil && globalCfg.UDPGW.Listen == addr && udpgwRunning()
|
||||
globalCfgMu.RUnlock()
|
||||
if current {
|
||||
return nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ln.Close()
|
||||
}
|
||||
|
||||
func normalizeDNSTTDomainList(primary string, domains []string) []string {
|
||||
seen := make(map[string]bool, len(domains)+1)
|
||||
out := make([]string, 0, len(domains)+1)
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimSuffix(v, ".")
|
||||
v = strings.ToLower(v)
|
||||
if v == "" || seen[v] {
|
||||
return
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
add(primary)
|
||||
for _, d := range domains {
|
||||
add(d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func udpAddrAvailableForDNSTT(addr string) error {
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
globalCfgMu.RLock()
|
||||
current := false
|
||||
if globalCfg != nil && globalCfg.DNSTT != nil && dnsttRunning() {
|
||||
current = sameUDPListenAddress(globalCfg.DNSTT.UDPListen, addr) || sameUDPListenAddress(globalCfg.DNSTT.FakeDNSListen, addr)
|
||||
}
|
||||
globalCfgMu.RUnlock()
|
||||
if current {
|
||||
return nil
|
||||
}
|
||||
pc, err := listenDNSTTPacket(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pc.Close()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSSHIdleTimeoutDisabledByDefault(t *testing.T) {
|
||||
for _, raw := range []string{"", "0", "0s", "off", "disabled"} {
|
||||
if got := parseSSHIdleTimeout(raw); got != 0 {
|
||||
t.Fatalf("parseSSHIdleTimeout(%q) = %s, want disabled", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHIdleTimeoutExplicitValue(t *testing.T) {
|
||||
if got := parseSSHIdleTimeout("30m"); got != 30*time.Minute {
|
||||
t.Fatalf("got %s, want 30m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeXHTTPConnectedIdleSweepDisabled(t *testing.T) {
|
||||
if got := nativeXHTTPIdleTimeout(); got != 0 {
|
||||
t.Fatalf("native XHTTP idle timeout = %s, want disabled", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func sealCredential(prefix, plain string) (string, error) {
|
||||
if plain == "" || strings.HasPrefix(plain, prefix) {
|
||||
return plain, nil
|
||||
}
|
||||
enc, err := encryptSecret(plain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.RawStdEncoding.EncodeToString(enc), nil
|
||||
}
|
||||
|
||||
func openCredential(prefix, stored string) (string, error) {
|
||||
if !strings.HasPrefix(stored, prefix) {
|
||||
return stored, nil
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode encrypted credential: %w", err)
|
||||
}
|
||||
return decryptSecret(raw)
|
||||
}
|
||||
+884
-148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# Trusted Go archives used by install.sh and update.sh.
|
||||
# Format: version architecture sha256
|
||||
1.25.12 amd64 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
1.25.12 arm64 8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2
|
||||
1.25.12 armv6l 6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1
|
||||
@@ -1,20 +1,23 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.12
|
||||
|
||||
require golang.org/x/crypto v0.45.0
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61
|
||||
github.com/xtaci/smux v1.5.50
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/time v0.15.0
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/flynn/noise v1.0.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61 // indirect
|
||||
github.com/xtaci/smux v1.5.50 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
@@ -27,26 +29,34 @@ github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/4
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61 h1:ajm12pGuWO+GWQNusPyPESC7Rq0yTC2rEXVYkM8ExOg=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
|
||||
github.com/xtaci/smux v1.5.50 h1:y/1DlWQC9bnMeZzsyk4oL2hbLK6uVk4BKTz5BeQqUEA=
|
||||
github.com/xtaci/smux v1.5.50/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -58,8 +68,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -70,13 +80,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -97,7 +111,10 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0 h1:Xi0lmT+5kcgzY7P+r726eBXKMZKgGoD8GTNKrlh8TuE=
|
||||
|
||||
+204
-20
@@ -9,7 +9,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -86,6 +88,51 @@ func (p *listenerPool) Sync(addrs []string) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
func (p *listenerPool) Has(addr string) bool {
|
||||
if p == nil || addr == "" {
|
||||
return false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.entries[addr]
|
||||
return ok
|
||||
}
|
||||
|
||||
// StopAll closes every listener in the pool. Active SSH sessions are not owned
|
||||
// by this pool; callers that want a hard restart should also close tracked SSH
|
||||
// server connections through userMgr.DisconnectAll().
|
||||
func (p *listenerPool) StopAll(reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for addr, ln := range p.entries {
|
||||
_ = ln.Close()
|
||||
delete(p.entries, addr)
|
||||
if reason != "" {
|
||||
log.Printf("hotreload: stopped %s (%s)", addr, reason)
|
||||
} else {
|
||||
log.Printf("hotreload: stopped %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *listenerPool) HasAll(addrs []string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if !p.Has(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- Dynamic TLS listener pool ----------
|
||||
|
||||
type tlsListenerPool struct {
|
||||
@@ -142,11 +189,52 @@ func (p *tlsListenerPool) Sync(forwarders []TLSForwarderConfig) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
func (p *tlsListenerPool) Has(addr string) bool {
|
||||
if p == nil || addr == "" {
|
||||
return false
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.entries[addr]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *tlsListenerPool) StopAll(reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for addr, ln := range p.entries {
|
||||
_ = ln.Close()
|
||||
delete(p.entries, addr)
|
||||
if reason != "" {
|
||||
log.Printf("hotreload: stopped TLS %s (%s)", addr, reason)
|
||||
} else {
|
||||
log.Printf("hotreload: stopped TLS %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *tlsListenerPool) HasAll(forwarders []TLSForwarderConfig) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
for _, f := range forwarders {
|
||||
if f.Listen == "" {
|
||||
continue
|
||||
}
|
||||
if !p.Has(f.Listen) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- Global pool instances (initialised in main) ----------
|
||||
|
||||
var (
|
||||
publicPool *listenerPool // HTTP+SSH: listen + extra_listen
|
||||
localPool *listenerPool // raw SSH: local_ssh_listen
|
||||
tlsPool *tlsListenerPool // TLS forwarders
|
||||
)
|
||||
|
||||
@@ -196,8 +284,43 @@ func getAdminHandler() http.Handler {
|
||||
// applyFullConfigReload applies every field in newCfg to the running server
|
||||
// without a process restart. Port changes, DNSTT/UDPGW changes, Xray changes,
|
||||
// and bandwidth defaults all take effect immediately.
|
||||
// The only field that still requires a restart is host_key_file.
|
||||
func applyFullConfigReload(newCfg *Config) {
|
||||
// It returns a status report so the panel can show crashed or blocked services.
|
||||
type ServiceReloadStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Listen string `json:"listen,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigReloadReport struct {
|
||||
Applied bool `json:"applied"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Services map[string]ServiceReloadStatus `json:"services"`
|
||||
}
|
||||
|
||||
func newReloadReport() ConfigReloadReport {
|
||||
return ConfigReloadReport{Applied: true, Services: map[string]ServiceReloadStatus{}}
|
||||
}
|
||||
|
||||
func (r *ConfigReloadReport) warnf(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
r.Warnings = append(r.Warnings, msg)
|
||||
log.Printf("config reload: %s", msg)
|
||||
}
|
||||
|
||||
func joinAddrs(addrs []string) string {
|
||||
clean := make([]string, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
clean = append(clean, a)
|
||||
}
|
||||
}
|
||||
return strings.Join(clean, ", ")
|
||||
}
|
||||
|
||||
func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
report := newReloadReport()
|
||||
stopProxyAutoRestart()
|
||||
// Banner
|
||||
bt := newCfg.Banner
|
||||
if bt == "" && newCfg.BannerFile != "" {
|
||||
@@ -207,8 +330,11 @@ func applyFullConfigReload(newCfg *Config) {
|
||||
}
|
||||
setBannerText(bt)
|
||||
|
||||
// Default per-connection bandwidth limits (picked up by new connections)
|
||||
// Default per-connection bandwidth limits and SSH inactivity cleanup
|
||||
// (picked up by new connections).
|
||||
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
|
||||
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
|
||||
setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections)
|
||||
|
||||
// Quiet logging / user count display
|
||||
if newCfg.Quiet {
|
||||
@@ -226,44 +352,102 @@ func applyFullConfigReload(newCfg *Config) {
|
||||
// Public SSH listeners (main listen + extra_listen)
|
||||
publicAddrs := append([]string{newCfg.Listen}, newCfg.ExtraListen...)
|
||||
for _, e := range publicPool.Sync(publicAddrs) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
report.warnf("SSH listener error: %v", e)
|
||||
}
|
||||
report.Services["ssh"] = ServiceReloadStatus{
|
||||
Enabled: true,
|
||||
Running: publicPool.HasAll(publicAddrs),
|
||||
Listen: joinAddrs(publicAddrs),
|
||||
}
|
||||
if !report.Services["ssh"].Running {
|
||||
report.Services["ssh"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: joinAddrs(publicAddrs), Error: "one or more SSH listeners could not be opened"}
|
||||
}
|
||||
|
||||
// Local raw SSH listener
|
||||
var localAddrs []string
|
||||
if newCfg.LocalSSHListen != "" {
|
||||
localAddrs = []string{newCfg.LocalSSHListen}
|
||||
}
|
||||
for _, e := range localPool.Sync(localAddrs) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
}
|
||||
// Legacy local_ssh_listen is intentionally ignored. DragonCore handles DNSTT in-process.
|
||||
newCfg.LocalSSHListen = ""
|
||||
|
||||
// TLS forwarders
|
||||
for _, e := range tlsPool.Sync(newCfg.TLSForwarders) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
report.warnf("TLS listener error: %v", e)
|
||||
}
|
||||
if len(newCfg.TLSForwarders) > 0 {
|
||||
report.Services["tls"] = ServiceReloadStatus{
|
||||
Enabled: true,
|
||||
Running: tlsPool.HasAll(newCfg.TLSForwarders),
|
||||
Listen: tlsForwarderList(newCfg.TLSForwarders),
|
||||
}
|
||||
if !report.Services["tls"].Running {
|
||||
report.Services["tls"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: tlsForwarderList(newCfg.TLSForwarders), Error: "one or more TLS forwarders could not be opened"}
|
||||
}
|
||||
} else {
|
||||
report.Services["tls"] = ServiceReloadStatus{Enabled: false, Running: false}
|
||||
}
|
||||
|
||||
// DNSTT — stop current instance (no-op if not running) then start new one
|
||||
// DNSTT — stop current instance (no-op if not running) then start new one.
|
||||
stopDNSTT()
|
||||
startDNSTT(newCfg.DNSTT, getSSHConfig())
|
||||
if newCfg.DNSTT != nil {
|
||||
if err := startDNSTT(newCfg.DNSTT, getSSHConfig()); err != nil {
|
||||
report.warnf("DNSTT failed to start: %v", err)
|
||||
report.Services["dnstt"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: newCfg.DNSTT.UDPListen, Error: err.Error()}
|
||||
} else {
|
||||
report.Services["dnstt"] = ServiceReloadStatus{Enabled: true, Running: true, Listen: newCfg.DNSTT.UDPListen}
|
||||
}
|
||||
} else {
|
||||
report.Services["dnstt"] = ServiceReloadStatus{Enabled: false, Running: false}
|
||||
}
|
||||
|
||||
// UDPGW — same pattern
|
||||
// UDPGW — same pattern.
|
||||
stopUDPGW()
|
||||
startUDPGW(newCfg.UDPGW)
|
||||
if newCfg.UDPGW != nil {
|
||||
if err := startUDPGW(newCfg.UDPGW); err != nil {
|
||||
report.warnf("UDPGW failed to start: %v", err)
|
||||
report.Services["udpgw"] = ServiceReloadStatus{Enabled: true, Running: false, Listen: newCfg.UDPGW.Listen, Error: err.Error()}
|
||||
} else {
|
||||
report.Services["udpgw"] = ServiceReloadStatus{Enabled: true, Running: udpgwRunning(), Listen: newCfg.UDPGW.Listen}
|
||||
}
|
||||
} else {
|
||||
report.Services["udpgw"] = ServiceReloadStatus{Enabled: false, Running: false}
|
||||
}
|
||||
|
||||
// Xray — update stored config then restart/stop as needed
|
||||
// Xray — update stored config then restart/stop as needed.
|
||||
if newCfg.Xray != nil {
|
||||
newCfg.Xray.NormalizeDefaults()
|
||||
xrayMgr.mu.Lock()
|
||||
xrayMgr.cfg = newCfg.Xray
|
||||
xrayMgr.mu.Unlock()
|
||||
if !newCfg.Xray.UseNative() {
|
||||
xrayMgr.startStatsPoller()
|
||||
}
|
||||
if newCfg.Xray.Enabled {
|
||||
_ = xrayMgr.Restart()
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
report.warnf("Xray failed to restart: %v", err)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
st := xrayMgr.Status()
|
||||
report.Services["xray"] = ServiceReloadStatus{Enabled: true, Running: st.Running, Error: st.Error}
|
||||
if !st.Running && st.Error == "" {
|
||||
report.Services["xray"] = ServiceReloadStatus{Enabled: true, Running: false, Error: "xray exited immediately; check logs"}
|
||||
}
|
||||
} else {
|
||||
_ = xrayMgr.Stop()
|
||||
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
|
||||
}
|
||||
} else {
|
||||
_ = xrayMgr.Stop()
|
||||
report.Services["xray"] = ServiceReloadStatus{Enabled: false, Running: false}
|
||||
}
|
||||
|
||||
setGlobalCfg(newCfg)
|
||||
startProxyAutoRestart(newCfg)
|
||||
return report
|
||||
}
|
||||
|
||||
func tlsForwarderList(forwarders []TLSForwarderConfig) string {
|
||||
addrs := make([]string, 0, len(forwarders))
|
||||
for _, f := range forwarders {
|
||||
if strings.TrimSpace(f.Listen) != "" {
|
||||
addrs = append(addrs, strings.TrimSpace(f.Listen))
|
||||
}
|
||||
}
|
||||
return strings.Join(addrs, ", ")
|
||||
}
|
||||
|
||||
+467
-77
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Auto-install script for SSH Panel + Xray-core (Ubuntu/Debian/CentOS)
|
||||
# Auto-install script for SSH Panel + Xray-core (multi-distro Linux/systemd)
|
||||
# Usage: sudo bash install.sh
|
||||
set -euo pipefail
|
||||
|
||||
@@ -11,58 +11,253 @@ error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
|
||||
# ── config ──────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="/opt/sshpanel"
|
||||
SERVICE_NAME="sshpanel"
|
||||
LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
GO_SHA256="${GO_SHA256:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
|
||||
# Cross-distro helpers -------------------------------------------------------
|
||||
PKG_MANAGER=""
|
||||
PKG_DEPS=()
|
||||
PKG_OPTIONAL_DEPS=()
|
||||
SYSTEMCTL_BIN=""
|
||||
SH_BIN="$(command -v sh 2>/dev/null || echo /bin/sh)"
|
||||
MOUNT_BIN="$(command -v mount 2>/dev/null || echo /bin/mount)"
|
||||
MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)"
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "$GO_SHA256" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
error "systemd was not found. This installer supports Linux distributions that use systemd for services."
|
||||
fi
|
||||
}
|
||||
|
||||
detect_pkg_manager() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
PKG_MANAGER="apt"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
PKG_MANAGER="dnf"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
PKG_MANAGER="yum"
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
PKG_MANAGER="zypper"
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
PKG_MANAGER="pacman"
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKG_MANAGER="apk"
|
||||
else
|
||||
error "No supported package manager found. Supported: apt, dnf, yum, zypper, pacman, apk."
|
||||
fi
|
||||
}
|
||||
|
||||
set_package_deps() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt)
|
||||
PKG_DEPS=(curl wget git rsync build-essential postgresql ca-certificates unzip openssh-client openssl python3 tar gzip)
|
||||
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
|
||||
;;
|
||||
dnf|yum)
|
||||
PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh-clients openssl python3 tar gzip)
|
||||
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
|
||||
;;
|
||||
zypper)
|
||||
PKG_DEPS=(curl wget git rsync gcc make postgresql-server ca-certificates unzip openssh openssl python3 tar gzip)
|
||||
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
|
||||
;;
|
||||
pacman)
|
||||
PKG_DEPS=(curl wget git rsync base-devel postgresql ca-certificates unzip openssh openssl python tar gzip)
|
||||
PKG_OPTIONAL_DEPS=(iptables-nft nftables)
|
||||
;;
|
||||
apk)
|
||||
PKG_DEPS=(curl wget git rsync build-base postgresql ca-certificates unzip openssh-client openssl python3 tar gzip)
|
||||
PKG_OPTIONAL_DEPS=(postgresql-contrib iptables nftables)
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_update() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt) apt-get update -qq ;;
|
||||
dnf) dnf makecache -q ;;
|
||||
yum) yum makecache -q ;;
|
||||
zypper) zypper --non-interactive refresh ;;
|
||||
pacman) pacman -Sy --noconfirm ;;
|
||||
apk) apk update ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_install() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" ;;
|
||||
dnf) dnf install -y "$@" ;;
|
||||
yum) yum install -y "$@" ;;
|
||||
zypper) zypper --non-interactive install -y "$@" ;;
|
||||
pacman) pacman -S --noconfirm --needed "$@" ;;
|
||||
apk) apk add --no-cache "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_install_optional() {
|
||||
local pkg
|
||||
for pkg in "$@"; do
|
||||
pkg_install "$pkg" >/dev/null 2>&1 || warn " Optional package '$pkg' could not be installed; continuing."
|
||||
done
|
||||
}
|
||||
|
||||
postgres_data_dir() {
|
||||
for dir in /var/lib/postgresql/data /var/lib/pgsql/data /var/lib/postgres/data; do
|
||||
[[ -d "$dir" || -d "$(dirname "$dir")" ]] && { printf '%s\n' "$dir"; return 0; }
|
||||
done
|
||||
printf '%s\n' /var/lib/postgresql/data
|
||||
}
|
||||
|
||||
init_postgresql_if_needed() {
|
||||
case "$PKG_MANAGER" in
|
||||
dnf|yum|zypper)
|
||||
postgresql-setup --initdb >/dev/null 2>&1 || true
|
||||
;;
|
||||
pacman)
|
||||
local data_dir
|
||||
data_dir="$(postgres_data_dir)"
|
||||
if [[ ! -s "$data_dir/PG_VERSION" ]]; then
|
||||
mkdir -p "$data_dir"
|
||||
chown -R postgres:postgres "$(dirname "$data_dir")"
|
||||
if command -v runuser >/dev/null 2>&1; then
|
||||
runuser -u postgres -- initdb -D "$data_dir" >/dev/null 2>&1 || true
|
||||
else
|
||||
su - postgres -c "initdb -D '$data_dir'" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
apk)
|
||||
if command -v rc-service >/dev/null 2>&1; then
|
||||
rc-service postgresql setup >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_enable_postgresql() {
|
||||
local started=false svc
|
||||
for svc in postgresql postgresql.service; do
|
||||
if "$SYSTEMCTL_BIN" start "$svc" >/dev/null 2>&1; then
|
||||
"$SYSTEMCTL_BIN" enable "$svc" >/dev/null 2>&1 || true
|
||||
started=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if ! $started && command -v service >/dev/null 2>&1; then
|
||||
service postgresql start >/dev/null 2>&1 && started=true || true
|
||||
fi
|
||||
$started || warn " Could not start PostgreSQL automatically; continuing in case it is already running."
|
||||
}
|
||||
|
||||
ensure_log_tmpfs_mount() {
|
||||
local log_dir="${INSTALL_DIR}/logs"
|
||||
local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755"
|
||||
local tmp_fstab
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
if [[ -f /etc/fstab ]]; then
|
||||
cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true
|
||||
tmp_fstab="$(mktemp)"
|
||||
awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab"
|
||||
printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab"
|
||||
cat "$tmp_fstab" > /etc/fstab
|
||||
rm -f "$tmp_fstab"
|
||||
info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})"
|
||||
else
|
||||
warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs"
|
||||
fi
|
||||
|
||||
"${SYSTEMCTL_BIN:-systemctl}" daemon-reload >/dev/null 2>&1 || true
|
||||
if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$log_dir"; then
|
||||
mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true
|
||||
else
|
||||
mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \
|
||||
warn " Could not mount $log_dir as tmpfs now; service startup fallback will try again"
|
||||
fi
|
||||
|
||||
touch "$log_dir/panel.log" >/dev/null 2>&1 || true
|
||||
chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
|
||||
|
||||
# ── 1. OS detection ──────────────────────────────────────────────────────────
|
||||
info "[1/9] Detecting OS…"
|
||||
# ── 1. OS / package-manager detection ────────────────────────────────────────
|
||||
info "[1/10] Detecting Linux distribution and package manager…"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-unknown}"
|
||||
OS_LIKE="${ID_LIKE:-}"
|
||||
OS_PRETTY="${PRETTY_NAME:-$OS_ID}"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
OS_LIKE=""
|
||||
OS_PRETTY="unknown Linux"
|
||||
fi
|
||||
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian|linuxmint)
|
||||
PKG_UPDATE="apt-get update -qq"
|
||||
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
|
||||
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
|
||||
;;
|
||||
centos|rhel|rocky|almalinux)
|
||||
PKG_UPDATE="yum makecache -q"
|
||||
PKG_INSTALL="yum install -y"
|
||||
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
|
||||
;;
|
||||
fedora)
|
||||
PKG_UPDATE="dnf makecache -q"
|
||||
PKG_INSTALL="dnf install -y"
|
||||
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
|
||||
;;
|
||||
*)
|
||||
warn "Unknown OS '$OS_ID' — attempting apt-get…"
|
||||
PKG_UPDATE="apt-get update -qq"
|
||||
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
|
||||
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
|
||||
;;
|
||||
esac
|
||||
info " OS: $OS_ID"
|
||||
require_systemd
|
||||
detect_pkg_manager
|
||||
set_package_deps
|
||||
info " OS : $OS_PRETTY"
|
||||
info " ID / ID_LIKE : $OS_ID / ${OS_LIKE:-none}"
|
||||
info " Package manager: $PKG_MANAGER"
|
||||
info " Service manager: systemd"
|
||||
|
||||
# ── 2. System dependencies ───────────────────────────────────────────────────
|
||||
info "[2/9] Installing system packages…"
|
||||
eval "$PKG_UPDATE"
|
||||
eval "$PKG_INSTALL $PKG_DEPS"
|
||||
info "[2/10] Installing system packages…"
|
||||
pkg_update
|
||||
pkg_install "${PKG_DEPS[@]}"
|
||||
pkg_install_optional "${PKG_OPTIONAL_DEPS[@]}"
|
||||
|
||||
# ── 3. Go ────────────────────────────────────────────────────────────────────
|
||||
info "[3/9] Installing Go ${GO_VERSION}…"
|
||||
info "[3/10] Installing Go ${GO_VERSION}…"
|
||||
NEED_GO=true
|
||||
if command -v go &>/dev/null; then
|
||||
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||||
@@ -73,16 +268,21 @@ if command -v go &>/dev/null; then
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
GO_EXPECTED_SHA256=""
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
GO_EXPECTED_SHA256="$(trusted_go_sha256 "$GO_VERSION" "$GOARCH" "$SCRIPT_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$GO_EXPECTED_SHA256" ]] || error "No trusted Go checksum for ${GO_VERSION}/${GOARCH}; set GO_SHA256 explicitly"
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading $GO_URL"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
verify_sha256_file "$GO_EXPECTED_SHA256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -94,51 +294,100 @@ export PATH=$PATH:/usr/local/go/bin
|
||||
go version
|
||||
|
||||
# ── 4. Directory layout ──────────────────────────────────────────────────────
|
||||
info "[4/9] Setting up ${INSTALL_DIR}…"
|
||||
info "[4/10] Setting up ${INSTALL_DIR}…"
|
||||
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs"
|
||||
ensure_log_tmpfs_mount
|
||||
|
||||
# ── 5. Build SSH panel binary ────────────────────────────────────────────────
|
||||
info "[5/9] Building SSH Panel binary…"
|
||||
info "[5/10] Building SSH Panel binary…"
|
||||
cd "$SCRIPT_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
BUILD_COMMIT="$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true)"
|
||||
BUILD_BRANCH="$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
BUILD_REPO_URL="$(git -C "$SCRIPT_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
|
||||
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
|
||||
[[ -n "$BUILD_REPO_URL" ]] || BUILD_REPO_URL="$REPO_URL"
|
||||
|
||||
go mod download
|
||||
go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
|
||||
go mod tidy
|
||||
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o "$INSTALL_DIR/sshpanel" .
|
||||
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
|
||||
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
|
||||
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
|
||||
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
|
||||
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
|
||||
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
|
||||
info " Binary: $INSTALL_DIR/sshpanel"
|
||||
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
|
||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
||||
info " Admin panel copied"
|
||||
if [[ -f "$SCRIPT_DIR/update.sh" ]]; then
|
||||
cp "$SCRIPT_DIR/update.sh" "$INSTALL_DIR/update.sh"
|
||||
chmod 700 "$INSTALL_DIR/update.sh"
|
||||
info " Git updater copied"
|
||||
fi
|
||||
if [[ -f "$SCRIPT_DIR/change_admin_password.sh" ]]; then
|
||||
cp "$SCRIPT_DIR/change_admin_password.sh" "$INSTALL_DIR/change_admin_password.sh"
|
||||
chmod 700 "$INSTALL_DIR/change_admin_password.sh"
|
||||
info " Admin password recovery script copied"
|
||||
fi
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/9] Downloading Xray-core…"
|
||||
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
|
||||
info "[6/10] Downloading Xray-core…"
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) XRAY_ARCH="64" ;;
|
||||
aarch64) XRAY_ARCH="arm64-v8a" ;;
|
||||
armv7l) XRAY_ARCH="arm32-v7a" ;;
|
||||
*) XRAY_ARCH="64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
||||
[[ -n "$PYTHON_BIN" ]] || error "Python is required to validate Xray release metadata"
|
||||
XRAY_RELEASE_JSON=/tmp/xray-release.json
|
||||
curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \
|
||||
-o "$XRAY_RELEASE_JSON" https://api.github.com/repos/XTLS/Xray-core/releases/latest
|
||||
readarray -t XRAY_META < <("$PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||
release = json.load(handle)
|
||||
tag = release.get("tag_name", "")
|
||||
name = sys.argv[2]
|
||||
asset = next((item for item in release.get("assets", []) if item.get("name") == name), None)
|
||||
if not tag or not asset:
|
||||
raise SystemExit(2)
|
||||
url = asset.get("browser_download_url", "")
|
||||
digest = asset.get("digest", "")
|
||||
prefix = "https://github.com/XTLS/Xray-core/releases/download/" + tag + "/"
|
||||
if not url.startswith(prefix) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit(3)
|
||||
print(tag)
|
||||
print(url)
|
||||
print(digest.split(":", 1)[1])
|
||||
' "$XRAY_RELEASE_JSON" "Xray-linux-${XRAY_ARCH}.zip")
|
||||
[[ ${#XRAY_META[@]} -eq 3 ]] || error "Xray release metadata is missing a trusted asset digest"
|
||||
XRAY_VER="${XRAY_META[0]}"
|
||||
XRAY_URL="${XRAY_META[1]}"
|
||||
XRAY_SHA256="${XRAY_META[2]}"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
verify_sha256_file "$XRAY_SHA256" /tmp/xray.zip
|
||||
info " Xray archive checksum verified"
|
||||
unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
|
||||
mkdir -p /tmp/xray_extract
|
||||
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
|
||||
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
|
||||
}
|
||||
chmod +x "$INSTALL_DIR/xray"
|
||||
rm -f /tmp/xray.zip
|
||||
rm -f /tmp/xray.zip "$XRAY_RELEASE_JSON"
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
info "[7/9] Configuring PostgreSQL…"
|
||||
case "$OS_ID" in
|
||||
centos|rhel|rocky|almalinux|fedora)
|
||||
postgresql-setup --initdb 2>/dev/null || true ;;
|
||||
esac
|
||||
systemctl start postgresql 2>/dev/null || service postgresql start 2>/dev/null || true
|
||||
systemctl enable postgresql 2>/dev/null || true
|
||||
info "[7/10] Configuring PostgreSQL…"
|
||||
init_postgresql_if_needed
|
||||
start_enable_postgresql
|
||||
|
||||
DB_NAME="sshpanel"
|
||||
DB_USER="sshpanel"
|
||||
@@ -215,11 +464,75 @@ CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Telegram sales bot (created idempotently by the app too; mirrored here for a clean install)
|
||||
CREATE TABLE IF NOT EXISTS bot_users (
|
||||
telegram_id BIGINT PRIMARY KEY,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
linked_admin_username TEXT NOT NULL DEFAULT '',
|
||||
credit_balance INT NOT NULL DEFAULT 0,
|
||||
trial_used BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_plans (
|
||||
id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
days INT NOT NULL DEFAULT 30, max_connections INT NOT NULL DEFAULT 1,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0, limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
xray_inbound_tag TEXT NOT NULL DEFAULT '', xray_protocol TEXT NOT NULL DEFAULT '',
|
||||
price_cents INT NOT NULL DEFAULT 0, credit_cost INT NOT NULL DEFAULT 1,
|
||||
server_id TEXT NOT NULL DEFAULT '', is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_credit_packages (
|
||||
id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', credits INT NOT NULL DEFAULT 0,
|
||||
price_cents INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_transactions (
|
||||
id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, type TEXT NOT NULL,
|
||||
plan_id INT, package_id INT, credits INT NOT NULL DEFAULT 0, amount_cents INT NOT NULL DEFAULT 0,
|
||||
mp_payment_id TEXT NOT NULL DEFAULT '', mp_qr_code TEXT NOT NULL DEFAULT '', mp_qr_base64 TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending', target_username TEXT NOT NULL DEFAULT '', renew_target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), paid_at TIMESTAMPTZ, expires_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_credits_ledger (
|
||||
id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, delta INT NOT NULL, reason TEXT NOT NULL DEFAULT '',
|
||||
ref_transaction_id INT, balance_after INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_config (
|
||||
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
telegram_mode TEXT NOT NULL DEFAULT 'polling', telegram_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
mp_confirm_mode TEXT NOT NULL DEFAULT 'polling', mp_poll_interval TEXT NOT NULL DEFAULT '20s',
|
||||
pix_expiration_minutes INT NOT NULL DEFAULT 30, trial_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
trial_hours INT NOT NULL DEFAULT 1, trial_max_connections INT NOT NULL DEFAULT 1,
|
||||
trial_kind TEXT NOT NULL DEFAULT 'ssh', trial_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}', currency TEXT NOT NULL DEFAULT 'BRL',
|
||||
public_host TEXT NOT NULL DEFAULT '', xray_public_host TEXT NOT NULL DEFAULT '',
|
||||
telegram_token_enc BYTEA, telegram_webhook_secret_enc BYTEA, mp_access_token_enc BYTEA, mp_webhook_secret_enc BYTEA,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER SCHEMA public OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS ssh_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS ssh_iface_totals OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS admin_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS xray_clients OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_plans OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_credit_packages OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_transactions OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_credits_ledger OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_settings OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_config OWNER TO ${DB_USER};
|
||||
ALTER SEQUENCE IF EXISTS admin_users_id_seq OWNER TO ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};
|
||||
@@ -230,7 +543,7 @@ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};
|
||||
info " PostgreSQL database '${DB_NAME}' ready"
|
||||
|
||||
# ── 8. Config files ──────────────────────────────────────────────────────────
|
||||
info "[8/9] Generating config files…"
|
||||
info "[8/10] Generating config files…"
|
||||
|
||||
# Admin token
|
||||
ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true)
|
||||
@@ -244,7 +557,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true)
|
||||
if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then
|
||||
ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}')
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null)
|
||||
[[ "$ADMIN_PASSWORD_HASH" == \$2* ]] || error "Failed to generate admin bcrypt password hash"
|
||||
su -c "psql -d ${DB_NAME}" postgres <<SQL
|
||||
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE)
|
||||
@@ -260,7 +574,6 @@ SQL
|
||||
cat > "$INSTALL_DIR/.env" <<EOF
|
||||
PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable
|
||||
ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ADMIN_HTTP_ADDR=0.0.0.0:9090
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
@@ -281,15 +594,17 @@ cat > "$INSTALL_DIR/config.json" <<EOF
|
||||
{
|
||||
"listen": "0.0.0.0:80",
|
||||
"extra_listen": ["0.0.0.0:8080"],
|
||||
"local_ssh_listen": "127.0.0.1:2222",
|
||||
"host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key",
|
||||
"quiet": false,
|
||||
"admin_dir": "${INSTALL_DIR}/admin",
|
||||
"banner_file": "${INSTALL_DIR}/banner.txt",
|
||||
"xray": {
|
||||
"enabled": true,
|
||||
"mode": "native",
|
||||
"native": true,
|
||||
"bin_path": "${INSTALL_DIR}/xray",
|
||||
"config_file": "${INSTALL_DIR}/xray_config.json"
|
||||
"config_file": "${INSTALL_DIR}/xray_config.json",
|
||||
"native_config_file": "${INSTALL_DIR}/xray_native_config.json"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
@@ -300,8 +615,9 @@ UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null \
|
||||
|| python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \
|
||||
|| echo "11111111-2222-3333-4444-555555555555")
|
||||
|
||||
# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed)
|
||||
cat > "$INSTALL_DIR/xray_config.json" <<EOF
|
||||
# xray_native_config.json is used by the internal emulator. xray_config.json is
|
||||
# kept only for optional external-xray mode. Both start with the same default.
|
||||
cat > "$INSTALL_DIR/xray_native_config.json" <<EOF
|
||||
{
|
||||
"log": { "loglevel": "warning" },
|
||||
"inbounds": [
|
||||
@@ -330,36 +646,109 @@ cat > "$INSTALL_DIR/xray_config.json" <<EOF
|
||||
]
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/xray_config.json"
|
||||
cp -f "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
|
||||
chmod 600 "$INSTALL_DIR/xray_native_config.json" "$INSTALL_DIR/xray_config.json"
|
||||
info " VLESS UUID: ${UUID}"
|
||||
|
||||
# ── 9. Systemd service ───────────────────────────────────────────────────────
|
||||
info "[9/9] Creating systemd service '${SERVICE_NAME}'…"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
# ── 9. DNSTT DNS/53 redirect ─────────────────────────────────────────────────
|
||||
info "[9/10] Configuring DNSTT DNS redirect (UDP 53 -> 5300)…"
|
||||
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
|
||||
DNSTT_PORT="${DNSTT_PORT:-5300}"
|
||||
|
||||
# Free port 53 on systemd-resolved based systems and keep outbound DNS working.
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f /etc/resolv.conf
|
||||
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
|
||||
|
||||
# Open DNS/UDP in common Linux firewalls when they are active.
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow 53/udp >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
||||
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
|
||||
firewall-cmd --reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
add_iptables_rule() {
|
||||
local bin="$1" chain="$2"
|
||||
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|
||||
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
|
||||
}
|
||||
|
||||
if command -v iptables >/dev/null 2>&1; then
|
||||
add_iptables_rule iptables PREROUTING
|
||||
fi
|
||||
|
||||
if command -v ip6tables >/dev/null 2>&1; then
|
||||
add_iptables_rule ip6tables PREROUTING || true
|
||||
fi
|
||||
|
||||
# Fallback for minimal systems where only nft is present.
|
||||
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
|
||||
nft add table inet sshpanel_nat 2>/dev/null || true
|
||||
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
|
||||
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|
||||
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
|
||||
fi
|
||||
EOS
|
||||
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||
|
||||
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF'
|
||||
[Unit]
|
||||
Description=SSH Panel + Xray-core Server
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
|
||||
After=network.target
|
||||
Before=sshpanel.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
LimitNOFILE=65536
|
||||
StandardOutput=append:${INSTALL_DIR}/logs/panel.log
|
||||
StandardError=append:${INSTALL_DIR}/logs/panel.log
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
"$SYSTEMCTL_BIN" daemon-reload
|
||||
"$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e"
|
||||
info " DNSTT DNS redirect installed: UDP 53 -> 5300"
|
||||
|
||||
# ── 10. Systemd service ──────────────────────────────────────────────────────
|
||||
info "[10/10] Creating systemd service '${SERVICE_NAME}'…"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=SSH Panel + Xray-core Server
|
||||
After=local-fs.target network.target postgresql.service sshpanel-dnstt-redirect.service
|
||||
Wants=postgresql.service sshpanel-dnstt-redirect.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log
|
||||
Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}
|
||||
ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs
|
||||
ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'
|
||||
ExecStartPre=${SH_BIN} -c '${TOUCH_BIN} ${INSTALL_DIR}/logs/panel.log && ${CHMOD_BIN} 0644 ${INSTALL_DIR}/logs/panel.log || true'
|
||||
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
LimitNOFILE=65536
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
"$SYSTEMCTL_BIN" daemon-reload
|
||||
"$SYSTEMCTL_BIN" enable "$SERVICE_NAME"
|
||||
"$SYSTEMCTL_BIN" restart "$SERVICE_NAME"
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
@@ -371,16 +760,17 @@ echo -e " Server IP : ${YELLOW}${SERVER_IP}${NC}"
|
||||
echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)"
|
||||
echo -e " VLESS port : 10086"
|
||||
echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}"
|
||||
echo -e " DNSTT DNS : UDP 53 redirects to local UDP 5300"
|
||||
echo ""
|
||||
echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}"
|
||||
echo -e " Admin login : ${YELLOW}admin${NC}"
|
||||
echo -e " Admin password: ${YELLOW}${ADMIN_PASSWORD}${NC}"
|
||||
echo -e " Admin token : ${YELLOW}${ADMIN_TOKEN}${NC}"
|
||||
echo ""
|
||||
echo -e " Token + DB creds stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " API token + DB credentials stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " Logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Save your admin login/password. The admin token is for API bearer-token access only.${NC}"
|
||||
echo ""
|
||||
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||
"$SYSTEMCTL_BIN" status "$SERVICE_NAME" --no-pager -l || true
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// This package is a local fork of www.bamsoftware.com/git/dnstt.git/turbotunnel
|
||||
// (upstream v1.20241021.0). The only behavioural change from upstream is that
|
||||
// RemoteMap's background expiry goroutine is now stoppable via Close(), wired
|
||||
// through QueuePacketConn.Close(); see remotemap.go and queuepacketconn.go.
|
||||
// Upstream leaks that goroutine for the process lifetime, which is harmless for
|
||||
// the upstream one-shot server but leaks one goroutine per DNSTT restart in this
|
||||
// integration (hot-reload / auto-restart). clientid.go and consts.go are copied
|
||||
// verbatim.
|
||||
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// ClientID is an abstract identifier that binds together all the communications
|
||||
// belonging to a single client session, even though those communications may
|
||||
// arrive from multiple IP addresses or over multiple lower-level connections.
|
||||
// It plays the same role that an (IP address, port number) tuple plays in a
|
||||
// net.UDPConn: it's the return address pertaining to a long-lived abstract
|
||||
// client session. The client attaches its ClientID to each of its
|
||||
// communications, enabling the server to disambiguate requests among its many
|
||||
// clients. ClientID implements the net.Addr interface.
|
||||
type ClientID [8]byte
|
||||
|
||||
func NewClientID() ClientID {
|
||||
var id ClientID
|
||||
_, err := rand.Read(id[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (id ClientID) Network() string { return "clientid" }
|
||||
func (id ClientID) String() string { return hex.EncodeToString(id[:]) }
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package turbotunnel is facilities for embedding packet-based reliability
|
||||
// protocols inside other protocols.
|
||||
//
|
||||
// https://github.com/net4people/bbs/issues/9
|
||||
package turbotunnel
|
||||
|
||||
import "errors"
|
||||
|
||||
// QueueSize is the size of send and receive queues in QueuePacketConn and
|
||||
// RemoteMap.
|
||||
const QueueSize = 128
|
||||
|
||||
var errClosedPacketConn = errors.New("operation on closed connection")
|
||||
var errNotImplemented = errors.New("not implemented")
|
||||
|
||||
// DummyAddr is a placeholder net.Addr, for when a programming interface
|
||||
// requires a net.Addr but there is none relevant. All DummyAddrs compare equal
|
||||
// to each other.
|
||||
type DummyAddr struct{}
|
||||
|
||||
func (addr DummyAddr) Network() string { return "dummy" }
|
||||
func (addr DummyAddr) String() string { return "dummy" }
|
||||
@@ -0,0 +1,165 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// taggedPacket is a combination of a []byte and a net.Addr, encapsulating the
|
||||
// return type of PacketConn.ReadFrom.
|
||||
type taggedPacket struct {
|
||||
P []byte
|
||||
Addr net.Addr
|
||||
}
|
||||
|
||||
// QueuePacketConn implements net.PacketConn by storing queues of packets. There
|
||||
// is one incoming queue (where packets are additionally tagged by the source
|
||||
// address of the peer that sent them). There are many outgoing queues, one for
|
||||
// each remote peer address that has been recently seen. The QueueIncoming
|
||||
// method inserts a packet into the incoming queue, to eventually be returned by
|
||||
// ReadFrom. WriteTo inserts a packet into an address-specific outgoing queue,
|
||||
// which can later by accessed through the OutgoingQueue method.
|
||||
//
|
||||
// Besides the outgoing queues, there is also a one-element "stash" for each
|
||||
// remote peer address. You can stash a packet using the Stash method, and get
|
||||
// it back later by receiving from the channel returned by Unstash. The stash is
|
||||
// meant as a convenient place to temporarily store a single packet, such as
|
||||
// when you've read one too many packets from the send queue and need to store
|
||||
// the extra packet to be processed first in the next pass. It's the caller's
|
||||
// responsibility to Unstash what they have Stashed. Calling Stash does not put
|
||||
// the packet at the head of the send queue; if there is the possibility that a
|
||||
// packet has been stashed, it must be checked for by calling Unstash in
|
||||
// addition to OutgoingQueue.
|
||||
type QueuePacketConn struct {
|
||||
remotes *RemoteMap
|
||||
localAddr net.Addr
|
||||
recvQueue chan taggedPacket
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
// What error to return when the QueuePacketConn is closed.
|
||||
err atomic.Value
|
||||
}
|
||||
|
||||
// NewQueuePacketConn makes a new QueuePacketConn, set to track recent peers
|
||||
// for at least a duration of timeout.
|
||||
func NewQueuePacketConn(localAddr net.Addr, timeout time.Duration) *QueuePacketConn {
|
||||
return &QueuePacketConn{
|
||||
remotes: NewRemoteMap(timeout),
|
||||
localAddr: localAddr,
|
||||
recvQueue: make(chan taggedPacket, QueueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// QueueIncoming queues and incoming packet and its source address, to be
|
||||
// returned in a future call to ReadFrom.
|
||||
func (c *QueuePacketConn) QueueIncoming(p []byte, addr net.Addr) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
// If we're closed, silently drop it.
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Copy the slice so that the caller may reuse it.
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
select {
|
||||
case c.recvQueue <- taggedPacket{buf, addr}:
|
||||
default:
|
||||
// Drop the incoming packet if the receive queue is full.
|
||||
}
|
||||
}
|
||||
|
||||
// OutgoingQueue returns the queue of outgoing packets corresponding to addr,
|
||||
// creating it if necessary. The contents of the queue will be packets that are
|
||||
// written to the address in question using WriteTo.
|
||||
func (c *QueuePacketConn) OutgoingQueue(addr net.Addr) <-chan []byte {
|
||||
return c.remotes.SendQueue(addr)
|
||||
}
|
||||
|
||||
// Stash places p in the stash for addr, if the stash is not already occupied.
|
||||
// Returns true if the packet was placed in the stash, or false if the stash was
|
||||
// already occupied. This method is similar to WriteTo, except that it puts the
|
||||
// packet in the stash queue (accessible via Unstash), rather than the outgoing
|
||||
// queue (accessible via OutgoingQueue).
|
||||
func (c *QueuePacketConn) Stash(p []byte, addr net.Addr) bool {
|
||||
return c.remotes.Stash(addr, p)
|
||||
}
|
||||
|
||||
// Unstash returns the channel that represents the stash for addr.
|
||||
func (c *QueuePacketConn) Unstash(addr net.Addr) <-chan []byte {
|
||||
return c.remotes.Unstash(addr)
|
||||
}
|
||||
|
||||
// ReadFrom returns a packet and address previously stored by QueueIncoming.
|
||||
func (c *QueuePacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
case packet := <-c.recvQueue:
|
||||
return copy(p, packet.P), packet.Addr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTo queues an outgoing packet for the given address. The queue can later
|
||||
// be retrieved using the OutgoingQueue method.
|
||||
func (c *QueuePacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, &net.OpError{Op: "write", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
default:
|
||||
}
|
||||
// Copy the slice so that the caller may reuse it.
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
select {
|
||||
case c.remotes.SendQueue(addr) <- buf:
|
||||
return len(buf), nil
|
||||
default:
|
||||
// Drop the outgoing packet if the send queue is full.
|
||||
return len(buf), nil
|
||||
}
|
||||
}
|
||||
|
||||
// closeWithError unblocks pending operations and makes future operations fail
|
||||
// with the given error. If err is nil, it becomes errClosedPacketConn.
|
||||
func (c *QueuePacketConn) closeWithError(err error) error {
|
||||
var newlyClosed bool
|
||||
c.closeOnce.Do(func() {
|
||||
newlyClosed = true
|
||||
// Store the error to be returned by future PacketConn
|
||||
// operations.
|
||||
if err == nil {
|
||||
err = errClosedPacketConn
|
||||
}
|
||||
c.err.Store(err)
|
||||
close(c.closed)
|
||||
// LOCAL FORK ADDITION: stop the RemoteMap expiry goroutine so it is not
|
||||
// leaked for the process lifetime. Upstream never does this.
|
||||
c.remotes.Close()
|
||||
})
|
||||
if !newlyClosed {
|
||||
return &net.OpError{Op: "close", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close unblocks pending operations and makes future operations fail with a
|
||||
// "closed connection" error.
|
||||
func (c *QueuePacketConn) Close() error {
|
||||
return c.closeWithError(nil)
|
||||
}
|
||||
|
||||
// LocalAddr returns the localAddr value that was passed to NewQueuePacketConn.
|
||||
func (c *QueuePacketConn) LocalAddr() net.Addr { return c.localAddr }
|
||||
|
||||
func (c *QueuePacketConn) SetDeadline(t time.Time) error { return errNotImplemented }
|
||||
func (c *QueuePacketConn) SetReadDeadline(t time.Time) error { return errNotImplemented }
|
||||
func (c *QueuePacketConn) SetWriteDeadline(t time.Time) error { return errNotImplemented }
|
||||
@@ -0,0 +1,198 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteRecord is a record of a recently seen remote peer, with the time it was
|
||||
// last seen and queues of outgoing packets.
|
||||
type remoteRecord struct {
|
||||
Addr net.Addr
|
||||
LastSeen time.Time
|
||||
SendQueue chan []byte
|
||||
Stash chan []byte
|
||||
}
|
||||
|
||||
// RemoteMap manages a mapping of live remote peers, keyed by address, to their
|
||||
// respective send queues. Each peer has two queues: a primary send queue, and a
|
||||
// "stash". The primary send queue is returned by the SendQueue method. The
|
||||
// stash is an auxiliary one-element queue accessed using the Stash and Unstash
|
||||
// methods. The stash is meant for use by callers that need to "unread" a packet
|
||||
// that's already been removed from the primary send queue.
|
||||
//
|
||||
// RemoteMap's functions are safe to call from multiple goroutines.
|
||||
type RemoteMap struct {
|
||||
// We use an inner structure to avoid exposing public heap.Interface
|
||||
// functions to users of remoteMap.
|
||||
inner remoteMapInner
|
||||
// Synchronizes access to inner.
|
||||
lock sync.Mutex
|
||||
// closed stops the background expiry goroutine. LOCAL FORK ADDITION:
|
||||
// upstream has no way to stop that goroutine, which leaks it for the
|
||||
// process lifetime on every RemoteMap created.
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewRemoteMap creates a RemoteMap that expires peers after a timeout.
|
||||
//
|
||||
// If the timeout is 0, peers never expire.
|
||||
//
|
||||
// The timeout does not have to be kept in sync with smux's idle timeout. If a
|
||||
// peer is removed from the map while the smux session is still live, the worst
|
||||
// that can happen is a loss of whatever packets were in the send queue at the
|
||||
// time. If smux later decides to send more packets to the same peer, we'll
|
||||
// instantiate a new send queue, and if the peer is ever seen again with a
|
||||
// matching address, we'll deliver them.
|
||||
func NewRemoteMap(timeout time.Duration) *RemoteMap {
|
||||
m := &RemoteMap{
|
||||
inner: remoteMapInner{
|
||||
byAge: make([]*remoteRecord, 0),
|
||||
byAddr: make(map[net.Addr]int),
|
||||
},
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
if timeout > 0 {
|
||||
// LOCAL FORK CHANGE: upstream is `for { time.Sleep(timeout/2); ... }`
|
||||
// with no exit. Use a ticker and select on m.closed so Close() can stop
|
||||
// this goroutine.
|
||||
go func() {
|
||||
ticker := time.NewTicker(timeout / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.closed:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
m.lock.Lock()
|
||||
m.inner.removeExpired(now, timeout)
|
||||
m.lock.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Close stops the background expiry goroutine started by NewRemoteMap. It is
|
||||
// safe to call more than once. LOCAL FORK ADDITION.
|
||||
func (m *RemoteMap) Close() error {
|
||||
m.closeOnce.Do(func() { close(m.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendQueue returns the send queue corresponding to addr, creating it if
|
||||
// necessary.
|
||||
func (m *RemoteMap) SendQueue(addr net.Addr) chan []byte {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.inner.Lookup(addr, time.Now()).SendQueue
|
||||
}
|
||||
|
||||
// Stash places p in the stash corresponding to addr, if the stash is not
|
||||
// already occupied. Returns true if the p was placed in the stash, false
|
||||
// otherwise.
|
||||
func (m *RemoteMap) Stash(addr net.Addr, p []byte) bool {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case m.inner.Lookup(addr, time.Now()).Stash <- p:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Unstash returns the channel that reads from the stash for addr.
|
||||
func (m *RemoteMap) Unstash(addr net.Addr) <-chan []byte {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.inner.Lookup(addr, time.Now()).Stash
|
||||
}
|
||||
|
||||
// remoteMapInner is the inner type of RemoteMap, implementing heap.Interface.
|
||||
// byAge is the backing store, a heap ordered by LastSeen time, to facilitate
|
||||
// expiring old records. byAddr is a map from addresses to heap indices, to
|
||||
// allow looking up by address. Unlike RemoteMap, remoteMapInner requires
|
||||
// external synchonization.
|
||||
type remoteMapInner struct {
|
||||
byAge []*remoteRecord
|
||||
byAddr map[net.Addr]int
|
||||
}
|
||||
|
||||
// removeExpired removes all records whose LastSeen timestamp is more than
|
||||
// timeout in the past.
|
||||
func (inner *remoteMapInner) removeExpired(now time.Time, timeout time.Duration) {
|
||||
for len(inner.byAge) > 0 && now.Sub(inner.byAge[0].LastSeen) >= timeout {
|
||||
record := heap.Pop(inner).(*remoteRecord)
|
||||
close(record.SendQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup finds the existing record corresponding to addr, or creates a new
|
||||
// one if none exists yet. It updates the record's LastSeen time and returns the
|
||||
// record.
|
||||
func (inner *remoteMapInner) Lookup(addr net.Addr, now time.Time) *remoteRecord {
|
||||
var record *remoteRecord
|
||||
i, ok := inner.byAddr[addr]
|
||||
if ok {
|
||||
// Found one, update its LastSeen.
|
||||
record = inner.byAge[i]
|
||||
record.LastSeen = now
|
||||
heap.Fix(inner, i)
|
||||
} else {
|
||||
// Not found, create a new one.
|
||||
record = &remoteRecord{
|
||||
Addr: addr,
|
||||
LastSeen: now,
|
||||
SendQueue: make(chan []byte, QueueSize),
|
||||
Stash: make(chan []byte, 1),
|
||||
}
|
||||
heap.Push(inner, record)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// heap.Interface for remoteMapInner.
|
||||
|
||||
func (inner *remoteMapInner) Len() int {
|
||||
if len(inner.byAge) != len(inner.byAddr) {
|
||||
panic("inconsistent remoteMap")
|
||||
}
|
||||
return len(inner.byAge)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Less(i, j int) bool {
|
||||
return inner.byAge[i].LastSeen.Before(inner.byAge[j].LastSeen)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Swap(i, j int) {
|
||||
inner.byAge[i], inner.byAge[j] = inner.byAge[j], inner.byAge[i]
|
||||
inner.byAddr[inner.byAge[i].Addr] = i
|
||||
inner.byAddr[inner.byAge[j].Addr] = j
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Push(x interface{}) {
|
||||
record := x.(*remoteRecord)
|
||||
if _, ok := inner.byAddr[record.Addr]; ok {
|
||||
panic("duplicate address in remoteMap")
|
||||
}
|
||||
// Insert into byAddr map.
|
||||
inner.byAddr[record.Addr] = len(inner.byAge)
|
||||
// Insert into byAge slice.
|
||||
inner.byAge = append(inner.byAge, record)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Pop() interface{} {
|
||||
n := len(inner.byAddr)
|
||||
// Remove from byAge slice.
|
||||
record := inner.byAge[n-1]
|
||||
inner.byAge[n-1] = nil
|
||||
inner.byAge = inner.byAge[:n-1]
|
||||
// Remove from byAddr map.
|
||||
delete(inner.byAddr, record.Addr)
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestQueuePacketConnCloseStopsGoroutine is the regression test for the local
|
||||
// fork's reason to exist: upstream's RemoteMap expiry goroutine runs forever,
|
||||
// so creating and discarding many QueuePacketConns (as DNSTT restart does)
|
||||
// leaks one goroutine each. After Close(), the count must return to baseline.
|
||||
func TestQueuePacketConnCloseStopsGoroutine(t *testing.T) {
|
||||
// Let any goroutines from earlier settle.
|
||||
settle := func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
runtime.GC()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
settle()
|
||||
base := runtime.NumGoroutine()
|
||||
|
||||
const n = 200
|
||||
for i := 0; i < n; i++ {
|
||||
// Short timeout so the goroutine is definitely started (timeout > 0).
|
||||
c := NewQueuePacketConn(DummyAddr{}, 50*time.Millisecond)
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}
|
||||
settle()
|
||||
|
||||
got := runtime.NumGoroutine()
|
||||
// Allow a small slack for scheduler/runtime goroutines; the key point is we
|
||||
// are nowhere near base+n (which is what the upstream leak would produce).
|
||||
if got > base+20 {
|
||||
t.Fatalf("goroutine leak: baseline=%d after %d create/close cycles=%d (want <= baseline+20)", base, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteMapCloseIdempotent verifies Close can be called repeatedly.
|
||||
func TestRemoteMapCloseIdempotent(t *testing.T) {
|
||||
m := NewRemoteMap(10 * time.Millisecond)
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const managedCredentialPrefix = "enc:v1:"
|
||||
|
||||
func sealManagedCredential(plain string) (string, error) {
|
||||
return sealCredential(managedCredentialPrefix, plain)
|
||||
}
|
||||
|
||||
func openManagedCredential(stored string) (string, error) {
|
||||
return openCredential(managedCredentialPrefix, stored)
|
||||
}
|
||||
|
||||
func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func remoteErrorSnippet(data []byte) string {
|
||||
const limit = 4096
|
||||
truncated := len(data) > limit
|
||||
if truncated {
|
||||
data = data[:limit]
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(data))
|
||||
value = strings.TrimSpace(value)
|
||||
if truncated {
|
||||
value += "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
BaseURL string
|
||||
AdminUsername string
|
||||
AdminKey string
|
||||
EnableSSH bool
|
||||
EnableXray bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ManagedServerDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
EnableSSH bool `json:"enable_ssh"`
|
||||
EnableXray bool `json:"enable_xray"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ManagedServerPayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AdminUsername string `json:"admin_username"`
|
||||
AdminKey string `json:"admin_key"`
|
||||
EnableSSH bool `json:"enable_ssh"`
|
||||
EnableXray bool `json:"enable_xray"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS managed_servers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL UNIQUE,
|
||||
admin_username TEXT NOT NULL DEFAULT 'admin',
|
||||
admin_key TEXT NOT NULL DEFAULT '',
|
||||
enable_ssh BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
enable_xray BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateManagedServerCredentials(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateManagedServerCredentials(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyCredential struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
var legacy []legacyCredential
|
||||
for rows.Next() {
|
||||
var item legacyCredential
|
||||
if err := rows.Scan(&item.id, &item.key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealManagedCredential(item.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
|
||||
FROM managed_servers ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*ManagedServer
|
||||
for rows.Next() {
|
||||
ms := &ManagedServer{}
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, error) {
|
||||
ms := &ManagedServer{}
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
|
||||
FROM managed_servers WHERE id=$1`, id).
|
||||
Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid server name")
|
||||
}
|
||||
if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin username")
|
||||
}
|
||||
if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin credential")
|
||||
}
|
||||
if baseURLErr != nil {
|
||||
return nil, baseURLErr
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, fmt.Errorf("invalid server id")
|
||||
}
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
|
||||
if sealErr != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetManagedServer(ctx, id)
|
||||
}
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
sealedKey, err := sealManagedCredential(p.AdminKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", err)
|
||||
}
|
||||
var id int
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
name=EXCLUDED.name,
|
||||
admin_username=EXCLUDED.admin_username,
|
||||
admin_key=EXCLUDED.admin_key,
|
||||
enable_ssh=EXCLUDED.enable_ssh,
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetManagedServer(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteManagedServer(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM managed_servers WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func managedServerToDTO(ms *ManagedServer) ManagedServerDTO {
|
||||
return ManagedServerDTO{
|
||||
ID: strconv.Itoa(ms.ID),
|
||||
Name: ms.Name,
|
||||
BaseURL: ms.BaseURL,
|
||||
AdminUsername: ms.AdminUsername,
|
||||
EnableSSH: ms.EnableSSH,
|
||||
EnableXray: ms.EnableXray,
|
||||
IsActive: ms.IsActive,
|
||||
CreatedAt: ms.CreatedAt,
|
||||
UpdatedAt: ms.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func localManagedServerDTO() ManagedServerDTO {
|
||||
cfg := getGlobalCfg()
|
||||
xrayEnabled := cfg != nil && cfg.Xray != nil && cfg.Xray.Enabled
|
||||
return ManagedServerDTO{
|
||||
ID: "local",
|
||||
Name: "Master node",
|
||||
BaseURL: "local",
|
||||
EnableSSH: true,
|
||||
EnableXray: xrayEnabled,
|
||||
IsActive: true,
|
||||
IsLocal: true,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
normalized, _ := validateManagedServerBaseURL(raw)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateManagedServerBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("base url required")
|
||||
}
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("base url must use http or https")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("base url must not contain credentials")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("base url must not contain a path")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
|
||||
return "", fmt.Errorf("base url uses a forbidden address")
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("server_id"))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(r.URL.Query().Get("server"))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func managedServerFromID(ctx context.Context, store *Store, id string) (*ManagedServer, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == "local" || id == "0" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if store == nil {
|
||||
return nil, false, fmt.Errorf("database not configured")
|
||||
}
|
||||
n, err := strconv.Atoi(id)
|
||||
if err != nil || n <= 0 {
|
||||
return nil, false, fmt.Errorf("invalid server id")
|
||||
}
|
||||
ms, err := store.GetManagedServer(ctx, n)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ms == nil {
|
||||
return nil, false, fmt.Errorf("server not found")
|
||||
}
|
||||
if !ms.IsActive {
|
||||
return nil, false, fmt.Errorf("server is disabled")
|
||||
}
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func writeManagedServerSelectionError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "invalid server id", "server not found", "server is disabled":
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case "database not configured":
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
default:
|
||||
writeInternalError(w, "select managed server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManagedServerSaveError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
safe := message == "server name required" || message == "invalid server name" ||
|
||||
message == "invalid admin username" || message == "invalid admin credential" ||
|
||||
message == "invalid server id" || message == "admin key/password required" ||
|
||||
strings.HasPrefix(message, "base url") || message == "invalid base url"
|
||||
if safe {
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "save managed server", err)
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := managedServerHTTPClient(15 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed with HTTP %d: %q", resp.StatusCode, remoteErrorSnippet(data))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
|
||||
return "", fmt.Errorf("remote login returned no token")
|
||||
}
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path string, body []byte, contentType string) (int, []byte, string, error) {
|
||||
token, err := remoteLoginToken(ctx, ms)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, ms.BaseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := managedServerHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if proxyManagedServerFromRequest(w, r, store, "", nil, "") {
|
||||
return
|
||||
}
|
||||
local(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if status >= http.StatusInternalServerError {
|
||||
if len(body) > 0 {
|
||||
log.Printf("managed server returned HTTP %d: %q", status, remoteErrorSnippet(body))
|
||||
}
|
||||
body = []byte("managed server request failed\n")
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if status == 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
return false
|
||||
}
|
||||
if remotePath == "" {
|
||||
remotePath = r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
q := r.URL.Query()
|
||||
q.Del("server_id")
|
||||
q.Del("server")
|
||||
if enc := q.Encode(); enc != "" {
|
||||
remotePath += "?" + enc
|
||||
}
|
||||
}
|
||||
}
|
||||
if body == nil && r.Body != nil && r.Method != http.MethodGet {
|
||||
body, _ = io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "proxy managed server request", err)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
if filtered, ok := filterRemoteOwnerJSON(remotePath, data, filterOwner); ok {
|
||||
data = filtered
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return true
|
||||
}
|
||||
|
||||
func filterRemoteOwnerJSON(path string, data []byte, owner string) ([]byte, bool) {
|
||||
if owner == "" || len(data) == 0 {
|
||||
return data, false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/users") {
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return data, false
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) == owner {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
filtered, _ := json.Marshal(out)
|
||||
return filtered, true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/xray/inbounds") {
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return data, false
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
filtered := make([]interface{}, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(m["owner_username"])) == owner {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
ib["clients"] = filtered
|
||||
}
|
||||
filtered, _ := json.Marshal(inbounds)
|
||||
return filtered, true
|
||||
}
|
||||
return data, false
|
||||
}
|
||||
|
||||
func handleServers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rows, err := store.ListManagedServers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := []ManagedServerDTO{localManagedServerDTO()}
|
||||
for _, ms := range rows {
|
||||
if sess != nil && sess.Role == RoleReseller && !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
dto := managedServerToDTO(ms)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
dto.AdminUsername = ""
|
||||
}
|
||||
out = append(out, dto)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
case http.MethodPost:
|
||||
if sess == nil || sess.Role != RoleSuperAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var p ManagedServerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
writeManagedServerSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(managedServerToDTO(ms))
|
||||
case http.MethodDelete:
|
||||
if sess == nil || sess.Role != RoleSuperAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
idStr := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteManagedServer(r.Context(), id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleServerTest(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var p ManagedServerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ms := &ManagedServer{Name: p.Name, BaseURL: normalizeManagedServerBaseURL(p.BaseURL), AdminUsername: strings.TrimSpace(p.AdminUsername), AdminKey: p.AdminKey, EnableSSH: p.EnableSSH, EnableXray: p.EnableXray, IsActive: true}
|
||||
if p.ID != "" && p.ID != "local" && (ms.BaseURL == "" || ms.AdminKey == "") {
|
||||
id, _ := strconv.Atoi(p.ID)
|
||||
if id > 0 {
|
||||
stored, err := store.GetManagedServer(r.Context(), id)
|
||||
if err == nil && stored != nil {
|
||||
if ms.BaseURL == "" {
|
||||
ms.BaseURL = stored.BaseURL
|
||||
}
|
||||
if ms.AdminUsername == "" {
|
||||
ms.AdminUsername = stored.AdminUsername
|
||||
}
|
||||
if ms.AdminKey == "" {
|
||||
ms.AdminKey = stored.AdminKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms.AdminUsername == "" {
|
||||
ms.AdminUsername = "admin"
|
||||
}
|
||||
if ms.BaseURL == "" || ms.AdminKey == "" {
|
||||
http.Error(w, "base url and admin key/password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "test managed server login", err)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "test managed server session", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
writeBadGatewayError(w, "test managed server session", fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "remote login ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := requestedServerID(r)
|
||||
if id == "" || id == "local" || id == "0" {
|
||||
handleServerConfig(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body := []byte(nil)
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
body, err = io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
handleServerConfig(w, r)
|
||||
return
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
writeBadGatewayError(w, "proxy managed server configuration", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserInfo(ctx context.Context, ms *ManagedServer, username string) (map[string]interface{}, bool, error) {
|
||||
if username == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return row, true, nil
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteSSHUserInfo(ctx, ms, username)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientInfo(ctx context.Context, ms *ManagedServer, uuid string) (map[string]interface{}, bool, error) {
|
||||
if uuid == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
m["inbound_tag"] = fmt.Sprint(ib["tag"])
|
||||
return m, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteXrayClientInfo(ctx, ms, uuid)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
type resellerQuotaUsage struct {
|
||||
Weighted int
|
||||
SSHAccounts int
|
||||
XrayAccounts int
|
||||
}
|
||||
|
||||
func ownedQuotaUsageAcrossManagedServers(ctx context.Context, store *Store, owner string) (resellerQuotaUsage, error) {
|
||||
usage := resellerQuotaUsage{}
|
||||
if owner == "" {
|
||||
return usage, nil
|
||||
}
|
||||
usage.Weighted = countOwnedQuota(ctx, store, owner)
|
||||
usage.SSHAccounts = countOwnedUsers(owner)
|
||||
usage.XrayAccounts = countOwnedXrayClients(ctx, store, owner)
|
||||
if store == nil {
|
||||
return usage, nil
|
||||
}
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
// Count every configured node and both account types. Temporarily disabling
|
||||
// a node or a protocol must not release its committed reseller quota.
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
|
||||
usage.SSHAccounts++
|
||||
}
|
||||
}
|
||||
status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"]))
|
||||
usage.XrayAccounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner)
|
||||
return usage.Weighted, err
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPanelLogMaxBytes int64 = 1 * 1024 * 1024
|
||||
defaultPanelLogCheckEvery = 10 * time.Second
|
||||
)
|
||||
|
||||
type panelLogResetResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
}
|
||||
|
||||
func panelLogFilePath() string {
|
||||
path := strings.TrimSpace(os.Getenv("PANEL_LOG_FILE"))
|
||||
if path == "" {
|
||||
path = defaultPanelLogFile
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func panelLogMaxBytes() int64 {
|
||||
raw := strings.TrimSpace(os.Getenv("PANEL_LOG_MAX_BYTES"))
|
||||
if raw == "" {
|
||||
return defaultPanelLogMaxBytes
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return defaultPanelLogMaxBytes
|
||||
}
|
||||
// Do not allow a tiny limit that would cause continuous truncation.
|
||||
if n < 64*1024 {
|
||||
return 64 * 1024
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func startPanelLogLimiter() {
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if path == "" || maxBytes <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = enforcePanelLogLimit(path, maxBytes)
|
||||
ticker := time.NewTicker(defaultPanelLogCheckEvery)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_ = enforcePanelLogLimit(path, maxBytes)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func enforcePanelLogLimit(path string, maxBytes int64) error {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if st.Size() <= maxBytes {
|
||||
return nil
|
||||
}
|
||||
return truncatePanelLog(path, maxBytes, "automatic 1 MiB log limit")
|
||||
}
|
||||
|
||||
func truncatePanelLog(path string, maxBytes int64, reason string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "%s sshpanel: panel log cleaned (%s, max=%d bytes)\n", time.Now().Format(time.RFC3339), reason, maxBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
|
||||
writeInternalError(w, "clear panel log", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(panelLogResetResponse{OK: true, Path: path, MaxBytes: maxBytes})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
proxyAutoMu sync.Mutex
|
||||
proxyAutoCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// startProxyAutoRestart starts a watchdog that periodically hard-restarts the
|
||||
// public SSH/HTTP proxy layer. Unlike a normal hot reload, this intentionally
|
||||
// closes active SSH sessions so the behavior is close to a service reboot.
|
||||
func startProxyAutoRestart(cfg *Config) {
|
||||
interval := proxyAutoRestartInterval(cfg)
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
grace := proxyAutoRestartGrace(cfg)
|
||||
cfgCopy := cloneProxyRestartConfig(cfg)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
proxyAutoMu.Lock()
|
||||
if proxyAutoCancel != nil {
|
||||
proxyAutoCancel()
|
||||
}
|
||||
proxyAutoCancel = cancel
|
||||
proxyAutoMu.Unlock()
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
log.Printf("proxy auto restart enabled: interval=%s grace=%s mode=hard", interval, grace)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
restartProxyHard(ctx, cfgCopy, grace)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func stopProxyAutoRestart() {
|
||||
proxyAutoMu.Lock()
|
||||
defer proxyAutoMu.Unlock()
|
||||
if proxyAutoCancel != nil {
|
||||
proxyAutoCancel()
|
||||
proxyAutoCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneProxyRestartConfig(cfg *Config) *Config {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Config{
|
||||
Listen: cfg.Listen,
|
||||
ProxyAutoRestartInterval: cfg.ProxyAutoRestartInterval,
|
||||
ProxyAutoRestartGrace: cfg.ProxyAutoRestartGrace,
|
||||
}
|
||||
out.ExtraListen = append([]string(nil), cfg.ExtraListen...)
|
||||
out.TLSForwarders = append([]TLSForwarderConfig(nil), cfg.TLSForwarders...)
|
||||
return out
|
||||
}
|
||||
|
||||
func restartProxyHard(ctx context.Context, cfg *Config, grace time.Duration) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("proxy auto restart: stopping public proxy listeners and active SSH sessions")
|
||||
if publicPool != nil {
|
||||
publicPool.StopAll("proxy auto restart")
|
||||
}
|
||||
if tlsPool != nil {
|
||||
tlsPool.StopAll("proxy auto restart")
|
||||
}
|
||||
closed := userMgr.DisconnectAll()
|
||||
if closed > 0 {
|
||||
log.Printf("proxy auto restart: closed %d active SSH session(s)", closed)
|
||||
}
|
||||
if !sleepOrContextDone(ctx, grace) {
|
||||
return
|
||||
}
|
||||
|
||||
publicAddrs := append([]string{cfg.Listen}, cfg.ExtraListen...)
|
||||
for attempt := 1; ; attempt++ {
|
||||
errs := []error{}
|
||||
if publicPool != nil {
|
||||
errs = append(errs, publicPool.Sync(publicAddrs)...)
|
||||
}
|
||||
if tlsPool != nil {
|
||||
errs = append(errs, tlsPool.Sync(cfg.TLSForwarders)...)
|
||||
}
|
||||
|
||||
ok := len(errs) == 0
|
||||
if publicPool != nil && !publicPool.HasAll(publicAddrs) {
|
||||
ok = false
|
||||
}
|
||||
if tlsPool != nil && !tlsPool.HasAll(cfg.TLSForwarders) {
|
||||
ok = false
|
||||
}
|
||||
if ok {
|
||||
log.Printf("proxy auto restart: public proxy restarted")
|
||||
return
|
||||
}
|
||||
|
||||
for _, err := range errs {
|
||||
log.Printf("proxy auto restart: start attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
log.Printf("proxy auto restart: start attempt %d incomplete; one or more listeners are still down", attempt)
|
||||
}
|
||||
if !sleepOrContextDone(ctx, 10*time.Second) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func proxyAutoRestartInterval(cfg *Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cfg.ProxyAutoRestartInterval)
|
||||
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
log.Printf("proxy auto restart disabled: invalid interval %q: %v", raw, err)
|
||||
return 0
|
||||
}
|
||||
if d < time.Minute {
|
||||
log.Printf("proxy auto restart disabled: interval %q is below minimum 1m", raw)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func proxyAutoRestartGrace(cfg *Config) time.Duration {
|
||||
if cfg == nil || strings.TrimSpace(cfg.ProxyAutoRestartGrace) == "" {
|
||||
return 2 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(cfg.ProxyAutoRestartGrace))
|
||||
if err != nil || d < 0 {
|
||||
log.Printf("proxy auto restart: invalid grace %q, using 2s", cfg.ProxyAutoRestartGrace)
|
||||
return 2 * time.Second
|
||||
}
|
||||
if d > time.Minute {
|
||||
return time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func sleepOrContextDone(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(d):
|
||||
return true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeQuotaMode(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"": QuotaModeSlots,
|
||||
"slots": QuotaModeSlots,
|
||||
"Validade": QuotaModeSlots,
|
||||
"credits": QuotaModeCredit,
|
||||
"Credito": QuotaModeCredit,
|
||||
} {
|
||||
if got := normalizeQuotaMode(input); got != expected {
|
||||
t.Fatalf("normalizeQuotaMode(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerProvisionCost(t *testing.T) {
|
||||
for input, expected := range map[int]int{-10: 1, 0: 1, 1: 1, 3: 3} {
|
||||
if got := resellerProvisionCost(input); got != expected {
|
||||
t.Fatalf("resellerProvisionCost(%d) = %d, want %d", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResellerSubtree(t *testing.T) {
|
||||
all := []*AdminUser{
|
||||
{Username: "root", Role: RoleReseller},
|
||||
{Username: "child-a", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "child-b", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "grandchild", Role: RoleReseller, ParentUsername: "child-a"},
|
||||
{Username: "admin", Role: RoleSuperAdmin},
|
||||
}
|
||||
got := listResellerSubtree(all, "root")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("subtree size = %d, want 4", len(got))
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range got {
|
||||
seen[user.Username] = true
|
||||
}
|
||||
for _, username := range []string{"root", "child-a", "child-b", "grandchild"} {
|
||||
if !seen[username] {
|
||||
t.Fatalf("subtree does not contain %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCanManageOnlyDirectChildren(t *testing.T) {
|
||||
sess := &AdminSession{Username: "parent", Role: RoleReseller}
|
||||
if !resellerCanManage(sess, &AdminUser{Username: "child", Role: RoleReseller, ParentUsername: "parent"}) {
|
||||
t.Fatal("parent could not manage its direct child")
|
||||
}
|
||||
if resellerCanManage(sess, &AdminUser{Username: "grandchild", Role: RoleReseller, ParentUsername: "child"}) {
|
||||
t.Fatal("parent was allowed to skip a hierarchy level")
|
||||
}
|
||||
admin := &AdminSession{Username: "admin", Role: RoleSuperAdmin}
|
||||
if !resellerCanManage(admin, &AdminUser{Username: "any", Role: RoleReseller}) {
|
||||
t.Fatal("superadmin could not manage a reseller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerExpiryExtensionDetection(t *testing.T) {
|
||||
existing := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if resellerExpiryExtended(existing.Format(time.RFC3339), existing.Format(time.RFC3339)) {
|
||||
t.Fatal("unchanged expiration was treated as an extension")
|
||||
}
|
||||
if !resellerExpiryExtended(existing.Format(time.RFC3339), existing.Add(time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("later expiration was not treated as an extension")
|
||||
}
|
||||
if resellerTimeExtended(&existing, existing.Add(-time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("shorter expiration was treated as an extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalExpiryUsesLaterBase(t *testing.T) {
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
got := renewalExpiry(&future, 30)
|
||||
want := future.AddDate(0, 0, 30)
|
||||
if got.Sub(want) > time.Second || want.Sub(got) > time.Second {
|
||||
t.Fatalf("renewal expiry = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountChainUsesPasswordFreeRuntimeState(t *testing.T) {
|
||||
parent := "runtime-parent-test"
|
||||
child := "runtime-child-test"
|
||||
adminUsers.delete(parent)
|
||||
adminUsers.delete(child)
|
||||
defer resellerRuntimeStates.delete(parent)
|
||||
defer resellerRuntimeStates.delete(child)
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: true})
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: child, ParentUsername: parent, IsActive: true})
|
||||
if err := adminAccountChainActive(child); err != nil {
|
||||
t.Fatalf("active replicated hierarchy was rejected: %v", err)
|
||||
}
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: false})
|
||||
if err := adminAccountChainActive(child); err == nil {
|
||||
t.Fatal("child remained active while its replicated parent was suspended")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResellerRuntimeState is a password-free ownership record replicated from a
|
||||
// master panel to its managed nodes. It lets a node enforce reseller
|
||||
// suspension and parent hierarchy locally without copying login credentials.
|
||||
type ResellerRuntimeState struct {
|
||||
OwnerUsername string
|
||||
ParentUsername string
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type resellerRuntimeStateCacheT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]ResellerRuntimeState
|
||||
}
|
||||
|
||||
var resellerRuntimeStates = &resellerRuntimeStateCacheT{m: make(map[string]ResellerRuntimeState)}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) get(username string) (ResellerRuntimeState, bool) {
|
||||
m.mu.RLock()
|
||||
state, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) set(state ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m[state.OwnerUsername] = state
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) list() []ResellerRuntimeState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResellerRuntimeState, 0, len(m.m))
|
||||
for _, state := range m.m {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) replaceAll(states []ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]ResellerRuntimeState, len(states))
|
||||
for _, state := range states {
|
||||
m.m[state.OwnerUsername] = state
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) ListResellerRuntimeStates(ctx context.Context) ([]ResellerRuntimeState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT owner_username, parent_username, is_active, expires_at
|
||||
FROM reseller_runtime_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ResellerRuntimeState
|
||||
for rows.Next() {
|
||||
var state ResellerRuntimeState
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&state.OwnerUsername, &state.ParentUsername, &state.IsActive, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
state.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertResellerRuntimeState(ctx context.Context, state ResellerRuntimeState) error {
|
||||
var expiresAt interface{}
|
||||
if state.ExpiresAt != nil {
|
||||
expiresAt = *state.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO reseller_runtime_state
|
||||
(owner_username, parent_username, is_active, expires_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (owner_username) DO UPDATE SET
|
||||
parent_username=EXCLUDED.parent_username,
|
||||
is_active=EXCLUDED.is_active,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=NOW()`,
|
||||
state.OwnerUsername, state.ParentUsername, state.IsActive, expiresAt)
|
||||
if err == nil {
|
||||
resellerRuntimeStates.set(state)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResellerRuntimeState(ctx context.Context, owner string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM reseller_runtime_state WHERE owner_username=$1`, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resellerRuntimeStates.delete(owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeChainActive(username string) error {
|
||||
seen := make(map[string]bool)
|
||||
now := time.Now()
|
||||
for depth := 0; username != "" && depth < 128; depth++ {
|
||||
if seen[username] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[username] = true
|
||||
state, ok := resellerRuntimeStates.get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller runtime state not found")
|
||||
}
|
||||
if !state.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if state.ExpiresAt != nil && now.After(*state.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
username = strings.TrimSpace(state.ParentUsername)
|
||||
}
|
||||
if username != "" {
|
||||
return fmt.Errorf("reseller hierarchy is too deep")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeStateFor(owner string, effectiveActive bool) (ResellerRuntimeState, error) {
|
||||
u, ok := adminUsers.get(owner)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return ResellerRuntimeState{}, fmt.Errorf("reseller account not found")
|
||||
}
|
||||
return ResellerRuntimeState{
|
||||
OwnerUsername: u.Username,
|
||||
ParentUsername: u.ParentUsername,
|
||||
IsActive: effectiveActive,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syncOwnerChainToManagedServer makes account creation on a managed node safe:
|
||||
// every parent is installed before the child, and no password/hash is sent.
|
||||
func syncOwnerChainToManagedServer(ctx context.Context, ms *ManagedServer, owner string) error {
|
||||
var chain []*AdminUser
|
||||
seen := make(map[string]bool)
|
||||
for current := strings.TrimSpace(owner); current != ""; {
|
||||
if seen[current] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[current] = true
|
||||
u, ok := adminUsers.get(current)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
chain = append(chain, u)
|
||||
current = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
state, err := resellerRuntimeStateFor(chain[i].Username, adminAccountChainActive(chain[i].Username) == nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := resellerRuntimePayloadFromState(state, "sync")
|
||||
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAllResellerRuntimeStates repairs legacy managed nodes after an upgrade.
|
||||
// It runs asynchronously and never prevents the local panel from starting.
|
||||
func startManagedResellerStateSync(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller state sync: %v", err)
|
||||
return
|
||||
}
|
||||
users := adminUsers.list()
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return resellerHierarchyDepth(users[i].Username) < resellerHierarchyDepth(users[j].Username)
|
||||
})
|
||||
for _, ms := range servers {
|
||||
for _, u := range users {
|
||||
if u.Role != RoleReseller {
|
||||
continue
|
||||
}
|
||||
action := "suspend"
|
||||
active := adminAccountChainActive(u.Username) == nil
|
||||
if active {
|
||||
action = "reactivate"
|
||||
}
|
||||
state, stateErr := resellerRuntimeStateFor(u.Username, active)
|
||||
if stateErr != nil {
|
||||
continue
|
||||
}
|
||||
if sendErr := sendResellerRuntimeToServer(ctx, ms, resellerRuntimePayloadFromState(state, action)); sendErr != nil {
|
||||
log.Printf("reseller state sync to %s for %s: %v", ms.Name, u.Username, sendErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileLocalResellerRuntimeStates reapplies replicated ownership state
|
||||
// after a managed node restarts.
|
||||
func reconcileLocalResellerRuntimeStates(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
action := "suspend"
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
||||
action = "reactivate"
|
||||
}
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, action); err != nil {
|
||||
log.Printf("reconcile local reseller runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resellerHierarchyDepth(username string) int {
|
||||
seen := make(map[string]bool)
|
||||
depth := 0
|
||||
for username != "" && depth < 128 {
|
||||
if seen[username] {
|
||||
return 128
|
||||
}
|
||||
seen[username] = true
|
||||
u, ok := adminUsers.get(username)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
username = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminPasswordHashAndLegacyUpgrade(t *testing.T) {
|
||||
password := "correct-horse-battery-staple"
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if valid, upgrade := verifyAdminPassword(hash, password); !valid || upgrade {
|
||||
t.Fatalf("bcrypt verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
if valid, _ := verifyAdminPassword(hash, "wrong-password"); valid {
|
||||
t.Fatal("wrong bcrypt password was accepted")
|
||||
}
|
||||
legacy := legacyAdminPasswordHash(password)
|
||||
if valid, upgrade := verifyAdminPassword(legacy, password); !valid || !upgrade {
|
||||
t.Fatalf("legacy verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSDomainRejectsTraversal(t *testing.T) {
|
||||
for _, value := range []string{"../root", `..\\root`, "/absolute", "host\nname"} {
|
||||
if _, _, err := normalizeTLSDomain(value, true); err == nil {
|
||||
t.Fatalf("normalizeTLSDomain(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if domain, _, err := normalizeTLSDomain("vpn.example.com", false); err != nil || domain != "vpn.example.com" {
|
||||
t.Fatalf("valid domain rejected: %q, %v", domain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedServerURLValidation(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"ftp://example.com", "https://user:pass@example.com", "https://example.com/admin", "http://169.254.10.20",
|
||||
} {
|
||||
if _, err := validateManagedServerBaseURL(value); err == nil {
|
||||
t.Fatalf("validateManagedServerBaseURL(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if got, err := validateManagedServerBaseURL("https://node.example.com/"); err != nil || got != "https://node.example.com" {
|
||||
t.Fatalf("valid managed server URL = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrorSnippetIsBoundedAndSingleLine(t *testing.T) {
|
||||
input := make([]byte, 5000)
|
||||
for i := range input {
|
||||
input[i] = 'x'
|
||||
}
|
||||
copy(input, []byte("first\nsecond\r\tsecret"))
|
||||
got := remoteErrorSnippet(input)
|
||||
if len(got) > 4100 {
|
||||
t.Fatalf("remote error snippet is too long: %d", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
t.Fatalf("remote error snippet retained control character %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
|
||||
const (
|
||||
secret = "test-secret-with-enough-entropy"
|
||||
dataID = "123456789"
|
||||
requestID = "request-123"
|
||||
)
|
||||
ts := time.Now().Format("150405")
|
||||
manifest := "id:" + dataID + ";request-id:" + requestID + ";ts:" + ts + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(manifest))
|
||||
signature := "ts=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if !verifyMPSignature(signature, requestID, dataID, secret) {
|
||||
t.Fatal("valid Mercado Pago signature was rejected")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "") {
|
||||
t.Fatal("unsigned webhook mode was accepted")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "wrong-secret") {
|
||||
t.Fatal("signature with wrong secret was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAdminRequestBody = 8 << 20
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeBadGatewayError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "managed server request failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// securePanelHandler applies baseline browser protections and a global request
|
||||
// body ceiling. Endpoint-specific handlers may impose a smaller limit.
|
||||
func securePanelHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces a sensitive configuration file without leaving a
|
||||
// partially written file behind after a crash or interrupted request.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".dragoncore-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+20
-6
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read server configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024+1))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 512*1024 {
|
||||
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -92,6 +96,9 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "listen address required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if newCfg.Xray != nil {
|
||||
newCfg.Xray.NormalizeDefaults()
|
||||
}
|
||||
|
||||
// Preserve file-based users array (not editable through the UI).
|
||||
globalCfgMu.RLock()
|
||||
@@ -100,18 +107,25 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
globalCfgMu.RUnlock()
|
||||
|
||||
portWarnings := normalizeRuntimePorts(&newCfg)
|
||||
|
||||
out, err := json.MarshalIndent(newCfg, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, "marshal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(globalCfgPath, out, 0o644); err != nil {
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
writeInternalError(w, "write server configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply all changes live — no restart needed.
|
||||
applyFullConfigReload(&newCfg)
|
||||
// Apply all changes live and return health checks to the panel.
|
||||
report := applyFullConfigReload(&newCfg)
|
||||
if len(portWarnings) > 0 {
|
||||
report.Warnings = append(portWarnings, report.Warnings...)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(report)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultPanelLogFile = "/opt/sshpanel/logs/panel.log"
|
||||
|
||||
type systemLogsResponse struct {
|
||||
Source string `json:"source"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
|
||||
func handleSystemLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
limit := 300
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("lines")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > 2000 {
|
||||
limit = 2000
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
|
||||
if source == "" {
|
||||
source = "panel"
|
||||
}
|
||||
|
||||
resp := systemLogsResponse{Source: source, Lines: []string{}}
|
||||
switch source {
|
||||
case "dnstt":
|
||||
resp.Lines = limitLines(getDNSTTLogLines(), limit)
|
||||
case "xray":
|
||||
resp.Lines = limitLines(xrayLogBuf.snapshot(), limit)
|
||||
default:
|
||||
resp.Source = "panel"
|
||||
path := panelLogFilePath()
|
||||
resp.Path = path
|
||||
lines, err := tailTextFile(path, limit)
|
||||
if err != nil {
|
||||
lines = []string{"unable to read " + path + ": " + err.Error()}
|
||||
}
|
||||
resp.Lines = lines
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func tailTextFile(path string, limit int) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 300
|
||||
}
|
||||
ring := make([]string, limit)
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
for scanner.Scan() {
|
||||
ring[count%limit] = scanner.Text()
|
||||
count++
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
outLen := count
|
||||
if outLen > limit {
|
||||
outLen = limit
|
||||
}
|
||||
out := make([]string, 0, outLen)
|
||||
start := 0
|
||||
if count > limit {
|
||||
start = count % limit
|
||||
}
|
||||
for i := 0; i < outLen; i++ {
|
||||
out = append(out, ring[(start+i)%limit])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func limitLines(lines []string, limit int) []string {
|
||||
if len(lines) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
if limit <= 0 || len(lines) <= limit {
|
||||
out := make([]string, len(lines))
|
||||
copy(out, lines)
|
||||
return out
|
||||
}
|
||||
out := make([]string, limit)
|
||||
copy(out, lines[len(lines)-limit:])
|
||||
return out
|
||||
}
|
||||
+110
-37
@@ -4,21 +4,57 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsCertsDir = "/opt/sshpanel/certs"
|
||||
|
||||
var (
|
||||
tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
tlsStoreNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
func normalizeTLSDomain(raw string, allowWildcard bool) (domain string, dirName string, err error) {
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(raw), ".")
|
||||
if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/\\\x00\r\n") {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
return domain, strings.ReplaceAll(domain, ":", "_"), nil
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") && !allowWildcard {
|
||||
return "", "", fmt.Errorf("wildcard domains are not supported by this operation")
|
||||
}
|
||||
if !tlsDNSNamePattern.MatchString(domain) {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
dirName = strings.ReplaceAll(domain, "*", "_wildcard_")
|
||||
return domain, dirName, nil
|
||||
}
|
||||
|
||||
func normalizeTLSStoreName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if !tlsStoreNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("name must use only letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the
|
||||
// given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths.
|
||||
func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,14 +65,20 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain, dirName, err := normalizeTLSDomain(req.Domain, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, req.Domain)
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
@@ -44,43 +86,48 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS private key", err)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "serial generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: req.Domain},
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{req.Domain},
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
if net.ParseIP(domain) != nil {
|
||||
tmpl.DNSNames = nil
|
||||
tmpl.IPAddresses = []net.IP{net.ParseIP(domain)}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS certificate", err)
|
||||
return
|
||||
}
|
||||
cf, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
cf.Close()
|
||||
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "encode TLS private key", err)
|
||||
return
|
||||
}
|
||||
kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS private key", err)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
kf.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" {
|
||||
http.Error(w, "domain and email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", req.Email, "-d", req.Domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
domain, _, err := normalizeTLSDomain(req.Domain, false)
|
||||
if err != nil || net.ParseIP(domain) != nil {
|
||||
http.Error(w, "a valid DNS domain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
parsedEmail, err := mail.ParseAddress(email)
|
||||
if err != nil || parsedEmail.Address != email || len(email) > 254 {
|
||||
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem"
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeInternalError(w, "obtain Let's Encrypt certificate", fmt.Errorf("certbot: %w: %s", err, strings.TrimSpace(string(out))))
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -136,28 +195,42 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Cert == "" || req.Key == "" {
|
||||
http.Error(w, "name, cert, and key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(req.Name)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
http.Error(w, "invalid name", http.StatusBadRequest)
|
||||
name, err := normalizeTLSStoreName(req.Name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Cert) > 1024*1024 || len(req.Key) > 1024*1024 {
|
||||
http.Error(w, "certificate or key is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(req.Cert), []byte(req.Key))
|
||||
if err != nil || len(pair.Certificate) == 0 {
|
||||
http.Error(w, "certificate and private key are invalid or do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
http.Error(w, "invalid leaf certificate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create uploaded TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := os.WriteFile(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS private key", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
+216
-21
@@ -20,24 +20,96 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
udpgwMu sync.Mutex
|
||||
udpgwLn net.Listener
|
||||
// Safe ceilings applied regardless of config, so oversized values left in an
|
||||
// existing config.json cannot bloat per-client memory at scale.
|
||||
const (
|
||||
udpgwSocketBufferMax = 512 * 1024 // per-client UDP socket buffer (kernel memory)
|
||||
udpgwWriteChanMax = 1024 // per-client reply queue slots
|
||||
udpgwDefaultMaxClients = 10000 // total concurrent client cap
|
||||
)
|
||||
|
||||
// stopUDPGW closes the active UDPGW listener, causing the accept loop to exit.
|
||||
// It is a no-op if UDPGW is not running.
|
||||
var (
|
||||
udpgwMu sync.Mutex
|
||||
udpgwLn net.Listener
|
||||
udpgwClients = make(map[net.Conn]struct{})
|
||||
// udpgwClientLimit is the max concurrent clients (0 = unlimited). Set when
|
||||
// the listener starts and read on every accept, all under udpgwMu.
|
||||
udpgwClientLimit int
|
||||
// udpgwClientsRejected counts clients turned away at the cap, for logging
|
||||
// and future stats. Guarded by udpgwMu.
|
||||
udpgwClientsRejected int64
|
||||
|
||||
udpgwAutoMu sync.Mutex
|
||||
udpgwAutoCancel context.CancelFunc
|
||||
)
|
||||
|
||||
// stopUDPGW closes the active UDPGW listener, all active UDPGW client TCP
|
||||
// sockets, and the optional auto-restart watchdog. It is a no-op if UDPGW is
|
||||
// not running.
|
||||
func stopUDPGW() {
|
||||
stopUDPGWAutoRestart()
|
||||
stopUDPGWInstance()
|
||||
}
|
||||
|
||||
func stopUDPGWInstance() {
|
||||
udpgwMu.Lock()
|
||||
defer udpgwMu.Unlock()
|
||||
if udpgwLn != nil {
|
||||
_ = udpgwLn.Close()
|
||||
udpgwLn = nil
|
||||
}
|
||||
for conn := range udpgwClients {
|
||||
_ = conn.Close()
|
||||
delete(udpgwClients, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func stopUDPGWAutoRestart() {
|
||||
udpgwAutoMu.Lock()
|
||||
defer udpgwAutoMu.Unlock()
|
||||
if udpgwAutoCancel != nil {
|
||||
udpgwAutoCancel()
|
||||
udpgwAutoCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func registerUDPGWClient(conn net.Conn) bool {
|
||||
udpgwMu.Lock()
|
||||
defer udpgwMu.Unlock()
|
||||
if udpgwLn == nil {
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
// Reject past the hard client cap so a surge cannot exhaust memory.
|
||||
if udpgwClientLimit > 0 && len(udpgwClients) >= udpgwClientLimit {
|
||||
udpgwClientsRejected++
|
||||
rejected := udpgwClientsRejected
|
||||
_ = conn.Close()
|
||||
// Log the first rejection and then every 1000th to avoid log spam.
|
||||
if rejected == 1 || rejected%1000 == 0 {
|
||||
log.Printf("udpgw: client cap reached (%d); rejected %d client(s) so far", udpgwClientLimit, rejected)
|
||||
}
|
||||
return false
|
||||
}
|
||||
udpgwClients[conn] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func unregisterUDPGWClient(conn net.Conn) {
|
||||
udpgwMu.Lock()
|
||||
delete(udpgwClients, conn)
|
||||
udpgwMu.Unlock()
|
||||
}
|
||||
|
||||
func udpgwRunning() bool {
|
||||
udpgwMu.Lock()
|
||||
defer udpgwMu.Unlock()
|
||||
return udpgwLn != nil
|
||||
}
|
||||
|
||||
// startUDPGW starts the integrated UDP gateway if cfg is non‑nil and
|
||||
@@ -46,17 +118,30 @@ func stopUDPGW() {
|
||||
// The server runs in a goroutine; any fatal errors are logged and
|
||||
// prevent the gateway from starting, but do not terminate the main
|
||||
// process.
|
||||
func startUDPGW(cfg *UDPGWConfig) {
|
||||
func startUDPGW(cfg *UDPGWConfig) error {
|
||||
if cfg == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
stopUDPGWAutoRestart()
|
||||
if err := startUDPGWInstance(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
startUDPGWAutoRestart(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func startUDPGWInstance(cfg *UDPGWConfig) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
// Default the listen address to the standalone default (0.0.0.0:7400) if
|
||||
// unspecified. This matches the behaviour of the original
|
||||
// badvpn-udpgw program, which listens on all interfaces by default.
|
||||
listenAddr := cfg.Listen
|
||||
if listenAddr == "" {
|
||||
listenAddr = "0.0.0.0:7400"
|
||||
listenAddr = defaultUDPGWListen
|
||||
}
|
||||
cfg.Listen = listenAddr
|
||||
// Apply defaults for numeric fields if zero.
|
||||
c := &internalUDPGWConfig{}
|
||||
c.listen = listenAddr
|
||||
@@ -71,21 +156,29 @@ func startUDPGW(cfg *UDPGWConfig) {
|
||||
} else {
|
||||
c.hexdumpN = 64
|
||||
}
|
||||
if cfg.WriteChan > 0 {
|
||||
// Per-client outgoing frame queue. A large queue costs ~24 B/slot of heap
|
||||
// per client even when empty; at thousands of clients that adds up. UDP is
|
||||
// lossy by nature, so a smaller queue that drops under backpressure is fine.
|
||||
// The value is clamped to udpgwWriteChanMax so an oversized value left in an
|
||||
// old config.json is ignored; only smaller custom values are honored.
|
||||
c.writeChan = udpgwWriteChanMax
|
||||
if cfg.WriteChan > 0 && cfg.WriteChan < udpgwWriteChanMax {
|
||||
c.writeChan = cfg.WriteChan
|
||||
} else {
|
||||
c.writeChan = 4096
|
||||
}
|
||||
c.udpBindIP = cfg.UDPBindIP
|
||||
if cfg.UDPRBuf > 0 {
|
||||
// Per-client UDP socket buffers are KERNEL memory, allocated per connected
|
||||
// client. 8 MB per socket is fine for a single process-wide listener, but
|
||||
// here every tunnel user gets its own socket, so a large value multiplied by
|
||||
// thousands of clients can exhaust kernel memory (especially if
|
||||
// net.core.rmem_max was raised for DNSTT). Clamp to udpgwSocketBufferMax so
|
||||
// old large config values are ignored; only smaller custom values are used.
|
||||
c.udpRBuf = udpgwSocketBufferMax
|
||||
if cfg.UDPRBuf > 0 && cfg.UDPRBuf < udpgwSocketBufferMax {
|
||||
c.udpRBuf = cfg.UDPRBuf
|
||||
} else {
|
||||
c.udpRBuf = 8 * 1024 * 1024
|
||||
}
|
||||
if cfg.UDPWBuf > 0 {
|
||||
c.udpWBuf = udpgwSocketBufferMax
|
||||
if cfg.UDPWBuf > 0 && cfg.UDPWBuf < udpgwSocketBufferMax {
|
||||
c.udpWBuf = cfg.UDPWBuf
|
||||
} else {
|
||||
c.udpWBuf = 8 * 1024 * 1024
|
||||
}
|
||||
// Parse durations with fallback defaults.
|
||||
if cfg.MapTTL != "" {
|
||||
@@ -131,19 +224,28 @@ func startUDPGW(cfg *UDPGWConfig) {
|
||||
} else {
|
||||
c.maxMapEntries = 32768
|
||||
}
|
||||
// Total concurrent client cap. New clients past this are rejected so a
|
||||
// surge (e.g. well past normal load) cannot exhaust memory and crash.
|
||||
if cfg.MaxClients > 0 {
|
||||
c.maxClients = cfg.MaxClients
|
||||
} else {
|
||||
c.maxClients = udpgwDefaultMaxClients
|
||||
}
|
||||
// Start listening.
|
||||
ln, err := net.Listen("tcp", c.listen)
|
||||
if err != nil {
|
||||
log.Printf("udpgw: listen failed on %s: %v", c.listen, err)
|
||||
return
|
||||
return fmt.Errorf("udpgw: listen failed on %s: %w", c.listen, err)
|
||||
}
|
||||
|
||||
// Register as the active listener so stopUDPGW can close it.
|
||||
// Register as the active listener so stopUDPGW can close it, and publish
|
||||
// the current client cap so registerUDPGWClient can enforce it.
|
||||
udpgwMu.Lock()
|
||||
if udpgwLn != nil {
|
||||
_ = udpgwLn.Close()
|
||||
}
|
||||
udpgwLn = ln
|
||||
udpgwClientLimit = c.maxClients
|
||||
udpgwMu.Unlock()
|
||||
|
||||
if c.debug {
|
||||
@@ -159,9 +261,97 @@ func startUDPGW(cfg *UDPGWConfig) {
|
||||
log.Printf("udpgw: accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go handleUDPGWClient(conn, c)
|
||||
if !registerUDPGWClient(conn) {
|
||||
continue
|
||||
}
|
||||
go func(client net.Conn) {
|
||||
defer unregisterUDPGWClient(client)
|
||||
handleUDPGWClient(client, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func startUDPGWAutoRestart(cfg *UDPGWConfig) {
|
||||
interval := udpgwAutoRestartInterval(cfg)
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
grace := udpgwAutoRestartGrace(cfg)
|
||||
cfgCopy := *cfg
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
udpgwAutoMu.Lock()
|
||||
if udpgwAutoCancel != nil {
|
||||
udpgwAutoCancel()
|
||||
}
|
||||
udpgwAutoCancel = cancel
|
||||
udpgwAutoMu.Unlock()
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
log.Printf("udpgw: auto restart enabled: interval=%s grace=%s mode=hard", interval, grace)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
log.Printf("udpgw: auto restart: stopping listener and connected clients")
|
||||
stopUDPGWInstance()
|
||||
if !sleepOrContextDone(ctx, grace) {
|
||||
return
|
||||
}
|
||||
for attempt := 1; ; attempt++ {
|
||||
if err := startUDPGWInstance(&cfgCopy); err != nil {
|
||||
log.Printf("udpgw: auto restart: start attempt %d failed: %v", attempt, err)
|
||||
if !sleepOrContextDone(ctx, 10*time.Second) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
log.Printf("udpgw: auto restart: listener and client handler restarted")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func udpgwAutoRestartInterval(cfg *UDPGWConfig) time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(cfg.AutoRestartInterval)
|
||||
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
log.Printf("udpgw: auto restart disabled: invalid interval %q: %v", raw, err)
|
||||
return 0
|
||||
}
|
||||
if d < time.Minute {
|
||||
log.Printf("udpgw: auto restart disabled: interval %q is below minimum 1m", raw)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func udpgwAutoRestartGrace(cfg *UDPGWConfig) time.Duration {
|
||||
if cfg == nil || strings.TrimSpace(cfg.AutoRestartGrace) == "" {
|
||||
return 2 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(cfg.AutoRestartGrace))
|
||||
if err != nil || d < 0 {
|
||||
log.Printf("udpgw: auto restart: invalid grace %q, using 2s", cfg.AutoRestartGrace)
|
||||
return 2 * time.Second
|
||||
}
|
||||
if d > time.Minute {
|
||||
return time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// internalUDPGWConfig mirrors the exported UDPGWConfig but with
|
||||
@@ -181,6 +371,7 @@ type internalUDPGWConfig struct {
|
||||
idleTimeout time.Duration
|
||||
maxClientConns int
|
||||
maxMapEntries int
|
||||
maxClients int
|
||||
}
|
||||
|
||||
// udpDestKey identifies a destination IPv4:port for the UDP gateway. A
|
||||
@@ -217,7 +408,11 @@ func handleUDPGWClient(conn net.Conn, c *internalUDPGWConfig) {
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
br := bufio.NewReaderSize(conn, 256*1024)
|
||||
// 32 KiB is ample for reading length-prefixed UDPGW frames (which are
|
||||
// MTU-sized in practice). bufio serves reads larger than its buffer by
|
||||
// reading straight into the caller's slice, so max-frame reads still work.
|
||||
// The old 256 KiB buffer wasted ~224 KiB of heap per connected client.
|
||||
br := bufio.NewReaderSize(conn, 32*1024)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Bind a UDP socket for this client. Use cfg.udpBindIP if provided.
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Update script for SSH Panel — updates the binary and admin panel in place.
|
||||
# Preserves: .env, config.json, xray_config.json, SSH keys, database, certs.
|
||||
# Usage: sudo bash update.sh
|
||||
# Update script for DragonCoreSSH / SSH Panel.
|
||||
# Pulls the newest source from Git, builds the new binary, and updates the
|
||||
# installed files in place.
|
||||
#
|
||||
# Preserved:
|
||||
# - /opt/sshpanel/.env
|
||||
# - /opt/sshpanel/config.json
|
||||
# - /opt/sshpanel/xray_config.json
|
||||
# - SSH keys, certs, logs, database, users
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash /opt/sshpanel/update.sh
|
||||
# sudo bash update.sh
|
||||
#
|
||||
# Optional:
|
||||
# sudo UPDATE_REF=main bash /opt/sshpanel/update.sh
|
||||
# sudo REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git bash /opt/sshpanel/update.sh
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
@@ -9,191 +23,650 @@ info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="/opt/sshpanel"
|
||||
SERVICE_NAME="sshpanel"
|
||||
# Config
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/sshpanel}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-sshpanel}"
|
||||
LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
UPDATE_REF="${UPDATE_REF:-}"
|
||||
SOURCE_CACHE_DIR="${SOURCE_CACHE_DIR:-${INSTALL_DIR}/source}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
SOURCE_DIR=""
|
||||
RESTART_NEEDED=false
|
||||
BUILD_COMMIT=""
|
||||
BUILD_BRANCH=""
|
||||
BUILD_TIME=""
|
||||
BUILD_REPO_URL=""
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
|
||||
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} SSH Panel · Updater ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
|
||||
# Cross-distro helpers -------------------------------------------------------
|
||||
PKG_MANAGER=""
|
||||
UPDATE_DEPS=()
|
||||
SYSTEMCTL_BIN=""
|
||||
SH_BIN="$(command -v sh 2>/dev/null || echo /bin/sh)"
|
||||
MOUNT_BIN="$(command -v mount 2>/dev/null || echo /bin/mount)"
|
||||
MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)"
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
# ── 1. Pre-flight checks ──────────────────────────────────────────────────────
|
||||
info "[1/5] Pre-flight checks…"
|
||||
|
||||
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found — run install.sh first."
|
||||
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found — run install.sh first."
|
||||
[[ -f "$SCRIPT_DIR/go.mod" ]] || error "go.mod not found — run this script from the source directory."
|
||||
|
||||
info " Install dir : $INSTALL_DIR"
|
||||
info " Source dir : $SCRIPT_DIR"
|
||||
info " Go version : $GO_VERSION"
|
||||
|
||||
# ── 2. Go toolchain ───────────────────────────────────────────────────────────
|
||||
info "[2/5] Checking Go toolchain…"
|
||||
|
||||
NEED_GO=true
|
||||
if command -v go &>/dev/null; then
|
||||
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||||
if [[ "$(printf '%s\n' "$GO_VERSION" "$CURRENT_GO" | sort -V | head -1)" == "$GO_VERSION" ]]; then
|
||||
info " Go $CURRENT_GO already installed — skipping"
|
||||
NEED_GO=false
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "${GO_SHA256:-}" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading Go ${GO_VERSION} (${GOARCH})…"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
chmod +x /etc/profile.d/go.sh
|
||||
fi
|
||||
}
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
go version
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 3. Build new binary ───────────────────────────────────────────────────────
|
||||
info "[3/5] Building new sshpanel binary…"
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
error "systemd was not found. This updater supports Linux distributions that use systemd for services."
|
||||
fi
|
||||
}
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
go mod download
|
||||
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
|
||||
info " Build complete."
|
||||
detect_pkg_manager() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
PKG_MANAGER="apt"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
PKG_MANAGER="dnf"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
PKG_MANAGER="yum"
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
PKG_MANAGER="zypper"
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
PKG_MANAGER="pacman"
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKG_MANAGER="apk"
|
||||
else
|
||||
error "No supported package manager found. Supported: apt, dnf, yum, zypper, pacman, apk."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 4. Apply update ───────────────────────────────────────────────────────────
|
||||
info "[4/5] Applying update…"
|
||||
set_update_deps() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt)
|
||||
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
|
||||
;;
|
||||
dnf|yum)
|
||||
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
|
||||
;;
|
||||
zypper)
|
||||
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
|
||||
;;
|
||||
pacman)
|
||||
UPDATE_DEPS=(git rsync wget ca-certificates python gcc make tar gzip)
|
||||
;;
|
||||
apk)
|
||||
UPDATE_DEPS=(git rsync wget ca-certificates python3 gcc make tar gzip)
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Stop the service
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
info " Stopping $SERVICE_NAME…"
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
RESTART_NEEDED=true
|
||||
else
|
||||
RESTART_NEEDED=false
|
||||
fi
|
||||
pkg_update() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt) apt-get update -qq ;;
|
||||
dnf) dnf makecache -q ;;
|
||||
yum) yum makecache -q ;;
|
||||
zypper) zypper --non-interactive refresh ;;
|
||||
pacman) pacman -Sy --noconfirm ;;
|
||||
apk) apk update ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Backup old binary
|
||||
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
|
||||
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
|
||||
info " Old binary backed up to sshpanel.bak"
|
||||
fi
|
||||
pkg_install() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" ;;
|
||||
dnf) dnf install -y "$@" ;;
|
||||
yum) yum install -y "$@" ;;
|
||||
zypper) zypper --non-interactive install -y "$@" ;;
|
||||
pacman) pacman -S --noconfirm --needed "$@" ;;
|
||||
apk) apk add --no-cache "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Replace binary
|
||||
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
|
||||
chmod +x "$INSTALL_DIR/sshpanel"
|
||||
info " Binary updated."
|
||||
ensure_update_dependencies() {
|
||||
local missing=false cmd
|
||||
for cmd in git rsync wget tar gzip gcc make; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing=true
|
||||
fi
|
||||
done
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
missing=true
|
||||
fi
|
||||
if $missing; then
|
||||
warn "One or more updater dependencies are missing. Installing them with $PKG_MANAGER..."
|
||||
pkg_update
|
||||
pkg_install "${UPDATE_DEPS[@]}"
|
||||
fi
|
||||
if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then
|
||||
ln -sf "$(command -v python)" /usr/local/bin/python3 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Update admin panel files
|
||||
mkdir -p "$INSTALL_DIR/admin"
|
||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
||||
info " Admin panel updated."
|
||||
echo -e "\n${GREEN}==========================================${NC}"
|
||||
echo -e "${GREEN} DragonCoreSSH / SSH Panel Updater ${NC}"
|
||||
echo -e "${GREEN}==========================================${NC}\n"
|
||||
|
||||
# Ensure banner file exists (new in this version)
|
||||
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then
|
||||
touch "$INSTALL_DIR/banner.txt"
|
||||
info " Created banner.txt"
|
||||
fi
|
||||
# Helpers
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || error "Required command not found: $1"
|
||||
}
|
||||
|
||||
# Ensure certs directory exists (new in this version)
|
||||
mkdir -p "$INSTALL_DIR/certs"
|
||||
ensure_log_tmpfs_mount() {
|
||||
local log_dir="${INSTALL_DIR}/logs"
|
||||
local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755"
|
||||
local tmp_fstab
|
||||
|
||||
# Patch config.json to add missing fields introduced in this version
|
||||
# without overwriting user-configured values.
|
||||
CFG="$INSTALL_DIR/config.json"
|
||||
if [[ -f "$CFG" ]]; then
|
||||
# Add banner_file if not present
|
||||
if ! python3 -c "import json,sys; d=json.load(open('$CFG')); sys.exit(0 if 'banner_file' in d else 1)" 2>/dev/null; then
|
||||
python3 - "$CFG" << 'PYEOF'
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
if [[ -f /etc/fstab ]]; then
|
||||
cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true
|
||||
tmp_fstab="$(mktemp)"
|
||||
awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab"
|
||||
printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab"
|
||||
cat "$tmp_fstab" > /etc/fstab
|
||||
rm -f "$tmp_fstab"
|
||||
info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})"
|
||||
else
|
||||
warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs"
|
||||
fi
|
||||
|
||||
"${SYSTEMCTL_BIN:-systemctl}" daemon-reload >/dev/null 2>&1 || true
|
||||
if command -v mountpoint >/dev/null 2>&1 && mountpoint -q "$log_dir"; then
|
||||
mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true
|
||||
else
|
||||
mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \
|
||||
warn " Could not mount $log_dir as tmpfs now; service startup fallback will try again"
|
||||
fi
|
||||
|
||||
touch "$log_dir/panel.log" >/dev/null 2>&1 || true
|
||||
chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
install_git_if_missing() {
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
warn "git is not installed. Trying to install it..."
|
||||
pkg_update
|
||||
pkg_install git ca-certificates
|
||||
}
|
||||
|
||||
remote_default_branch() {
|
||||
local branch
|
||||
branch="$(git ls-remote --symref "$REPO_URL" HEAD 2>/dev/null | awk '/^ref:/ {sub("refs/heads/", "", $2); print $2; exit}')"
|
||||
if [[ -n "$branch" ]]; then
|
||||
printf '%s\n' "$branch"
|
||||
else
|
||||
printf 'main\n'
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_source_from_git() {
|
||||
install_git_if_missing
|
||||
|
||||
if [[ -z "$UPDATE_REF" ]]; then
|
||||
UPDATE_REF="$(remote_default_branch)"
|
||||
fi
|
||||
|
||||
info "[1/7] Fetching latest files from Git..."
|
||||
info " Repo : $REPO_URL"
|
||||
info " Ref : $UPDATE_REF"
|
||||
|
||||
# If update.sh is being run from a real clone of this repo, update that folder.
|
||||
# This is useful for developers who run the updater from the cloned project.
|
||||
if [[ -d "$SCRIPT_DIR/.git" && -f "$SCRIPT_DIR/go.mod" ]]; then
|
||||
SOURCE_DIR="$SCRIPT_DIR"
|
||||
info " Updating existing source folder: $SOURCE_DIR"
|
||||
git -C "$SOURCE_DIR" remote set-url origin "$REPO_URL" >/dev/null 2>&1 || true
|
||||
git -C "$SOURCE_DIR" fetch --prune origin
|
||||
git -C "$SOURCE_DIR" checkout "$UPDATE_REF" >/dev/null 2>&1 || true
|
||||
git -C "$SOURCE_DIR" reset --hard "origin/$UPDATE_REF"
|
||||
git -C "$SOURCE_DIR" clean -fd
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Normal installed-server path: keep a local Git cache under /opt/sshpanel/source.
|
||||
mkdir -p "$(dirname "$SOURCE_CACHE_DIR")"
|
||||
if [[ -d "$SOURCE_CACHE_DIR/.git" ]]; then
|
||||
SOURCE_DIR="$SOURCE_CACHE_DIR"
|
||||
info " Updating cached source folder: $SOURCE_DIR"
|
||||
git -C "$SOURCE_DIR" remote set-url origin "$REPO_URL" >/dev/null 2>&1 || true
|
||||
git -C "$SOURCE_DIR" fetch --prune origin
|
||||
git -C "$SOURCE_DIR" checkout "$UPDATE_REF" >/dev/null 2>&1 || true
|
||||
git -C "$SOURCE_DIR" reset --hard "origin/$UPDATE_REF"
|
||||
git -C "$SOURCE_DIR" clean -fd
|
||||
else
|
||||
rm -rf "$SOURCE_CACHE_DIR"
|
||||
info " Cloning source folder to: $SOURCE_CACHE_DIR"
|
||||
git clone --depth 1 --branch "$UPDATE_REF" "$REPO_URL" "$SOURCE_CACHE_DIR" || {
|
||||
warn "Clone with ref '$UPDATE_REF' failed. Trying default clone..."
|
||||
rm -rf "$SOURCE_CACHE_DIR"
|
||||
git clone --depth 1 "$REPO_URL" "$SOURCE_CACHE_DIR"
|
||||
}
|
||||
SOURCE_DIR="$SOURCE_CACHE_DIR"
|
||||
fi
|
||||
|
||||
[[ -f "$SOURCE_DIR/go.mod" ]] || error "Downloaded source is invalid: go.mod not found in $SOURCE_DIR"
|
||||
[[ -d "$SOURCE_DIR/admin" ]] || error "Downloaded source is invalid: admin folder not found in $SOURCE_DIR"
|
||||
}
|
||||
|
||||
install_go_if_needed() {
|
||||
local go_version machine goarch go_url go_expected_sha256 current_go need_go
|
||||
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
|
||||
need_go=true
|
||||
|
||||
info "[2/7] Checking Go toolchain..."
|
||||
info " Required Go: $go_version"
|
||||
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
current_go="$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')"
|
||||
if [[ "$(printf '%s\n' "$go_version" "$current_go" | sort -V | head -1)" == "$go_version" ]]; then
|
||||
info " Go $current_go already installed."
|
||||
need_go=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if $need_go; then
|
||||
machine="$(uname -m)"
|
||||
case "$machine" in
|
||||
x86_64) goarch="amd64" ;;
|
||||
aarch64) goarch="arm64" ;;
|
||||
armv7l) goarch="armv6l" ;;
|
||||
*) error "Unsupported CPU architecture: $machine" ;;
|
||||
esac
|
||||
go_expected_sha256="$(trusted_go_sha256 "$go_version" "$goarch" "$SOURCE_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$go_expected_sha256" ]] || error "No trusted Go checksum for ${go_version}/${goarch}; set GO_SHA256 explicitly"
|
||||
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
|
||||
info " Downloading Go ${go_version} (${goarch})..."
|
||||
need_cmd wget
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
|
||||
verify_sha256_file "$go_expected_sha256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
chmod +x /etc/profile.d/go.sh
|
||||
fi
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
go version
|
||||
}
|
||||
|
||||
build_binary() {
|
||||
info "[3/7] Building new sshpanel binary..."
|
||||
cd "$SOURCE_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
|
||||
BUILD_COMMIT="$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)"
|
||||
BUILD_BRANCH="${UPDATE_REF:-$(git -C "$SOURCE_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
BUILD_REPO_URL="$REPO_URL"
|
||||
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
|
||||
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
|
||||
|
||||
go mod download
|
||||
go mod tidy
|
||||
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o /tmp/sshpanel_new .
|
||||
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
|
||||
info " Build complete."
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
info "[4/7] Stopping service..."
|
||||
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
"$SYSTEMCTL_BIN" stop "$SERVICE_NAME"
|
||||
RESTART_NEEDED=true
|
||||
info " $SERVICE_NAME stopped."
|
||||
else
|
||||
RESTART_NEEDED=false
|
||||
warn " $SERVICE_NAME was not running."
|
||||
fi
|
||||
}
|
||||
|
||||
copy_optional_script() {
|
||||
local name mode
|
||||
name="$1"
|
||||
mode="$2"
|
||||
if [[ -f "$SOURCE_DIR/$name" ]]; then
|
||||
cp "$SOURCE_DIR/$name" "$INSTALL_DIR/$name"
|
||||
chmod "$mode" "$INSTALL_DIR/$name"
|
||||
info " Updated $name"
|
||||
fi
|
||||
}
|
||||
|
||||
apply_update() {
|
||||
info "[5/7] Applying update..."
|
||||
|
||||
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/logs" "$INSTALL_DIR/certs"
|
||||
ensure_log_tmpfs_mount
|
||||
|
||||
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
|
||||
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
|
||||
info " Old binary backed up to $INSTALL_DIR/sshpanel.bak"
|
||||
fi
|
||||
|
||||
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
|
||||
chmod 755 "$INSTALL_DIR/sshpanel"
|
||||
info " Binary updated."
|
||||
|
||||
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
|
||||
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
|
||||
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
|
||||
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
|
||||
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
|
||||
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
|
||||
info " Build metadata updated."
|
||||
|
||||
rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
|
||||
info " Admin panel updated."
|
||||
|
||||
copy_optional_script "update.sh" 700
|
||||
copy_optional_script "install.sh" 700
|
||||
copy_optional_script "change_admin_password.sh" 700
|
||||
|
||||
# Keep a local copy of the latest source for easier support and future updates.
|
||||
if [[ "$SOURCE_DIR" != "$SOURCE_CACHE_DIR" ]]; then
|
||||
rm -rf "$SOURCE_CACHE_DIR"
|
||||
mkdir -p "$SOURCE_CACHE_DIR"
|
||||
rsync -a --delete --exclude '.git' "$SOURCE_DIR/" "$SOURCE_CACHE_DIR/"
|
||||
info " Source files copied to $SOURCE_CACHE_DIR"
|
||||
fi
|
||||
|
||||
[[ -f "$INSTALL_DIR/banner.txt" ]] || touch "$INSTALL_DIR/banner.txt"
|
||||
}
|
||||
|
||||
patch_configs() {
|
||||
info "[6/7] Patching config files without overwriting user settings..."
|
||||
|
||||
local cfg xcfg
|
||||
cfg="$INSTALL_DIR/config.json"
|
||||
xcfg="$INSTALL_DIR/xray_config.json"
|
||||
|
||||
native_xcfg="$INSTALL_DIR/xray_native_config.json"
|
||||
if [[ ! -f "$native_xcfg" && -f "$xcfg" ]]; then
|
||||
cp -f "$xcfg" "$native_xcfg"
|
||||
chmod 600 "$native_xcfg" || true
|
||||
info " Created independent native Xray config: $native_xcfg"
|
||||
fi
|
||||
|
||||
if [[ -f "$cfg" ]]; then
|
||||
python3 - "$cfg" <<'PYEOF'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
try:
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[!] Could not parse {path}: {e}")
|
||||
sys.exit(0)
|
||||
changed = False
|
||||
if 'banner_file' not in d:
|
||||
d['banner_file'] = '/opt/sshpanel/banner.txt'
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f, indent=2)
|
||||
changed = True
|
||||
if 'local_ssh_listen' in d:
|
||||
d.pop('local_ssh_listen', None)
|
||||
changed = True
|
||||
x = d.get('xray')
|
||||
if isinstance(x, dict):
|
||||
mode = str(x.get('mode') or '').strip().lower()
|
||||
if mode not in ('native', 'external'):
|
||||
x['mode'] = 'native'
|
||||
x['native'] = True
|
||||
changed = True
|
||||
elif mode == 'native' and x.get('native') is not True:
|
||||
x['native'] = True
|
||||
changed = True
|
||||
elif mode == 'external' and x.get('native') is not False:
|
||||
x['native'] = False
|
||||
changed = True
|
||||
x.setdefault('bin_path', '/opt/sshpanel/xray')
|
||||
x.setdefault('config_file', '/opt/sshpanel/xray_config.json')
|
||||
x.setdefault('native_config_file', '/opt/sshpanel/xray_native_config.json')
|
||||
if changed:
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write('\n')
|
||||
PYEOF
|
||||
info " Added banner_file to config.json"
|
||||
info " config.json checked."
|
||||
fi
|
||||
|
||||
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json
|
||||
XCFG="$INSTALL_DIR/xray_config.json"
|
||||
if [[ -f "$XCFG" ]]; then
|
||||
if grep -q '"geoip:private"' "$XCFG" 2>/dev/null; then
|
||||
python3 - "$XCFG" << 'PYEOF'
|
||||
if [[ -f "$xcfg" ]] && grep -q '"geoip:private"' "$xcfg" 2>/dev/null; then
|
||||
python3 - "$xcfg" <<'PYEOF'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
try:
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[!] Could not parse {path}: {e}")
|
||||
sys.exit(0)
|
||||
routing = d.get('routing', {})
|
||||
rules = routing.get('rules', [])
|
||||
# Remove rules that reference geoip:private
|
||||
new_rules = [r for r in rules if 'geoip:private' not in r.get('ip', [])]
|
||||
if new_rules != rules:
|
||||
if new_rules:
|
||||
d['routing']['rules'] = new_rules
|
||||
d.setdefault('routing', {})['rules'] = new_rules
|
||||
else:
|
||||
d.pop('routing', None)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write('\n')
|
||||
PYEOF
|
||||
info " Removed geoip:private routing rule from xray_config.json"
|
||||
fi
|
||||
info " Removed geoip:private routing rule from xray_config.json"
|
||||
fi
|
||||
}
|
||||
|
||||
dnstt_redirect_is_enabled() {
|
||||
# Updates must not resurrect this service when an admin intentionally
|
||||
# disabled/removed it because it can break ip6tables on some machines.
|
||||
local unit="sshpanel-dnstt-redirect.service"
|
||||
|
||||
if "$SYSTEMCTL_BIN" is-enabled --quiet "$unit" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
write_sshpanel_systemd_override() {
|
||||
local include_dnstt_redirect="${1:-false}"
|
||||
|
||||
mkdir -p /etc/systemd/system/sshpanel.service.d
|
||||
{
|
||||
echo "[Unit]"
|
||||
if [[ "$include_dnstt_redirect" == "true" ]]; then
|
||||
echo "Wants=sshpanel-dnstt-redirect.service"
|
||||
echo "After=local-fs.target sshpanel-dnstt-redirect.service"
|
||||
else
|
||||
echo "After=local-fs.target"
|
||||
fi
|
||||
echo
|
||||
echo "[Service]"
|
||||
echo "Environment=PANEL_LOG_FILE=${INSTALL_DIR}/logs/panel.log"
|
||||
echo "Environment=PANEL_LOG_MAX_BYTES=${PANEL_LOG_MAX_BYTES}"
|
||||
echo "ExecStartPre="
|
||||
echo "ExecStartPre=${MKDIR_BIN} -p ${INSTALL_DIR}/logs"
|
||||
echo "ExecStartPre=${SH_BIN} -c '${MOUNTPOINT_BIN} -q ${INSTALL_DIR}/logs || ${MOUNT_BIN} -t tmpfs -o size=${LOG_TMPFS_SIZE},mode=0755 tmpfs ${INSTALL_DIR}/logs || true'"
|
||||
echo "ExecStartPre=${SH_BIN} -c '${TOUCH_BIN} ${INSTALL_DIR}/logs/panel.log && ${CHMOD_BIN} 0644 ${INSTALL_DIR}/logs/panel.log || true'"
|
||||
echo "StandardOutput=journal"
|
||||
echo "StandardError=journal"
|
||||
} > /etc/systemd/system/sshpanel.service.d/override.conf
|
||||
}
|
||||
|
||||
ensure_dnstt_redirect() {
|
||||
if ! dnstt_redirect_is_enabled; then
|
||||
warn " sshpanel-dnstt-redirect is disabled or removed; update will not recreate or enable it."
|
||||
write_sshpanel_systemd_override false
|
||||
"$SYSTEMCTL_BIN" daemon-reload
|
||||
return 0
|
||||
fi
|
||||
|
||||
info " Ensuring DNSTT DNS redirect service exists..."
|
||||
cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}"
|
||||
DNSTT_PORT="${DNSTT_PORT:-5300}"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f /etc/resolv.conf
|
||||
printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf
|
||||
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw allow 53/udp >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
|
||||
firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true
|
||||
firewall-cmd --reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── 5. Restart service ────────────────────────────────────────────────────────
|
||||
info "[5/5] Restarting service…"
|
||||
add_iptables_rule() {
|
||||
local bin="$1" chain="$2"
|
||||
"$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \
|
||||
|| "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT"
|
||||
}
|
||||
|
||||
if $RESTART_NEEDED; then
|
||||
systemctl start "$SERVICE_NAME"
|
||||
if command -v iptables >/dev/null 2>&1; then
|
||||
add_iptables_rule iptables PREROUTING
|
||||
fi
|
||||
if command -v ip6tables >/dev/null 2>&1; then
|
||||
add_iptables_rule ip6tables PREROUTING || true
|
||||
fi
|
||||
if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then
|
||||
nft add table inet sshpanel_nat 2>/dev/null || true
|
||||
nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true
|
||||
nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \
|
||||
|| nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT"
|
||||
fi
|
||||
EOS
|
||||
chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||
|
||||
cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF2'
|
||||
[Unit]
|
||||
Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300)
|
||||
After=network.target
|
||||
Before=sshpanel.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF2
|
||||
|
||||
write_sshpanel_systemd_override true
|
||||
|
||||
"$SYSTEMCTL_BIN" daemon-reload
|
||||
"$SYSTEMCTL_BIN" enable --now sshpanel-dnstt-redirect.service || warn "DNSTT redirect service failed. Check: journalctl -u sshpanel-dnstt-redirect -e"
|
||||
}
|
||||
|
||||
restart_service() {
|
||||
info "[7/7] Restarting service..."
|
||||
ensure_dnstt_redirect
|
||||
|
||||
if $RESTART_NEEDED; then
|
||||
info " Starting $SERVICE_NAME after update..."
|
||||
else
|
||||
warn " $SERVICE_NAME was not running before update; starting it now."
|
||||
fi
|
||||
|
||||
"$SYSTEMCTL_BIN" start "$SERVICE_NAME"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
if "$SYSTEMCTL_BIN" is-active --quiet "$SERVICE_NAME"; then
|
||||
info " $SERVICE_NAME is running."
|
||||
else
|
||||
warn " $SERVICE_NAME failed to start — check logs:"
|
||||
warn " journalctl -u $SERVICE_NAME -n 30 --no-pager"
|
||||
warn " You can restore the old binary:"
|
||||
warn " mv $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
|
||||
warn " $SERVICE_NAME failed to start. Check logs:"
|
||||
warn " journalctl -u $SERVICE_NAME -n 50 --no-pager"
|
||||
if [[ -f "$INSTALL_DIR/sshpanel.bak" ]]; then
|
||||
warn " Restore command:"
|
||||
warn " cp $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn " Service was not running; start it with: systemctl start $SERVICE_NAME"
|
||||
fi
|
||||
}
|
||||
|
||||
# Pre-flight
|
||||
info "[0/7] Pre-flight checks..."
|
||||
require_systemd
|
||||
detect_pkg_manager
|
||||
set_update_deps
|
||||
ensure_update_dependencies
|
||||
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found. Run install.sh first."
|
||||
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found. Run install.sh first."
|
||||
need_cmd python3
|
||||
need_cmd rsync
|
||||
need_cmd git
|
||||
need_cmd wget
|
||||
|
||||
info " Install dir : $INSTALL_DIR"
|
||||
info " Cache dir : $SOURCE_CACHE_DIR"
|
||||
info " Package manager : $PKG_MANAGER"
|
||||
info " Service manager : systemd"
|
||||
|
||||
prepare_source_from_git
|
||||
install_go_if_needed
|
||||
build_binary
|
||||
stop_service
|
||||
apply_update
|
||||
patch_configs
|
||||
restart_service
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}==========================================${NC}"
|
||||
echo -e "${GREEN} Update complete! ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}==========================================${NC}"
|
||||
echo ""
|
||||
echo -e " Logs: ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
|
||||
echo -e " Updated from: ${YELLOW}${REPO_URL}${NC}"
|
||||
echo -e " Source ref : ${YELLOW}${UPDATE_REF}${NC}"
|
||||
echo -e " Source cache: ${YELLOW}${SOURCE_CACHE_DIR}${NC}"
|
||||
echo -e " Logs : ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
|
||||
echo -e " Backup : ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
|
||||
echo ""
|
||||
echo -e " Backup: ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
|
||||
echo -e "${YELLOW}Updated:${NC}"
|
||||
echo -e " - sshpanel binary"
|
||||
echo -e " - Admin panel"
|
||||
echo -e " - update.sh / install.sh / helper scripts when available"
|
||||
echo ""
|
||||
echo -e "${YELLOW}What was updated:${NC}"
|
||||
echo -e " • sshpanel binary"
|
||||
echo -e " • Admin panel (admin/index.html)"
|
||||
echo -e "${YELLOW}What was preserved:${NC}"
|
||||
echo -e " • .env (DB credentials, tokens)"
|
||||
echo -e " • config.json (your server settings)"
|
||||
echo -e " • xray_config.json (your Xray settings)"
|
||||
echo -e " • SSH host keys"
|
||||
echo -e " • All user data in PostgreSQL"
|
||||
echo -e "${YELLOW}Preserved:${NC}"
|
||||
echo -e " - .env"
|
||||
echo -e " - config.json"
|
||||
echo -e " - xray_config.json"
|
||||
echo -e " - SSH keys, certs, logs, database and users"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUpdateRepoURL = "https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git"
|
||||
defaultUpdateBranch = "main"
|
||||
updateStatusCacheTTL = 5 * time.Minute
|
||||
updateCheckTimeout = 12 * time.Second
|
||||
)
|
||||
|
||||
// These values are injected by install.sh/update.sh with -ldflags. The
|
||||
// runtime/debug fallback keeps the endpoint useful for normal git builds.
|
||||
var (
|
||||
buildCommit = ""
|
||||
buildBranch = ""
|
||||
buildTime = ""
|
||||
buildRepoURL = ""
|
||||
)
|
||||
|
||||
type updateStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
UpToDate bool `json:"up_to_date"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
LocalModified bool `json:"local_modified"`
|
||||
CurrentCommit string `json:"current_commit,omitempty"`
|
||||
CurrentCommitShort string `json:"current_commit_short,omitempty"`
|
||||
LatestCommit string `json:"latest_commit,omitempty"`
|
||||
LatestCommitShort string `json:"latest_commit_short,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
BuildTime string `json:"build_time,omitempty"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
RepoWebURL string `json:"repo_web_url"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Cached bool `json:"cached"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var updateStatusCache struct {
|
||||
sync.Mutex
|
||||
checkedAt time.Time
|
||||
response updateStatusResponse
|
||||
}
|
||||
|
||||
func handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
force := r.URL.Query().Get("refresh") == "1"
|
||||
resp := getUpdateStatus(r.Context(), force)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func getUpdateStatus(parent context.Context, force bool) updateStatusResponse {
|
||||
updateStatusCache.Lock()
|
||||
defer updateStatusCache.Unlock()
|
||||
|
||||
if !force && !updateStatusCache.checkedAt.IsZero() && time.Since(updateStatusCache.checkedAt) < updateStatusCacheTTL {
|
||||
resp := updateStatusCache.response
|
||||
resp.Cached = true
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := checkRemoteUpdate(parent)
|
||||
updateStatusCache.checkedAt = time.Now()
|
||||
updateStatusCache.response = resp
|
||||
return resp
|
||||
}
|
||||
|
||||
func checkRemoteUpdate(parent context.Context) updateStatusResponse {
|
||||
repoURL := firstNonEmptyTrimmed(
|
||||
strings.TrimSpace(os.Getenv("DRAGON_UPDATE_REPO_URL")),
|
||||
readSingleLineFile("/opt/sshpanel/.installed_repo_url"),
|
||||
strings.TrimSpace(buildRepoURL),
|
||||
defaultUpdateRepoURL,
|
||||
)
|
||||
branch := firstNonEmptyTrimmed(
|
||||
strings.TrimSpace(os.Getenv("DRAGON_UPDATE_BRANCH")),
|
||||
strings.TrimSpace(buildBranch),
|
||||
readSingleLineFile("/opt/sshpanel/.installed_branch"),
|
||||
defaultUpdateBranch,
|
||||
)
|
||||
currentCommit, localModified, resolvedBuildTime := resolveCurrentBuildInfo()
|
||||
if resolvedBuildTime == "" {
|
||||
resolvedBuildTime = readSingleLineFile("/opt/sshpanel/.installed_build_time")
|
||||
}
|
||||
if currentCommit == "" {
|
||||
currentCommit = normalizeGitCommit(readSingleLineFile("/opt/sshpanel/.installed_commit"))
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
resp := updateStatusResponse{
|
||||
Status: "unknown",
|
||||
CurrentCommit: currentCommit,
|
||||
CurrentCommitShort: shortCommit(currentCommit),
|
||||
Branch: branch,
|
||||
BuildTime: resolvedBuildTime,
|
||||
RepoURL: safeRepoURL(repoURL),
|
||||
RepoWebURL: repoWebURL(repoURL),
|
||||
CheckedAt: now,
|
||||
LocalModified: localModified,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, updateCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
latestCommit, err := queryRemoteCommit(ctx, repoURL, branch)
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
resp.Error = "remote update check timed out"
|
||||
} else {
|
||||
resp.Error = "could not read the remote Git branch"
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
resp.LatestCommit = latestCommit
|
||||
resp.LatestCommitShort = shortCommit(latestCommit)
|
||||
|
||||
if currentCommit == "" {
|
||||
resp.Error = "current build commit is unavailable"
|
||||
return resp
|
||||
}
|
||||
|
||||
resp.Status, resp.UpToDate, resp.UpdateAvailable = classifyUpdateStatus(currentCommit, latestCommit, localModified)
|
||||
return resp
|
||||
}
|
||||
|
||||
func classifyUpdateStatus(currentCommit, latestCommit string, localModified bool) (status string, upToDate bool, updateAvailable bool) {
|
||||
if currentCommit == "" || latestCommit == "" {
|
||||
return "unknown", false, false
|
||||
}
|
||||
if strings.EqualFold(currentCommit, latestCommit) {
|
||||
if localModified {
|
||||
return "local_changes", false, false
|
||||
}
|
||||
return "up_to_date", true, false
|
||||
}
|
||||
return "update_available", false, true
|
||||
}
|
||||
|
||||
func queryRemoteCommit(ctx context.Context, repoURL, branch string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", "--heads", repoURL, "refs/heads/"+branch)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_ASKPASS=/bin/false",
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) < 2 {
|
||||
return "", errors.New("invalid git ls-remote response")
|
||||
}
|
||||
commit := normalizeGitCommit(fields[0])
|
||||
if commit == "" {
|
||||
return "", errors.New("invalid remote commit")
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func resolveCurrentBuildInfo() (commit string, modified bool, builtAt string) {
|
||||
commit = normalizeGitCommit(buildCommit)
|
||||
builtAt = strings.TrimSpace(buildTime)
|
||||
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return commit, false, builtAt
|
||||
}
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = normalizeGitCommit(setting.Value)
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = strings.EqualFold(setting.Value, "true")
|
||||
case "vcs.time":
|
||||
if builtAt == "" {
|
||||
builtAt = strings.TrimSpace(setting.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return commit, modified, builtAt
|
||||
}
|
||||
|
||||
func normalizeGitCommit(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.EqualFold(value, "unknown") || len(value) < 7 || len(value) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range value {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func shortCommit(commit string) string {
|
||||
if len(commit) <= 12 {
|
||||
return commit
|
||||
}
|
||||
return commit[:12]
|
||||
}
|
||||
|
||||
func readSingleLineFile(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(string(data))
|
||||
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func firstNonEmptyTrimmed(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeRepoURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func repoWebURL(raw string) string {
|
||||
value := safeRepoURL(raw)
|
||||
return strings.TrimSuffix(value, ".git")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeGitCommit(t *testing.T) {
|
||||
valid := "CF49340B9A1234567890ABCDEF1234567890ABCD"
|
||||
got := normalizeGitCommit(valid)
|
||||
want := "cf49340b9a1234567890abcdef1234567890abcd"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeGitCommit() = %q, want %q", got, want)
|
||||
}
|
||||
for _, value := range []string{"", "unknown", "xyz1234", "123 4567", "123456"} {
|
||||
if got := normalizeGitCommit(value); got != "" {
|
||||
t.Fatalf("normalizeGitCommit(%q) = %q, want empty", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyUpdateStatus(t *testing.T) {
|
||||
const current = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
const latest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
status, upToDate, updateAvailable := classifyUpdateStatus(current, current, false)
|
||||
if status != "up_to_date" || !upToDate || updateAvailable {
|
||||
t.Fatalf("same clean commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
|
||||
status, upToDate, updateAvailable = classifyUpdateStatus(current, current, true)
|
||||
if status != "local_changes" || upToDate || updateAvailable {
|
||||
t.Fatalf("modified commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
|
||||
status, upToDate, updateAvailable = classifyUpdateStatus(current, latest, false)
|
||||
if status != "update_available" || upToDate || !updateAvailable {
|
||||
t.Fatalf("different commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWebURLRemovesCredentialsAndGitSuffix(t *testing.T) {
|
||||
got := repoWebURL("https://user:secret@git.example.test/owner/repo.git")
|
||||
want := "https://git.example.test/owner/repo"
|
||||
if got != want {
|
||||
t.Fatalf("repoWebURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRemoteCommit(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script git stub is Unix-specific")
|
||||
}
|
||||
const want = "cf49340b9a1234567890abcdef1234567890abcd"
|
||||
dir := t.TempDir()
|
||||
gitPath := filepath.Join(dir, "git")
|
||||
script := "#!/bin/sh\nprintf '%s\\trefs/heads/main\\n' '" + want + "'\n"
|
||||
if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
got, err := queryRemoteCommit(context.Background(), "https://git.example.test/owner/repo.git", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("queryRemoteCommit() error = %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("queryRemoteCommit() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IfaceUsageDelta struct {
|
||||
Iface string
|
||||
RxBytes uint64
|
||||
TxBytes uint64
|
||||
At time.Time
|
||||
}
|
||||
|
||||
var ifaceUsagePending = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]ifaceCounters
|
||||
}{m: make(map[string]ifaceCounters)}
|
||||
|
||||
func addPendingIfaceUsage(iface string, rxBytes, txBytes uint64) {
|
||||
if isIgnoredInterface(iface) || (rxBytes == 0 && txBytes == 0) {
|
||||
return
|
||||
}
|
||||
ifaceUsagePending.mu.Lock()
|
||||
defer ifaceUsagePending.mu.Unlock()
|
||||
p := ifaceUsagePending.m[iface]
|
||||
p.RxBytes += rxBytes
|
||||
p.TxBytes += txBytes
|
||||
ifaceUsagePending.m[iface] = p
|
||||
}
|
||||
|
||||
func flushPendingIfaceUsage(at time.Time) []IfaceUsageDelta {
|
||||
ifaceUsagePending.mu.Lock()
|
||||
defer ifaceUsagePending.mu.Unlock()
|
||||
if len(ifaceUsagePending.m) == 0 {
|
||||
return nil
|
||||
}
|
||||
deltas := make([]IfaceUsageDelta, 0, len(ifaceUsagePending.m))
|
||||
for iface, ctrs := range ifaceUsagePending.m {
|
||||
if isIgnoredInterface(iface) {
|
||||
continue
|
||||
}
|
||||
deltas = append(deltas, IfaceUsageDelta{Iface: iface, RxBytes: ctrs.RxBytes, TxBytes: ctrs.TxBytes, At: at})
|
||||
}
|
||||
ifaceUsagePending.m = make(map[string]ifaceCounters)
|
||||
return deltas
|
||||
}
|
||||
|
||||
func restorePendingIfaceUsage(deltas []IfaceUsageDelta) {
|
||||
ifaceUsagePending.mu.Lock()
|
||||
defer ifaceUsagePending.mu.Unlock()
|
||||
for _, d := range deltas {
|
||||
if isIgnoredInterface(d.Iface) {
|
||||
continue
|
||||
}
|
||||
p := ifaceUsagePending.m[d.Iface]
|
||||
p.RxBytes += d.RxBytes
|
||||
p.TxBytes += d.TxBytes
|
||||
ifaceUsagePending.m[d.Iface] = p
|
||||
}
|
||||
}
|
||||
|
||||
func clearPendingIfaceUsage() {
|
||||
ifaceUsagePending.mu.Lock()
|
||||
ifaceUsagePending.m = make(map[string]ifaceCounters)
|
||||
ifaceUsagePending.mu.Unlock()
|
||||
}
|
||||
|
||||
type VnstatUsageRow struct {
|
||||
Iface string `json:"iface"`
|
||||
Period string `json:"period"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
}
|
||||
|
||||
type VnstatDTO struct {
|
||||
Daily []VnstatUsageRow `json:"daily"`
|
||||
Monthly []VnstatUsageRow `json:"monthly"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TodayPeriod string `json:"today_period"`
|
||||
MonthPeriod string `json:"month_period"`
|
||||
TodayTotalBytes uint64 `json:"today_total_bytes"`
|
||||
MonthTotalBytes uint64 `json:"month_total_bytes"`
|
||||
InterfaceCount int `json:"interface_count"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureIfaceUsageTables(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS ssh_iface_daily_usage (
|
||||
usage_date DATE NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (usage_date, iface)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS ssh_iface_monthly_usage (
|
||||
month_start DATE NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (month_start, iface)
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertIfaceUsageDeltas(ctx context.Context, deltas []IfaceUsageDelta) error {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, d := range deltas {
|
||||
if isIgnoredInterface(d.Iface) || (d.RxBytes == 0 && d.TxBytes == 0) {
|
||||
continue
|
||||
}
|
||||
at := d.At
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
day := at.Format("2006-01-02")
|
||||
month := time.Date(at.Year(), at.Month(), 1, 0, 0, 0, 0, at.Location()).Format("2006-01-02")
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ssh_iface_daily_usage (usage_date, iface, rx_bytes, tx_bytes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (usage_date, iface) DO UPDATE
|
||||
SET rx_bytes = ssh_iface_daily_usage.rx_bytes + EXCLUDED.rx_bytes,
|
||||
tx_bytes = ssh_iface_daily_usage.tx_bytes + EXCLUDED.tx_bytes,
|
||||
updated_at = NOW()`,
|
||||
day, d.Iface, d.RxBytes, d.TxBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ssh_iface_monthly_usage (month_start, iface, rx_bytes, tx_bytes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (month_start, iface) DO UPDATE
|
||||
SET rx_bytes = ssh_iface_monthly_usage.rx_bytes + EXCLUDED.rx_bytes,
|
||||
tx_bytes = ssh_iface_monthly_usage.tx_bytes + EXCLUDED.tx_bytes,
|
||||
updated_at = NOW()`,
|
||||
month, d.Iface, d.RxBytes, d.TxBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) LoadIfaceUsage(ctx context.Context, days, months int) (VnstatDTO, error) {
|
||||
if days <= 0 || days > 366 {
|
||||
days = 31
|
||||
}
|
||||
if months <= 0 || months > 60 {
|
||||
months = 12
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
todayPeriod := now.Format("2006-01-02")
|
||||
monthPeriod := now.Format("2006-01")
|
||||
out := VnstatDTO{UpdatedAt: now, TodayPeriod: todayPeriod, MonthPeriod: monthPeriod}
|
||||
ifaceSet := make(map[string]struct{})
|
||||
|
||||
dailyRows, err := s.db.QueryContext(ctx, `
|
||||
SELECT iface, usage_date::text, rx_bytes, tx_bytes
|
||||
FROM ssh_iface_daily_usage
|
||||
WHERE usage_date >= CURRENT_DATE - $1::int
|
||||
AND iface <> 'lo'
|
||||
ORDER BY usage_date DESC, iface ASC`, days-1)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer dailyRows.Close()
|
||||
for dailyRows.Next() {
|
||||
var r VnstatUsageRow
|
||||
if err := dailyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil {
|
||||
return out, err
|
||||
}
|
||||
r.TotalBytes = r.RxBytes + r.TxBytes
|
||||
out.Daily = append(out.Daily, r)
|
||||
ifaceSet[r.Iface] = struct{}{}
|
||||
if r.Period == todayPeriod {
|
||||
out.TodayTotalBytes += r.TotalBytes
|
||||
}
|
||||
}
|
||||
if err := dailyRows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
monthlyRows, err := s.db.QueryContext(ctx, `
|
||||
SELECT iface, to_char(month_start, 'YYYY-MM') AS period, rx_bytes, tx_bytes
|
||||
FROM ssh_iface_monthly_usage
|
||||
WHERE month_start >= (date_trunc('month', CURRENT_DATE)::date - ($1::int * INTERVAL '1 month'))
|
||||
AND iface <> 'lo'
|
||||
ORDER BY month_start DESC, iface ASC`, months-1)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer monthlyRows.Close()
|
||||
for monthlyRows.Next() {
|
||||
var r VnstatUsageRow
|
||||
if err := monthlyRows.Scan(&r.Iface, &r.Period, &r.RxBytes, &r.TxBytes); err != nil {
|
||||
return out, err
|
||||
}
|
||||
r.TotalBytes = r.RxBytes + r.TxBytes
|
||||
out.Monthly = append(out.Monthly, r)
|
||||
ifaceSet[r.Iface] = struct{}{}
|
||||
if r.Period == monthPeriod {
|
||||
out.MonthTotalBytes += r.TotalBytes
|
||||
}
|
||||
}
|
||||
if err := monthlyRows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
out.InterfaceCount = len(ifaceSet)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ResetIfaceUsage(ctx context.Context) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `TRUNCATE TABLE ssh_iface_daily_usage, ssh_iface_monthly_usage`); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceIfaceTotals(ctx context.Context, rows []IfaceTotals) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM ssh_iface_totals`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if isIgnoredInterface(r.Iface) {
|
||||
continue
|
||||
}
|
||||
resetAt := r.ResetAt
|
||||
if resetAt.IsZero() {
|
||||
resetAt = time.Now()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ssh_iface_totals (iface, total_rx_bytes, total_tx_bytes, last_kernel_rx_bytes, last_kernel_tx_bytes, updated_at, reset_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
|
||||
r.Iface, r.TotalRxBytes, r.TotalTxBytes, r.LastKernelRxBytes, r.LastKernelTxBytes, resetAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func handleVnstat(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
days := parsePositiveInt(r.URL.Query().Get("days"), 31)
|
||||
months := parsePositiveInt(r.URL.Query().Get("months"), 12)
|
||||
data, err := store.LoadIfaceUsage(r.Context(), days, months)
|
||||
if err != nil {
|
||||
log.Printf("failed to load vnstat usage: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
}
|
||||
|
||||
func handleVnstatReset(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := store.ResetIfaceUsage(r.Context()); err != nil {
|
||||
log.Printf("failed to reset vnstat usage: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
clearPendingIfaceUsage()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func handleResetInterfaceStats(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil || ifaceTotalsMgr == nil {
|
||||
http.Error(w, "interface totals persistence not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
netMap, err := readNetDev()
|
||||
if err != nil {
|
||||
log.Printf("failed to read interfaces for reset: %v", err)
|
||||
http.Error(w, "failed to read interfaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows := ifaceTotalsMgr.ResetAllToKernel(netMap)
|
||||
if err := store.ReplaceIfaceTotals(r.Context(), rows); err != nil {
|
||||
log.Printf("failed to reset interface totals: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stats := getCurrentStats()
|
||||
for i := range stats.Interfaces {
|
||||
stats.Interfaces[i].RxBytes = 0
|
||||
stats.Interfaces[i].TxBytes = 0
|
||||
}
|
||||
setCurrentStats(stats)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
+355
-56
@@ -3,34 +3,62 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client.
|
||||
// Xray's own config only stores uuid/email/level; expiry and display name live here.
|
||||
// Xray's own config only stores uuid/email/level; expiry, display name,
|
||||
// reseller owner, and connection policy live here.
|
||||
type XrayClientMeta struct {
|
||||
UUID string
|
||||
Name string
|
||||
Email string
|
||||
InboundTag string
|
||||
ExpiresAt *time.Time
|
||||
MaxConns int
|
||||
CreatedAt time.Time
|
||||
UUID string
|
||||
Name string
|
||||
Email string
|
||||
InboundTag string
|
||||
OwnerUsername string
|
||||
ExpiresAt *time.Time
|
||||
MaxConns int
|
||||
CreatedAt time.Time
|
||||
TotalUplinkBytes int64
|
||||
TotalDownlinkBytes int64
|
||||
LastActive *time.Time
|
||||
ActiveConnections int
|
||||
}
|
||||
|
||||
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_conns INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
owner_username TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_conns INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
last_active TIMESTAMPTZ,
|
||||
active_connections INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_uplink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`,
|
||||
// Keep legacy reseller-owned Xray accounts aligned with weighted quota
|
||||
// accounting. A reseller account always consumes at least one slot.
|
||||
`UPDATE xray_clients SET max_conns = 1
|
||||
WHERE owner_username <> '' AND max_conns < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error {
|
||||
@@ -39,31 +67,37 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
|
||||
expiresAt = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
inbound_tag = EXCLUDED.inbound_tag,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns)
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
|
||||
owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClientMeta, error) {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
var lastActive sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE uuid = $1`, uuid).
|
||||
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt)
|
||||
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
if lastActive.Valid {
|
||||
m.LastActive = &lastActive.Time
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -74,52 +108,304 @@ func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
|
||||
|
||||
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*XrayClientMeta
|
||||
for rows.Next() {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, m)
|
||||
return scanXrayClientMetaRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, rows.Err()
|
||||
defer rows.Close()
|
||||
return scanXrayClientMetaRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM xray_clients WHERE owner_username = $1`, ownerUsername).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanXrayClientMetaRows(rows)
|
||||
}
|
||||
|
||||
func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
|
||||
var out []*XrayClientMeta
|
||||
for rows.Next() {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
|
||||
var lastActive sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
if lastActive.Valid {
|
||||
m.LastActive = &lastActive.Time
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker runs a background goroutine that removes expired
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
// ResetXrayActiveConnections clears stale online counters after the panel starts.
|
||||
// Native mode then increments/decrements active_connections for real live streams.
|
||||
func (s *Store) ResetXrayActiveConnections(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE xray_clients SET active_connections = 0`)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddXrayClientTrafficBatch persists native-emulator traffic deltas. It keeps
|
||||
// totals in PostgreSQL so bandwidth remains visible after panel restarts.
|
||||
func (s *Store) AddXrayClientTrafficBatch(ctx context.Context, deltas map[string]xrayPendingTraffic) error {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
UPDATE xray_clients SET
|
||||
email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END,
|
||||
name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END,
|
||||
total_uplink_bytes = GREATEST(total_uplink_bytes + GREATEST($3::BIGINT, 0), 0),
|
||||
total_downlink_bytes = GREATEST(total_downlink_bytes + GREATEST($4::BIGINT, 0), 0),
|
||||
last_active = NOW()
|
||||
WHERE uuid = $1`)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for uuid, d := range deltas {
|
||||
if uuid == "" || (d.Uplink == 0 && d.Downlink == 0) {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, uuid, d.Email, d.Uplink, d.Downlink); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateXrayClientActive adjusts the native online connection counter.
|
||||
func (s *Store) UpdateXrayClientActive(ctx context.Context, uuid, email string, delta int) error {
|
||||
if uuid == "" || delta == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE xray_clients SET
|
||||
email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END,
|
||||
name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END,
|
||||
last_active = CASE WHEN $3::INT > 0 THEN NOW() ELSE last_active END,
|
||||
active_connections = GREATEST(active_connections + $3::INT, 0)
|
||||
WHERE uuid = $1`, uuid, email, delta)
|
||||
return err
|
||||
}
|
||||
|
||||
func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("count xray clients for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SumXrayClientQuotaByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
if s == nil || ownerUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(GREATEST(max_conns, 1)), 0)
|
||||
FROM xray_clients WHERE owner_username=$1`, ownerUsername).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func countOwnedSSHQuota(ownerUsername string) int {
|
||||
total := 0
|
||||
for _, user := range userMgr.List() {
|
||||
if user.Cfg.OwnerUsername == ownerUsername {
|
||||
total += resellerProvisionCost(user.Cfg.MaxConnections)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedXrayQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
total, err := store.SumXrayClientQuotaByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("sum Xray quota for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
return countOwnedSSHQuota(ownerUsername) + countOwnedXrayQuota(ctx, store, ownerUsername)
|
||||
}
|
||||
|
||||
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("xray owner cleanup: list %s: %v", ownerUsername, err)
|
||||
return
|
||||
}
|
||||
needRestart := false
|
||||
for _, m := range clients {
|
||||
if m.InboundTag != "" {
|
||||
if err := xrayMgr.RemoveXrayClient(m.InboundTag, m.UUID); err != nil {
|
||||
log.Printf("xray owner cleanup: remove %s from %s: %v", m.UUID, m.InboundTag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray owner cleanup: delete meta %s: %v", m.UUID, err)
|
||||
}
|
||||
}
|
||||
if needRestart {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
}
|
||||
|
||||
// suspendOwnerXrayClients removes an owner's clients from the live Xray config
|
||||
// while keeping their metadata. That makes reseller suspension reversible.
|
||||
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.InboundTag == "" || !present[client.InboundTag][client.UUID] {
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(client.InboundTag, client.UUID); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("suspend Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOwnerXrayClients restores metadata-backed clients after a reseller is
|
||||
// reactivated. Existing entries are left untouched, so retries are idempotent.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.ExpiresAt != nil && time.Now().After(*client.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
if client.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
clientsForInbound, ok := present[client.InboundTag]
|
||||
if !ok {
|
||||
failures = append(failures, client.UUID+": inbound "+client.InboundTag+" no longer exists")
|
||||
continue
|
||||
}
|
||||
if clientsForInbound[client.UUID] {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(client.Name)
|
||||
}
|
||||
if email == "" {
|
||||
email = client.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(client.InboundTag, client.UUID, email); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
clientsForInbound[client.UUID] = true
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("restore Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker removes expired clients from the live config.
|
||||
// Reseller-owned metadata is retained so a paid renewal can restore access.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
@@ -138,26 +424,39 @@ func startXrayClientExpiryChecker(store *Store) {
|
||||
continue
|
||||
}
|
||||
needRestart := false
|
||||
present := make(map[string]map[string]bool)
|
||||
if inbounds, listErr := xrayMgr.ListInbounds(); listErr == nil {
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
if m.OwnerUsername == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
if present[tag][m.UUID] {
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
if m.OwnerUsername == "" {
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
|
||||
}
|
||||
if needRestart {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
log.Printf("xray expiry: restart error: %v", err)
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnsureXrayConfigSchema creates the DB table used as the canonical store for
|
||||
// Xray JSON configs. The config_file path is used as a stable key so local and
|
||||
// remote nodes can keep independent configs in the same database if needed.
|
||||
func (s *Store) EnsureXrayConfigSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS xray_configs (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE xray_configs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetXrayConfig(ctx context.Context, configKey string) ([]byte, bool, error) {
|
||||
if configKey == "" {
|
||||
configKey = "default"
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT config_json::text FROM xray_configs WHERE config_key = $1`, configKey).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(raw)) {
|
||||
return nil, false, fmt.Errorf("stored Xray config %q is not valid JSON", configKey)
|
||||
}
|
||||
return []byte(raw), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertXrayConfig(ctx context.Context, configKey string, data []byte) error {
|
||||
if configKey == "" {
|
||||
configKey = "default"
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return fmt.Errorf("invalid Xray JSON config")
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_configs (config_key, config_json, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW())
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_json = EXCLUDED.config_json,
|
||||
updated_at = NOW()`, configKey, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListXrayClientsByInbound(ctx context.Context, inboundTag string) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at,
|
||||
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
|
||||
FROM xray_clients WHERE inbound_tag = $1 ORDER BY created_at DESC`, inboundTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanXrayClientMetaRows(rows)
|
||||
}
|
||||
|
||||
// ImportXrayClientsFromConfig mirrors client UUIDs found in an existing Xray
|
||||
// JSON config into xray_clients. This lets native mode inherit users from the
|
||||
// external Xray config and keeps the DB as the hot-reloadable client index.
|
||||
// It intentionally preserves owner/expiry/quota fields for existing rows.
|
||||
func (s *Store) ImportXrayClientsFromConfig(ctx context.Context, data []byte) (int, error) {
|
||||
if s == nil || len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Settings struct {
|
||||
Clients []struct {
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
} `json:"clients"`
|
||||
Users []struct {
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
} `json:"users"`
|
||||
} `json:"settings"`
|
||||
} `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
imported := 0
|
||||
for _, ib := range cfg.Inbounds {
|
||||
proto := strings.ToLower(strings.TrimSpace(ib.Protocol))
|
||||
if proto != "vless" && proto != "vmess" && proto != "trojan" {
|
||||
continue
|
||||
}
|
||||
inboundTag := strings.TrimSpace(ib.Tag)
|
||||
configClients := ib.Settings.Clients
|
||||
if len(ib.Settings.Users) > 0 {
|
||||
configClients = append(configClients, ib.Settings.Users...)
|
||||
}
|
||||
for _, c := range configClients {
|
||||
uuid := strings.TrimSpace(c.ID)
|
||||
if uuid == "" {
|
||||
uuid = strings.TrimSpace(c.Password)
|
||||
}
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
email = uuid
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
email = CASE WHEN xray_clients.email = '' THEN EXCLUDED.email ELSE xray_clients.email END,
|
||||
name = CASE WHEN xray_clients.name = '' THEN EXCLUDED.name ELSE xray_clients.name END,
|
||||
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END`,
|
||||
uuid, email, email, inboundTag)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
+2245
-97
File diff suppressed because it is too large
Load Diff
+1417
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
nativeMuxStatusNew = 0x01
|
||||
nativeMuxStatusKeep = 0x02
|
||||
nativeMuxStatusEnd = 0x03
|
||||
nativeMuxStatusKeepAlive = 0x04
|
||||
|
||||
nativeMuxOptionData = 0x01
|
||||
nativeMuxOptionError = 0x02
|
||||
|
||||
nativeMuxNetworkTCP = 0x01
|
||||
nativeMuxNetworkUDP = 0x02
|
||||
|
||||
// Keep packet buffers below the kernel max. Mux packets are length-prefixed and
|
||||
// capped at nativeUDPMaxPacket, so larger buffers only increase memory pressure.
|
||||
)
|
||||
|
||||
type nativeMuxMetadata struct {
|
||||
sessionID uint16
|
||||
status byte
|
||||
option byte
|
||||
network byte
|
||||
host string
|
||||
port uint16
|
||||
globalID [8]byte
|
||||
}
|
||||
|
||||
type nativeMuxPacket struct {
|
||||
payload []byte
|
||||
host string
|
||||
port uint16
|
||||
discard bool
|
||||
}
|
||||
|
||||
type nativeMuxUplinkItem struct {
|
||||
payload []byte
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
|
||||
const nativeMuxUplinkQueue = 64
|
||||
|
||||
var nativeMuxFramePool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make([]byte, 0, 2+512+2+nativeUDPMaxPacket)
|
||||
return &b
|
||||
},
|
||||
}
|
||||
|
||||
type nativeMuxSession struct {
|
||||
id uint16
|
||||
network byte
|
||||
xudp bool
|
||||
|
||||
tcp net.Conn
|
||||
udp net.PacketConn
|
||||
|
||||
udpNetwork string
|
||||
udpTarget net.Addr
|
||||
|
||||
lastUDPHost string
|
||||
lastUDPPort uint16
|
||||
lastUDPAddr net.Addr
|
||||
|
||||
writeMu *sync.Mutex
|
||||
client io.Writer
|
||||
uuid string
|
||||
email string
|
||||
|
||||
upLimiter *rate.Limiter
|
||||
downLimiter *rate.Limiter
|
||||
upMeter *trafficMeter
|
||||
downMeter *trafficMeter
|
||||
|
||||
uplink chan nativeMuxUplinkItem
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
onClose func(*nativeMuxSession)
|
||||
releaseSlot func()
|
||||
globalID [8]byte
|
||||
}
|
||||
|
||||
var nativeMuxGlobalActive atomic.Int64
|
||||
|
||||
func acquireNativeMuxGlobalSlot() (func(), bool) {
|
||||
limit := int64(nativeMuxGlobalSessionLimit())
|
||||
if limit <= 0 {
|
||||
return func() {}, true
|
||||
}
|
||||
for {
|
||||
cur := nativeMuxGlobalActive.Load()
|
||||
if cur >= limit {
|
||||
return nil, false
|
||||
}
|
||||
if nativeMuxGlobalActive.CompareAndSwap(cur, cur+1) {
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { nativeMuxGlobalActive.Add(-1) }) }, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nativeVLESSMuxTunnel implements the server side of Xray's Mux.Cool framing
|
||||
// for VLESS CommandMux. CommandMux does not carry a VLESS target address; every
|
||||
// child TCP/UDP request is described by mux frame metadata. UDP is treated as a
|
||||
// packet protocol, not as a byte stream, and XUDP-style GlobalID/endpoint
|
||||
// metadata is accepted for full-cone friendly clients.
|
||||
func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, email string) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray VLESS mux user=%s", email))
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
|
||||
writeMu := &sync.Mutex{}
|
||||
sessions := make(map[uint16]*nativeMuxSession)
|
||||
xudpSessions := make(map[[8]byte]*nativeMuxSession)
|
||||
readScratch := make([]byte, 0, nativeUDPMaxPacket)
|
||||
var mu sync.Mutex
|
||||
|
||||
removeSession := func(s *nativeMuxSession) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
if cur := sessions[s.id]; cur == s {
|
||||
delete(sessions, s.id)
|
||||
}
|
||||
if s.xudp && s.globalID != [8]byte{} {
|
||||
if cur := xudpSessions[s.globalID]; cur == s {
|
||||
delete(xudpSessions, s.globalID)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
closeSession := func(id uint16) {
|
||||
mu.Lock()
|
||||
s := sessions[id]
|
||||
if s != nil {
|
||||
delete(sessions, id)
|
||||
if s.xudp && s.globalID != [8]byte{} {
|
||||
delete(xudpSessions, s.globalID)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if s != nil {
|
||||
s.closeBackend()
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
all := make([]*nativeMuxSession, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
all = append(all, s)
|
||||
}
|
||||
sessions = make(map[uint16]*nativeMuxSession)
|
||||
xudpSessions = make(map[[8]byte]*nativeMuxSession)
|
||||
mu.Unlock()
|
||||
for _, s := range all {
|
||||
s.closeBackend()
|
||||
}
|
||||
_ = stream.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
meta, err := readNativeMuxMetadata(stream)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VLESS mux metadata read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch meta.status {
|
||||
case nativeMuxStatusKeepAlive:
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
}
|
||||
|
||||
case nativeMuxStatusEnd:
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
}
|
||||
closeSession(meta.sessionID)
|
||||
|
||||
case nativeMuxStatusNew:
|
||||
if meta.network != nativeMuxNetworkTCP && meta.network != nativeMuxNetworkUDP {
|
||||
xrayLogf("native xray: VLESS mux session %d unsupported network %d", meta.sessionID, meta.network)
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
tooManySessions := len(sessions) >= nativeMuxMaxSessionLimit()
|
||||
mu.Unlock()
|
||||
if tooManySessions {
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_ = discardNativeMuxDataBlock(stream)
|
||||
}
|
||||
xrayTracef("native xray: VLESS mux rejected new session=%d over limit=%d user=%s", meta.sessionID, nativeMuxMaxSessionLimit(), email)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
|
||||
writeMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
isXUDP := meta.globalID != [8]byte{}
|
||||
pkt := nativeMuxPacket{}
|
||||
|
||||
targetHost, targetPort := meta.host, meta.port
|
||||
if isNativeDNSSinkTarget(targetHost) {
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_ = discardNativeMuxDataBlock(stream)
|
||||
}
|
||||
xrayTracef("native xray: VLESS mux fast-ignored DNS sink target session=%d network=%s host=%q port=%d xudp=%v", meta.sessionID, nativeMuxNetworkName(meta.network), targetHost, targetPort, isXUDP)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, false)
|
||||
writeMu.Unlock()
|
||||
continue
|
||||
}
|
||||
if invalidNativeDestination(targetHost, targetPort) {
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_ = discardNativeMuxDataBlock(stream)
|
||||
}
|
||||
xrayTracef("native xray: VLESS mux rejected invalid target session=%d network=%s host=%q port=%d xudp=%v", meta.sessionID, nativeMuxNetworkName(meta.network), targetHost, targetPort, isXUDP)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
|
||||
writeMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
pkt.payload, readScratch, err = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux first packet read failed session=%d: %v", meta.sessionID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, removeSession)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux session %s setup failed: %v", target, err)
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
|
||||
writeMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if old := sessions[meta.sessionID]; old != nil {
|
||||
old.closeBackend()
|
||||
}
|
||||
sessions[meta.sessionID] = s
|
||||
if isXUDP {
|
||||
if old := xudpSessions[meta.globalID]; old != nil && old != s {
|
||||
old.closeBackend()
|
||||
}
|
||||
xudpSessions[meta.globalID] = s
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP)
|
||||
ib2, host2, port2 := ib, targetHost, targetPort
|
||||
xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) })
|
||||
if len(pkt.payload) > 0 {
|
||||
s.enqueueUplink(pkt.payload, pkt.host, pkt.port)
|
||||
}
|
||||
|
||||
case nativeMuxStatusKeep:
|
||||
mu.Lock()
|
||||
s := sessions[meta.sessionID]
|
||||
if s == nil && meta.globalID != [8]byte{} {
|
||||
s = xudpSessions[meta.globalID]
|
||||
}
|
||||
mu.Unlock()
|
||||
if meta.option&nativeMuxOptionData == 0 {
|
||||
continue
|
||||
}
|
||||
pkt := nativeMuxPacket{}
|
||||
pkt.payload, readScratch, err = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux keep packet read failed session=%d: %v", meta.sessionID, err)
|
||||
if s != nil {
|
||||
closeSession(s.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s == nil {
|
||||
writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(stream, meta.sessionID, false)
|
||||
writeMu.Unlock()
|
||||
continue
|
||||
}
|
||||
// Official Mux.Cool packet sessions read exactly one packet block here.
|
||||
// XUDP is represented by GlobalID on the New frame and optional UDP
|
||||
// endpoint metadata on Keep frames, not by auto-detecting metadata inside
|
||||
// the UDP payload. Auto-detecting inside payload can block QUIC if a real
|
||||
// datagram happens to look like XUDP control bytes.
|
||||
if meta.host != "" {
|
||||
pkt.host = meta.host
|
||||
pkt.port = meta.port
|
||||
}
|
||||
if len(pkt.payload) > 0 {
|
||||
s.enqueueUplink(pkt.payload, pkt.host, pkt.port)
|
||||
}
|
||||
|
||||
default:
|
||||
xrayLogf("native xray: VLESS mux unknown status %d", meta.status)
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
_, readScratch, _ = readNativeMuxDataBlockScratch(stream, readScratch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) {
|
||||
target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port)))
|
||||
if invalidNativeDestination(host, port) {
|
||||
return nil, target, fmt.Errorf("invalid destination")
|
||||
}
|
||||
releaseSlot, ok := acquireNativeMuxGlobalSlot()
|
||||
if !ok {
|
||||
return nil, target, fmt.Errorf("global mux session limit reached")
|
||||
}
|
||||
s := &nativeMuxSession{
|
||||
id: id,
|
||||
network: network,
|
||||
xudp: xudp,
|
||||
writeMu: writeMu,
|
||||
client: client,
|
||||
uuid: uuid,
|
||||
email: email,
|
||||
upLimiter: ib.upLimiter(),
|
||||
downLimiter: ib.downLimiter(),
|
||||
upMeter: &trafficMeter{uuid: uuid, email: email, uplink: true},
|
||||
downMeter: &trafficMeter{uuid: uuid, email: email, uplink: false},
|
||||
uplink: make(chan nativeMuxUplinkItem, nativeMuxUplinkQueue),
|
||||
closed: make(chan struct{}),
|
||||
onClose: onClose,
|
||||
releaseSlot: releaseSlot,
|
||||
globalID: globalID,
|
||||
}
|
||||
s.ctx, s.cancel = context.WithCancel(context.Background())
|
||||
return s, target, nil
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id))
|
||||
|
||||
select {
|
||||
case <-s.closed:
|
||||
s.failInit(false)
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if s.network == nativeMuxNetworkTCP {
|
||||
backend, target, err := ib.nativeDialTCP(host, port)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux TCP dial %s failed session=%d: %v", target, s.id, err)
|
||||
s.failInit(true)
|
||||
return
|
||||
}
|
||||
s.tcp = backend
|
||||
} else {
|
||||
pc, udpNetwork, udpTarget, target, err := ib.nativeOpenMuxUDP(host, port)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux UDP open %s failed session=%d: %v", target, s.id, err)
|
||||
s.failInit(true)
|
||||
return
|
||||
}
|
||||
s.udp = pc
|
||||
s.udpNetwork = udpNetwork
|
||||
s.udpTarget = udpTarget
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.closed:
|
||||
s.closeBackend()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
xrayGo(fmt.Sprintf("native xray mux backend session=%d", s.id), func() { s.readBackendLoop() })
|
||||
s.uplinkLoop()
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) failInit(notifyClient bool) {
|
||||
if notifyClient {
|
||||
s.writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(s.client, s.id, true)
|
||||
s.writeMu.Unlock()
|
||||
}
|
||||
if s.onClose != nil {
|
||||
s.onClose(s)
|
||||
}
|
||||
s.closeBackend()
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) {
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
cp := make([]byte, len(payload))
|
||||
copy(cp, payload)
|
||||
select {
|
||||
case s.uplink <- nativeMuxUplinkItem{payload: cp, host: host, port: port}:
|
||||
case <-s.closed:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) uplinkLoop() {
|
||||
defer s.upMeter.flush()
|
||||
for {
|
||||
select {
|
||||
case <-s.closed:
|
||||
return
|
||||
case item := <-s.uplink:
|
||||
if !s.writeBackendItem(item) {
|
||||
s.closeBackend()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) nativeOpenMuxUDP(host string, port uint16) (net.PacketConn, string, net.Addr, string, error) {
|
||||
targetHost := normalizeNativeTargetHost(host)
|
||||
target := net.JoinHostPort(targetHost, strconv.Itoa(int(port)))
|
||||
udpNetwork := nativeDialNetwork("udp", targetHost)
|
||||
udpTarget, err := net.ResolveUDPAddr(udpNetwork, target)
|
||||
if err != nil {
|
||||
return nil, udpNetwork, nil, target, err
|
||||
}
|
||||
|
||||
var local *net.UDPAddr
|
||||
if addr := nativeLocalAddrForDial(udpNetwork, targetHost, ib.listen); addr != nil {
|
||||
if udpAddr, ok := addr.(*net.UDPAddr); ok {
|
||||
local = udpAddr
|
||||
}
|
||||
}
|
||||
pc, err := net.ListenUDP(udpNetwork, local)
|
||||
if err != nil {
|
||||
return nil, udpNetwork, nil, target, err
|
||||
}
|
||||
if nativeMuxUDPReadBufferSize() > 0 {
|
||||
_ = pc.SetReadBuffer(nativeMuxUDPReadBufferSize())
|
||||
}
|
||||
if nativeMuxUDPWriteBufferSize() > 0 {
|
||||
_ = pc.SetWriteBuffer(nativeMuxUDPWriteBufferSize())
|
||||
}
|
||||
return pc, udpNetwork, udpTarget, target, nil
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
payload := item.payload
|
||||
if len(payload) == 0 {
|
||||
return true
|
||||
}
|
||||
if s.upLimiter != nil {
|
||||
if err := s.upLimiter.WaitN(s.ctx, len(payload)); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var n int
|
||||
var err error
|
||||
if s.network == nativeMuxNetworkTCP {
|
||||
n, err = s.tcp.Write(payload)
|
||||
} else {
|
||||
target := s.udpTarget
|
||||
if item.host != "" && item.port != 0 {
|
||||
if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) {
|
||||
// AdGuard/blocked endpoints must be ignored at the cheapest possible
|
||||
// point. Do not resolve, dial, log loudly, or keep the mux child busy.
|
||||
xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, item.host, item.port)
|
||||
return true
|
||||
}
|
||||
if s.lastUDPAddr != nil && s.lastUDPHost == item.host && s.lastUDPPort == item.port {
|
||||
target = s.lastUDPAddr
|
||||
} else if addr, rerr := resolveNativeMuxUDPAddr(s.udpNetwork, item.host, item.port); rerr == nil {
|
||||
target = addr
|
||||
s.lastUDPHost = item.host
|
||||
s.lastUDPPort = item.port
|
||||
s.lastUDPAddr = addr
|
||||
} else {
|
||||
xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, item.host, item.port, rerr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
n, err = s.udp.WriteTo(payload, target)
|
||||
}
|
||||
if s.network == nativeMuxNetworkUDP && err == nil {
|
||||
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
|
||||
}
|
||||
if n > 0 {
|
||||
s.upMeter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) readBackendLoop() {
|
||||
defer xrayRecover(fmt.Sprintf("native xray mux backend loop session=%d", s.id))
|
||||
sendEnd := true
|
||||
defer func() {
|
||||
s.downMeter.flush()
|
||||
if sendEnd {
|
||||
s.writeMu.Lock()
|
||||
_ = writeNativeMuxEnd(s.client, s.id, false)
|
||||
s.writeMu.Unlock()
|
||||
}
|
||||
if s.onClose != nil {
|
||||
s.onClose(s)
|
||||
}
|
||||
s.closeBackend()
|
||||
}()
|
||||
|
||||
if s.network == nativeMuxNetworkTCP {
|
||||
s.readTCPBackendLoop()
|
||||
return
|
||||
}
|
||||
// For UDP/QUIC, an idle backend timeout is only local cleanup. Sending an
|
||||
// End frame on idle makes some clients close the whole video/QUIC flow after
|
||||
// a short quiet period. Real errors still return true and notify the client.
|
||||
sendEnd = s.readUDPBackendLoop()
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) waitDownRate(n int) error {
|
||||
if s.downLimiter == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.downLimiter.WaitN(s.ctx, n)
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) readTCPBackendLoop() {
|
||||
buf := make([]byte, 16*1024)
|
||||
for {
|
||||
n, err := s.tcp.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VLESS mux TCP backend read failed session=%d: %v", s.id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
return
|
||||
}
|
||||
s.downMeter.add(n)
|
||||
s.writeMu.Lock()
|
||||
werr := writeNativeMuxData(s.client, s.id, nativeMuxStatusKeep, buf[:n])
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
xrayLogf("native xray: VLESS mux TCP client write failed session=%d: %v", s.id, werr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) readUDPBackendLoop() bool {
|
||||
buf := make([]byte, nativeUDPBufferSize)
|
||||
for {
|
||||
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
|
||||
n, addr, err := s.udp.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return false
|
||||
}
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VLESS mux UDP backend read failed session=%d: %v", s.id, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
return true
|
||||
}
|
||||
s.downMeter.add(n)
|
||||
s.writeMu.Lock()
|
||||
// Include the UDP source endpoint on XUDP responses so clients that rely on
|
||||
// full-cone packet addressing can associate the datagram with the correct
|
||||
// origin. Classic mux UDP also accepts this optional metadata in Xray.
|
||||
werr := writeNativeMuxPacketData(s.client, s.id, nativeMuxStatusKeep, buf[:n], addr, s.xudp)
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nativeMuxSession) closeBackend() {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closed)
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
if s.releaseSlot != nil {
|
||||
s.releaseSlot()
|
||||
}
|
||||
if s.tcp != nil {
|
||||
_ = s.tcp.Close()
|
||||
}
|
||||
if s.udp != nil {
|
||||
_ = s.udp.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func nativeMuxNetworkName(network byte) string {
|
||||
if network == nativeMuxNetworkUDP {
|
||||
return "udp"
|
||||
}
|
||||
return "tcp"
|
||||
}
|
||||
|
||||
func readNativeMuxMetadata(r io.Reader) (nativeMuxMetadata, error) {
|
||||
var meta nativeMuxMetadata
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
metaLen := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if metaLen < 4 || metaLen > 512 {
|
||||
return meta, fmt.Errorf("invalid mux metadata length %d", metaLen)
|
||||
}
|
||||
var stack [512]byte
|
||||
b := stack[:metaLen]
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
meta.sessionID = binary.BigEndian.Uint16(b[0:2])
|
||||
meta.status = b[2]
|
||||
meta.option = b[3]
|
||||
off := 4
|
||||
if meta.status == nativeMuxStatusNew {
|
||||
if off >= len(b) {
|
||||
return meta, fmt.Errorf("mux new frame missing network")
|
||||
}
|
||||
meta.network = b[off]
|
||||
off++
|
||||
host, port, next, err := parseNativeMuxAddressPort(b, off)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
meta.host, meta.port, off = host, port, next
|
||||
} else if meta.status == nativeMuxStatusKeep && off < len(b) && b[off] == nativeMuxNetworkUDP {
|
||||
meta.network = b[off]
|
||||
off++
|
||||
host, port, next, err := parseNativeMuxAddressPort(b, off)
|
||||
if err == nil {
|
||||
meta.host, meta.port, off = host, port, next
|
||||
}
|
||||
}
|
||||
if meta.status == nativeMuxStatusNew && meta.network == nativeMuxNetworkUDP && meta.option&nativeMuxOptionData != 0 && len(b)-off >= 8 {
|
||||
copy(meta.globalID[:], b[len(b)-8:])
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func parseNativeMuxAddressPort(b []byte, off int) (string, uint16, int, error) {
|
||||
if off+3 > len(b) {
|
||||
return "", 0, off, io.ErrUnexpectedEOF
|
||||
}
|
||||
port := binary.BigEndian.Uint16(b[off : off+2])
|
||||
off += 2
|
||||
atyp := b[off]
|
||||
off++
|
||||
switch atyp {
|
||||
case atypIPv4:
|
||||
if off+4 > len(b) {
|
||||
return "", 0, off, io.ErrUnexpectedEOF
|
||||
}
|
||||
host := net.IP(b[off : off+4]).String()
|
||||
return host, port, off + 4, nil
|
||||
case atypIPv6:
|
||||
if off+16 > len(b) {
|
||||
return "", 0, off, io.ErrUnexpectedEOF
|
||||
}
|
||||
host := net.IP(b[off : off+16]).String()
|
||||
return host, port, off + 16, nil
|
||||
case atypDomain:
|
||||
if off >= len(b) {
|
||||
return "", 0, off, io.ErrUnexpectedEOF
|
||||
}
|
||||
l := int(b[off])
|
||||
off++
|
||||
if off+l > len(b) {
|
||||
return "", 0, off, io.ErrUnexpectedEOF
|
||||
}
|
||||
return string(b[off : off+l]), port, off + l, nil
|
||||
default:
|
||||
return "", 0, off, fmt.Errorf("unknown mux address type %d", atyp)
|
||||
}
|
||||
}
|
||||
|
||||
func appendNativeMuxAddressPort(dst []byte, host string, port uint16) []byte {
|
||||
var p [2]byte
|
||||
binary.BigEndian.PutUint16(p[:], port)
|
||||
dst = append(dst, p[:]...)
|
||||
ip := net.ParseIP(stripNativeIPZone(normalizeNativeTargetHost(host)))
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
dst = append(dst, atypIPv4)
|
||||
dst = append(dst, ip4...)
|
||||
return dst
|
||||
}
|
||||
if ip16 := ip.To16(); ip16 != nil {
|
||||
dst = append(dst, atypIPv6)
|
||||
dst = append(dst, ip16...)
|
||||
return dst
|
||||
}
|
||||
if len(host) > 255 {
|
||||
host = host[:255]
|
||||
}
|
||||
dst = append(dst, atypDomain, byte(len(host)))
|
||||
dst = append(dst, []byte(host)...)
|
||||
return dst
|
||||
}
|
||||
|
||||
func readNativeMuxDataBlock(r io.Reader) ([]byte, error) {
|
||||
payload, _, err := readNativeMuxDataBlockScratch(r, nil)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func readNativeMuxDataBlockScratch(r io.Reader, scratch []byte) ([]byte, []byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, scratch, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, scratch, fmt.Errorf("mux payload too large: %d", n)
|
||||
}
|
||||
if cap(scratch) < n {
|
||||
scratch = make([]byte, n)
|
||||
}
|
||||
payload := scratch[:n]
|
||||
_, err := io.ReadFull(r, payload)
|
||||
return payload, scratch, err
|
||||
}
|
||||
|
||||
func discardNativeMuxDataBlock(r io.Reader) error {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("mux payload too large: %d", n)
|
||||
}
|
||||
_, err := io.CopyN(io.Discard, r, int64(n))
|
||||
return err
|
||||
}
|
||||
|
||||
func readNativeMuxPacket(r io.Reader, allowXUDP bool) (nativeMuxPacket, error) {
|
||||
var pkt nativeMuxPacket
|
||||
for {
|
||||
block, err := readNativeMuxDataBlock(r)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
if !allowXUDP || !isNativeXUDPMetadata(block) {
|
||||
pkt.payload = block
|
||||
return pkt, nil
|
||||
}
|
||||
inner, err := parseNativeXUDPMetadata(block)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
if inner.discard {
|
||||
return inner, nil
|
||||
}
|
||||
if block[3]&1 == 0 {
|
||||
continue
|
||||
}
|
||||
inner.payload, err = readNativeMuxDataBlock(r)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
return inner, nil
|
||||
}
|
||||
}
|
||||
|
||||
func appendUint16(dst []byte, v uint16) []byte {
|
||||
return append(dst, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
func writeNativeMuxData(w io.Writer, sessionID uint16, status byte, payload []byte) error {
|
||||
return writeNativeMuxPacketData(w, sessionID, status, payload, nil, false)
|
||||
}
|
||||
|
||||
func writeNativeMuxPacketData(w io.Writer, sessionID uint16, status byte, payload []byte, udpAddr net.Addr, includeUDPAddr bool) error {
|
||||
if len(payload) > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("mux payload too large: %d", len(payload))
|
||||
}
|
||||
bufp := nativeMuxFramePool.Get().(*[]byte)
|
||||
frame := (*bufp)[:0]
|
||||
frame = append(frame, 0, 0) // metadata length placeholder
|
||||
metaStart := len(frame)
|
||||
frame = append(frame, byte(sessionID>>8), byte(sessionID), status, nativeMuxOptionData)
|
||||
if includeUDPAddr && udpAddr != nil {
|
||||
if host, port, ok := nativeMuxAddrHostPort(udpAddr); ok {
|
||||
frame = append(frame, nativeMuxNetworkUDP)
|
||||
frame = appendNativeMuxAddressPort(frame, host, port)
|
||||
}
|
||||
}
|
||||
metaLen := len(frame) - metaStart
|
||||
binary.BigEndian.PutUint16(frame[:2], uint16(metaLen))
|
||||
frame = appendUint16(frame, uint16(len(payload)))
|
||||
frame = append(frame, payload...)
|
||||
_, err := w.Write(frame)
|
||||
*bufp = frame[:0]
|
||||
nativeMuxFramePool.Put(bufp)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeNativeMuxEnd(w io.Writer, sessionID uint16, hasError bool) error {
|
||||
opt := byte(0)
|
||||
if hasError {
|
||||
opt = nativeMuxOptionError
|
||||
}
|
||||
var frame [6]byte
|
||||
binary.BigEndian.PutUint16(frame[0:2], 4)
|
||||
binary.BigEndian.PutUint16(frame[2:4], sessionID)
|
||||
frame[4] = nativeMuxStatusEnd
|
||||
frame[5] = opt
|
||||
_, err := w.Write(frame[:])
|
||||
return err
|
||||
}
|
||||
|
||||
func isNativeXUDPMetadata(meta []byte) bool {
|
||||
if len(meta) < 4 || len(meta) > 512 {
|
||||
return false
|
||||
}
|
||||
// Xray's xudp.PacketWriter stores a two-byte mux session id at the start of
|
||||
// the inner metadata. For client-generated packets this is normally zero.
|
||||
if meta[0] != 0 || meta[1] != 0 {
|
||||
return false
|
||||
}
|
||||
cmd := meta[2]
|
||||
opt := meta[3]
|
||||
if cmd != 1 && cmd != 2 && cmd != 4 {
|
||||
return false
|
||||
}
|
||||
if opt != 0 && opt != 1 {
|
||||
return false
|
||||
}
|
||||
if len(meta) == 4 {
|
||||
return true
|
||||
}
|
||||
return meta[4] == nativeMuxNetworkUDP
|
||||
}
|
||||
|
||||
func parseNativeXUDPMetadata(meta []byte) (nativeMuxPacket, error) {
|
||||
var pkt nativeMuxPacket
|
||||
if !isNativeXUDPMetadata(meta) {
|
||||
return pkt, fmt.Errorf("invalid xudp metadata")
|
||||
}
|
||||
cmd := meta[2]
|
||||
opt := meta[3]
|
||||
if cmd == 4 {
|
||||
pkt.discard = true
|
||||
return pkt, nil
|
||||
}
|
||||
if len(meta) > 4 && meta[4] == nativeMuxNetworkUDP {
|
||||
host, port, _, err := parseNativeMuxAddressPort(meta, 5)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
pkt.host = host
|
||||
pkt.port = port
|
||||
}
|
||||
if opt&1 == 0 {
|
||||
return pkt, nil
|
||||
}
|
||||
// Payload length and bytes follow in the outer stream, so the caller must
|
||||
// fill pkt.payload. This path is only used by readNativeXUDPPacket below.
|
||||
return pkt, nil
|
||||
}
|
||||
|
||||
func readNativeXUDPPacket(r io.Reader) (nativeMuxPacket, error) {
|
||||
var pkt nativeMuxPacket
|
||||
for {
|
||||
meta, err := readNativeMuxDataBlock(r)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
pkt, err = parseNativeXUDPMetadata(meta)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
if pkt.discard {
|
||||
return pkt, nil
|
||||
}
|
||||
if meta[3]&1 == 0 {
|
||||
continue
|
||||
}
|
||||
payload, err := readNativeMuxDataBlock(r)
|
||||
if err != nil {
|
||||
return pkt, err
|
||||
}
|
||||
pkt.payload = payload
|
||||
return pkt, nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveNativeMuxUDPAddr(network, host string, port uint16) (net.Addr, error) {
|
||||
targetHost := normalizeNativeTargetHost(host)
|
||||
if network == "" {
|
||||
network = nativeDialNetwork("udp", targetHost)
|
||||
}
|
||||
return net.ResolveUDPAddr(network, net.JoinHostPort(targetHost, strconv.Itoa(int(port))))
|
||||
}
|
||||
|
||||
func nativeMuxAddrHostPort(addr net.Addr) (string, uint16, bool) {
|
||||
switch a := addr.(type) {
|
||||
case *net.UDPAddr:
|
||||
if a == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return a.IP.String(), uint16(a.Port), true
|
||||
case *net.TCPAddr:
|
||||
if a == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return a.IP.String(), uint16(a.Port), true
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return stripNativeIPZone(host), uint16(port64), true
|
||||
}
|
||||
|
||||
func stripNativeIPZone(host string) string {
|
||||
if i := strings.LastIndexByte(host, '%'); i >= 0 {
|
||||
return host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
// xrayRecover prevents a bad client packet, closed HTTP stream, or mux/session
|
||||
// race from taking down the whole sshpanel process. A panic should only kill the
|
||||
// current native Xray connection/session and must always leave a visible stack
|
||||
// trace in /api/xray/logs and journald.
|
||||
func xrayRecover(where string) {
|
||||
if r := recover(); r != nil {
|
||||
xrayLogf("native xray: panic recovered in %s: %v\n%s", where, r, debug.Stack())
|
||||
}
|
||||
}
|
||||
|
||||
func xrayGo(where string, fn func()) {
|
||||
go func() {
|
||||
defer xrayRecover(where)
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
+1147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type XrayNativeTuning struct {
|
||||
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
|
||||
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
|
||||
TracePackets bool `json:"trace_packets,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultNativeRuntimeGOMAXPROCS = 0
|
||||
defaultNativeMuxGlobalSessions = 32768
|
||||
|
||||
fixedNativeMuxMaxSessions = 128
|
||||
fixedNativeMuxUDPIdleMS = 120000
|
||||
fixedNativeMuxUDPReadBuffer = 256 * 1024
|
||||
fixedNativeMuxUDPWriteBuffer = 256 * 1024
|
||||
|
||||
defaultNativeXHTTPMaxSessions = 16384
|
||||
defaultNativeXHTTPBufferedPosts = 512
|
||||
|
||||
// Do not impose an application-level lifetime on a connected XHTTP VPN
|
||||
// session. The official Xray server keeps a connected session for the
|
||||
// lifetime of its stream-down GET; request cancellation and I/O errors own
|
||||
// cleanup. A fixed five-minute sweeper incorrectly killed healthy but idle
|
||||
// VPNs. Zero disables the connected-session sweeper.
|
||||
fixedNativeXHTTPIdleMS = 0
|
||||
)
|
||||
|
||||
var (
|
||||
nativeTuneRuntimeGOMAXPROCS atomic.Int64
|
||||
nativeTuneMuxGlobalSessions atomic.Int64
|
||||
nativeTuneTracePackets atomic.Bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
applyNativeXrayTuning(nil)
|
||||
}
|
||||
|
||||
func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
||||
if t == nil {
|
||||
t = &XrayNativeTuning{}
|
||||
}
|
||||
out := *t
|
||||
if out.RuntimeGOMAXPROCS < 0 {
|
||||
out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS
|
||||
}
|
||||
if out.MuxGlobalSessions <= 0 {
|
||||
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
|
||||
out := normalizeNativeXrayTuning(t)
|
||||
gomax := out.RuntimeGOMAXPROCS
|
||||
if gomax <= 0 {
|
||||
gomax = runtime.NumCPU()
|
||||
}
|
||||
if gomax < 1 {
|
||||
gomax = 1
|
||||
}
|
||||
runtime.GOMAXPROCS(gomax)
|
||||
nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax))
|
||||
nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions))
|
||||
nativeTuneTracePackets.Store(out.TracePackets)
|
||||
return out
|
||||
}
|
||||
|
||||
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
|
||||
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
|
||||
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
|
||||
|
||||
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
|
||||
func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
|
||||
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
|
||||
func nativeXHTTPMaxSessionLimit() int { return defaultNativeXHTTPMaxSessions }
|
||||
func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts }
|
||||
func nativeMuxUDPIdleTimeout() time.Duration {
|
||||
return fixedNativeMuxUDPIdleMS * time.Millisecond
|
||||
}
|
||||
func nativeXHTTPIdleTimeout() time.Duration {
|
||||
return fixedNativeXHTTPIdleMS * time.Millisecond
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
nativeUDPMaxPacket = 65535
|
||||
nativeUDPBufferSize = 64 * 1024
|
||||
nativeUDPIdle = 2 * time.Minute
|
||||
)
|
||||
|
||||
// nativeVLESSUDPTunnel implements VLESS UDP-over-stream framing for a normal
|
||||
// VLESS CommandUDP request. Xray uses classic 2-byte length-prefixed packets
|
||||
// for this command. Do not auto-detect XUDP here: real DNS queries often have
|
||||
// bytes 2/3 equal to 0x01/0x00, which looked like our old loose XUDP metadata
|
||||
// check and caused the server to block waiting for a fake second payload.
|
||||
// XUDP belongs to VLESS CommandMux and is handled separately when Mux support
|
||||
// is implemented.
|
||||
func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
defer xrayRecover(fmt.Sprintf("native xray VLESS UDP tunnel user=%s", email))
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var closeOnce sync.Once
|
||||
closeAll := func() {
|
||||
closeOnce.Do(func() {
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
xrayGo("native xray VLESS UDP uplink", func() {
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
for {
|
||||
payload, err := readVLESSLengthPacket(client)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VLESS UDP client read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(payload)); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(payload)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS UDP backend write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Add(1)
|
||||
xrayGo("native xray VLESS UDP downlink", func() {
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
buf := make([]byte, nativeUDPBufferSize)
|
||||
for {
|
||||
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
|
||||
n, err := backend.Read(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return
|
||||
}
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VLESS UDP backend read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
return
|
||||
}
|
||||
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
|
||||
xrayLogf("native xray: VLESS UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
upMeter.flush()
|
||||
downMeter.flush()
|
||||
closeAll()
|
||||
}
|
||||
|
||||
type vlessUDPPacketCodec struct {
|
||||
mu sync.RWMutex
|
||||
decided bool
|
||||
xudp bool
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) setXUDP(v bool) {
|
||||
c.mu.Lock()
|
||||
if !c.decided {
|
||||
c.decided = true
|
||||
c.xudp = v
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) useXUDP() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.decided && c.xudp
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) Read(r io.Reader) ([]byte, error) {
|
||||
if c.useXUDP() {
|
||||
return readVLESSXUDPPacket(r)
|
||||
}
|
||||
return c.readAuto(r)
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) Write(w io.Writer, payload []byte) error {
|
||||
if c.useXUDP() {
|
||||
return writeVLESSXUDPPacket(w, payload)
|
||||
}
|
||||
return writeVLESSLengthPacket(w, payload)
|
||||
}
|
||||
|
||||
func (c *vlessUDPPacketCodec) readAuto(r io.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
c.setXUDP(false)
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("udp packet too large: %d", n)
|
||||
}
|
||||
|
||||
// XUDP starts with a metadata frame length, not a payload length. Metadata is
|
||||
// small and has command/option bytes at offsets 2/3 after the two-byte mux ID.
|
||||
// Read a possible metadata frame once and fall back to normal length-prefixed
|
||||
// UDP if it does not match the XUDP shape. This lets the native emulator work
|
||||
// with clients whose default packet encoding is xudp while preserving classic
|
||||
// VLESS UDP framing.
|
||||
if n >= 4 && n <= 512 {
|
||||
candidate := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isVLESSXUDPMetadata(candidate) {
|
||||
c.setXUDP(true)
|
||||
return readVLESSXUDPPayloadAfterMeta(r, candidate)
|
||||
}
|
||||
c.setXUDP(false)
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
c.setXUDP(false)
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func readVLESSLengthPacket(r io.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("udp packet too large: %d", n)
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func writeVLESSLengthPacket(w io.Writer, payload []byte) error {
|
||||
if len(payload) > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("udp packet too large: %d", len(payload))
|
||||
}
|
||||
// One Write is important for XHTTP because the response writer flushes once per
|
||||
// Write. Two writes per UDP packet doubles flush/syscall pressure.
|
||||
frame := make([]byte, 0, 2+len(payload))
|
||||
frame = appendUint16(frame, uint16(len(payload)))
|
||||
frame = append(frame, payload...)
|
||||
_, err := w.Write(frame)
|
||||
return err
|
||||
}
|
||||
|
||||
func isVLESSXUDPMetadata(meta []byte) bool {
|
||||
if len(meta) < 4 {
|
||||
return false
|
||||
}
|
||||
cmd := meta[2]
|
||||
opt := meta[3]
|
||||
if cmd != 1 && cmd != 2 && cmd != 4 { // New, Keep, End/discard
|
||||
return false
|
||||
}
|
||||
return opt == 0 || opt == 1
|
||||
}
|
||||
|
||||
func readVLESSXUDPPacket(r io.Reader) ([]byte, error) {
|
||||
for {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n < 4 || n > 512 {
|
||||
return nil, fmt.Errorf("bad xudp metadata length: %d", n)
|
||||
}
|
||||
meta := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isVLESSXUDPMetadata(meta) {
|
||||
return nil, fmt.Errorf("bad xudp metadata command/option")
|
||||
}
|
||||
payload, err := readVLESSXUDPPayloadAfterMeta(r, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload != nil {
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readVLESSXUDPPayloadAfterMeta(r io.Reader, meta []byte) ([]byte, error) {
|
||||
if len(meta) < 4 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
if meta[2] == 4 { // discard/end marker
|
||||
return nil, nil
|
||||
}
|
||||
if meta[3] != 1 { // no payload attached
|
||||
return nil, nil
|
||||
}
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(lenBuf[:]))
|
||||
if n == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if n > nativeUDPMaxPacket {
|
||||
return nil, fmt.Errorf("xudp payload too large: %d", n)
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
_, err := io.ReadFull(r, pkt)
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
func writeVLESSXUDPPacket(w io.Writer, payload []byte) error {
|
||||
if len(payload) > nativeUDPMaxPacket {
|
||||
return fmt.Errorf("udp packet too large: %d", len(payload))
|
||||
}
|
||||
// Metadata length 4, mux session id 0, command Keep, option payload-present.
|
||||
// This is accepted by Xray's xudp.PacketReader for responses when the UDP
|
||||
// destination is already known from the request header.
|
||||
var header [8]byte
|
||||
binary.BigEndian.PutUint16(header[0:2], 4)
|
||||
header[2] = 0
|
||||
header[3] = 0
|
||||
header[4] = 2 // Keep
|
||||
header[5] = 1 // Opt: payload follows
|
||||
binary.BigEndian.PutUint16(header[6:8], uint16(len(payload)))
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// nativeVMessUDPTunnel maps one VMess body chunk to one UDP datagram. VMess AEAD
|
||||
// chunking already preserves packet boundaries, so no extra VLESS length prefix
|
||||
// is added inside the encrypted body.
|
||||
func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, up, down *rate.Limiter) {
|
||||
xrayMgr.recordNativeConnect(uuid, email)
|
||||
defer xrayMgr.recordNativeDisconnect(uuid, email)
|
||||
defer xrayRecover(fmt.Sprintf("native xray VMess UDP tunnel user=%s", email))
|
||||
|
||||
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true}
|
||||
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var closeOnce sync.Once
|
||||
closeAll := func() {
|
||||
closeOnce.Do(func() {
|
||||
_ = backend.Close()
|
||||
_ = client.Close()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
xrayGo("native xray VMess UDP uplink", func() {
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
for {
|
||||
pkt, err := client.ReadPacket()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VMess UDP client read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(pkt) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(up, len(pkt)); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(pkt)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
}
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VMess UDP backend write failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Add(1)
|
||||
xrayGo("native xray VMess UDP downlink", func() {
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
buf := make([]byte, nativeUDPBufferSize)
|
||||
for {
|
||||
_ = backend.SetReadDeadline(time.Now().Add(nativeUDPIdle))
|
||||
n, err := backend.Read(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return
|
||||
}
|
||||
if err != io.EOF {
|
||||
xrayLogf("native xray: VMess UDP backend read failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
return
|
||||
}
|
||||
if err := client.WritePacket(buf[:n]); err != nil {
|
||||
xrayLogf("native xray: VMess UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
upMeter.flush()
|
||||
downMeter.flush()
|
||||
closeAll()
|
||||
}
|
||||
|
||||
func waitNativeRate(lim *rate.Limiter, n int) error {
|
||||
if lim == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
return lim.WaitN(context.Background(), n)
|
||||
}
|
||||
+728
@@ -0,0 +1,728 @@
|
||||
package main
|
||||
|
||||
// Pure-Go VMess (AEAD) server, part of the in-process Xray emulator.
|
||||
//
|
||||
// This implements the modern "VMess AEAD" protocol (alterId = 0) exactly as
|
||||
// spoken by current Xray/v2ray clients: AEAD-authenticated request header,
|
||||
// AES-128-GCM / ChaCha20-Poly1305 chunked body with SHAKE-masked lengths,
|
||||
// optional global padding and authenticated length, and the AEAD response
|
||||
// header + body. Byte offsets, KDF labels and orderings follow the v2fly/xray
|
||||
// reference (proxy/vmess/{aead,encoding}). Legacy MD5-auth VMess (alterId > 0)
|
||||
// is intentionally not supported.
|
||||
//
|
||||
// TCP and UDP commands are served. Mux is intentionally not supported in the
|
||||
// native emulator yet.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// ---------- constants ----------
|
||||
|
||||
const vmessCmdKeyMagic = "c48619fe-8f02-49e0-b9e9-edf763e17e21"
|
||||
|
||||
const (
|
||||
kdfSaltVMessAEADKDF = "VMess AEAD KDF"
|
||||
|
||||
kdfLabelAuthIDEncryptionKey = "AES Auth ID Encryption"
|
||||
|
||||
kdfLabelReqHeaderLenKey = "VMess Header AEAD Key_Length"
|
||||
kdfLabelReqHeaderLenIV = "VMess Header AEAD Nonce_Length"
|
||||
kdfLabelReqHeaderKey = "VMess Header AEAD Key"
|
||||
kdfLabelReqHeaderIV = "VMess Header AEAD Nonce"
|
||||
|
||||
kdfLabelRespHeaderLenKey = "AEAD Resp Header Len Key"
|
||||
kdfLabelRespHeaderLenIV = "AEAD Resp Header Len IV"
|
||||
kdfLabelRespHeaderKey = "AEAD Resp Header Key"
|
||||
kdfLabelRespHeaderIV = "AEAD Resp Header IV"
|
||||
|
||||
kdfLabelAuthLen = "auth_len"
|
||||
)
|
||||
|
||||
// VMess request option flags (header byte 34).
|
||||
const (
|
||||
vmessOptChunkStream = 0x01
|
||||
vmessOptChunkMasking = 0x04
|
||||
vmessOptGlobalPadding = 0x08
|
||||
vmessOptAuthenticatedLength = 0x10
|
||||
)
|
||||
|
||||
// VMess security types (low nibble of header byte 35).
|
||||
const (
|
||||
vmessSecAES128GCM = 3
|
||||
vmessSecChaCha20Poly1305 = 4
|
||||
vmessSecNone = 5
|
||||
)
|
||||
|
||||
// VMess commands (header byte 37).
|
||||
const (
|
||||
vmessCmdTCP = 1
|
||||
vmessCmdUDP = 2
|
||||
vmessCmdMux = 3
|
||||
)
|
||||
|
||||
const vmessTimeWindowSeconds = 120
|
||||
|
||||
// ---------- KDF ("VMess AEAD KDF", nested HMAC-SHA256) ----------
|
||||
|
||||
type hmacCreator struct {
|
||||
parent *hmacCreator
|
||||
value []byte
|
||||
}
|
||||
|
||||
func newHMAC(f func() hash.Hash, key []byte) hash.Hash {
|
||||
return hmac.New(f, key)
|
||||
}
|
||||
|
||||
func (h *hmacCreator) create() hash.Hash {
|
||||
if h.parent == nil {
|
||||
return newHMAC(sha256.New, h.value)
|
||||
}
|
||||
return newHMAC(h.parent.create, h.value)
|
||||
}
|
||||
|
||||
func vmessKDF(key []byte, path ...string) []byte {
|
||||
c := &hmacCreator{value: []byte(kdfSaltVMessAEADKDF)}
|
||||
for _, p := range path {
|
||||
c = &hmacCreator{value: []byte(p), parent: c}
|
||||
}
|
||||
h := c.create()
|
||||
h.Write(key)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func vmessKDF16(key []byte, path ...string) []byte {
|
||||
return vmessKDF(key, path...)[:16]
|
||||
}
|
||||
|
||||
// ---------- command key / crypto helpers ----------
|
||||
|
||||
func vmessCmdKey(uuid [16]byte) [16]byte {
|
||||
h := md5.New()
|
||||
h.Write(uuid[:])
|
||||
h.Write([]byte(vmessCmdKeyMagic))
|
||||
var out [16]byte
|
||||
copy(out[:], h.Sum(nil))
|
||||
return out
|
||||
}
|
||||
|
||||
func newAESGCM(key []byte) cipher.AEAD {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic(err) // only happens on wrong key length — a programmer error
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return gcm
|
||||
}
|
||||
|
||||
// vmessChaChaKey expands a 16-byte key into the 32-byte ChaCha20 key VMess uses:
|
||||
// MD5(key) || MD5(MD5(key)).
|
||||
func vmessChaChaKey(key []byte) []byte {
|
||||
h1 := md5.Sum(key)
|
||||
h2 := md5.Sum(h1[:])
|
||||
out := make([]byte, 32)
|
||||
copy(out[0:16], h1[:])
|
||||
copy(out[16:32], h2[:])
|
||||
return out
|
||||
}
|
||||
|
||||
func absInt64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func shakeNext(s sha3.ShakeHash) uint16 {
|
||||
var b [2]byte
|
||||
_, _ = s.Read(b[:])
|
||||
return binary.BigEndian.Uint16(b[:])
|
||||
}
|
||||
|
||||
// ---------- auth ID matching ----------
|
||||
|
||||
// matchVMess tries every VMess client's auth-ID cipher against the 16-byte
|
||||
// auth ID, returning the client whose key decrypts to a CRC-valid, in-window
|
||||
// timestamp. This is O(clients) AES blocks per connection.
|
||||
func (ib *nativeInbound) matchVMess(authid [16]byte, now int64) *nativeXrayClient {
|
||||
ib.clientMu.RLock()
|
||||
defer ib.clientMu.RUnlock()
|
||||
for _, c := range ib.clientsByID {
|
||||
if c.authIDCipher == nil {
|
||||
continue
|
||||
}
|
||||
var dec [16]byte
|
||||
c.authIDCipher.Decrypt(dec[:], authid[:])
|
||||
if crc32.ChecksumIEEE(dec[0:12]) != binary.BigEndian.Uint32(dec[12:16]) {
|
||||
continue
|
||||
}
|
||||
t := int64(binary.BigEndian.Uint64(dec[0:8]))
|
||||
if t < 0 || absInt64(t-now) > vmessTimeWindowSeconds {
|
||||
continue
|
||||
}
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- request header ----------
|
||||
|
||||
type vmessRequest struct {
|
||||
bodyIV [16]byte
|
||||
bodyKey [16]byte
|
||||
respV byte
|
||||
option byte
|
||||
security byte
|
||||
command byte
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
|
||||
// openVMessHeader reads and decrypts the AEAD request header from r, given the
|
||||
// user's command key and the already-read 16-byte auth ID. r must be positioned
|
||||
// immediately after the auth ID.
|
||||
func openVMessHeader(cmdKey [16]byte, authid [16]byte, r io.Reader) ([]byte, error) {
|
||||
var lenBlock [18]byte // 2-byte length + 16-byte tag
|
||||
if _, err := io.ReadFull(r, lenBlock[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var connNonce [8]byte
|
||||
if _, err := io.ReadFull(r, connNonce[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aid := string(authid[:])
|
||||
cn := string(connNonce[:])
|
||||
|
||||
lenGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderLenKey, aid, cn))
|
||||
lenNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderLenIV, aid, cn)[:12]
|
||||
lenPlain, err := lenGCM.Open(nil, lenNonce, lenBlock[:], authid[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: header length decrypt: %w", err)
|
||||
}
|
||||
headerLen := int(binary.BigEndian.Uint16(lenPlain))
|
||||
if headerLen < 38 || headerLen > 512 {
|
||||
return nil, fmt.Errorf("vmess: implausible header length %d", headerLen)
|
||||
}
|
||||
|
||||
payload := make([]byte, headerLen+16)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payGCM := newAESGCM(vmessKDF16(cmdKey[:], kdfLabelReqHeaderKey, aid, cn))
|
||||
payNonce := vmessKDF(cmdKey[:], kdfLabelReqHeaderIV, aid, cn)[:12]
|
||||
header, err := payGCM.Open(nil, payNonce, payload, authid[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: header payload decrypt: %w", err)
|
||||
}
|
||||
return header, nil
|
||||
}
|
||||
|
||||
// parseVMessHeader parses the decrypted request header plaintext.
|
||||
func parseVMessHeader(h []byte) (vmessRequest, error) {
|
||||
var req vmessRequest
|
||||
if len(h) < 40 {
|
||||
return req, errors.New("vmess: header too short")
|
||||
}
|
||||
if h[0] != 1 {
|
||||
return req, fmt.Errorf("vmess: unsupported version %d", h[0])
|
||||
}
|
||||
copy(req.bodyIV[:], h[1:17])
|
||||
copy(req.bodyKey[:], h[17:33])
|
||||
req.respV = h[33]
|
||||
req.option = h[34]
|
||||
req.security = h[35] & 0x0f
|
||||
paddingLen := int(h[35] >> 4)
|
||||
req.command = h[37]
|
||||
req.port = binary.BigEndian.Uint16(h[38:40])
|
||||
|
||||
host, next, err := parseVMessAddress(h, 40)
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.host = host
|
||||
|
||||
if next+paddingLen+4 != len(h) {
|
||||
return req, fmt.Errorf("vmess: header length mismatch (addr end %d + pad %d + 4 != %d)", next, paddingLen, len(h))
|
||||
}
|
||||
|
||||
f := fnv.New32a()
|
||||
f.Write(h[:len(h)-4])
|
||||
if binary.BigEndian.Uint32(h[len(h)-4:]) != f.Sum32() {
|
||||
return req, errors.New("vmess: header checksum mismatch")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func parseVMessAddress(h []byte, off int) (host string, next int, err error) {
|
||||
if off >= len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
atyp := h[off]
|
||||
off++
|
||||
switch atyp {
|
||||
case atypIPv4:
|
||||
if off+4 > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = net.IP(h[off : off+4]).String()
|
||||
off += 4
|
||||
case atypIPv6:
|
||||
if off+16 > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = net.IP(h[off : off+16]).String()
|
||||
off += 16
|
||||
case atypDomain:
|
||||
if off >= len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
l := int(h[off])
|
||||
off++
|
||||
if off+l > len(h) {
|
||||
return "", 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
host = string(h[off : off+l])
|
||||
off += l
|
||||
default:
|
||||
return "", 0, fmt.Errorf("vmess: unknown address type %d", atyp)
|
||||
}
|
||||
return host, off, nil
|
||||
}
|
||||
|
||||
// ---------- response header ----------
|
||||
|
||||
func writeVMessResponseHeader(w io.Writer, respBodyKey, respBodyIV [16]byte, respV byte) error {
|
||||
header := []byte{respV, 0, 0, 0} // V echo, option 0, command 0, command-data-length 0
|
||||
|
||||
lenGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderLenKey))
|
||||
lenNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderLenIV)[:12]
|
||||
var lenPlain [2]byte
|
||||
binary.BigEndian.PutUint16(lenPlain[:], uint16(len(header)))
|
||||
lenSealed := lenGCM.Seal(nil, lenNonce, lenPlain[:], nil) // AAD nil
|
||||
|
||||
payGCM := newAESGCM(vmessKDF16(respBodyKey[:], kdfLabelRespHeaderKey))
|
||||
payNonce := vmessKDF(respBodyIV[:], kdfLabelRespHeaderIV)[:12]
|
||||
paySealed := payGCM.Seal(nil, payNonce, header, nil) // AAD nil
|
||||
|
||||
out := make([]byte, 0, len(lenSealed)+len(paySealed))
|
||||
out = append(out, lenSealed...)
|
||||
out = append(out, paySealed...)
|
||||
_, err := w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- authenticated-length size parser ----------
|
||||
|
||||
// vmessAuthLen encodes/decodes the 2-byte chunk length as an AEAD-sealed field
|
||||
// (option AuthenticatedLength). It always derives its key from the *request*
|
||||
// body key/IV, in both directions, per the reference.
|
||||
type vmessAuthLen struct {
|
||||
aead cipher.AEAD
|
||||
count uint16
|
||||
ivTail [10]byte
|
||||
nonce [12]byte
|
||||
}
|
||||
|
||||
func newVMessAuthLen(reqBodyKey, reqBodyIV [16]byte, chacha bool) *vmessAuthLen {
|
||||
keyMat := vmessKDF16(reqBodyKey[:], kdfLabelAuthLen)
|
||||
var aead cipher.AEAD
|
||||
if chacha {
|
||||
a, _ := chacha20poly1305.New(vmessChaChaKey(keyMat))
|
||||
aead = a
|
||||
} else {
|
||||
aead = newAESGCM(keyMat)
|
||||
}
|
||||
al := &vmessAuthLen{aead: aead}
|
||||
copy(al.ivTail[:], reqBodyIV[2:12])
|
||||
return al
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(al.nonce[0:2], al.count)
|
||||
copy(al.nonce[2:12], al.ivTail[:])
|
||||
al.count++
|
||||
return al.nonce[:12]
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) decode(r io.Reader) (int, error) {
|
||||
var buf [18]byte
|
||||
if _, err := io.ReadFull(r, buf[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
plain, err := al.aead.Open(nil, al.nextNonce(), buf[:], nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("vmess: auth-len decrypt: %w", err)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(plain)) + 16, nil
|
||||
}
|
||||
|
||||
func (al *vmessAuthLen) encode(out *bytes.Buffer, size int) {
|
||||
var lenPlain [2]byte
|
||||
binary.BigEndian.PutUint16(lenPlain[:], uint16(size-16))
|
||||
out.Write(al.aead.Seal(nil, al.nextNonce(), lenPlain[:], nil))
|
||||
}
|
||||
|
||||
// ---------- body chunk reader/writer ----------
|
||||
|
||||
const vmessMaxChunk = 64*1024 + 64
|
||||
|
||||
type vmessChunkReader struct {
|
||||
r io.Reader
|
||||
aead cipher.AEAD // nil for security "none"
|
||||
overhead int
|
||||
ivTail [10]byte
|
||||
count uint16
|
||||
nonce [12]byte
|
||||
shake sha3.ShakeHash // non-nil when chunk masking is enabled
|
||||
authLen *vmessAuthLen // non-nil when authenticated length is enabled
|
||||
globalPad bool
|
||||
leftover []byte
|
||||
eof bool
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(cr.nonce[0:2], cr.count)
|
||||
copy(cr.nonce[2:12], cr.ivTail[:])
|
||||
cr.count++
|
||||
return cr.nonce[:12]
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) readChunk() ([]byte, error) {
|
||||
// Padding length is always drawn from the SHAKE stream before the size.
|
||||
pad := 0
|
||||
if cr.shake != nil && cr.globalPad {
|
||||
pad = int(shakeNext(cr.shake) % 64)
|
||||
}
|
||||
|
||||
var size int
|
||||
switch {
|
||||
case cr.authLen != nil:
|
||||
s, err := cr.authLen.decode(cr.r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = s
|
||||
case cr.shake != nil:
|
||||
var b [2]byte
|
||||
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = int(shakeNext(cr.shake) ^ binary.BigEndian.Uint16(b[:]))
|
||||
default:
|
||||
var b [2]byte
|
||||
if _, err := io.ReadFull(cr.r, b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size = int(binary.BigEndian.Uint16(b[:]))
|
||||
}
|
||||
|
||||
// size == overhead + pad means an empty (terminating) chunk.
|
||||
if size == cr.overhead+pad {
|
||||
return nil, io.EOF
|
||||
}
|
||||
if size < cr.overhead+pad || size > vmessMaxChunk {
|
||||
return nil, fmt.Errorf("vmess: bad chunk size %d", size)
|
||||
}
|
||||
|
||||
data := make([]byte, size)
|
||||
if _, err := io.ReadFull(cr.r, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sealed := data[:size-pad] // trailing pad bytes are clear-text, discarded
|
||||
if cr.aead == nil {
|
||||
return sealed, nil
|
||||
}
|
||||
plain, err := cr.aead.Open(nil, cr.nextNonce(), sealed, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess: body decrypt: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func (cr *vmessChunkReader) Read(p []byte) (int, error) {
|
||||
for len(cr.leftover) == 0 {
|
||||
if cr.eof {
|
||||
return 0, io.EOF
|
||||
}
|
||||
chunk, err := cr.readChunk()
|
||||
if err == io.EOF {
|
||||
cr.eof = true
|
||||
return 0, io.EOF
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cr.leftover = chunk
|
||||
}
|
||||
n := copy(p, cr.leftover)
|
||||
cr.leftover = cr.leftover[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type vmessChunkWriter struct {
|
||||
w io.Writer
|
||||
aead cipher.AEAD
|
||||
overhead int
|
||||
ivTail [10]byte
|
||||
count uint16
|
||||
nonce [12]byte
|
||||
shake sha3.ShakeHash
|
||||
authLen *vmessAuthLen
|
||||
globalPad bool
|
||||
}
|
||||
|
||||
func (cw *vmessChunkWriter) nextNonce() []byte {
|
||||
binary.BigEndian.PutUint16(cw.nonce[0:2], cw.count)
|
||||
copy(cw.nonce[2:12], cw.ivTail[:])
|
||||
cw.count++
|
||||
return cw.nonce[:12]
|
||||
}
|
||||
|
||||
func (cw *vmessChunkWriter) writeChunk(p []byte) error {
|
||||
var out bytes.Buffer
|
||||
|
||||
pad := 0
|
||||
if cw.shake != nil && cw.globalPad {
|
||||
pad = int(shakeNext(cw.shake) % 64)
|
||||
}
|
||||
size := len(p) + cw.overhead + pad
|
||||
|
||||
switch {
|
||||
case cw.authLen != nil:
|
||||
cw.authLen.encode(&out, size)
|
||||
case cw.shake != nil:
|
||||
var b [2]byte
|
||||
binary.BigEndian.PutUint16(b[:], shakeNext(cw.shake)^uint16(size))
|
||||
out.Write(b[:])
|
||||
default:
|
||||
var b [2]byte
|
||||
binary.BigEndian.PutUint16(b[:], uint16(size))
|
||||
out.Write(b[:])
|
||||
}
|
||||
|
||||
if cw.aead != nil {
|
||||
out.Write(cw.aead.Seal(nil, cw.nextNonce(), p, nil))
|
||||
} else {
|
||||
out.Write(p)
|
||||
}
|
||||
if pad > 0 {
|
||||
padBytes := make([]byte, pad)
|
||||
_, _ = rand.Read(padBytes)
|
||||
out.Write(padBytes)
|
||||
}
|
||||
_, err := cw.w.Write(out.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- vmessConn: net.Conn view of a decoded VMess session ----------
|
||||
|
||||
type nativeVMessStream interface {
|
||||
net.Conn
|
||||
ReadPacket() ([]byte, error)
|
||||
WritePacket([]byte) error
|
||||
}
|
||||
|
||||
type vmessConn struct {
|
||||
net.Conn
|
||||
reader *vmessChunkReader
|
||||
writer *vmessChunkWriter
|
||||
terminateOnClose bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (c *vmessConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
|
||||
|
||||
// ReadPacket returns exactly one decrypted VMess body chunk. UDP-over-VMess uses
|
||||
// one VMess chunk per UDP datagram, so packet handling must bypass the streamy
|
||||
// Read method that can merge/split chunks.
|
||||
func (c *vmessConn) ReadPacket() ([]byte, error) { return c.reader.readChunk() }
|
||||
|
||||
func (c *vmessConn) WritePacket(p []byte) error { return c.writer.writeChunk(p) }
|
||||
|
||||
func (c *vmessConn) Write(p []byte) (int, error) {
|
||||
// Bound each chunk well under the uint16 length field.
|
||||
const maxChunk = 16 * 1024
|
||||
total := 0
|
||||
for len(p) > 0 {
|
||||
n := len(p)
|
||||
if n > maxChunk {
|
||||
n = maxChunk
|
||||
}
|
||||
if err := c.writer.writeChunk(p[:n]); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += n
|
||||
p = p[n:]
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (c *vmessConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
if c.terminateOnClose {
|
||||
_ = c.writer.writeChunk(nil) // terminating empty chunk
|
||||
}
|
||||
})
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// vmessRawConn is used for VMess security=none when the client did not request
|
||||
// ChunkStream. Xray's own server returns a raw reader/writer in that exact case;
|
||||
// treating the following TLS ClientHello/HTTP bytes as a VMess chunk length makes
|
||||
// real clients authenticate but then pass no data.
|
||||
type vmessRawConn struct {
|
||||
net.Conn
|
||||
}
|
||||
|
||||
func (c *vmessRawConn) ReadPacket() ([]byte, error) {
|
||||
buf := make([]byte, 64*1024)
|
||||
n, err := c.Conn.Read(buf)
|
||||
if n > 0 {
|
||||
return buf[:n], nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (c *vmessRawConn) WritePacket(p []byte) error {
|
||||
_, err := c.Conn.Write(p)
|
||||
return err
|
||||
}
|
||||
|
||||
func newVMessConn(stream net.Conn, req vmessRequest, respBodyKey, respBodyIV [16]byte) (nativeVMessStream, error) {
|
||||
chunkMask := req.option&vmessOptChunkMasking != 0
|
||||
globalPad := req.option&vmessOptGlobalPadding != 0
|
||||
authLen := req.option&vmessOptAuthenticatedLength != 0
|
||||
chacha := req.security == vmessSecChaCha20Poly1305
|
||||
|
||||
if req.security == vmessSecNone && req.option&vmessOptChunkStream == 0 {
|
||||
return &vmessRawConn{Conn: stream}, nil
|
||||
}
|
||||
|
||||
var readAEAD, writeAEAD cipher.AEAD
|
||||
overhead := 16
|
||||
switch req.security {
|
||||
case vmessSecAES128GCM:
|
||||
readAEAD = newAESGCM(req.bodyKey[:])
|
||||
writeAEAD = newAESGCM(respBodyKey[:])
|
||||
case vmessSecChaCha20Poly1305:
|
||||
ra, _ := chacha20poly1305.New(vmessChaChaKey(req.bodyKey[:]))
|
||||
wa, _ := chacha20poly1305.New(vmessChaChaKey(respBodyKey[:]))
|
||||
readAEAD, writeAEAD = ra, wa
|
||||
case vmessSecNone:
|
||||
overhead = 0
|
||||
default:
|
||||
return nil, fmt.Errorf("vmess: unsupported security %d", req.security)
|
||||
}
|
||||
|
||||
cr := &vmessChunkReader{r: stream, aead: readAEAD, overhead: overhead, globalPad: globalPad}
|
||||
copy(cr.ivTail[:], req.bodyIV[2:12])
|
||||
cw := &vmessChunkWriter{w: stream, aead: writeAEAD, overhead: overhead, globalPad: globalPad}
|
||||
copy(cw.ivTail[:], respBodyIV[2:12])
|
||||
|
||||
if chunkMask {
|
||||
rs := sha3.NewShake128()
|
||||
rs.Write(req.bodyIV[:])
|
||||
cr.shake = rs
|
||||
ws := sha3.NewShake128()
|
||||
ws.Write(respBodyIV[:])
|
||||
cw.shake = ws
|
||||
}
|
||||
if authLen {
|
||||
cr.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
|
||||
cw.authLen = newVMessAuthLen(req.bodyKey, req.bodyIV, chacha)
|
||||
}
|
||||
|
||||
return &vmessConn{Conn: stream, reader: cr, writer: cw, terminateOnClose: req.option&vmessOptChunkStream != 0 || req.security != vmessSecNone}, nil
|
||||
}
|
||||
|
||||
// ---------- handler ----------
|
||||
|
||||
func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
|
||||
_ = stream.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
var authid [16]byte
|
||||
if _, err := io.ReadFull(stream, authid[:]); err != nil {
|
||||
return
|
||||
}
|
||||
client := ib.matchVMess(authid, time.Now().Unix())
|
||||
if client == nil {
|
||||
log.Printf("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote)
|
||||
return
|
||||
}
|
||||
|
||||
header, err := openVMessHeader(client.cmdKey, authid, stream)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess header open failed from %s: %v", ib.tag, remote, err)
|
||||
return
|
||||
}
|
||||
req, err := parseVMessHeader(header)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess header parse failed from %s: %v", ib.tag, remote, err)
|
||||
return
|
||||
}
|
||||
_ = stream.SetReadDeadline(time.Time{})
|
||||
|
||||
if req.command != vmessCmdTCP && req.command != vmessCmdUDP {
|
||||
log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command)
|
||||
return
|
||||
}
|
||||
|
||||
respBodyKey := sha256.Sum256(req.bodyKey[:])
|
||||
respBodyIV := sha256.Sum256(req.bodyIV[:])
|
||||
var rk, riv [16]byte
|
||||
copy(rk[:], respBodyKey[:16])
|
||||
copy(riv[:], respBodyIV[:16])
|
||||
|
||||
if err := writeVMessResponseHeader(stream, rk, riv, req.respV); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
vc, err := newVMessConn(stream, req, rk, riv)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess codec: %v", ib.tag, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.command {
|
||||
case vmessCmdTCP:
|
||||
backend, target, err := ib.nativeDialTCP(req.host, req.port)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess TCP dial %s failed: %v", ib.tag, target, err)
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
case vmessCmdUDP:
|
||||
backend, target, err := ib.nativeDialUDP(req.host, req.port)
|
||||
if err != nil {
|
||||
log.Printf("native xray: inbound %q VMess UDP dial %s failed: %v", ib.tag, target, err)
|
||||
return
|
||||
}
|
||||
log.Printf("native xray: vmess/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
|
||||
nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter())
|
||||
}
|
||||
}
|
||||
+1167
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user