diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index 9383bf2..23b7fdc 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -172,6 +172,24 @@ Object.assign(I18N_TEXT["pt-BR"], { "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." }); +Object.assign(I18N_TEXT["en-US"], { + "Reset":"Reset","Reset traffic":"Reset traffic","Reset SSH traffic":"Reset SSH traffic","Reset Xray traffic":"Reset Xray traffic", + "Reset traffic for user \"{name}\"?":"Reset traffic for user \"{name}\"?","Reset traffic for client {id}…?":"Reset traffic for client {id}…?", + "Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.", + "Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.", + "Resetting traffic for {name}…":"Resetting traffic for {name}…","Resetting traffic for client {id}…":"Resetting traffic for client {id}…", + "Traffic reset successfully.":"Traffic reset successfully.","Xray traffic reset successfully.":"Xray traffic reset successfully.", + "Could not reset traffic: {error}":"Could not reset traffic: {error}","Reset traffic counter":"Reset traffic counter" +}); +Object.assign(I18N_TEXT["pt-BR"], { + "Reset":"Zerar","Reset traffic":"Zerar tráfego","Reset SSH traffic":"Zerar tráfego SSH","Reset Xray traffic":"Zerar tráfego Xray", + "Reset traffic for user \"{name}\"?":"Zerar o tráfego do usuário \"{name}\"?","Reset traffic for client {id}…?":"Zerar o tráfego do cliente {id}…?", + "Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, senha, validade e cota não serão alteradas.", + "Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, validade e cota não serão alteradas.", + "Resetting traffic for {name}…":"Zerando o tráfego de {name}…","Resetting traffic for client {id}…":"Zerando o tráfego do cliente {id}…", + "Traffic reset successfully.":"Tráfego zerado com sucesso.","Xray traffic reset successfully.":"Tráfego Xray zerado com sucesso.", + "Could not reset traffic: {error}":"Não foi possível zerar o tráfego: {error}","Reset traffic counter":"Zerar contador de tráfego" +}); 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; diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index 22c34db..b5a79cc 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -173,12 +173,18 @@ function renderUsers(users) { className:"btn btn-ghost btn-sm", textContent:t("Edit"), onclick: () => fillUserForm(u), }); + const resetBtn = Object.assign(document.createElement("button"), { + className:"btn btn-warn btn-sm", textContent:t("Reset"), + style: "margin-left:4px;", + title: t("Reset traffic"), + onclick: () => resetUserTraffic(u.username, resetBtn), + }); 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.append(editBtn, delBtn); + tdA.append(editBtn, resetBtn, delBtn); tr.appendChild(tdA); usersBody.appendChild(tr); }); @@ -260,6 +266,37 @@ userForm.addEventListener("submit", async e => { } }); +async function resetUserTraffic(username, button) { + const accepted = await panelConfirm({ + tone:"warning", icon:"↺", title:t("Reset SSH traffic"), + message:t("Reset traffic for user \"{name}\"?", {name: username}), + detail:t("Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged."), + confirmLabel:t("Reset traffic"), + }); + if (!accepted) return; + const previousDisabled = !!button?.disabled; + if (button) button.disabled = true; + userStatus.textContent = t("Resetting traffic for {name}…", {name: username}); + try { + const res = await api("/api/users/reset-traffic", { + method:"POST", + body: JSON.stringify({ username, server_id:selectedSSHServer() }), + }); + if (!res.ok) throw new Error((await res.text()) || "reset failed"); + userStatus.textContent = t("Traffic reset successfully."); + showPanelToast(t("Traffic reset successfully."), "success", t("SSH / SlowDNS")); + await loadUsers(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else { + userStatus.textContent = t("Could not reset traffic: {error}", {error:e.message}); + showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("SSH / SlowDNS")); + } + } finally { + if (button) button.disabled = previousDisabled; + } +} + async function deleteUser(username) { const accepted = await panelConfirm({ tone:"danger", icon:"×", title:t("Delete SSH account"), diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 60ef477..2452c2e 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -244,12 +244,18 @@ function renderInbounds(inbounds, options = {}) { editBtn.style.marginLeft = "4px"; editBtn.textContent = t("Edit"); editBtn.onclick = () => openEditXrayClient(ib.tag, c); + const resetBtn = document.createElement("button"); + resetBtn.className = "btn btn-warn btn-sm"; + resetBtn.style.marginLeft = "4px"; + resetBtn.textContent = t("Reset"); + resetBtn.title = t("Reset traffic"); + resetBtn.onclick = () => resetXrayClientTraffic(c.id, resetBtn); 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, editBtn, delBtn); + actTd.append(copyBtn, editBtn, resetBtn, delBtn); tr.appendChild(actTd); tbody.appendChild(tr); }); @@ -394,6 +400,44 @@ async function submitXrayClientCreator(event) { } } +async function resetXrayClientTraffic(uuid, button) { + const shortID = String(uuid || "").slice(0, 8); + const accepted = await panelConfirm({ + tone:"warning", icon:"↺", title:t("Reset Xray traffic"), + message:t("Reset traffic for client {id}…?", {id:shortID}), + detail:t("Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged."), + confirmLabel:t("Reset traffic"), + }); + if (!accepted) return; + const previousDisabled = !!button?.disabled; + if (button) button.disabled = true; + xStatus.textContent = t("Resetting traffic for client {id}…", {id:shortID}); + try { + const res = await api("/api/xray/clients/reset-traffic", { + method:"POST", + body:JSON.stringify({ uuid, server_id:selectedXrayServer() }), + }); + if (!res.ok) throw new Error((await res.text()) || "reset failed"); + xStatus.textContent = t("Xray traffic reset successfully."); + showPanelToast(t("Xray traffic reset successfully."), "success", t("Xray user")); + if (editingXrayClientId === uuid) { + const usage = document.getElementById("editXrayUsage"); + if (usage) usage.value = "0 B (↑ 0 B · ↓ 0 B)"; + const resetUsage = document.getElementById("editXrayResetUsage"); + if (resetUsage) resetUsage.checked = false; + } + await loadInbounds({ force:true }); + } catch (e) { + if (e.message === "auth") doAuthError(); + else { + xStatus.textContent = t("Could not reset traffic: {error}", {error:e.message}); + showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("Xray user")); + } + } finally { + if (button) button.disabled = previousDisabled; + } +} + async function removeClient(tag, uuid) { const accepted = await panelConfirm({ tone:"danger", icon:"×", title:t("Remove Xray client"), diff --git a/admin/index.html b/admin/index.html index 86e94e0..086b2b9 100644 --- a/admin/index.html +++ b/admin/index.html @@ -1555,10 +1555,10 @@ - + - - + + diff --git a/main.go b/main.go index 35b9b28..4d19b3e 100644 --- a/main.go +++ b/main.go @@ -399,6 +399,7 @@ type UserState struct { totalBytes int64 pendingUplinkBytes int64 pendingDownlinkBytes int64 + trafficMu sync.RWMutex quotaLimiter *rate.Limiter quotaLimiterMbps int } @@ -1688,6 +1689,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) { // SSH user management (session required; role-filtered inside handlers) mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers))) mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store)))) + mux.Handle("/api/users/reset-traffic", sessionMiddleware(http.HandlerFunc(handleResetUserTraffic(store)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store)))) // Server stats: visible to authenticated sessions; reset remains superadmin-only. @@ -1724,6 +1726,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) { mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate))) + mux.Handle("/api/xray/clients/reset-traffic", sessionMiddleware(http.HandlerFunc(handleXrayClientResetTraffic))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove))) // Superadmin-only: TLS certificate generation @@ -2065,6 +2068,75 @@ func handleCreateUser(store *Store) http.HandlerFunc { } } +func handleResetUserTraffic(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 req struct { + Username string `json:"username"` + ServerID string `json:"server_id,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + http.Error(w, "username required", http.StatusBadRequest) + return + } + + ctx := r.Context() + if ms, remote, err := managedServerFromID(ctx, store, req.ServerID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } else if remote { + if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, req.Username, sess.Username) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + req.ServerID = "" + body, _ := json.Marshal(req) + status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/reset-traffic", body, "application/json") + if err != nil { + http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) + return + } + writeProxyResponse(w, status, data, ct) + return + } + + var owner string + if err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, req.Username).Scan(&owner); err != nil { + if err == sql.ErrNoRows { + http.Error(w, "user not found", http.StatusNotFound) + } else { + http.Error(w, "db error", http.StatusInternalServerError) + } + return + } + if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && strings.TrimSpace(owner) != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + if err := resetSSHUserTrafficAccounting(ctx, store, req.Username); err != nil { + log.Printf("failed to reset SSH traffic for %s: %v", req.Username, err) + http.Error(w, "could not reset traffic", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": req.Username}) + } +} + func handleDeleteUser(store *Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { diff --git a/quota.go b/quota.go index c537ea4..a73e1fa 100644 --- a/quota.go +++ b/quota.go @@ -100,9 +100,8 @@ func initSSHRuntimeUsage(u *UserState, uplink, downlink int64) { atomic.StoreInt64(&u.pendingDownlinkBytes, 0) } -func resetSSHRuntimeUsage(username string) { - u, ok := userMgr.Get(username) - if !ok || u == nil { +func resetSSHRuntimeUsageLocked(u *UserState) { + if u == nil { return } initSSHRuntimeUsage(u, 0, 0) @@ -112,13 +111,30 @@ func resetSSHRuntimeUsage(username string) { u.mu.Unlock() } +func resetSSHRuntimeUsage(username string) { + u, ok := userMgr.Get(username) + if !ok || u == nil { + return + } + u.trafficMu.Lock() + resetSSHRuntimeUsageLocked(u) + u.trafficMu.Unlock() +} + func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error { + u, _ := userMgr.Get(username) + if u != nil { + u.trafficMu.Lock() + defer u.trafficMu.Unlock() + } sshTrafficPersistenceMu.Lock() defer sshTrafficPersistenceMu.Unlock() if err := store.ResetSSHUserTraffic(ctx, username); err != nil { return err } - resetSSHRuntimeUsage(username) + if u != nil { + resetSSHRuntimeUsageLocked(u) + } return nil } @@ -221,6 +237,10 @@ type sshQuotaWriter struct { } func (qw sshQuotaWriter) Write(p []byte) (int, error) { + if qw.user != nil { + qw.user.trafficMu.RLock() + defer qw.user.trafficMu.RUnlock() + } allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p)) if allowed <= 0 { return 0, errDataQuotaExceeded diff --git a/xray_integration.go b/xray_integration.go index 0697a0d..8c0c153 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -2719,6 +2719,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func handleXrayClientResetTraffic(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var req struct { + UUID string `json:"uuid"` + ServerID string `json:"server_id,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + req.UUID = strings.TrimSpace(req.UUID) + if req.UUID == "" { + http.Error(w, "uuid required", http.StatusBadRequest) + return + } + + ctx := r.Context() + if ms, remote, err := managedServerFromID(ctx, statsStore, req.ServerID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } else if remote { + if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(ctx, ms, req.UUID, sess.Username) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + req.ServerID = "" + body, _ := json.Marshal(req) + status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/reset-traffic", body, "application/json") + if err != nil { + http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway) + return + } + writeProxyResponse(w, status, data, ct) + return + } + if statsStore == nil { + http.Error(w, "storage not available", http.StatusInternalServerError) + return + } + + existing, err := statsStore.GetXrayClientMeta(ctx, req.UUID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "client not found", http.StatusNotFound) + } else { + http.Error(w, "database error", http.StatusInternalServerError) + } + return + } + if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if err := xrayMgr.resetNativeTrafficAccounting(ctx, statsStore, req.UUID, existing.Email); err != nil { + http.Error(w, "usage reset failed: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "uuid": req.UUID}) +} + func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed)