Compare commits

..
60 Commits
Author SHA1 Message Date
penguinehis a345e70e5a Beta 1 2026-07-13 18:01:39 -03:00
penguinehis dab8b09f0c Fix panel 2026-07-13 02:24:33 -03:00
penguinehis 635f190630 Panel update 2026-07-13 02:21:56 -03:00
penguinehis 64b1fc5cb3 Fix admin panel 2026-07-13 02:00:27 -03:00
penguinehis 047e4be207 XHTTP Panel Fix 2026-07-13 01:34:28 -03:00
penguinehis 7d90568869 Xhttp and panel update 2026-07-13 01:20:57 -03:00
penguinehis 92c5c2ace6 security fix 2026-07-13 00:57:35 -03:00
penguinehis 9001b47204 security fix 2026-07-13 00:57:28 -03:00
penguinehis ba5b581aaf Fix admin bot 2026-07-13 00:23:16 -03:00
penguinehis 2776eba034 Bot TG 2026-07-13 00:17:55 -03:00
penguinehis 09c1f57a34 bot TG 2026-07-13 00:06:51 -03:00
penguinehis a4798f8db6 fix go mod 2026-07-11 02:12:01 -03:00
penguinehis cc72b92932 Fix Update 2026-07-11 02:07:00 -03:00
penguinehis aea27916e8 Check Update 2026-07-11 02:04:36 -03:00
penguinehis 6f7fa2fad1 Fix idle 2026-07-11 01:51:21 -03:00
penguinehis 4e3c99650e XHTTP SSH 2026-07-10 23:58:27 -03:00
penguinehis cf49340b9a Fix memory leaks 2026-07-10 12:07:13 -03:00
penguinehis 0b80a69192 Fix memory leak 2026-07-10 11:52:08 -03:00
penguinehis 1e9cef9e02 New Documentation 2026-07-09 16:33:29 -03:00
penguinehis 4a0383dce1 Optimization 2026-07-09 15:58:22 -03:00
penguinehisandClaude Opus 4.8 d4046526c9 Mux: refresh UDP idle deadline on uplink; strip added comments
Mux/XUDP cross-check vs xray-core: the wire format (frame layout, status/option
constants, address serialization, GlobalID placement, Keep response framing) is
byte-faithful. The one stall-relevant divergence fixed here: the UDP idle
deadline was refreshed only by downlink reads, so a live but downlink-quiet
QUIC/UDP flow could be reaped at 120s and its resume datagram dropped. Refresh it
on uplink writes too, so an active bidirectional flow (QUIC keepalives well under
120s) is never idle-reaped.

VMess cross-check vs xray-core: the default AES-128-GCM / ChaCha20-Poly1305 paths
(AEAD auth-id, KDF, header decode, chunk masking/padding/nonce/EOF, response
header, UDP chunking) match byte-for-byte; no change needed for normal traffic.

Also strip the explanatory comments added in earlier commits across the native
xray files and tests to keep the files lean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:59:14 -03:00
penguinehisandClaude Opus 4.8 e779d2486a Strip added commentary from xray_xhttp.go and tuning
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:48:37 -03:00
penguinehisandClaude Opus 4.8 8f088f7cca Match xray-core: keep connected XHTTP sessions alive; WebSocket early data
Two cross-checks against xray-core found separate CRITICAL divergences that stall
real traffic:

XHTTP: the native session reaper had a 5-minute idle timeout that xray-core does
not have. In the default stream-up/stream-down (auto/H2) mode a long download
rides inside the already-open GET/POST and generates no new HTTP requests, so the
idle timer fired and tore the tunnel down mid-transfer (YouTube/large downloads
stalling after a few minutes). Now mirror hub.go: give the downlink GET 30s to
attach, and once connected stop reaping entirely -- the session lives for the
life of the GET, cleaned up by handleXHTTPDownload's deferred delete.

WebSocket: the server handshake ignored Sec-WebSocket-Protocol, silently dropping
0-RTT early data. Clients configured with ?ed=N put the VLESS/VMess request
header there and send no first frame, so the server blocked forever waiting for a
header that never arrived -- every ed= WS client stalled. Now decode the
base64url early data, deliver it before the first frame, and echo the header back,
matching transport/internet/websocket. Also match only the path (ignore the ?ed=
query) when validating the WS path.

