diff --git a/README.md b/README.md index c7e6571..ff49ce4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine - Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual - Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel - Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis +- Velocidade ao vivo (subida/descida) por conta nas listas SSH e Xray, somando todas as conexões do usuário, com ordenação por velocidade +- Listas de usuários em formato de cartão no celular: cada linha vira um cartão com rótulos, sem rolagem lateral - Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray - Contas de revendedor (reseller) com cota de usuários e escopo próprio - Gerenciamento multi-servidor (master/slave) direto pelo painel @@ -557,6 +559,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post - Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS - Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher - Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations +- Live per-account up/down speed in the SSH and Xray user lists, summed across every connection the account has open, sortable by speed +- User lists collapse into labelled cards on phones, so there is no sideways scrolling - Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray - Reseller accounts with a user quota and self-scoped access - Multi-server (master/slave) management directly from the panel @@ -1145,7 +1149,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN" #### `GET /api/users` — session - Optional query: `server_id`. Resellers see only their own users; superadmins see all. -- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty). +- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `data_quota_bytes` (int64), `quota_action` (`block` or `throttle`), `quota_throttle_mbps` (int), `total_uplink_bytes`, `total_downlink_bytes`, `total_bytes`, `up_bytes_per_sec` (float, live account-wide upload speed), `down_bytes_per_sec` (float, live account-wide download speed), `quota_exceeded`, `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty). #### `POST /api/users/create` — session Creates or updates (upsert) an SSH user. @@ -1265,7 +1269,7 @@ Read/write a managed server's `config.json`. Query: `server_id`. Local delegates #### `GET /api/xray/inbounds` — session - Optional `server_id`. Lists only inbounds that carry client lists (vless/vmess/trojan). Resellers see all inbounds but only their own clients. Clients are enriched with DB metadata and runtime stats. - `200`: array of `{ "tag": string, "protocol": string, "port": , "listen": string, "clients": [ XrayClientInfo ] }`. -- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`. +- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `up_bytes_per_sec` (float, live client-wide upload speed), `down_bytes_per_sec` (float, live client-wide download speed), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `quota_exceeded`, `owner_username`, `expired`. #### `POST /api/xray/clients/add` — session - Body: `inbound_tag` (string, required), `uuid` (valid UUID, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (RFC3339, `YYYY-MM-DDThh:mm`, or `YYYY-MM-DD`), `max_connections` (0–10000), `data_quota_bytes`, `quota_action`, `quota_throttle_mbps`, `owner_username` (string, optional — superadmin only), `server_id` (string, optional). diff --git a/admin/assets/app.css b/admin/assets/app.css index c4e7d68..61fd270 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -761,3 +761,44 @@ th[data-sort-key].sort-desc::after{content:" \25BC";} .user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)} .user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap} @media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}} + +/* Live per-account speed (whole account, all connections summed) */ +.speed-cell{display:inline-flex;flex-direction:column;gap:1px;line-height:1.25;font-variant-numeric:tabular-nums;font-weight:850;white-space:nowrap;} +.speed-cell .speed-down{color:var(--accent-3);} +.speed-cell .speed-up{color:var(--accent);} + +/* Card tables: on phones the wide user lists stop scrolling sideways and each + row becomes a labelled card. Labels come from each cell's data-label. */ +@media(max-width:760px){ + .tbl-wrap:has(table.table-cards){overflow:visible;border:0;border-radius:0;background:transparent;} + table.table-cards{display:block;min-width:0;width:100%;font-size:.78rem;} + table.table-cards thead{display:none;} + table.table-cards tbody{display:flex;flex-direction:column;gap:10px;} + table.table-cards tr{ + display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px; + padding:13px 14px;border:1px solid rgba(148,163,184,.14);border-radius:18px; + background:rgba(3,6,10,.55); + } + table.table-cards tbody tr:hover{background:rgba(34,211,238,.05);} + table.table-cards td{ + display:flex;flex-direction:column;gap:3px;min-width:0; + padding:0;border:0;font-size:.78rem!important;overflow-wrap:anywhere; + } + table.table-cards td::before{ + content:attr(data-label);color:var(--muted);font-size:.6rem;font-weight:900; + letter-spacing:.1em;text-transform:uppercase; + } + table.table-cards td[colspan]{grid-column:1/-1;text-align:center;} + table.table-cards td:not([data-label])::before{display:none;} + table.table-cards td.cell-primary{grid-column:1/-1;font-size:.98rem!important;font-weight:900;color:var(--text);} + table.table-cards td.cell-primary::before{display:none;} + table.table-cards td.cell-wide{grid-column:1/-1;} + table.table-cards td.cell-actions{ + grid-column:1/-1;flex-direction:row;flex-wrap:wrap;gap:6px; + padding-top:4px;white-space:normal!important; + } + table.table-cards td.cell-actions::before{display:none;} + table.table-cards td.cell-actions .btn{flex:1 1 auto;margin:0!important;min-height:36px;} + table.table-cards .table-meter{max-width:none;} + table.table-cards .speed-cell{flex-direction:row;gap:12px;} +} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index 7b6ae38..bd172ca 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -711,6 +711,34 @@ function clientTrafficHTML(c) { return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}
↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}
`; } +// ─── Live bandwidth ─────────────────────────────────────────────────────────── +// The API reports the account's current speed in bytes per second, summed over +// every connection it has open. Speeds are shown in bits per second because +// that is the unit the per-user limits use. +function formatSpeed(bytesPerSec) { + const bits = Number(bytesPerSec || 0) * 8; + if (!Number.isFinite(bits) || bits < 1000) return "0"; + if (bits < 1e6) return `${Math.round(bits / 1e3)} kbps`; + if (bits < 1e9) return `${(bits / 1e6).toFixed(bits < 1e7 ? 2 : 1)} Mbps`; + return `${(bits / 1e9).toFixed(2)} Gbps`; +} + +function isIdleSpeed(upBytesPerSec, downBytesPerSec) { + return Number(upBytesPerSec || 0) * 8 < 1000 && Number(downBytesPerSec || 0) * 8 < 1000; +} + +function speedHTML(upBytesPerSec, downBytesPerSec) { + if (isIdleSpeed(upBytesPerSec, downBytesPerSec)) return `${t("idle")}`; + return `` + + `↓ ${escapeHTML(formatSpeed(downBytesPerSec))}` + + `↑ ${escapeHTML(formatSpeed(upBytesPerSec))}` + + ``; +} + +function speedTotalBytesPerSec(entry) { + return Number(entry?.up_bytes_per_sec || 0) + Number(entry?.down_bytes_per_sec || 0); +} + function updateCell(row, name, html) { const cell = row?.querySelector?.(`[data-cell="${name}"]`); if (cell && cell.innerHTML !== html) cell.innerHTML = html; @@ -747,6 +775,7 @@ function patchRenderedInbounds(inbounds) { updateCell(row, "status", clientStatusHTML(c)); updateCell(row, "online", clientOnlineHTML(c)); updateCell(row, "connections", escapeHTML(c.active_connections || 0)); + updateCell(row, "speed", speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec)); updateCell(row, "traffic", clientTrafficHTML(c)); updateCell(row, "max", escapeHTML(c.max_conns || "∞")); } @@ -782,3 +811,13 @@ Object.assign(I18N_TEXT["pt-BR"], { "Apply safe defaults":"Aplicar padrões seguros", "XHTTP is handled as VPN tunnel traffic: packet requests and reassembly are limited only by bounded byte backpressure, never by a request count. Existing saved web-style caps are ignored automatically after update. Per-user max_conns, quota, and bandwidth policies still work normally.":"O XHTTP é tratado como tráfego de túnel VPN: requisições de pacotes e remontagem usam somente backpressure com limite de bytes, nunca limite por quantidade de requisições. Limites web antigos já salvos são ignorados automaticamente após a atualização. As regras por usuário de max_conns, cota e banda continuam funcionando normalmente." }); + +// Live per-account bandwidth column, shared by the SSH and Xray user lists. +Object.assign(I18N_TEXT["en-US"], { + "Speed":"Speed", "Limit up":"Limit up", "Limit down":"Limit down", + "Current up/down speed of the whole account, across all of its connections.":"Current up/down speed of the whole account, across all of its connections.", +}); +Object.assign(I18N_TEXT["pt-BR"], { + "Speed":"Velocidade", "Limit up":"Limite de envio", "Limit down":"Limite de download", + "Current up/down speed of the whole account, across all of its connections.":"Velocidade atual de envio/recebimento da conta inteira, somando todas as conexões.", +}); diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index a5e468e..85fbad1 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -75,16 +75,17 @@ const USER_SORT_EXTRACT = { max: u => u.max_connections || 0, up: u => u.limit_mbps_up || 0, down: u => u.limit_mbps_down || 0, + speed: u => speedTotalBytesPerSec(u), usage: u => Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0), expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity, owner: u => String(u.owner_username || "").toLowerCase(), }; // Columns that default to descending on first click (most/online first). -const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]); +const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "speed", "usage"]); const USER_SORT_OPTIONS = [ ["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"], - ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], ["up", "Up"], ["down", "Dn"], - ["owner", "Owner"], + ["speed", "Speed"], ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], + ["up", "Up"], ["down", "Dn"], ["owner", "Owner"], ]; const USER_FILTER_OPTIONS = [ ["all", "All"], ["online", "Online"], ["offline", "Offline"], @@ -227,25 +228,36 @@ function renderUsers(users) { users.forEach(u => { const on = (u.active_conns || 0) > 0; const tr = document.createElement("tr"); + // Every cell carries its column label so the table can collapse into + // labelled cards on phones instead of scrolling sideways. "wide" cells span + // the full card width there. const cells = [ - u.username, - on ? `${t("online")}` : `${t("idle")}`, - u.use_pam ? "PAM" : (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, - sshTrafficHTML(u), - u.expires_at ? fmtDate(u.expires_at) : "—", + { label:"User", text:u.username, cls:"cell-primary" }, + { label:"Status", html: on ? `${t("online")}` : `${t("idle")}` }, + { label:"Auth", text: u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password") }, + { label:"Conn", text: String(u.active_conns ?? 0) }, + { label:"Max", text: String(u.max_connections || 0) }, + // "Up"/"Dn" are speed limits, not current speed: spell that out on the + // card layout where the label sits right next to the live speed. + { label:"Up", cardLabel:"Limit up", text: String(u.limit_mbps_up || 0) }, + { label:"Dn", cardLabel:"Limit down", text: String(u.limit_mbps_down || 0) }, + { label:"Speed", html: speedHTML(u.up_bytes_per_sec, u.down_bytes_per_sec), small:true, cls:"cell-wide" }, + { label:"Traffic", html: sshTrafficHTML(u), small:true, cls:"cell-wide" }, + { label:"Expires", text: u.expires_at ? fmtDate(u.expires_at) : "—" }, ]; - if (isSA) cells.push(u.owner_username || "—"); - cells.forEach((c, i) => { + if (isSA) cells.push({ label:"Owner", text: u.owner_username || "—" }); + cells.forEach(cell => { const td = document.createElement("td"); - if (i === 1 || i === 7) td.innerHTML = c; else td.textContent = c; - if (i === 7) td.style.fontSize = ".7rem"; + td.dataset.label = t(cell.cardLabel || cell.label); + if (cell.cls) td.className = cell.cls; + if (cell.html !== undefined) td.innerHTML = cell.html; + else td.textContent = cell.text ?? "—"; + if (cell.small) td.style.fontSize = ".7rem"; tr.appendChild(td); }); const tdA = document.createElement("td"); + tdA.dataset.label = t("Actions"); + tdA.className = "cell-actions"; const editBtn = Object.assign(document.createElement("button"), { className:"btn btn-ghost btn-sm", textContent:t("Edit"), onclick: () => fillUserForm(u), @@ -268,7 +280,7 @@ function renderUsers(users) { if (!users.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); - cell.colSpan = isSA ? 11 : 10; + cell.colSpan = isSA ? 12 : 11; cell.className = "hint"; cell.style.cssText = "padding:24px;text-align:center;"; cell.textContent = t("No SSH users match this filter."); diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 4e7d988..b5f8fd5 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -26,14 +26,15 @@ const XRAY_CLIENT_SORT_EXTRACT = { status: c => c.expired ? 0 : 1, online: c => c.online ? 1 : 0, connections: c => Number(c.active_connections || 0), + speed: c => speedTotalBytesPerSec(c), usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0), expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity, max: c => Number(c.max_conns || 0), }; -const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "usage", "max"]); +const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "speed", "usage", "max"]); const XRAY_CLIENT_SORT_OPTIONS = [ ["name", "Name"], ["status", "Status"], ["online", "Online"], - ["connections", "Connections"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"], + ["connections", "Connections"], ["speed", "Speed"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"], ]; const XRAY_CLIENT_FILTER_OPTIONS = [ ["all", "All"], ["online", "Online"], ["offline", "Offline"], @@ -354,22 +355,27 @@ function renderInbounds(inbounds, options = {}) { tblWrap.innerHTML = `
${t("No clients.")}
`; } else { const tbl = document.createElement("table"); - tbl.innerHTML = `${t("Name")}UUID${t("Email")}${t("Expiry")}${t("Status")}${t("Online")}${t("Conn")}${t("Traffic")}${t("Max")}${t("Actions")}`; + tbl.className = "table-cards"; + tbl.innerHTML = `${t("Name")}UUID${t("Email")}${t("Expiry")}${t("Status")}${t("Online")}${t("Conn")}${t("Speed")}${t("Traffic")}${t("Max")}${t("Actions")}`; const tbody = document.createElement("tbody"); clients.forEach(c => { const tr = document.createElement("tr"); tr.dataset.clientId = String(c.id || ""); + // data-label drives the labelled card layout used on narrow screens. tr.innerHTML = ` - ${escapeHTML(c.name || "—")} - ${escapeHTML(c.id || "—")} - ${escapeHTML(c.email || "—")} - ${escapeHTML(clientExpiryLabel(c))} - ${clientStatusHTML(c)} - ${clientOnlineHTML(c)} - ${escapeHTML(c.active_connections || 0)} - ${clientTrafficHTML(c)} - ${escapeHTML(c.max_conns || "∞")}`; + ${escapeHTML(c.name || "—")} + ${escapeHTML(c.id || "—")} + ${escapeHTML(c.email || "—")} + ${escapeHTML(clientExpiryLabel(c))} + ${clientStatusHTML(c)} + ${clientOnlineHTML(c)} + ${escapeHTML(c.active_connections || 0)} + ${speedHTML(c.up_bytes_per_sec, c.down_bytes_per_sec)} + ${clientTrafficHTML(c)} + ${escapeHTML(c.max_conns || "∞")}`; const actTd = document.createElement("td"); + actTd.dataset.label = t("Actions"); + actTd.className = "cell-actions"; actTd.style.whiteSpace = "nowrap"; const copyBtn = document.createElement("button"); copyBtn.className = "btn btn-ghost btn-sm"; diff --git a/admin/index.html b/admin/index.html index 8c40bdf..e0a84fd 100644 --- a/admin/index.html +++ b/admin/index.html @@ -281,10 +281,10 @@
- +
- + diff --git a/bandwidth_rate.go b/bandwidth_rate.go new file mode 100644 index 0000000..2fbb8f7 --- /dev/null +++ b/bandwidth_rate.go @@ -0,0 +1,231 @@ +package main + +// Live per-account bandwidth. The panel already keeps cumulative uploaded and +// downloaded byte counters for every SSH user and Xray client; this file turns +// those counters into a current speed so the UI can show "↑ 12 Mbps ↓ 40 Mbps" +// for the whole account instead of only lifetime totals. Speeds are always the +// sum of every connection the account has open, because the counters they are +// derived from are per account, not per connection. + +import ( + "math" + "strings" + "sync" + "sync/atomic" + "time" +) + +// bandwidthRate is a smoothed instantaneous speed in bytes per second. +type bandwidthRate struct { + UpBytesPerSec float64 + DownBytesPerSec float64 +} + +func (r bandwidthRate) isZero() bool { + return r.UpBytesPerSec == 0 && r.DownBytesPerSec == 0 +} + +type bandwidthSample struct { + up int64 + down int64 + at time.Time + rate bandwidthRate +} + +// bandwidthSampler converts monotonically increasing byte counters into a +// speed. Deltas smaller than minSampleInterval are ignored so a double sample +// cannot divide by an almost-zero interval, and a counter that moves backwards +// (traffic reset, account recreated) re-baselines instead of reporting a +// nonsensical negative or huge rate. +type bandwidthSampler struct { + mu sync.Mutex + samples map[string]bandwidthSample + + // tau is the exponential smoothing time constant. Larger values give a + // calmer number; zero disables smoothing. + tau time.Duration + // staleAfter makes Rate report zero for accounts that stopped being + // sampled (idle Xray clients dropped by the stats poller, for example), + // instead of freezing the last speed on screen forever. + staleAfter time.Duration +} + +const minBandwidthSampleInterval = 250 * time.Millisecond + +func newBandwidthSampler(tau, staleAfter time.Duration) *bandwidthSampler { + return &bandwidthSampler{ + samples: make(map[string]bandwidthSample), + tau: tau, + staleAfter: staleAfter, + } +} + +// Observe records the current cumulative counters for key. The first +// observation only establishes a baseline; the rate stays zero until a second +// one arrives. +func (s *bandwidthSampler) Observe(key string, up, down int64, now time.Time) { + if s == nil { + return + } + key = strings.TrimSpace(key) + if key == "" { + return + } + if up < 0 { + up = 0 + } + if down < 0 { + down = 0 + } + s.mu.Lock() + defer s.mu.Unlock() + prev, ok := s.samples[key] + if !ok { + s.samples[key] = bandwidthSample{up: up, down: down, at: now} + return + } + // Counters went backwards: the account's traffic was reset or the entry was + // recycled. Start over from this value. + if up < prev.up || down < prev.down { + s.samples[key] = bandwidthSample{up: up, down: down, at: now} + return + } + dt := now.Sub(prev.at) + if dt < minBandwidthSampleInterval { + return + } + seconds := dt.Seconds() + instant := bandwidthRate{ + UpBytesPerSec: float64(up-prev.up) / seconds, + DownBytesPerSec: float64(down-prev.down) / seconds, + } + next := instant + if s.tau > 0 && !prev.rate.isZero() { + // alpha derived from the real interval so an irregular sampling + // cadence still converges on the true average. + alpha := 1 - math.Exp(-seconds/s.tau.Seconds()) + if alpha > 1 { + alpha = 1 + } + next = bandwidthRate{ + UpBytesPerSec: prev.rate.UpBytesPerSec + alpha*(instant.UpBytesPerSec-prev.rate.UpBytesPerSec), + DownBytesPerSec: prev.rate.DownBytesPerSec + alpha*(instant.DownBytesPerSec-prev.rate.DownBytesPerSec), + } + } + if next.UpBytesPerSec < 0 { + next.UpBytesPerSec = 0 + } + if next.DownBytesPerSec < 0 { + next.DownBytesPerSec = 0 + } + s.samples[key] = bandwidthSample{up: up, down: down, at: now, rate: next} +} + +// Rate returns the last known speed for key. Stale entries report zero. +func (s *bandwidthSampler) Rate(key string) (bandwidthRate, bool) { + if s == nil { + return bandwidthRate{}, false + } + key = strings.TrimSpace(key) + if key == "" { + return bandwidthRate{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.rateLocked(key) +} + +// RateForKeys returns the first known speed among keys. Xray clients are +// tracked under their UUID in native mode and under their email in external +// mode, so callers pass every identifier the client may be stored under. +func (s *bandwidthSampler) RateForKeys(keys ...string) (bandwidthRate, bool) { + if s == nil { + return bandwidthRate{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if rate, ok := s.rateLocked(key); ok { + return rate, true + } + } + return bandwidthRate{}, false +} + +func (s *bandwidthSampler) rateLocked(key string) (bandwidthRate, bool) { + sample, ok := s.samples[key] + if !ok { + return bandwidthRate{}, false + } + if s.staleAfter > 0 && !sample.at.IsZero() && time.Since(sample.at) > s.staleAfter { + return bandwidthRate{}, true + } + return sample.rate, true +} + +// Retain drops every tracked key that is not in keep, so the map cannot grow +// forever as accounts are deleted or recreated. +func (s *bandwidthSampler) Retain(keep map[string]struct{}) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for key := range s.samples { + if _, ok := keep[key]; !ok { + delete(s.samples, key) + } + } +} + +// ---- SSH accounts ---- + +const sshBandwidthSampleInterval = 2 * time.Second + +var sshBandwidth = newBandwidthSampler(5*time.Second, 20*time.Second) + +func startSSHUserRateSampler() { + go func() { + ticker := time.NewTicker(sshBandwidthSampleInterval) + defer ticker.Stop() + for range ticker.C { + sampleSSHUserRates(time.Now()) + } + }() +} + +func sampleSSHUserRates(now time.Time) { + if userMgr == nil { + return + } + states := userMgr.List() + active := make(map[string]struct{}, len(states)) + for _, u := range states { + if u == nil { + continue + } + u.mu.Lock() + username := strings.TrimSpace(u.Cfg.Username) + u.mu.Unlock() + if username == "" { + continue + } + sshBandwidth.Observe( + username, + atomic.LoadInt64(&u.TotalUplinkBytes), + atomic.LoadInt64(&u.TotalDownlinkBytes), + now, + ) + active[username] = struct{}{} + } + sshBandwidth.Retain(active) +} + +func sshUserRate(username string) bandwidthRate { + rate, _ := sshBandwidth.Rate(username) + return rate +} diff --git a/bandwidth_rate_test.go b/bandwidth_rate_test.go new file mode 100644 index 0000000..ae5950d --- /dev/null +++ b/bandwidth_rate_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "testing" + "time" +) + +func TestBandwidthSamplerFirstObservationIsBaselineOnly(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) + now := time.Now() + s.Observe("bob", 10_000, 20_000, now) + rate, ok := s.Rate("bob") + if !ok { + t.Fatal("expected the account to be tracked after the first observation") + } + if !rate.isZero() { + t.Fatalf("first observation must not report a speed, got %+v", rate) + } +} + +func TestBandwidthSamplerComputesBytesPerSecond(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) // no smoothing: exact delta/dt + now := time.Now() + s.Observe("bob", 0, 0, now) + // 2 MB up and 10 MB down over 2 seconds. + s.Observe("bob", 2<<20, 10<<20, now.Add(2*time.Second)) + rate, _ := s.Rate("bob") + if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp { + t.Fatalf("up = %v, want %v", rate.UpBytesPerSec, wantUp) + } + if wantDown := float64(5 << 20); rate.DownBytesPerSec != wantDown { + t.Fatalf("down = %v, want %v", rate.DownBytesPerSec, wantDown) + } +} + +func TestBandwidthSamplerIgnoresSamplesTakenTooCloseTogether(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) + now := time.Now() + s.Observe("bob", 0, 0, now) + s.Observe("bob", 5<<20, 5<<20, now.Add(10*time.Millisecond)) + rate, _ := s.Rate("bob") + if !rate.isZero() { + t.Fatalf("a 10ms interval must not produce a speed, got %+v", rate) + } +} + +func TestBandwidthSamplerRebaselinesAfterTrafficReset(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) + now := time.Now() + s.Observe("bob", 0, 0, now) + s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second)) + // Panel reset the account's traffic: counters go back to zero. + s.Observe("bob", 0, 0, now.Add(4*time.Second)) + rate, _ := s.Rate("bob") + if !rate.isZero() { + t.Fatalf("counters moving backwards must reset the speed, got %+v", rate) + } + s.Observe("bob", 2<<20, 0, now.Add(6*time.Second)) + rate, _ = s.Rate("bob") + if wantUp := float64(1 << 20); rate.UpBytesPerSec != wantUp { + t.Fatalf("up after reset = %v, want %v", rate.UpBytesPerSec, wantUp) + } +} + +func TestBandwidthSamplerReportsZeroWhenIdle(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) + now := time.Now() + s.Observe("bob", 0, 0, now) + s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second)) + s.Observe("bob", 4<<20, 4<<20, now.Add(4*time.Second)) + rate, _ := s.Rate("bob") + if !rate.isZero() { + t.Fatalf("unchanged counters must report an idle account, got %+v", rate) + } +} + +func TestBandwidthSamplerDropsStaleSpeeds(t *testing.T) { + s := newBandwidthSampler(0, time.Second) + now := time.Now().Add(-time.Hour) + s.Observe("bob", 0, 0, now) + s.Observe("bob", 4<<20, 4<<20, now.Add(2*time.Second)) + rate, ok := s.Rate("bob") + if !ok { + t.Fatal("expected the account to still be tracked") + } + if !rate.isZero() { + t.Fatalf("an hour-old sample must not still report a speed, got %+v", rate) + } +} + +func TestBandwidthSamplerSmoothsWithTimeConstant(t *testing.T) { + s := newBandwidthSampler(5*time.Second, time.Minute) + now := time.Now() + s.Observe("bob", 0, 0, now) + // First real sample has no previous rate to blend with, so it lands exactly. + s.Observe("bob", 2<<20, 0, now.Add(2*time.Second)) + first, _ := s.Rate("bob") + if first.UpBytesPerSec != float64(1<<20) { + t.Fatalf("first speed = %v, want %v", first.UpBytesPerSec, float64(1<<20)) + } + // Traffic stops: the smoothed value has to fall without jumping to zero. + s.Observe("bob", 2<<20, 0, now.Add(4*time.Second)) + second, _ := s.Rate("bob") + if second.UpBytesPerSec <= 0 || second.UpBytesPerSec >= first.UpBytesPerSec { + t.Fatalf("smoothed speed = %v, want a value between 0 and %v", second.UpBytesPerSec, first.UpBytesPerSec) + } +} + +func TestBandwidthSamplerRateForKeysAndRetain(t *testing.T) { + s := newBandwidthSampler(0, time.Minute) + now := time.Now() + s.Observe("uuid-1", 0, 0, now) + s.Observe("uuid-1", 1<<20, 0, now.Add(1*time.Second)) + if _, ok := s.RateForKeys("", "unknown@example", "uuid-1"); !ok { + t.Fatal("RateForKeys must find the client under any of its identifiers") + } + s.Retain(map[string]struct{}{"uuid-2": {}}) + if _, ok := s.Rate("uuid-1"); ok { + t.Fatal("Retain must drop accounts that no longer exist") + } +} diff --git a/main.go b/main.go index afa1fb8..231aab2 100644 --- a/main.go +++ b/main.go @@ -1784,28 +1784,32 @@ func startAdminAPI(store *Store, addr string, adminDir string) { // UserDTO is returned by the admin API for listing. type UserDTO struct { - Username string `json:"username"` - ActiveConns int `json:"active_conns"` - MaxConnections int `json:"max_connections"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - LimitUpMbps int `json:"limit_mbps_up"` - LimitDownMbps int `json:"limit_mbps_down"` - DataQuotaBytes int64 `json:"data_quota_bytes"` - QuotaAction string `json:"quota_action"` - QuotaThrottleMbps int `json:"quota_throttle_mbps"` - TotalUplinkBytes int64 `json:"total_uplink_bytes"` - TotalDownlinkBytes int64 `json:"total_downlink_bytes"` - TotalBytes int64 `json:"total_bytes"` - QuotaExceeded bool `json:"quota_exceeded"` - TOTPSecret string `json:"totp_secret,omitempty"` - TOTPPeriod int `json:"totp_period"` - TOTPWindow int `json:"totp_window"` - TOTPDigits int `json:"totp_digits"` - AllowStaticPassword bool `json:"allow_static_password"` - UsePAM bool `json:"use_pam"` - TOTPEnabled bool `json:"totp_enabled"` - OwnerUsername string `json:"owner_username,omitempty"` - ServerID string `json:"server_id,omitempty"` + Username string `json:"username"` + ActiveConns int `json:"active_conns"` + MaxConnections int `json:"max_connections"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LimitUpMbps int `json:"limit_mbps_up"` + LimitDownMbps int `json:"limit_mbps_down"` + DataQuotaBytes int64 `json:"data_quota_bytes"` + QuotaAction string `json:"quota_action"` + QuotaThrottleMbps int `json:"quota_throttle_mbps"` + TotalUplinkBytes int64 `json:"total_uplink_bytes"` + TotalDownlinkBytes int64 `json:"total_downlink_bytes"` + TotalBytes int64 `json:"total_bytes"` + // Live account-wide speed in bytes per second, summed across every + // connection the user has open. + UpBytesPerSec float64 `json:"up_bytes_per_sec"` + DownBytesPerSec float64 `json:"down_bytes_per_sec"` + QuotaExceeded bool `json:"quota_exceeded"` + TOTPSecret string `json:"totp_secret,omitempty"` + TOTPPeriod int `json:"totp_period"` + TOTPWindow int `json:"totp_window"` + TOTPDigits int `json:"totp_digits"` + AllowStaticPassword bool `json:"allow_static_password"` + UsePAM bool `json:"use_pam"` + TOTPEnabled bool `json:"totp_enabled"` + OwnerUsername string `json:"owner_username,omitempty"` + ServerID string `json:"server_id,omitempty"` } func handleListUsers(w http.ResponseWriter, r *http.Request) { @@ -1840,6 +1844,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) { continue } + rate := sshUserRate(cfg.Username) + out = append(out, UserDTO{ Username: cfg.Username, ActiveConns: c, @@ -1853,6 +1859,8 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) { TotalUplinkBytes: totalUp, TotalDownlinkBytes: totalDown, TotalBytes: totalBytes, + UpBytesPerSec: rate.UpBytesPerSec, + DownBytesPerSec: rate.DownBytesPerSec, QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes, TOTPSecret: cfg.TOTPSecret, TOTPPeriod: cfg.TOTPPeriod, @@ -3298,6 +3306,9 @@ func main() { primeCurrentStats() startStatsCollector() + // Turn the per-account byte counters into live up/down speeds for the panel. + startSSHUserRateSampler() + adminAddr := os.Getenv("ADMIN_HTTP_ADDR") if adminAddr == "" { adminAddr = "0.0.0.0:9090" diff --git a/xray_integration.go b/xray_integration.go index 25ca3d9..ad84477 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -252,11 +252,12 @@ type XrayManager struct { startTime time.Time lastErr string - statsMu sync.RWMutex - statsByEmail map[string]xrayRuntimeStat - lastStatsErr string - lastStatsPoll time.Time - pollStarted bool + statsMu sync.RWMutex + statsByEmail map[string]xrayRuntimeStat + lastStatsErr string + lastStatsPoll time.Time + pollStarted bool + rateSamplerStarted bool nativeDBMu sync.Mutex nativeTrafficPersistMu sync.Mutex @@ -319,6 +320,7 @@ func initXrayManager(cfg *XrayConfig) { // external `xray api statsquery` poller is not started (it would overwrite // the native counters with errors from a non-existent CLI endpoint). xrayMgr.startNativeStatsFlusher() + xrayMgr.startRateSampler() if !cfg.UseNative() { xrayMgr.startStatsPoller() } @@ -886,6 +888,73 @@ func (m *XrayManager) startStatsPoller() { }() } +// Live per-client speed, derived from the same cumulative counters the panel +// already reports as lifetime traffic. +var xrayBandwidth = newBandwidthSampler(6*time.Second, 45*time.Second) + +const xrayNativeRateSampleInterval = 2 * time.Second + +// startRateSampler keeps xrayBandwidth fresh in native mode, where the +// in-process runtime updates the counters continuously. In external mode the +// counters only move once per stats poll (15s by default), so refreshRuntimeStats +// feeds the sampler at its own cadence instead — sampling faster than the source +// updates would show alternating spikes and zeros. The mode is re-checked on +// every tick because a hot reload can switch it while running. +func (m *XrayManager) startRateSampler() { + m.mu.Lock() + if m.rateSamplerStarted { + m.mu.Unlock() + return + } + m.rateSamplerStarted = true + m.mu.Unlock() + + go func() { + ticker := time.NewTicker(xrayNativeRateSampleInterval) + defer ticker.Stop() + for range ticker.C { + if !m.usesNativeSnapshot() { + continue + } + m.sampleRuntimeRates(time.Now()) + } + }() +} + +func (m *XrayManager) usesNativeSnapshot() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.cfg != nil && m.cfg.UseNative() +} + +func (m *XrayManager) sampleRuntimeRates(now time.Time) { + type counterSnapshot struct { + key string + uplink int64 + downlink int64 + } + m.statsMu.RLock() + snapshots := make([]counterSnapshot, 0, len(m.statsByEmail)) + for key, st := range m.statsByEmail { + snapshots = append(snapshots, counterSnapshot{key: key, uplink: st.Uplink, downlink: st.Downlink}) + } + m.statsMu.RUnlock() + + active := make(map[string]struct{}, len(snapshots)) + for _, snapshot := range snapshots { + xrayBandwidth.Observe(snapshot.key, snapshot.uplink, snapshot.downlink, now) + active[snapshot.key] = struct{}{} + } + xrayBandwidth.Retain(active) +} + +// RuntimeRateForKeys resolves a client's live speed. Clients are tracked under +// their UUID in native mode and under their stats-API email in external mode, +// so callers pass every identifier the client may be stored under. +func (m *XrayManager) RuntimeRateForKeys(keys ...string) (bandwidthRate, bool) { + return xrayBandwidth.RateForKeys(keys...) +} + func (m *XrayManager) isRunningSnapshot() bool { m.mu.Lock() native := m.cfg != nil && m.cfg.UseNative() @@ -965,9 +1034,12 @@ func (m *XrayManager) refreshRuntimeStats() { if m.statsByEmail == nil { m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic)) } - seen := make(map[string]bool, len(traffic)) + // External mode: the counters only move once per poll, so this is also the + // natural cadence for the live speed sampler. + active := make(map[string]struct{}, len(traffic)) for email, counters := range traffic { - seen[email] = true + active[email] = struct{}{} + xrayBandwidth.Observe(email, counters.Uplink, counters.Downlink, now) prev := m.statsByEmail[email] st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive, ActiveConnections: prev.ActiveConnections} changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink @@ -979,9 +1051,10 @@ func (m *XrayManager) refreshRuntimeStats() { } m.statsByEmail[email] = st } - // Keep old entries, but do not delete them immediately. Xray may omit zero - // counters for users that have not moved traffic yet. - _ = seen + // Keep old stat entries, but do not delete them immediately: Xray may omit + // zero counters for users that have not moved traffic yet. Speed samples are + // dropped for absent users because a missing baseline only costs one poll. + xrayBandwidth.Retain(active) } func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) { @@ -2161,6 +2234,10 @@ type XrayClientInfo struct { DownlinkBytes int64 `json:"downlink_bytes,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"` ActiveConnections int `json:"active_connections,omitempty"` + // Live speed in bytes per second for the whole client, summed across every + // connection it has open. + UpBytesPerSec float64 `json:"up_bytes_per_sec"` + DownBytesPerSec float64 `json:"down_bytes_per_sec"` // Metadata from PostgreSQL (enriched by handleXrayInbounds) Name string `json:"name,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -2565,6 +2642,10 @@ func applyXrayRuntimeStats(c *XrayClientInfo) { if c == nil { return } + if rate, ok := xrayMgr.RuntimeRateForKeys(c.Email, c.UUID, c.Name); ok { + c.UpBytesPerSec = rate.UpBytesPerSec + c.DownBytesPerSec = rate.DownBytesPerSec + } st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name) if !ok { return
UserStatusAuthConnMaxUpDnTrafficExpiresConnMaxUpDnSpeedTrafficExpires Actions