Quota per user

This commit is contained in:
2026-07-14 22:39:36 -03:00
parent c6bfefe2fb
commit ed0e240241
15 changed files with 1033 additions and 99 deletions
+6 -1
View File
@@ -683,7 +683,12 @@ function clientTrafficHTML(c) {
const up = Number(c.uplink_bytes || 0); const up = Number(c.uplink_bytes || 0);
const down = Number(c.downlink_bytes || 0); const down = Number(c.downlink_bytes || 0);
const total = Number(c.total_bytes || (up + down) || 0); const total = Number(c.total_bytes || (up + down) || 0);
return `${escapeHTML(formatBytes(total))}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`; const quota = Number(c.data_quota_bytes || 0);
const quotaLabel = quota > 0 ? formatBytes(quota) : "∞";
const state = c.quota_exceeded
? (c.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
: "";
return `${escapeHTML(formatBytes(total))} / ${escapeHTML(quotaLabel)}${escapeHTML(state)}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
} }
function updateCell(row, name, html) { function updateCell(row, name, html) {
+24 -1
View File
@@ -9,6 +9,10 @@ cancelUserBtn.addEventListener("click", () => {
function prepareNewSSHUser() { function prepareNewSSHUser() {
userForm.reset(); userForm.reset();
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6; fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
fQuotaAction.value = "block";
fQuotaThrottle.value = 1;
fUsageDisplay.value = "0 B";
fResetUsage.checked = false;
const heading = document.getElementById("userFormHeading"); const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle"); const title = document.getElementById("userFormTitle");
if (heading) heading.textContent = t("Create user"); if (heading) heading.textContent = t("Create user");
@@ -68,11 +72,12 @@ const USER_SORT_EXTRACT = {
max: u => u.max_connections || 0, max: u => u.max_connections || 0,
up: u => u.limit_mbps_up || 0, up: u => u.limit_mbps_up || 0,
down: u => u.limit_mbps_down || 0, down: u => u.limit_mbps_down || 0,
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, expires: u => u.expires_at ? new Date(u.expires_at).getTime() : Infinity,
owner: u => String(u.owner_username || "").toLowerCase(), owner: u => String(u.owner_username || "").toLowerCase(),
}; };
// Columns that default to descending on first click (most/online first). // Columns that default to descending on first click (most/online first).
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down"]); const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]);
let userSort = { key: "username", dir: "asc" }; let userSort = { key: "username", dir: "asc" };
let lastUsersData = []; let lastUsersData = [];
@@ -139,6 +144,12 @@ function renderUsers(users) {
const on = (u.active_conns || 0) > 0; const on = (u.active_conns || 0) > 0;
if (on) online++; if (on) online++;
if (isExpiredDate(u.expires_at)) expiredCount++; if (isExpiredDate(u.expires_at)) expiredCount++;
const totalBytes = Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0);
const quotaBytes = Number(u.data_quota_bytes || 0);
const quotaLabel = quotaBytes > 0 ? formatBytes(quotaBytes) : "∞";
const quotaState = u.quota_exceeded
? (u.quota_action === "throttle" ? ` · ${t("throttled")}` : ` · ${t("blocked")}`)
: "";
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const cells = [ const cells = [
u.username, u.username,
@@ -148,6 +159,7 @@ function renderUsers(users) {
u.max_connections || 0, u.max_connections || 0,
u.limit_mbps_up || 0, u.limit_mbps_up || 0,
u.limit_mbps_down || 0, u.limit_mbps_down || 0,
`${formatBytes(totalBytes)} / ${quotaLabel}${quotaState}`,
u.expires_at ? fmtDate(u.expires_at) : "—", u.expires_at ? fmtDate(u.expires_at) : "—",
]; ];
if (isSA) cells.push(u.owner_username || "—"); if (isSA) cells.push(u.owner_username || "—");
@@ -194,6 +206,12 @@ function fillUserForm(u) {
fMaxConn.value = u.max_connections || ""; fMaxConn.value = u.max_connections || "";
fUp.value = u.limit_mbps_up || ""; fUp.value = u.limit_mbps_up || "";
fDown.value = u.limit_mbps_down || ""; fDown.value = u.limit_mbps_down || "";
fQuotaGB.value = u.data_quota_bytes ? (Number(u.data_quota_bytes) / (1024 ** 3)).toFixed(2).replace(/\.00$/, "") : "0";
fQuotaAction.value = u.quota_action === "throttle" ? "throttle" : "block";
fQuotaThrottle.value = u.quota_throttle_mbps || 1;
const totalBytes = Number(u.total_bytes || ((u.total_uplink_bytes || 0) + (u.total_downlink_bytes || 0)) || 0);
fUsageDisplay.value = `${formatBytes(totalBytes)} (↑ ${formatBytes(u.total_uplink_bytes || 0)} · ↓ ${formatBytes(u.total_downlink_bytes || 0)})`;
fResetUsage.checked = false;
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : ""; fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
const heading = document.getElementById("userFormHeading"); const heading = document.getElementById("userFormHeading");
const title = document.getElementById("userFormTitle"); const title = document.getElementById("userFormTitle");
@@ -218,6 +236,10 @@ userForm.addEventListener("submit", async e => {
expires_at: isoFromLocal(fExpires.value), expires_at: isoFromLocal(fExpires.value),
limit_mbps_up: parseInt(fUp.value||"0",10), limit_mbps_up: parseInt(fUp.value||"0",10),
limit_mbps_down: parseInt(fDown.value||"0",10), limit_mbps_down: parseInt(fDown.value||"0",10),
data_quota_bytes: Math.round((parseFloat(fQuotaGB.value || "0") || 0) * (1024 ** 3)),
quota_action: fQuotaAction.value === "throttle" ? "throttle" : "block",
quota_throttle_mbps: parseInt(fQuotaThrottle.value || "1", 10) || 1,
reset_usage: !!fResetUsage.checked,
server_id: selectedSSHServer(), server_id: selectedSSHServer(),
}; };
try { try {
@@ -225,6 +247,7 @@ userForm.addEventListener("submit", async e => {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
userStatus.textContent = t("Saved."); userStatus.textContent = t("Saved.");
fPassword.value = ""; fPassword.value = "";
fResetUsage.checked = false;
loadUsers(); loadUsers();
if (currentRole === "reseller") loadMe(); if (currentRole === "reseller") loadMe();
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS")); showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
+9
View File
@@ -325,6 +325,12 @@ function prepareXrayClientCreator(preferredTag = "") {
if (uuid) uuid.value = genUUID(); if (uuid) uuid.value = genUUID();
const maxConns = document.getElementById("xCreateMaxConns"); const maxConns = document.getElementById("xCreateMaxConns");
if (maxConns) maxConns.value = "0"; if (maxConns) maxConns.value = "0";
const quotaGB = document.getElementById("xCreateQuotaGB");
if (quotaGB) quotaGB.value = "0";
const quotaAction = document.getElementById("xCreateQuotaAction");
if (quotaAction) quotaAction.value = "block";
const quotaThrottle = document.getElementById("xCreateQuotaThrottle");
if (quotaThrottle) quotaThrottle.value = "1";
const status = document.getElementById("xCreateClientStatus"); 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."); if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
updateXrayCreatorInboundLabel(); updateXrayCreatorInboundLabel();
@@ -351,6 +357,9 @@ async function submitXrayClientCreator(event) {
name: (document.getElementById("xCreateName")?.value || "").trim(), name: (document.getElementById("xCreateName")?.value || "").trim(),
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""), expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0, max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0,
data_quota_bytes: Math.round((parseFloat(document.getElementById("xCreateQuotaGB")?.value || "0") || 0) * (1024 ** 3)),
quota_action: document.getElementById("xCreateQuotaAction")?.value === "throttle" ? "throttle" : "block",
quota_throttle_mbps: parseInt(document.getElementById("xCreateQuotaThrottle")?.value || "1", 10) || 1,
server_id: selectedXrayServer(), server_id: selectedXrayServer(),
}; };
if (button) button.disabled = true; if (button) button.disabled = true;
+9
View File
@@ -6,6 +6,11 @@ function openEditXrayClient(tag, client) {
document.getElementById("editXrayEmail").value = client.email || ""; document.getElementById("editXrayEmail").value = client.email || "";
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : ""; document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
document.getElementById("editXrayMaxConns").value = client.max_conns || 0; document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
document.getElementById("editXrayQuotaGB").value = client.data_quota_bytes ? (Number(client.data_quota_bytes) / (1024 ** 3)).toFixed(2).replace(/\.00$/, "") : "0";
document.getElementById("editXrayQuotaAction").value = client.quota_action === "throttle" ? "throttle" : "block";
document.getElementById("editXrayQuotaThrottle").value = client.quota_throttle_mbps || 1;
document.getElementById("editXrayUsage").value = `${formatBytes(client.total_bytes || 0)} (↑ ${formatBytes(client.uplink_bytes || 0)} · ↓ ${formatBytes(client.downlink_bytes || 0)})`;
document.getElementById("editXrayResetUsage").checked = false;
document.getElementById("editXrayClientStatus").textContent = ""; document.getElementById("editXrayClientStatus").textContent = "";
document.getElementById("editXrayClientPanel").classList.remove("hidden"); document.getElementById("editXrayClientPanel").classList.remove("hidden");
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" }); document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
@@ -26,6 +31,10 @@ async function saveEditXrayClient() {
email: document.getElementById("editXrayEmail").value.trim(), email: document.getElementById("editXrayEmail").value.trim(),
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value), expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10), max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
data_quota_bytes: Math.round((parseFloat(document.getElementById("editXrayQuotaGB").value || "0") || 0) * (1024 ** 3)),
quota_action: document.getElementById("editXrayQuotaAction").value === "throttle" ? "throttle" : "block",
quota_throttle_mbps: parseInt(document.getElementById("editXrayQuotaThrottle").value || "1", 10) || 1,
reset_usage: !!document.getElementById("editXrayResetUsage").checked,
server_id: selectedXrayServer(), server_id: selectedXrayServer(),
}; };
try { try {
+18 -5
View File
@@ -273,7 +273,7 @@
<table> <table>
<thead><tr> <thead><tr>
<th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th> <th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th>
<th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="expires">Expires</th> <th data-sort-key="conn">Conn</th><th data-sort-key="max">Max</th><th data-sort-key="up">Up</th><th data-sort-key="down">Dn</th><th data-sort-key="usage">Usage / Quota</th><th data-sort-key="expires">Expires</th>
<th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th> <th id="ownerColHead" data-sort-key="owner" class="superadmin-only hidden">Owner</th>
<th>Actions</th> <th>Actions</th>
</tr></thead> </tr></thead>
@@ -310,6 +310,11 @@
<div class="field"><label>Expires at</label><input id="fExpires" type="datetime-local"/></div> <div class="field"><label>Expires at</label><input id="fExpires" type="datetime-local"/></div>
<div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div> <div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div>
<div class="field"><label>Max Download (Mb/s)</label><input id="fDown" type="number" min="0" placeholder="0 = default"/></div> <div class="field"><label>Max Download (Mb/s)</label><input id="fDown" type="number" min="0" placeholder="0 = default"/></div>
<div class="field"><label>Data quota (GB) <span class="hint">0 = unlimited · 1024 = 1 TB</span></label><input id="fQuotaGB" type="number" min="0" step="0.01" placeholder="0"/></div>
<div class="field"><label>When quota is reached</label><select id="fQuotaAction"><option value="block">Block user</option><option value="throttle">Reduce speed</option></select></div>
<div class="field"><label>Post-quota speed (Mb/s)</label><input id="fQuotaThrottle" type="number" min="1" value="1"/></div>
<div class="field"><label>Current usage</label><input id="fUsageDisplay" readonly value="0 B"/></div>
<div class="field"><label>Reset traffic counter</label><input id="fResetUsage" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn" type="submit" id="saveUserBtn">Save user</button> <button class="btn" type="submit" id="saveUserBtn">Save user</button>
@@ -372,6 +377,11 @@
<div class="field"><label>Email / Label</label><input id="editXrayEmail" autocomplete="off"/></div> <div class="field"><label>Email / Label</label><input id="editXrayEmail" autocomplete="off"/></div>
<div class="field"><label>Expiry Date</label><input type="datetime-local" id="editXrayExpiry" style="color-scheme:dark;"/></div> <div class="field"><label>Expiry Date</label><input type="datetime-local" id="editXrayExpiry" style="color-scheme:dark;"/></div>
<div class="field"><label>Max Connections <span class="hint">(0 = unlimited)</span></label><input type="number" min="0" id="editXrayMaxConns"/></div> <div class="field"><label>Max Connections <span class="hint">(0 = unlimited)</span></label><input type="number" min="0" id="editXrayMaxConns"/></div>
<div class="field"><label>Data quota (GB) <span class="hint">0 = unlimited · 1024 = 1 TB</span></label><input type="number" min="0" step="0.01" id="editXrayQuotaGB"/></div>
<div class="field"><label>When quota is reached</label><select id="editXrayQuotaAction"><option value="block">Block user</option><option value="throttle">Reduce speed</option></select></div>
<div class="field"><label>Post-quota speed (Mb/s)</label><input type="number" min="1" id="editXrayQuotaThrottle" value="1"/></div>
<div class="field"><label>Current usage</label><input id="editXrayUsage" readonly value="0 B"/></div>
<div class="field"><label>Reset traffic counter</label><input id="editXrayResetUsage" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
</div> </div>
<div class="form-actions" style="margin-top:8px;"> <div class="form-actions" style="margin-top:8px;">
<button class="btn btn-sm" onclick="saveEditXrayClient()">Save Changes</button> <button class="btn btn-sm" onclick="saveEditXrayClient()">Save Changes</button>
@@ -428,6 +438,9 @@
<div class="field"><label>Email / identificação</label><input id="xCreateEmail" autocomplete="off" placeholder="cliente@example"/></div> <div class="field"><label>Email / identificação</label><input id="xCreateEmail" autocomplete="off" placeholder="cliente@example"/></div>
<div class="field"><label>Expira em</label><input id="xCreateExpiry" type="datetime-local"/></div> <div class="field"><label>Expira em</label><input id="xCreateExpiry" type="datetime-local"/></div>
<div class="field"><label>Máximo de conexões <span class="hint">0 = ilimitado</span></label><input id="xCreateMaxConns" type="number" min="0" value="0"/></div> <div class="field"><label>Máximo de conexões <span class="hint">0 = ilimitado</span></label><input id="xCreateMaxConns" type="number" min="0" value="0"/></div>
<div class="field"><label>Cota de dados (GB) <span class="hint">0 = ilimitado · 1024 = 1 TB</span></label><input id="xCreateQuotaGB" type="number" min="0" step="0.01" value="0"/></div>
<div class="field"><label>Ao atingir a cota</label><select id="xCreateQuotaAction"><option value="block">Bloquear usuário</option><option value="throttle">Reduzir velocidade</option></select></div>
<div class="field"><label>Velocidade após a cota (Mb/s)</label><input id="xCreateQuotaThrottle" type="number" min="1" value="1"/></div>
</div> </div>
<div class="form-actions"><button class="btn" id="xCreateClientBtn" type="submit">Criar usuário</button><button class="btn btn-ghost" id="xCreateCancelBtn" type="button">Voltar aos usuários</button></div> <div class="form-actions"><button class="btn" id="xCreateClientBtn" type="submit">Criar usuário</button><button class="btn btn-ghost" id="xCreateCancelBtn" type="button">Voltar aos usuários</button></div>
<div class="statusbar"><span id="xCreateClientStatus">Preencha os dados do novo cliente.</span></div> <div class="statusbar"><span id="xCreateClientStatus">Preencha os dados do novo cliente.</span></div>
@@ -1542,15 +1555,15 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260714pamfix1"></script> <script defer src="assets/js/01-core.js?v=20260714quota1"></script>
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script> <script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260714pamfix1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260714quota1"></script>
<script defer src="assets/js/04-xray.js?v=20260714pamfix1"></script> <script defer src="assets/js/04-xray.js?v=20260714quota1"></script>
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script> <script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script> <script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
<script defer src="assets/js/08-server-config.js?v=20260714pamfix1"></script> <script defer src="assets/js/08-server-config.js?v=20260714pamfix1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260714pamfix1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260714quota1"></script>
<script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script> <script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script>
<script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script> <script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
<script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script> <script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script>
+94 -4
View File
@@ -371,6 +371,13 @@ type UserConfig struct {
LimitMbpsUp int `json:"limit_mbps_up"` // Mbps upstream LimitMbpsUp int `json:"limit_mbps_up"` // Mbps upstream
LimitMbpsDown int `json:"limit_mbps_down"` // Mbps downstream LimitMbpsDown int `json:"limit_mbps_down"` // Mbps downstream
// Persistent data quota. Zero means unlimited. When the total uploaded +
// downloaded bytes reaches the quota, QuotaAction either blocks traffic or
// throttles the account to QuotaThrottleMbps.
DataQuotaBytes int64 `json:"data_quota_bytes"`
QuotaAction string `json:"quota_action"`
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
// OwnerUsername is the reseller who created this SSH user. Empty = superadmin-owned. // OwnerUsername is the reseller who created this SSH user. Empty = superadmin-owned.
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
} }
@@ -383,6 +390,17 @@ type UserState struct {
mu sync.Mutex mu sync.Mutex
ActiveConns int ActiveConns int
conns map[*ssh.ServerConn]struct{} // active SSH connections for this user conns map[*ssh.ServerConn]struct{} // active SSH connections for this user
// Persistent per-user tunnel traffic. totalBytes includes reservations made
// by concurrent copy loops, while directional totals only include bytes that
// were actually written. The pending counters are flushed to PostgreSQL.
TotalUplinkBytes int64
TotalDownlinkBytes int64
totalBytes int64
pendingUplinkBytes int64
pendingDownlinkBytes int64
quotaLimiter *rate.Limiter
quotaLimiterMbps int
} }
type UserManager struct { type UserManager struct {
@@ -1373,6 +1391,11 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
expires_at TEXT, expires_at TEXT,
limit_mbps_up INT NOT NULL DEFAULT 0, limit_mbps_up INT NOT NULL DEFAULT 0,
limit_mbps_down INT NOT NULL DEFAULT 0, limit_mbps_down INT NOT NULL DEFAULT 0,
data_quota_bytes BIGINT NOT NULL DEFAULT 0,
quota_action TEXT NOT NULL DEFAULT 'block',
quota_throttle_mbps INT NOT NULL DEFAULT 1,
total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
totp_secret TEXT NOT NULL DEFAULT '', totp_secret TEXT NOT NULL DEFAULT '',
totp_period INT NOT NULL DEFAULT 60, totp_period INT NOT NULL DEFAULT 60,
totp_window INT NOT NULL DEFAULT 1, totp_window INT NOT NULL DEFAULT 1,
@@ -1386,6 +1409,11 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS use_pam BOOLEAN NOT NULL DEFAULT FALSE`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS use_pam BOOLEAN NOT NULL DEFAULT FALSE`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS data_quota_bytes BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS quota_action TEXT NOT NULL DEFAULT 'block'`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS quota_throttle_mbps INT NOT NULL DEFAULT 1`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS total_uplink_bytes BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''`, `ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''`,
} }
for _, stmt := range stmts { for _, stmt := range stmts {
@@ -1433,6 +1461,8 @@ func (s *Store) migrateSSHPasswords(ctx context.Context) error {
func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, SELECT username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1),
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0),
COALESCE(totp_secret, ''), COALESCE(totp_period, 60), COALESCE(totp_window, 1), COALESCE(totp_secret, ''), COALESCE(totp_period, 60), COALESCE(totp_window, 1),
COALESCE(totp_digits, 6), COALESCE(allow_static_password, FALSE), COALESCE(totp_digits, 6), COALESCE(allow_static_password, FALSE),
COALESCE(use_pam, FALSE), COALESCE(owner_username, '') COALESCE(use_pam, FALSE), COALESCE(owner_username, '')
@@ -1451,6 +1481,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
expiresAt sql.NullString expiresAt sql.NullString
limitUp int limitUp int
limitDown int limitDown int
dataQuotaBytes int64
quotaAction string
quotaThrottleMbps int
totalUplinkBytes int64
totalDownlinkBytes int64
totpSecret string totpSecret string
totpPeriod int totpPeriod int
totpWindow int totpWindow int
@@ -1460,6 +1495,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
ownerUsername string ownerUsername string
) )
if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown, if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown,
&dataQuotaBytes, &quotaAction, &quotaThrottleMbps, &totalUplinkBytes, &totalDownlinkBytes,
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil { &totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil {
return nil, err return nil, err
} }
@@ -1474,6 +1510,9 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
MaxConnections: maxConnections, MaxConnections: maxConnections,
LimitMbpsUp: limitUp, LimitMbpsUp: limitUp,
LimitMbpsDown: limitDown, LimitMbpsDown: limitDown,
DataQuotaBytes: dataQuotaBytes,
QuotaAction: normalizeQuotaAction(quotaAction),
QuotaThrottleMbps: quotaThrottleMbps,
TOTPSecret: totpSecret, TOTPSecret: totpSecret,
TOTPPeriod: totpPeriod, TOTPPeriod: totpPeriod,
TOTPWindow: totpWindow, TOTPWindow: totpWindow,
@@ -1484,6 +1523,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
} }
st := &UserState{Cfg: cfg} st := &UserState{Cfg: cfg}
initSSHRuntimeUsage(st, totalUplinkBytes, totalDownlinkBytes)
if expiresAt.Valid && expiresAt.String != "" { if expiresAt.Valid && expiresAt.String != "" {
t, err := time.Parse(time.RFC3339, expiresAt.String) t, err := time.Parse(time.RFC3339, expiresAt.String)
if err != nil { if err != nil {
@@ -1510,15 +1550,19 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
INSERT INTO ssh_users ( INSERT INTO ssh_users (
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
data_quota_bytes, quota_action, quota_throttle_mbps,
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, use_pam, owner_username totp_secret, totp_period, totp_window, totp_digits, allow_static_password, use_pam, owner_username
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (username) DO UPDATE ON CONFLICT (username) DO UPDATE
SET password = EXCLUDED.password, SET password = EXCLUDED.password,
max_connections = EXCLUDED.max_connections, max_connections = EXCLUDED.max_connections,
expires_at = EXCLUDED.expires_at, expires_at = EXCLUDED.expires_at,
limit_mbps_up = EXCLUDED.limit_mbps_up, limit_mbps_up = EXCLUDED.limit_mbps_up,
limit_mbps_down = EXCLUDED.limit_mbps_down, limit_mbps_down = EXCLUDED.limit_mbps_down,
data_quota_bytes = EXCLUDED.data_quota_bytes,
quota_action = EXCLUDED.quota_action,
quota_throttle_mbps = EXCLUDED.quota_throttle_mbps,
totp_secret = EXCLUDED.totp_secret, totp_secret = EXCLUDED.totp_secret,
totp_period = EXCLUDED.totp_period, totp_period = EXCLUDED.totp_period,
totp_window = EXCLUDED.totp_window, totp_window = EXCLUDED.totp_window,
@@ -1527,6 +1571,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
use_pam = EXCLUDED.use_pam`, use_pam = EXCLUDED.use_pam`,
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only. // owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown, u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
u.DataQuotaBytes, normalizeQuotaAction(u.QuotaAction), quotaThrottleMbpsOrDefault(u.QuotaThrottleMbps),
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername) u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername)
return err return err
} }
@@ -1739,6 +1784,13 @@ type UserDTO struct {
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
LimitUpMbps int `json:"limit_mbps_up"` LimitUpMbps int `json:"limit_mbps_up"`
LimitDownMbps int `json:"limit_mbps_down"` 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"` TOTPSecret string `json:"totp_secret,omitempty"`
TOTPPeriod int `json:"totp_period"` TOTPPeriod int `json:"totp_period"`
TOTPWindow int `json:"totp_window"` TOTPWindow int `json:"totp_window"`
@@ -1773,6 +1825,9 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
cfg := u.Cfg cfg := u.Cfg
expires := u.ExpiresAt expires := u.ExpiresAt
u.mu.Unlock() u.mu.Unlock()
totalUp := atomic.LoadInt64(&u.TotalUplinkBytes)
totalDown := atomic.LoadInt64(&u.TotalDownlinkBytes)
totalBytes := atomic.LoadInt64(&u.totalBytes)
// Resellers only see their own users // Resellers only see their own users
if sess != nil && sess.Role == RoleReseller && cfg.OwnerUsername != sess.Username { if sess != nil && sess.Role == RoleReseller && cfg.OwnerUsername != sess.Username {
@@ -1786,6 +1841,13 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
ExpiresAt: expires, ExpiresAt: expires,
LimitUpMbps: cfg.LimitMbpsUp, LimitUpMbps: cfg.LimitMbpsUp,
LimitDownMbps: cfg.LimitMbpsDown, LimitDownMbps: cfg.LimitMbpsDown,
DataQuotaBytes: cfg.DataQuotaBytes,
QuotaAction: normalizeQuotaAction(cfg.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(cfg.QuotaThrottleMbps),
TotalUplinkBytes: totalUp,
TotalDownlinkBytes: totalDown,
TotalBytes: totalBytes,
QuotaExceeded: cfg.DataQuotaBytes > 0 && totalBytes >= cfg.DataQuotaBytes,
TOTPSecret: cfg.TOTPSecret, TOTPSecret: cfg.TOTPSecret,
TOTPPeriod: cfg.TOTPPeriod, TOTPPeriod: cfg.TOTPPeriod,
TOTPWindow: cfg.TOTPWindow, TOTPWindow: cfg.TOTPWindow,
@@ -1809,6 +1871,10 @@ type UserPayload struct {
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
LimitUpMbps int `json:"limit_mbps_up"` LimitUpMbps int `json:"limit_mbps_up"`
LimitDownMbps int `json:"limit_mbps_down"` LimitDownMbps int `json:"limit_mbps_down"`
DataQuotaBytes int64 `json:"data_quota_bytes"`
QuotaAction string `json:"quota_action"`
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
ResetUsage bool `json:"reset_usage,omitempty"`
TOTPSecret string `json:"totp_secret"` TOTPSecret string `json:"totp_secret"`
TOTPPeriod int `json:"totp_period"` TOTPPeriod int `json:"totp_period"`
TOTPWindow int `json:"totp_window"` TOTPWindow int `json:"totp_window"`
@@ -1839,6 +1905,10 @@ func handleCreateUser(store *Store) http.HandlerFunc {
http.Error(w, "username required", http.StatusBadRequest) http.Error(w, "username required", http.StatusBadRequest)
return return
} }
if err := validateQuotaConfig(p.DataQuotaBytes, p.QuotaAction, p.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context() ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil { if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
@@ -1964,6 +2034,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
ExpiresAt: p.ExpiresAt, ExpiresAt: p.ExpiresAt,
LimitMbpsUp: p.LimitUpMbps, LimitMbpsUp: p.LimitUpMbps,
LimitMbpsDown: p.LimitDownMbps, LimitMbpsDown: p.LimitDownMbps,
DataQuotaBytes: p.DataQuotaBytes,
QuotaAction: normalizeQuotaAction(p.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(p.QuotaThrottleMbps),
TOTPSecret: strings.TrimSpace(p.TOTPSecret), TOTPSecret: strings.TrimSpace(p.TOTPSecret),
TOTPPeriod: p.TOTPPeriod, TOTPPeriod: p.TOTPPeriod,
TOTPWindow: p.TOTPWindow, TOTPWindow: p.TOTPWindow,
@@ -1978,9 +2051,14 @@ func handleCreateUser(store *Store) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
// Force-disconnect all active sessions for this user so new config applies. // Force-disconnect all active sessions for this user so new config applies.
userMgr.DisconnectUser(p.Username) userMgr.DisconnectUser(p.Username)
if p.ResetUsage {
if err := resetSSHUserTrafficAccounting(ctx, store, p.Username); err != nil {
http.Error(w, "could not reset usage", http.StatusInternalServerError)
return
}
}
reloadUsersFromDB(ctx, store) reloadUsersFromDB(ctx, store)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -2191,6 +2269,10 @@ func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, err
log.Printf("user %s tried to connect but account is expired", meta.User()) log.Printf("user %s tried to connect but account is expired", meta.User())
return nil, fmt.Errorf("account expired") return nil, fmt.Errorf("account expired")
} }
if sshUserQuotaBlocked(u) {
log.Printf("user %s tried to connect after reaching the data quota", meta.User())
return nil, errDataQuotaExceeded
}
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil { if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err) return nil, fmt.Errorf("authentication failed: %w", err)
} }
@@ -2246,6 +2328,9 @@ func publicKeyCallback(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissio
log.Printf("user %s tried to connect but account is expired", meta.User()) log.Printf("user %s tried to connect but account is expired", meta.User())
return nil, fmt.Errorf("account expired") return nil, fmt.Errorf("account expired")
} }
if sshUserQuotaBlocked(u) {
return nil, errDataQuotaExceeded
}
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil { if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err) return nil, fmt.Errorf("authentication failed: %w", err)
} }
@@ -2377,6 +2462,10 @@ type directTCPIPReq struct {
} }
func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimiter *rate.Limiter) { func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimiter *rate.Limiter) {
if sshUserQuotaBlocked(u) {
newChan.Reject(ssh.Prohibited, "data quota exceeded")
return
}
var req directTCPIPReq var req directTCPIPReq
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil { if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil {
newChan.Reject(ssh.Prohibited, "bad direct-tcpip request") newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
@@ -2421,7 +2510,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
// upstream: SSH channel -> backend, in its own goroutine. // upstream: SSH channel -> backend, in its own goroutine.
go func() { go func() {
_, _ = copyWithRateLimit(backend, ch, upLimiter) _, _ = copyWithRateLimit(sshQuotaWriter{w: backend, user: u, uplink: true}, ch, upLimiter)
// Signal to the backend that we are done writing. // Signal to the backend that we are done writing.
if cw, ok := backend.(interface{ CloseWrite() error }); ok { if cw, ok := backend.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite() _ = cw.CloseWrite()
@@ -2432,7 +2521,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
// downstream: backend -> SSH channel, run in this goroutine. // downstream: backend -> SSH channel, run in this goroutine.
// handleDirectTCPIP already runs as its own goroutine (see handleConn), // handleDirectTCPIP already runs as its own goroutine (see handleConn),
// so reusing it here avoids spawning a third goroutine per channel. // so reusing it here avoids spawning a third goroutine per channel.
_, _ = copyWithRateLimit(ch, backend, downLimiter) _, _ = copyWithRateLimit(sshQuotaWriter{w: ch, user: u, uplink: false}, backend, downLimiter)
closeAll() closeAll()
} }
@@ -3074,6 +3163,7 @@ func main() {
// Optional: initialize interface totals persistence (best-effort). // Optional: initialize interface totals persistence (best-effort).
if store != nil { if store != nil {
statsStore = store statsStore = store
startSSHUserTrafficFlusher(store)
ctx := context.Background() ctx := context.Background()
if err := store.EnsureXrayClientsSchema(ctx); err != nil { if err := store.EnsureXrayClientsSchema(ctx); err != nil {
log.Printf("xray clients table: %v", err) log.Printf("xray clients table: %v", err)
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/time/rate"
)
const (
quotaActionBlock = "block"
quotaActionThrottle = "throttle"
)
var errDataQuotaExceeded = errors.New("data quota exceeded")
func normalizeQuotaAction(v string) string {
if strings.EqualFold(strings.TrimSpace(v), quotaActionThrottle) {
return quotaActionThrottle
}
return quotaActionBlock
}
func quotaThrottleMbpsOrDefault(v int) int {
if v <= 0 {
return 1
}
return v
}
type sshTrafficDelta struct {
Uplink int64
Downlink int64
}
var sshTrafficPersistenceMu sync.Mutex
func (s *Store) AddSSHUserTrafficBatch(ctx context.Context, deltas map[string]sshTrafficDelta) error {
if s == nil || len(deltas) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `
UPDATE ssh_users SET
total_uplink_bytes = GREATEST(total_uplink_bytes + GREATEST($2::BIGINT, 0), 0),
total_downlink_bytes = GREATEST(total_downlink_bytes + GREATEST($3::BIGINT, 0), 0)
WHERE username = $1`)
if err != nil {
_ = tx.Rollback()
return err
}
defer stmt.Close()
for username, d := range deltas {
if strings.TrimSpace(username) == "" || (d.Uplink == 0 && d.Downlink == 0) {
continue
}
if _, err := stmt.ExecContext(ctx, username, d.Uplink, d.Downlink); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (s *Store) ResetSSHUserTraffic(ctx context.Context, username string) error {
if s == nil || strings.TrimSpace(username) == "" {
return nil
}
_, err := s.db.ExecContext(ctx, `
UPDATE ssh_users
SET total_uplink_bytes = 0, total_downlink_bytes = 0
WHERE username = $1`, username)
return err
}
func initSSHRuntimeUsage(u *UserState, uplink, downlink int64) {
if u == nil {
return
}
if uplink < 0 {
uplink = 0
}
if downlink < 0 {
downlink = 0
}
atomic.StoreInt64(&u.TotalUplinkBytes, uplink)
atomic.StoreInt64(&u.TotalDownlinkBytes, downlink)
atomic.StoreInt64(&u.totalBytes, uplink+downlink)
atomic.StoreInt64(&u.pendingUplinkBytes, 0)
atomic.StoreInt64(&u.pendingDownlinkBytes, 0)
}
func resetSSHRuntimeUsage(username string) {
u, ok := userMgr.Get(username)
if !ok || u == nil {
return
}
initSSHRuntimeUsage(u, 0, 0)
u.mu.Lock()
u.quotaLimiter = nil
u.quotaLimiterMbps = 0
u.mu.Unlock()
}
func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error {
sshTrafficPersistenceMu.Lock()
defer sshTrafficPersistenceMu.Unlock()
if err := store.ResetSSHUserTraffic(ctx, username); err != nil {
return err
}
resetSSHRuntimeUsage(username)
return nil
}
func sshUserQuotaBlocked(u *UserState) bool {
if u == nil {
return false
}
u.mu.Lock()
quota := u.Cfg.DataQuotaBytes
action := normalizeQuotaAction(u.Cfg.QuotaAction)
u.mu.Unlock()
return quota > 0 && action == quotaActionBlock && atomic.LoadInt64(&u.totalBytes) >= quota
}
func sshQuotaLimiter(u *UserState, mbps int) *rate.Limiter {
mbps = quotaThrottleMbpsOrDefault(mbps)
u.mu.Lock()
defer u.mu.Unlock()
if u.quotaLimiter == nil || u.quotaLimiterMbps != mbps {
bps := mbpsToBytesPerSec(mbps)
burst := int(bps)
if burst < copyBufSize {
burst = copyBufSize
}
u.quotaLimiter = rate.NewLimiter(rate.Limit(bps), burst)
u.quotaLimiterMbps = mbps
}
return u.quotaLimiter
}
func reserveSSHUserBytes(u *UserState, requested int) (allowed int, throttle *rate.Limiter, stopAfter bool) {
if u == nil || requested <= 0 {
return 0, nil, false
}
u.mu.Lock()
quota := u.Cfg.DataQuotaBytes
action := normalizeQuotaAction(u.Cfg.QuotaAction)
throttleMbps := u.Cfg.QuotaThrottleMbps
u.mu.Unlock()
n := int64(requested)
if quota <= 0 {
atomic.AddInt64(&u.totalBytes, n)
return requested, nil, false
}
if action == quotaActionThrottle {
previous := atomic.AddInt64(&u.totalBytes, n) - n
if previous+n > quota {
return requested, sshQuotaLimiter(u, throttleMbps), false
}
return requested, nil, false
}
for {
used := atomic.LoadInt64(&u.totalBytes)
remaining := quota - used
if remaining <= 0 {
return 0, nil, true
}
take := n
if take > remaining {
take = remaining
}
if atomic.CompareAndSwapInt64(&u.totalBytes, used, used+take) {
return int(take), nil, take < n || used+take >= quota
}
}
}
func finishSSHUserReservation(u *UserState, uplink bool, reserved, written int) {
if u == nil || reserved <= 0 {
return
}
if written < 0 {
written = 0
}
if written > reserved {
written = reserved
}
if written < reserved {
atomic.AddInt64(&u.totalBytes, -int64(reserved-written))
}
if written == 0 {
return
}
if uplink {
atomic.AddInt64(&u.TotalUplinkBytes, int64(written))
atomic.AddInt64(&u.pendingUplinkBytes, int64(written))
} else {
atomic.AddInt64(&u.TotalDownlinkBytes, int64(written))
atomic.AddInt64(&u.pendingDownlinkBytes, int64(written))
}
}
type sshQuotaWriter struct {
w io.Writer
user *UserState
uplink bool
}
func (qw sshQuotaWriter) Write(p []byte) (int, error) {
allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p))
if allowed <= 0 {
return 0, errDataQuotaExceeded
}
if quotaLimiter != nil {
if err := quotaLimiter.WaitN(context.Background(), allowed); err != nil {
finishSSHUserReservation(qw.user, qw.uplink, allowed, 0)
return 0, err
}
}
n, err := qw.w.Write(p[:allowed])
finishSSHUserReservation(qw.user, qw.uplink, allowed, n)
if err != nil {
return n, err
}
if stopAfter || allowed < len(p) {
return n, errDataQuotaExceeded
}
return n, nil
}
func startSSHUserTrafficFlusher(store *Store) {
if store == nil {
return
}
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
flushSSHUserTraffic(store)
}
}()
}
func flushSSHUserTraffic(store *Store) {
if store == nil {
return
}
sshTrafficPersistenceMu.Lock()
defer sshTrafficPersistenceMu.Unlock()
deltas := make(map[string]sshTrafficDelta)
states := make(map[string]*UserState)
for _, u := range userMgr.List() {
if u == nil || strings.TrimSpace(u.Cfg.Username) == "" {
continue
}
up := atomic.SwapInt64(&u.pendingUplinkBytes, 0)
down := atomic.SwapInt64(&u.pendingDownlinkBytes, 0)
if up == 0 && down == 0 {
continue
}
username := u.Cfg.Username
deltas[username] = sshTrafficDelta{Uplink: up, Downlink: down}
states[username] = u
}
if len(deltas) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := store.AddSSHUserTrafficBatch(ctx, deltas); err != nil {
log.Printf("ssh traffic flush failed: %v", err)
for username, d := range deltas {
if u := states[username]; u != nil {
atomic.AddInt64(&u.pendingUplinkBytes, d.Uplink)
atomic.AddInt64(&u.pendingDownlinkBytes, d.Downlink)
}
}
}
}
func validateQuotaConfig(quotaBytes int64, action string, throttleMbps int) error {
if quotaBytes < 0 {
return fmt.Errorf("data_quota_bytes must be non-negative")
}
action = normalizeQuotaAction(action)
if quotaBytes > 0 && action == quotaActionThrottle && throttleMbps < 0 {
return fmt.Errorf("quota_throttle_mbps must be non-negative")
}
return nil
}
+50 -11
View File
@@ -18,6 +18,9 @@ type XrayClientMeta struct {
OwnerUsername string OwnerUsername string
ExpiresAt *time.Time ExpiresAt *time.Time
MaxConns int MaxConns int
DataQuotaBytes int64
QuotaAction string
QuotaThrottleMbps int
CreatedAt time.Time CreatedAt time.Time
TotalUplinkBytes int64 TotalUplinkBytes int64
TotalDownlinkBytes int64 TotalDownlinkBytes int64
@@ -35,6 +38,9 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
owner_username TEXT NOT NULL DEFAULT '', owner_username TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
max_conns INT NOT NULL DEFAULT 0, max_conns INT NOT NULL DEFAULT 0,
data_quota_bytes BIGINT NOT NULL DEFAULT 0,
quota_action TEXT NOT NULL DEFAULT 'block',
quota_throttle_mbps INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
total_uplink_bytes BIGINT NOT NULL DEFAULT 0, total_uplink_bytes BIGINT NOT NULL DEFAULT 0,
total_downlink_bytes BIGINT NOT NULL DEFAULT 0, total_downlink_bytes BIGINT NOT NULL DEFAULT 0,
@@ -42,6 +48,9 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
active_connections INT NOT NULL DEFAULT 0 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 owner_username TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS data_quota_bytes BIGINT NOT NULL DEFAULT 0`,
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS quota_action TEXT NOT NULL DEFAULT 'block'`,
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS quota_throttle_mbps INT NOT NULL DEFAULT 1`,
`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_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 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 last_active TIMESTAMPTZ`,
@@ -61,16 +70,20 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
expiresAt = *m.ExpiresAt expiresAt = *m.ExpiresAt
} }
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns) INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns, data_quota_bytes, quota_action, quota_throttle_mbps)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
email = EXCLUDED.email, email = EXCLUDED.email,
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END, 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, owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
expires_at = EXCLUDED.expires_at, expires_at = EXCLUDED.expires_at,
max_conns = EXCLUDED.max_conns`, max_conns = EXCLUDED.max_conns,
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns) data_quota_bytes = EXCLUDED.data_quota_bytes,
quota_action = EXCLUDED.quota_action,
quota_throttle_mbps = EXCLUDED.quota_throttle_mbps`,
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns,
m.DataQuotaBytes, normalizeQuotaAction(m.QuotaAction), quotaThrottleMbpsOrDefault(m.QuotaThrottleMbps))
return err return err
} }
@@ -79,10 +92,13 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
var expiresAt sql.NullTime var expiresAt sql.NullTime
var lastActive sql.NullTime var lastActive sql.NullTime
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
FROM xray_clients WHERE uuid = $1`, uuid). FROM xray_clients WHERE uuid = $1`, uuid).
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt, &m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections) Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns,
&m.DataQuotaBytes, &m.QuotaAction, &m.QuotaThrottleMbps, &m.CreatedAt,
&m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -97,12 +113,16 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error { func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM xray_clients WHERE uuid = $1`, uuid) _, err := s.db.ExecContext(ctx, `DELETE FROM xray_clients WHERE uuid = $1`, uuid)
if err == nil {
xrayMgr.removeNativeQuotaPolicy(uuid)
}
return err return err
} }
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0)
FROM xray_clients ORDER BY created_at DESC`) FROM xray_clients ORDER BY created_at DESC`)
if err != nil { if err != nil {
@@ -114,7 +134,8 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) { func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) 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) FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
if err != nil { if err != nil {
@@ -132,7 +153,8 @@ func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername strin
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) 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()`) FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
if err != nil { if err != nil {
@@ -148,7 +170,9 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
var lastActive sql.NullTime 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 { if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns,
&m.DataQuotaBytes, &m.QuotaAction, &m.QuotaThrottleMbps, &m.CreatedAt,
&m.TotalUplinkBytes, &m.TotalDownlinkBytes, &lastActive, &m.ActiveConnections); err != nil {
return nil, err return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
@@ -304,3 +328,18 @@ func startXrayClientExpiryChecker(store *Store) {
} }
}() }()
} }
// ResetXrayClientTraffic clears a client's persistent usage without removing
// the account or changing its expiry/quota policy.
func (s *Store) ResetXrayClientTraffic(ctx context.Context, uuid string) error {
if s == nil || uuid == "" {
return nil
}
_, err := s.db.ExecContext(ctx, `
UPDATE xray_clients SET
total_uplink_bytes = 0,
total_downlink_bytes = 0,
last_active = NULL
WHERE uuid = $1`, uuid)
return err
}
+2 -1
View File
@@ -64,7 +64,8 @@ func (s *Store) UpsertXrayConfig(ctx context.Context, configKey string, data []b
func (s *Store) ListXrayClientsByInbound(ctx context.Context, inboundTag string) ([]*XrayClientMeta, error) { func (s *Store) ListXrayClientsByInbound(ctx context.Context, inboundTag string) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at, SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns,
COALESCE(data_quota_bytes, 0), COALESCE(quota_action, 'block'), COALESCE(quota_throttle_mbps, 1), created_at,
COALESCE(total_uplink_bytes, 0), COALESCE(total_downlink_bytes, 0), last_active, COALESCE(active_connections, 0) 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) FROM xray_clients WHERE inbound_tag = $1 ORDER BY created_at DESC`, inboundTag)
if err != nil { if err != nil {
+105 -44
View File
@@ -259,8 +259,12 @@ type XrayManager struct {
pollStarted bool pollStarted bool
nativeDBMu sync.Mutex nativeDBMu sync.Mutex
nativeTrafficPersistMu sync.Mutex
nativeTrafficPending map[string]xrayPendingTraffic nativeTrafficPending map[string]xrayPendingTraffic
nativeStatsFlushStarted bool nativeStatsFlushStarted bool
nativeQuotaMu sync.RWMutex
nativeQuotaByUUID map[string]*xrayNativeQuotaState
} }
type xrayTrafficCounters struct { type xrayTrafficCounters struct {
@@ -296,6 +300,8 @@ func initXrayManager(cfg *XrayConfig) {
} }
xrayMgr.mu.Unlock() xrayMgr.mu.Unlock()
xrayMgr.reloadNativeQuotaPolicies()
// In native mode the in-process emulator records traffic directly, so the // In native mode the in-process emulator records traffic directly, so the
// external `xray api statsquery` poller is not started (it would overwrite // external `xray api statsquery` poller is not started (it would overwrite
// the native counters with errors from a non-existent CLI endpoint). // the native counters with errors from a non-existent CLI endpoint).
@@ -514,7 +520,7 @@ func (m *XrayManager) recordNativeDisconnect(uuid, email string) {
// recordNativeTraffic accumulates in-process byte counters for a client and // recordNativeTraffic accumulates in-process byte counters for a client and
// queues DB persistence. Used by the native emulator instead of external // queues DB persistence. Used by the native emulator instead of external
// `xray api statsquery` polling. // `xray api statsquery` polling.
func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) { func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64, generation uint64) {
uuid = strings.TrimSpace(uuid) uuid = strings.TrimSpace(uuid)
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
if email == "" { if email == "" {
@@ -523,18 +529,14 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
if email == "" || (up == 0 && down == 0) { if email == "" || (up == 0 && down == 0) {
return return
} }
now := time.Now() state := m.nativeQuotaState(uuid)
m.statsMu.Lock() if state != nil {
if m.statsByEmail == nil { state.mu.Lock()
m.statsByEmail = make(map[string]xrayRuntimeStat) defer state.mu.Unlock()
if generation != state.generation {
return
}
} }
st := m.statsByEmail[email]
st.Email = email
st.Uplink += up
st.Downlink += down
st.LastActive = now
m.statsByEmail[email] = st
m.statsMu.Unlock()
if statsStore != nil && uuid != "" { if statsStore != nil && uuid != "" {
m.nativeDBMu.Lock() m.nativeDBMu.Lock()
@@ -548,6 +550,20 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
m.nativeTrafficPending[uuid] = p m.nativeTrafficPending[uuid] = p
m.nativeDBMu.Unlock() m.nativeDBMu.Unlock()
} }
now := time.Now()
m.statsMu.Lock()
if m.statsByEmail == nil {
m.statsByEmail = make(map[string]xrayRuntimeStat)
}
st := m.statsByEmail[email]
st.Email = email
st.Uplink += up
st.Downlink += down
st.LastActive = now
m.statsByEmail[email] = st
m.statsMu.Unlock()
} }
func (m *XrayManager) startNativeStatsFlusher() { func (m *XrayManager) startNativeStatsFlusher() {
@@ -594,6 +610,8 @@ func (m *XrayManager) flushNativeStatsToDB() {
if statsStore == nil { if statsStore == nil {
return return
} }
m.nativeTrafficPersistMu.Lock()
defer m.nativeTrafficPersistMu.Unlock()
m.nativeDBMu.Lock() m.nativeDBMu.Lock()
pending := m.nativeTrafficPending pending := m.nativeTrafficPending
m.nativeTrafficPending = nil m.nativeTrafficPending = nil
@@ -2027,12 +2045,16 @@ type XrayClientInfo struct {
TotalBytes int64 `json:"total_bytes,omitempty"` TotalBytes int64 `json:"total_bytes,omitempty"`
ActiveConnections int `json:"active_connections,omitempty"` ActiveConnections int `json:"active_connections,omitempty"`
// Metadata from PostgreSQL (enriched by handleXrayInbounds) // Metadata from PostgreSQL (enriched by handleXrayInbounds)
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
ExpirationDays int `json:"expiration_days"` ExpirationDays int `json:"expiration_days"`
MaxConns int `json:"max_conns"` MaxConns int `json:"max_conns"`
OwnerUsername string `json:"owner_username,omitempty"` DataQuotaBytes int64 `json:"data_quota_bytes"`
Expired bool `json:"expired,omitempty"` QuotaAction string `json:"quota_action"`
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
QuotaExceeded bool `json:"quota_exceeded,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
Expired bool `json:"expired,omitempty"`
} }
// XrayInboundInfo is returned by /api/xray/inbounds. // XrayInboundInfo is returned by /api/xray/inbounds.
@@ -2338,6 +2360,10 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
Name: m.Name, Name: m.Name,
ExpiresAt: m.ExpiresAt, ExpiresAt: m.ExpiresAt,
MaxConns: m.MaxConns, MaxConns: m.MaxConns,
DataQuotaBytes: m.DataQuotaBytes,
QuotaAction: normalizeQuotaAction(m.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(m.QuotaThrottleMbps),
QuotaExceeded: m.DataQuotaBytes > 0 && m.TotalUplinkBytes+m.TotalDownlinkBytes >= m.DataQuotaBytes,
OwnerUsername: m.OwnerUsername, OwnerUsername: m.OwnerUsername,
UplinkBytes: m.TotalUplinkBytes, UplinkBytes: m.TotalUplinkBytes,
DownlinkBytes: m.TotalDownlinkBytes, DownlinkBytes: m.TotalDownlinkBytes,
@@ -2421,6 +2447,7 @@ func applyXrayRuntimeStats(c *XrayClientInfo) {
c.DownlinkBytes = st.Downlink c.DownlinkBytes = st.Downlink
} }
c.TotalBytes = c.UplinkBytes + c.DownlinkBytes c.TotalBytes = c.UplinkBytes + c.DownlinkBytes
c.QuotaExceeded = c.DataQuotaBytes > 0 && c.TotalBytes >= c.DataQuotaBytes
if st.ActiveConnections > c.ActiveConnections { if st.ActiveConnections > c.ActiveConnections {
c.ActiveConnections = st.ActiveConnections c.ActiveConnections = st.ActiveConnections
} }
@@ -2437,14 +2464,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
InboundTag string `json:"inbound_tag"` InboundTag string `json:"inbound_tag"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty
MaxConnections int `json:"max_connections"` MaxConnections int `json:"max_connections"`
OwnerUsername string `json:"owner_username,omitempty"` DataQuotaBytes int64 `json:"data_quota_bytes"`
ServerID string `json:"server_id,omitempty"` QuotaAction string `json:"quota_action"`
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
@@ -2454,6 +2484,10 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest) http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return return
} }
if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -2543,12 +2577,15 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
} }
if statsStore != nil { if statsStore != nil {
meta := XrayClientMeta{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
InboundTag: req.InboundTag, InboundTag: req.InboundTag,
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
MaxConns: req.MaxConnections, MaxConns: req.MaxConnections,
DataQuotaBytes: req.DataQuotaBytes,
QuotaAction: normalizeQuotaAction(req.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
} }
if req.ExpiresAt != "" { if req.ExpiresAt != "" {
var t time.Time var t time.Time
@@ -2565,6 +2602,8 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
} }
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil { if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
xrayLogf("xray: save meta for %s: %v", req.UUID, err) xrayLogf("xray: save meta for %s: %v", req.UUID, err)
} else {
xrayMgr.setNativeQuotaPolicy(&meta)
} }
} }
xrayMgr.restartIfExternalRunning() xrayMgr.restartIfExternalRunning()
@@ -2579,12 +2618,16 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
MaxConnections int `json:"max_connections"` MaxConnections int `json:"max_connections"`
ServerID string `json:"server_id,omitempty"` DataQuotaBytes int64 `json:"data_quota_bytes"`
QuotaAction string `json:"quota_action"`
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
ResetUsage bool `json:"reset_usage,omitempty"`
ServerID string `json:"server_id,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
@@ -2594,6 +2637,10 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "uuid required", http.StatusBadRequest) http.Error(w, "uuid required", http.StatusBadRequest)
return return
} }
if err := validateQuotaConfig(req.DataQuotaBytes, req.QuotaAction, req.QuotaThrottleMbps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil { if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -2629,12 +2676,17 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
} }
meta := XrayClientMeta{ meta := XrayClientMeta{
UUID: req.UUID, UUID: req.UUID,
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
InboundTag: existing.InboundTag, InboundTag: existing.InboundTag,
OwnerUsername: existing.OwnerUsername, OwnerUsername: existing.OwnerUsername,
MaxConns: req.MaxConnections, MaxConns: req.MaxConnections,
DataQuotaBytes: req.DataQuotaBytes,
QuotaAction: normalizeQuotaAction(req.QuotaAction),
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
TotalUplinkBytes: existing.TotalUplinkBytes,
TotalDownlinkBytes: existing.TotalDownlinkBytes,
} }
if req.ExpiresAt != "" { if req.ExpiresAt != "" {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
@@ -2648,6 +2700,15 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
if req.ResetUsage {
if err := xrayMgr.resetNativeTrafficAccounting(r.Context(), statsStore, req.UUID, existing.Email); err != nil {
http.Error(w, "usage reset failed: "+err.Error(), http.StatusInternalServerError)
return
}
meta.TotalUplinkBytes = 0
meta.TotalDownlinkBytes = 0
}
xrayMgr.setNativeQuotaPolicy(&meta)
if req.Email != "" { if req.Email != "" {
if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil { if err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil {
xrayLogf("xray: update config email for %s: %v", req.UUID, err) xrayLogf("xray: update config email for %s: %v", req.UUID, err)
+24 -19
View File
@@ -352,6 +352,10 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
xrayLogf("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote) xrayLogf("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote)
return return
} }
if xrayMgr.nativeQuotaBlocked(client.uuid) {
xrayLogf("native xray: inbound %q rejected VLESS user %s after data quota", ib.tag, client.email)
return
}
if addonLen := int(head[17]); addonLen > 0 { if addonLen := int(head[17]); addonLen > 0 {
if _, err := io.CopyN(io.Discard, stream, int64(addonLen)); err != nil { if _, err := io.CopyN(io.Discard, stream, int64(addonLen)); err != nil {
@@ -678,7 +682,7 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
xrayGo("native xray TCP uplink", func() { // client -> backend xrayGo("native xray TCP uplink", func() { // client -> backend
defer wg.Done() defer wg.Done()
defer closeAll() defer closeAll()
_, _ = copyWithRateLimit(meteredWriter{w: backend, meter: upMeter}, client, up) _, _ = copyWithRateLimit(xrayQuotaMeteredWriter{w: backend, meter: upMeter}, client, up)
if cw, ok := backend.(interface{ CloseWrite() error }); ok { if cw, ok := backend.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite() _ = cw.CloseWrite()
} }
@@ -688,7 +692,7 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
xrayGo("native xray TCP downlink", func() { // backend -> client xrayGo("native xray TCP downlink", func() { // backend -> client
defer wg.Done() defer wg.Done()
defer closeAll() defer closeAll()
_, _ = copyWithRateLimit(meteredWriter{w: client, meter: downMeter}, backend, down) _, _ = copyWithRateLimit(xrayQuotaMeteredWriter{w: client, meter: downMeter}, backend, down)
}) })
wg.Wait() wg.Wait()
@@ -700,15 +704,17 @@ func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email strin
// trafficMeter accumulates bytes for one direction and flushes them to the // trafficMeter accumulates bytes for one direction and flushes them to the
// stats manager in batches to avoid locking on every write. // stats manager in batches to avoid locking on every write.
type trafficMeter struct { type trafficMeter struct {
uuid string uuid string
email string email string
uplink bool uplink bool
n int64 n int64
quotaGeneration uint64
} }
const trafficFlushThreshold = 1024 * 1024 const trafficFlushThreshold = 1024 * 1024
func (t *trafficMeter) add(n int) { func (t *trafficMeter) add(n int) {
t.syncQuotaGeneration()
t.n += int64(n) t.n += int64(n)
if t.n >= trafficFlushThreshold { if t.n >= trafficFlushThreshold {
t.flush() t.flush()
@@ -716,29 +722,28 @@ func (t *trafficMeter) add(n int) {
} }
func (t *trafficMeter) flush() { func (t *trafficMeter) flush() {
t.syncQuotaGeneration()
if t.n == 0 || t.email == "" { if t.n == 0 || t.email == "" {
return return
} }
if t.uplink { if t.uplink {
xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0) xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0, t.quotaGeneration)
} else { } else {
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n) xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n, t.quotaGeneration)
} }
t.n = 0 t.n = 0
} }
// meteredWriter counts bytes as they are written through to the wrapped writer. func (t *trafficMeter) syncQuotaGeneration() {
type meteredWriter struct { generation := xrayMgr.nativeQuotaGeneration(t.uuid)
w io.Writer if t.quotaGeneration == 0 {
meter *trafficMeter t.quotaGeneration = generation
} return
}
func (mw meteredWriter) Write(p []byte) (int, error) { if generation != t.quotaGeneration {
n, err := mw.w.Write(p) t.n = 0
if n > 0 { t.quotaGeneration = generation
mw.meter.add(n)
} }
return n, err
} }
func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) } func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) }
+37 -5
View File
@@ -481,6 +481,16 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
return false return false
} }
} }
quotaLimiter, quotaErr := reserveNativePacketQuota(s.upMeter, len(payload))
if quotaErr != nil {
return false
}
if quotaLimiter != nil {
if err := quotaLimiter.WaitN(s.ctx, len(payload)); err != nil {
finishNativePacketQuota(s.upMeter, len(payload), 0)
return false
}
}
var n int var n int
var err error var err error
@@ -492,6 +502,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) { if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) {
// AdGuard/blocked endpoints must be ignored at the cheapest possible // AdGuard/blocked endpoints must be ignored at the cheapest possible
// point. Do not resolve, dial, log loudly, or keep the mux child busy. // point. Do not resolve, dial, log loudly, or keep the mux child busy.
finishNativePacketQuota(s.upMeter, len(payload), 0)
xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, item.host, item.port) xrayTracef("native xray: VLESS mux UDP fast-ignored override sink session=%d target=%s:%d", s.id, item.host, item.port)
return true return true
} }
@@ -503,6 +514,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
s.lastUDPPort = item.port s.lastUDPPort = item.port
s.lastUDPAddr = addr s.lastUDPAddr = addr
} else { } else {
finishNativePacketQuota(s.upMeter, len(payload), 0)
xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, item.host, item.port, rerr) xrayTracef("native xray: VLESS mux UDP override resolve failed session=%d target=%s:%d: %v", s.id, item.host, item.port, rerr)
return true return true
} }
@@ -512,9 +524,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
if s.network == nativeMuxNetworkUDP && err == nil { if s.network == nativeMuxNetworkUDP && err == nil {
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout())) _ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
} }
if n > 0 { finishNativePacketQuota(s.upMeter, len(payload), n)
s.upMeter.add(n)
}
if err != nil { if err != nil {
xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err) xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err)
return false return false
@@ -571,14 +581,25 @@ func (s *nativeMuxSession) readTCPBackendLoop() {
if err := s.waitDownRate(n); err != nil { if err := s.waitDownRate(n); err != nil {
return return
} }
s.downMeter.add(n) quotaLimiter, quotaErr := reserveNativePacketQuota(s.downMeter, n)
if quotaErr != nil {
return
}
if quotaLimiter != nil {
if err := quotaLimiter.WaitN(s.ctx, n); err != nil {
finishNativePacketQuota(s.downMeter, n, 0)
return
}
}
s.writeMu.Lock() s.writeMu.Lock()
werr := writeNativeMuxData(s.client, s.id, nativeMuxStatusKeep, buf[:n]) werr := writeNativeMuxData(s.client, s.id, nativeMuxStatusKeep, buf[:n])
s.writeMu.Unlock() s.writeMu.Unlock()
if werr != nil { if werr != nil {
finishNativePacketQuota(s.downMeter, n, 0)
xrayLogf("native xray: VLESS mux TCP client write failed session=%d: %v", s.id, werr) xrayLogf("native xray: VLESS mux TCP client write failed session=%d: %v", s.id, werr)
return return
} }
finishNativePacketQuota(s.downMeter, n, n)
} }
} }
@@ -602,7 +623,16 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
if err := s.waitDownRate(n); err != nil { if err := s.waitDownRate(n); err != nil {
return true return true
} }
s.downMeter.add(n) quotaLimiter, quotaErr := reserveNativePacketQuota(s.downMeter, n)
if quotaErr != nil {
return true
}
if quotaLimiter != nil {
if err := quotaLimiter.WaitN(s.ctx, n); err != nil {
finishNativePacketQuota(s.downMeter, n, 0)
return true
}
}
s.writeMu.Lock() s.writeMu.Lock()
// Include the UDP source endpoint on XUDP responses so clients that rely on // Include the UDP source endpoint on XUDP responses so clients that rely on
// full-cone packet addressing can associate the datagram with the correct // full-cone packet addressing can associate the datagram with the correct
@@ -610,9 +640,11 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
werr := writeNativeMuxPacketData(s.client, s.id, nativeMuxStatusKeep, buf[:n], addr, s.xudp) werr := writeNativeMuxPacketData(s.client, s.id, nativeMuxStatusKeep, buf[:n], addr, s.xudp)
s.writeMu.Unlock() s.writeMu.Unlock()
if werr != nil { if werr != nil {
finishNativePacketQuota(s.downMeter, n, 0)
xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr) xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr)
return true return true
} }
finishNativePacketQuota(s.downMeter, n, n)
} }
} }
+38 -8
View File
@@ -60,10 +60,16 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
if err := waitNativeRate(up, len(payload)); err != nil { if err := waitNativeRate(up, len(payload)); err != nil {
return return
} }
n, err := backend.Write(payload) quotaLimiter, err := reserveNativePacketQuota(upMeter, len(payload))
if n > 0 { if err != nil {
upMeter.add(n) return
} }
if err := waitNativeRate(quotaLimiter, len(payload)); err != nil {
finishNativePacketQuota(upMeter, len(payload), 0)
return
}
n, err := backend.Write(payload)
finishNativePacketQuota(upMeter, len(payload), n)
if err != nil { if err != nil {
xrayLogf("native xray: VLESS UDP backend write failed: %v", err) xrayLogf("native xray: VLESS UDP backend write failed: %v", err)
return return
@@ -94,11 +100,20 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
if err := waitNativeRate(down, n); err != nil { if err := waitNativeRate(down, n); err != nil {
return return
} }
quotaLimiter, err := reserveNativePacketQuota(downMeter, n)
if err != nil {
return
}
if err := waitNativeRate(quotaLimiter, n); err != nil {
finishNativePacketQuota(downMeter, n, 0)
return
}
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil { if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
finishNativePacketQuota(downMeter, n, 0)
xrayLogf("native xray: VLESS UDP client write failed: %v", err) xrayLogf("native xray: VLESS UDP client write failed: %v", err)
return return
} }
downMeter.add(n) finishNativePacketQuota(downMeter, n, n)
} }
}) })
@@ -336,10 +351,16 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
if err := waitNativeRate(up, len(pkt)); err != nil { if err := waitNativeRate(up, len(pkt)); err != nil {
return return
} }
n, err := backend.Write(pkt) quotaLimiter, err := reserveNativePacketQuota(upMeter, len(pkt))
if n > 0 { if err != nil {
upMeter.add(n) return
} }
if err := waitNativeRate(quotaLimiter, len(pkt)); err != nil {
finishNativePacketQuota(upMeter, len(pkt), 0)
return
}
n, err := backend.Write(pkt)
finishNativePacketQuota(upMeter, len(pkt), n)
if err != nil { if err != nil {
xrayLogf("native xray: VMess UDP backend write failed: %v", err) xrayLogf("native xray: VMess UDP backend write failed: %v", err)
return return
@@ -370,11 +391,20 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
if err := waitNativeRate(down, n); err != nil { if err := waitNativeRate(down, n); err != nil {
return return
} }
quotaLimiter, err := reserveNativePacketQuota(downMeter, n)
if err != nil {
return
}
if err := waitNativeRate(quotaLimiter, n); err != nil {
finishNativePacketQuota(downMeter, n, 0)
return
}
if err := client.WritePacket(buf[:n]); err != nil { if err := client.WritePacket(buf[:n]); err != nil {
finishNativePacketQuota(downMeter, n, 0)
xrayLogf("native xray: VMess UDP client write failed: %v", err) xrayLogf("native xray: VMess UDP client write failed: %v", err)
return return
} }
downMeter.add(n) finishNativePacketQuota(downMeter, n, n)
} }
}) })
+309
View File
@@ -0,0 +1,309 @@
package main
import (
"context"
"io"
"strings"
"sync"
"golang.org/x/time/rate"
)
type xrayNativeQuotaState struct {
mu sync.Mutex
usedBytes int64
quotaBytes int64
action string
throttleMbps int
limiter *rate.Limiter
generation uint64
}
func (m *XrayManager) reloadNativeQuotaPolicies() {
if statsStore == nil {
return
}
metas, err := statsStore.ListAllXrayClients(context.Background())
if err != nil {
xrayLogf("xray native quota: load policies failed: %v", err)
return
}
next := make(map[string]*xrayNativeQuotaState, len(metas))
for _, meta := range metas {
if meta == nil || strings.TrimSpace(meta.UUID) == "" {
continue
}
next[meta.UUID] = newXrayNativeQuotaState(meta)
}
m.nativeQuotaMu.Lock()
m.nativeQuotaByUUID = next
m.nativeQuotaMu.Unlock()
}
func newXrayNativeQuotaState(meta *XrayClientMeta) *xrayNativeQuotaState {
used := meta.TotalUplinkBytes + meta.TotalDownlinkBytes
if used < 0 {
used = 0
}
return &xrayNativeQuotaState{
usedBytes: used,
quotaBytes: meta.DataQuotaBytes,
action: normalizeQuotaAction(meta.QuotaAction),
throttleMbps: quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps),
generation: 1,
}
}
func (m *XrayManager) setNativeQuotaPolicy(meta *XrayClientMeta) {
if meta == nil || strings.TrimSpace(meta.UUID) == "" {
return
}
uuid := strings.TrimSpace(meta.UUID)
m.nativeQuotaMu.Lock()
if m.nativeQuotaByUUID == nil {
m.nativeQuotaByUUID = make(map[string]*xrayNativeQuotaState)
}
existing := m.nativeQuotaByUUID[uuid]
if existing == nil {
m.nativeQuotaByUUID[uuid] = newXrayNativeQuotaState(meta)
m.nativeQuotaMu.Unlock()
return
}
m.nativeQuotaMu.Unlock()
existing.mu.Lock()
existing.quotaBytes = meta.DataQuotaBytes
existing.action = normalizeQuotaAction(meta.QuotaAction)
existing.throttleMbps = quotaThrottleMbpsOrDefault(meta.QuotaThrottleMbps)
existing.limiter = nil
existing.mu.Unlock()
}
func (m *XrayManager) removeNativeQuotaPolicy(uuid string) {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return
}
m.nativeQuotaMu.Lock()
delete(m.nativeQuotaByUUID, uuid)
m.nativeQuotaMu.Unlock()
}
func (m *XrayManager) resetNativeQuotaUsage(uuid string) {
uuid = strings.TrimSpace(uuid)
m.nativeQuotaMu.RLock()
state := m.nativeQuotaByUUID[uuid]
m.nativeQuotaMu.RUnlock()
if state == nil {
return
}
state.mu.Lock()
state.usedBytes = 0
state.limiter = nil
state.generation++
if state.generation == 0 {
state.generation = 1
}
state.mu.Unlock()
}
func (m *XrayManager) nativeQuotaGeneration(uuid string) uint64 {
state := m.nativeQuotaState(uuid)
if state == nil {
return 0
}
state.mu.Lock()
generation := state.generation
state.mu.Unlock()
return generation
}
func (m *XrayManager) resetNativeTrafficAccounting(ctx context.Context, store *Store, uuid, email string) error {
state := m.nativeQuotaState(uuid)
if state != nil {
state.mu.Lock()
defer state.mu.Unlock()
}
m.nativeTrafficPersistMu.Lock()
defer m.nativeTrafficPersistMu.Unlock()
m.nativeDBMu.Lock()
key := strings.TrimSpace(uuid)
var pending xrayPendingTraffic
hadPending := false
if m.nativeTrafficPending != nil {
pending, hadPending = m.nativeTrafficPending[key]
delete(m.nativeTrafficPending, key)
}
err := store.ResetXrayClientTraffic(ctx, uuid)
if err != nil && hadPending {
if m.nativeTrafficPending == nil {
m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
}
m.nativeTrafficPending[key] = pending
}
m.nativeDBMu.Unlock()
if err != nil {
return err
}
if state != nil {
state.usedBytes = 0
state.limiter = nil
state.generation++
if state.generation == 0 {
state.generation = 1
}
}
m.statsMu.Lock()
for _, key := range []string{strings.TrimSpace(email), strings.TrimSpace(uuid)} {
if key == "" {
continue
}
if runtime, ok := m.statsByEmail[key]; ok {
runtime.Uplink = 0
runtime.Downlink = 0
m.statsByEmail[key] = runtime
}
}
m.statsMu.Unlock()
return nil
}
func (m *XrayManager) nativeQuotaState(uuid string) *xrayNativeQuotaState {
m.nativeQuotaMu.RLock()
state := m.nativeQuotaByUUID[strings.TrimSpace(uuid)]
m.nativeQuotaMu.RUnlock()
return state
}
func (m *XrayManager) nativeQuotaBlocked(uuid string) bool {
state := m.nativeQuotaState(uuid)
if state == nil {
return false
}
state.mu.Lock()
defer state.mu.Unlock()
return state.quotaBytes > 0 && normalizeQuotaAction(state.action) == quotaActionBlock && state.usedBytes >= state.quotaBytes
}
func (m *XrayManager) reserveNativeQuota(uuid string, requested int) (allowed int, limiter *rate.Limiter, stopAfter bool) {
if requested <= 0 {
return 0, nil, false
}
state := m.nativeQuotaState(uuid)
if state == nil {
return requested, nil, false
}
state.mu.Lock()
defer state.mu.Unlock()
n := int64(requested)
if state.quotaBytes <= 0 {
state.usedBytes += n
return requested, nil, false
}
if normalizeQuotaAction(state.action) == quotaActionThrottle {
previous := state.usedBytes
state.usedBytes += n
if previous+n > state.quotaBytes {
if state.limiter == nil {
bps := mbpsToBytesPerSec(quotaThrottleMbpsOrDefault(state.throttleMbps))
burst := int(bps)
if burst < copyBufSize {
burst = copyBufSize
}
state.limiter = rate.NewLimiter(rate.Limit(bps), burst)
}
return requested, state.limiter, false
}
return requested, nil, false
}
remaining := state.quotaBytes - state.usedBytes
if remaining <= 0 {
return 0, nil, true
}
take := n
if take > remaining {
take = remaining
}
state.usedBytes += take
return int(take), nil, take < n
}
func (m *XrayManager) finishNativeQuotaReservation(uuid string, reserved, written int) {
if reserved <= 0 || written >= reserved {
return
}
if written < 0 {
written = 0
}
state := m.nativeQuotaState(uuid)
if state == nil {
return
}
state.mu.Lock()
state.usedBytes -= int64(reserved - written)
if state.usedBytes < 0 {
state.usedBytes = 0
}
state.mu.Unlock()
}
type xrayQuotaMeteredWriter struct {
w io.Writer
meter *trafficMeter
}
func (mw xrayQuotaMeteredWriter) Write(p []byte) (int, error) {
if mw.meter == nil {
return mw.w.Write(p)
}
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(mw.meter.uuid, len(p))
if allowed <= 0 {
return 0, errDataQuotaExceeded
}
if limiter != nil {
if err := limiter.WaitN(context.Background(), allowed); err != nil {
xrayMgr.finishNativeQuotaReservation(mw.meter.uuid, allowed, 0)
return 0, err
}
}
n, err := mw.w.Write(p[:allowed])
xrayMgr.finishNativeQuotaReservation(mw.meter.uuid, allowed, n)
if n > 0 {
mw.meter.add(n)
}
if err != nil {
return n, err
}
if stopAfter || allowed < len(p) || xrayMgr.nativeQuotaBlocked(mw.meter.uuid) {
return n, errDataQuotaExceeded
}
return n, nil
}
func reserveNativePacketQuota(meter *trafficMeter, n int) (*rate.Limiter, error) {
if meter == nil || n <= 0 {
return nil, nil
}
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(meter.uuid, n)
if allowed != n || stopAfter {
if allowed > 0 {
xrayMgr.finishNativeQuotaReservation(meter.uuid, allowed, 0)
}
return nil, errDataQuotaExceeded
}
return limiter, nil
}
func finishNativePacketQuota(meter *trafficMeter, reserved, written int) {
if meter == nil || reserved <= 0 {
return
}
xrayMgr.finishNativeQuotaReservation(meter.uuid, reserved, written)
if written > 0 {
meter.add(written)
}
}
+4
View File
@@ -673,6 +673,10 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
log.Printf("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote) log.Printf("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote)
return return
} }
if xrayMgr.nativeQuotaBlocked(client.uuid) {
log.Printf("native xray: inbound %q rejected VMess user %s after data quota", ib.tag, client.email)
return
}
header, err := openVMessHeader(client.cmdKey, authid, stream) header, err := openVMessHeader(client.cmdKey, authid, stream)
if err != nil { if err != nil {