Add TestVLESSOverWebSocketEarlyData.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:43:24 -03:00
penguinehis 7200dbd236 Fix XHTTP 2026-07-04 23:59:03 -03:00
penguinehisandClaude Opus 4.8 f87023ebff Fix XHTTP regression: drop download-reject guard, widen reorder buffer
The single-download guard added earlier rejected a second stream-down GET for a
session with HTTP 400. On a client reconnect (network hiccup / H2 retry) while
the previous download handler is still blocked on a dead socket, that 400 makes
the client tear the whole session down and every retry keeps failing -- the
tunnel stops passing data entirely. xray-core never rejects a re-GET, so remove
the guard and match it.

Also raise the default XHTTP packet-up reorder buffer from 30 to 512. xray-core
uses 30 because its own client sends POSTs near-in-order, but other clients fan
out many concurrent POSTs that arrive well out of order; 30 tripped the
reassembly-too-large teardown and stalled traffic. The per-inbound
scMaxBufferedPosts still overrides this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:50:49 -03:00
penguinehisandClaude Opus 4.8 e77dc6f62f Pin native Xray transport params to xray-core defaults; keep only safe knobs
Remove the transport-shaping tuning footguns that could break data flow if
misconfigured, and hardcode them to xray-core / Go net/http2 defaults instead:

- HTTP/2: stop overriding MaxConcurrentStreams and the per-conn/per-stream upload
  buffers; use Go's defaults exactly like xray-core's splithttp hub does.
- XHTTP reorder buffer default is now 30 (xray-core scMaxBufferedPosts), still
  overridable per-inbound from the generated config.
- Mux per-connection session cap, mux UDP idle/read/write buffers, and the XHTTP
  session cap are now fixed constants rather than admin knobs.

The XrayNativeTuning struct and admin UI keep only the operator-safe controls:
Go GOMAXPROCS, the global mux-session DoS cap, and the packet-trace debug toggle.
Old config.json files with the removed keys still load (unknown fields ignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:38:38 -03:00
penguinehisandClaude Opus 4.8 4b9f6c123a Align native Xray with xray-core; drop dead knobs; split admin app.js
Fix the two reliability problems in the in-process Xray emulator by matching
XTLS/Xray-core's transport semantics:

- XHTTP upload queue: rewrite as a faithful port of xray-core's uploadQueue
  (bounded channel + sequence reorder heap). Packet-up POSTs are now acked
  immediately on buffering instead of blocking until the tunnel reader consumes
  them. The old block-until-consumed behavior throttled the uplink to the
  reassembly rate and deadlocked against the client's concurrent-POST limit,
  which showed up as "download a burst, stall, repeat" on video/large downloads.
- Mux: dial the backend and pump uplink on a per-session goroutine fed by a
  bounded channel (mirrors xray-core's per-session buffered pipe). Previously the
  dial and backend writes ran inline in the shared read loop, so one slow target
  or backpressured session stalled every other muxed session.
- XHTTP download writer: flush every write (matches httpServerConn.Write) instead
  of batching behind a 2ms/32KB window.
- XHTTP: enforce a single download (stream-down) per session to stop two GETs
  from splitting the decoded stream and corrupting the tunnel.
- Fix a close-of-closed-channel race in the mux session teardown (sync.Once).

Remove the now-inert XHTTP tuning knobs (xhttp_queue_timeout_ms, xhttp_flush_ms,
xhttp_flush_bytes) from the backend struct and the admin panel UI.

Split admin/assets/app.js into ordered classic-script modules under
admin/assets/js/ for maintainability. The concatenation is byte-identical to the
old file and load order is preserved via defer, so behavior is unchanged.

Add regression tests for the mux head-of-line stall and the out-of-order
packet-up burst-stall; add golang.org/x/text to go.mod so tests build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:27:11 -03:00
penguinehis aa676eb081 Fix xray native 2026-07-04 20:24:29 -03:00
penguinehis ea15f1bfa1 Fix xray 2026-07-04 20:24:20 -03:00
penguinehis 4866f0cf10 fix gomod 2026-07-04 18:43:15 -03:00
penguinehis 4f3b961fa3 Merge branch 'main' of https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB 2026-07-04 18:40:08 -03:00
penguinehis 0b5679bfeb Atualizar update.sh 2026-07-04 18:37:08 -03:00
penguinehis ffe7964330 Atualizar install.sh 2026-07-04 18:36:47 -03:00
penguinehis 0eaa48ffd0 Native Xray 2026-07-04 17:26:01 -03:00
penguinehis 6cd9626db9 optimization 2 2026-05-27 15:09:42 -03:00
penguinehis 1479e6ac73 Optimization 2026-05-27 15:04:01 -03:00
penguinehis f64f7fdc4d Install in any distro 2026-05-15 17:01:24 -03:00
penguinehis 15859dc7f3 Fix apt 2026-05-14 14:33:40 -03:00
penguinehis 60cb2e3cdb Fix xray config bug 2026-05-11 22:45:22 -03:00
penguinehis f1a587e00d Mult node launch 2026-05-11 22:10:17 -03:00
penguinehis 1ad8b868ab FIx mult panel server 2026-05-11 21:52:07 -03:00
penguinehis 67d56b2a76 Fix Mult server 2026-05-11 14:39:55 -03:00
penguinehis b66d194fa7 Mult server 2026-05-11 14:32:16 -03:00
penguinehis 391db7708f New panel 2026-05-10 18:42:38 -03:00
penguinehis 603ae906a1 Fix panel 2026-05-10 18:32:59 -03:00
penguinehis 4a04ff79f0 Fix panel 2026-05-10 18:21:03 -03:00
penguinehis e00a7bd93c Fix panel 2026-05-10 18:14:16 -03:00
penguinehis 77a722d4ed Fix Admin panel and xray count 2026-05-10 18:05:24 -03:00
penguinehis 03c43debf4 Panel Update 2026-05-10 17:52:36 -03:00
penguinehis 51aedfd3c7 Fix Mkdir crash service 2026-05-03 22:02:12 -03:00
penguinehis 3c7b02b8db Fix Daily usage 2026-05-03 21:54:48 -03:00
penguinehis 3ddd934d9a Ignore LO , dont re-enable the iptables redirect if disabled 2026-05-03 11:14:32 -03:00
penguinehis c74f6e2282 New Features and safe log 2026-05-03 11:05:13 -03:00
penguinehis 43482c88fa Fix stuck users 2026-05-03 10:15:28 -03:00
penguinehis 09f3959aa2 Simple update 2026-05-02 23:40:09 -03:00
penguinehis 9b5f436a6e Fix udp description 2026-05-02 23:34:30 -03:00
penguinehis d01fb919aa Safe Update 2026-05-02 23:20:13 -03:00
penguinehis 41aca3b7f3 Update Vmess Doc 2026-05-02 18:48:26 -03:00
penguinehis c1bb3c7a97 Merge branch 'main' of https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB 2026-05-02 18:47:14 -03:00
penguinehis f8fac513e3 Update Vmess 2026-05-02 18:46:46 -03:00
74 changed files with 29532 additions and 2650 deletions
+3 -27
View File
@@ -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
+1000 -18
View File
File diff suppressed because it is too large Load Diff
+340
View File
@@ -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)
}
+110
View File
@@ -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)
}
+57
View File
@@ -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)
}
}
+753
View File
@@ -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%;}}
+737
View File
@@ -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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;",
}[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;
}
+458
View File
@@ -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();
}
+233
View File
@@ -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");
}
}
+484
View File
@@ -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(); }
}
+297
View File
@@ -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>`;
}
}
+706
View File
@@ -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;
}
}
+313
View File
@@ -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;
}
}
+448
View File
@@ -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;
}
}
+959
View File
@@ -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}) };
}
}
+27
View File
@@ -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");
}
});
+119
View File
@@ -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);
}
});
+629
View File
@@ -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);
+1025 -1599
View File
File diff suppressed because it is too large Load Diff
+332 -209
View File
@@ -4,22 +4,34 @@ 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"
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 {
@@ -28,6 +40,11 @@ type AdminUser struct {
PasswordHash string
Role string
MaxUsers int
ParentUsername string
QuotaMode string
CreditBalance int
WhatsApp string
MonthlyPriceCents int
ExpiresAt *time.Time
IsActive bool
CreatedAt time.Time
@@ -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 != 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
}
if expiresAt.Valid {
u.ExpiresAt = &expiresAt.Time
}
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)
// 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)
}
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
}
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 {
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
resp["used_users"] = countOwnedUsers(s.Username)
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["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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &lt;telegram_id&gt; &lt;quantidade&gt;", 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
}
+242
View File
@@ -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
View File
@@ -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")}))
}
+272
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
+224
View File
@@ -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
View File
@@ -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()
+316
View File
@@ -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()
}
+26
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
+824 -88
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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
+12 -9
View File
@@ -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
)
+25 -8
View File
@@ -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=
+206 -22
View File
@@ -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()
} else {
_ = xrayMgr.Stop()
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
View File
@@ -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
+37
View File
@@ -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[:]) }
+22
View File
@@ -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" }
+165
View File
@@ -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 }
+198
View File
@@ -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)
}
}
+957 -152
View File
File diff suppressed because it is too large Load Diff
+865
View File
@@ -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
}
+105
View File
@@ -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})
}
+176
View File
@@ -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
+107
View File
@@ -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")
}
}
+265
View File
@@ -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
}
+90
View File
@@ -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")
}
}
+77
View File
@@ -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
View File
@@ -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)
}
+113
View File
@@ -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
View File
@@ -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")
+213 -18
View File
@@ -20,24 +20,96 @@ import (
"io"
"log"
"net"
"strings"
"sync"
"time"
)
// 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
)
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, causing the accept loop to exit.
// It is a no-op if UDPGW is not running.
// 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 nonnil 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.
+618 -145
View File
@@ -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"
}
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 updater 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_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
}
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
}
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
}
echo -e "\n${GREEN}==========================================${NC}"
echo -e "${GREEN} DragonCoreSSH / SSH Panel Updater ${NC}"
echo -e "${GREEN}==========================================${NC}\n"
# Helpers
need_cmd() {
command -v "$1" >/dev/null 2>&1 || error "Required command not found: $1"
}
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
}
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
# ── 3. Build new binary ───────────────────────────────────────────────────────
info "[3/5] Building new sshpanel binary…"
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."
# ── 4. Apply update ───────────────────────────────────────────────────────────
info "[4/5] Applying update…"
# 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
# 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
# Replace binary
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
chmod +x "$INSTALL_DIR/sshpanel"
info " Binary updated."
# Update admin panel files
mkdir -p "$INSTALL_DIR/admin"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel updated."
# 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
# Ensure certs directory exists (new in this version)
mkdir -p "$INSTALL_DIR/certs"
# 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'
import json, sys
path = sys.argv[1]
with open(path) as f:
d = json.load(f)
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)
PYEOF
info " Added banner_file to config.json"
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'
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:
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'
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 " config.json checked."
fi
if [[ -f "$xcfg" ]] && grep -q '"geoip:private"' "$xcfg" 2>/dev/null; then
python3 - "$xcfg" <<'PYEOF'
import json, sys
path = sys.argv[1]
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
}
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 " 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 ""
+259
View File
@@ -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")
}
+73
View File
@@ -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
View File
@@ -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
}
+329 -30
View File
@@ -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
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 (
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()
)`)
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,
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, expiresAt, m.MaxConns)
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 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
}
if expiresAt.Valid {
m.ExpiresAt = &expiresAt.Time
}
out = append(out, m)
}
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 == "" {
if m.OwnerUsername == "" {
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
}
continue
}
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 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()
}
}
}()
+144
View File
@@ -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
}
+2229 -76
View File
File diff suppressed because it is too large Load Diff
+1417
View File
File diff suppressed because it is too large Load Diff
+971
View File
@@ -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
}
+20
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -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
}
+392
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff