Quota per user
This commit is contained in:
@@ -683,7 +683,12 @@ function clientTrafficHTML(c) {
|
||||
const up = Number(c.uplink_bytes || 0);
|
||||
const down = Number(c.downlink_bytes || 0);
|
||||
const total = Number(c.total_bytes || (up + down) || 0);
|
||||
return `${escapeHTML(formatBytes(total))}<div class="hint">↑ ${escapeHTML(formatBytes(up))} · ↓ ${escapeHTML(formatBytes(down))}</div>`;
|
||||
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) {
|
||||
|
||||
@@ -9,6 +9,10 @@ cancelUserBtn.addEventListener("click", () => {
|
||||
function prepareNewSSHUser() {
|
||||
userForm.reset();
|
||||
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 title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
@@ -68,11 +72,12 @@ const USER_SORT_EXTRACT = {
|
||||
max: u => u.max_connections || 0,
|
||||
up: u => u.limit_mbps_up || 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,
|
||||
owner: u => String(u.owner_username || "").toLowerCase(),
|
||||
};
|
||||
// Columns that default to descending on first click (most/online first).
|
||||
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down"]);
|
||||
const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]);
|
||||
|
||||
let userSort = { key: "username", dir: "asc" };
|
||||
let lastUsersData = [];
|
||||
@@ -139,6 +144,12 @@ function renderUsers(users) {
|
||||
const on = (u.active_conns || 0) > 0;
|
||||
if (on) online++;
|
||||
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 cells = [
|
||||
u.username,
|
||||
@@ -148,6 +159,7 @@ function renderUsers(users) {
|
||||
u.max_connections || 0,
|
||||
u.limit_mbps_up || 0,
|
||||
u.limit_mbps_down || 0,
|
||||
`${formatBytes(totalBytes)} / ${quotaLabel}${quotaState}`,
|
||||
u.expires_at ? fmtDate(u.expires_at) : "—",
|
||||
];
|
||||
if (isSA) cells.push(u.owner_username || "—");
|
||||
@@ -194,6 +206,12 @@ function fillUserForm(u) {
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
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) : "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
@@ -218,6 +236,10 @@ userForm.addEventListener("submit", async e => {
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.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(),
|
||||
};
|
||||
try {
|
||||
@@ -225,6 +247,7 @@ userForm.addEventListener("submit", async e => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
userStatus.textContent = t("Saved.");
|
||||
fPassword.value = "";
|
||||
fResetUsage.checked = false;
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
|
||||
|
||||
@@ -325,6 +325,12 @@ function prepareXrayClientCreator(preferredTag = "") {
|
||||
if (uuid) uuid.value = genUUID();
|
||||
const maxConns = document.getElementById("xCreateMaxConns");
|
||||
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");
|
||||
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
|
||||
updateXrayCreatorInboundLabel();
|
||||
@@ -351,6 +357,9 @@ async function submitXrayClientCreator(event) {
|
||||
name: (document.getElementById("xCreateName")?.value || "").trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
|
||||
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(),
|
||||
};
|
||||
if (button) button.disabled = true;
|
||||
|
||||
@@ -6,6 +6,11 @@ function openEditXrayClient(tag, client) {
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
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("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
@@ -26,6 +31,10 @@ async function saveEditXrayClient() {
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
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(),
|
||||
};
|
||||
try {
|
||||
|
||||
+18
-5
@@ -273,7 +273,7 @@
|
||||
<table>
|
||||
<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="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>Actions</th>
|
||||
</tr></thead>
|
||||
@@ -310,6 +310,11 @@
|
||||
<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 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 class="form-actions">
|
||||
<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>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>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 class="form-actions" style="margin-top:8px;">
|
||||
<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>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>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 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>
|
||||
@@ -1542,15 +1555,15 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution 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/03-ssh-users.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/04-xray.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=20260714quota1"></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/07-stats-logs.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/12-bot.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script>
|
||||
|
||||
@@ -371,6 +371,13 @@ type UserConfig struct {
|
||||
LimitMbpsUp int `json:"limit_mbps_up"` // Mbps upstream
|
||||
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 string `json:"owner_username,omitempty"`
|
||||
}
|
||||
@@ -383,6 +390,17 @@ type UserState struct {
|
||||
mu sync.Mutex
|
||||
ActiveConns int
|
||||
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 {
|
||||
@@ -1373,6 +1391,11 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
expires_at TEXT,
|
||||
limit_mbps_up 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_period INT NOT NULL DEFAULT 60,
|
||||
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 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 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 ''`,
|
||||
}
|
||||
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) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
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_digits, 6), COALESCE(allow_static_password, FALSE),
|
||||
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
|
||||
limitUp int
|
||||
limitDown int
|
||||
dataQuotaBytes int64
|
||||
quotaAction string
|
||||
quotaThrottleMbps int
|
||||
totalUplinkBytes int64
|
||||
totalDownlinkBytes int64
|
||||
totpSecret string
|
||||
totpPeriod int
|
||||
totpWindow int
|
||||
@@ -1460,6 +1495,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
ownerUsername string
|
||||
)
|
||||
if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown,
|
||||
&dataQuotaBytes, "aAction, "aThrottleMbps, &totalUplinkBytes, &totalDownlinkBytes,
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1474,6 +1510,9 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
MaxConnections: maxConnections,
|
||||
LimitMbpsUp: limitUp,
|
||||
LimitMbpsDown: limitDown,
|
||||
DataQuotaBytes: dataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(quotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbps,
|
||||
TOTPSecret: totpSecret,
|
||||
TOTPPeriod: totpPeriod,
|
||||
TOTPWindow: totpWindow,
|
||||
@@ -1484,6 +1523,7 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
}
|
||||
|
||||
st := &UserState{Cfg: cfg}
|
||||
initSSHRuntimeUsage(st, totalUplinkBytes, totalDownlinkBytes)
|
||||
if expiresAt.Valid && expiresAt.String != "" {
|
||||
t, err := time.Parse(time.RFC3339, expiresAt.String)
|
||||
if err != nil {
|
||||
@@ -1510,15 +1550,19 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO ssh_users (
|
||||
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
|
||||
)
|
||||
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
|
||||
SET password = EXCLUDED.password,
|
||||
max_connections = EXCLUDED.max_connections,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
limit_mbps_up = EXCLUDED.limit_mbps_up,
|
||||
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_period = EXCLUDED.totp_period,
|
||||
totp_window = EXCLUDED.totp_window,
|
||||
@@ -1527,6 +1571,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
use_pam = EXCLUDED.use_pam`,
|
||||
// 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.DataQuotaBytes, normalizeQuotaAction(u.QuotaAction), quotaThrottleMbpsOrDefault(u.QuotaThrottleMbps),
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername)
|
||||
return err
|
||||
}
|
||||
@@ -1739,6 +1784,13 @@ type UserDTO struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LimitUpMbps int `json:"limit_mbps_up"`
|
||||
LimitDownMbps int `json:"limit_mbps_down"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
TotalUplinkBytes int64 `json:"total_uplink_bytes"`
|
||||
TotalDownlinkBytes int64 `json:"total_downlink_bytes"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
QuotaExceeded bool `json:"quota_exceeded"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPPeriod int `json:"totp_period"`
|
||||
TOTPWindow int `json:"totp_window"`
|
||||
@@ -1773,6 +1825,9 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := u.Cfg
|
||||
expires := u.ExpiresAt
|
||||
u.mu.Unlock()
|
||||
totalUp := atomic.LoadInt64(&u.TotalUplinkBytes)
|
||||
totalDown := atomic.LoadInt64(&u.TotalDownlinkBytes)
|
||||
totalBytes := atomic.LoadInt64(&u.totalBytes)
|
||||
|
||||
// Resellers only see their own users
|
||||
if sess != nil && sess.Role == RoleReseller && cfg.OwnerUsername != sess.Username {
|
||||
@@ -1786,6 +1841,13 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresAt: expires,
|
||||
LimitUpMbps: cfg.LimitMbpsUp,
|
||||
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,
|
||||
TOTPPeriod: cfg.TOTPPeriod,
|
||||
TOTPWindow: cfg.TOTPWindow,
|
||||
@@ -1809,6 +1871,10 @@ type UserPayload struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
LimitUpMbps int `json:"limit_mbps_up"`
|
||||
LimitDownMbps int `json:"limit_mbps_down"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
ResetUsage bool `json:"reset_usage,omitempty"`
|
||||
TOTPSecret string `json:"totp_secret"`
|
||||
TOTPPeriod int `json:"totp_period"`
|
||||
TOTPWindow int `json:"totp_window"`
|
||||
@@ -1839,6 +1905,10 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateQuotaConfig(p.DataQuotaBytes, p.QuotaAction, p.QuotaThrottleMbps); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
@@ -1964,6 +2034,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
LimitMbpsUp: p.LimitUpMbps,
|
||||
LimitMbpsDown: p.LimitDownMbps,
|
||||
DataQuotaBytes: p.DataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(p.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(p.QuotaThrottleMbps),
|
||||
TOTPSecret: strings.TrimSpace(p.TOTPSecret),
|
||||
TOTPPeriod: p.TOTPPeriod,
|
||||
TOTPWindow: p.TOTPWindow,
|
||||
@@ -1978,9 +2051,14 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Force-disconnect all active sessions for this user so new config applies.
|
||||
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)
|
||||
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())
|
||||
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 {
|
||||
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())
|
||||
return nil, fmt.Errorf("account expired")
|
||||
}
|
||||
if sshUserQuotaBlocked(u) {
|
||||
return nil, errDataQuotaExceeded
|
||||
}
|
||||
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
|
||||
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) {
|
||||
if sshUserQuotaBlocked(u) {
|
||||
newChan.Reject(ssh.Prohibited, "data quota exceeded")
|
||||
return
|
||||
}
|
||||
var req directTCPIPReq
|
||||
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil {
|
||||
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.
|
||||
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.
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
@@ -2432,7 +2521,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
// downstream: backend -> SSH channel, run in this goroutine.
|
||||
// handleDirectTCPIP already runs as its own goroutine (see handleConn),
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -3074,6 +3163,7 @@ func main() {
|
||||
// Optional: initialize interface totals persistence (best-effort).
|
||||
if store != nil {
|
||||
statsStore = store
|
||||
startSSHUserTrafficFlusher(store)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureXrayClientsSchema(ctx); err != nil {
|
||||
log.Printf("xray clients table: %v", err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+49
-10
@@ -18,6 +18,9 @@ type XrayClientMeta struct {
|
||||
OwnerUsername string
|
||||
ExpiresAt *time.Time
|
||||
MaxConns int
|
||||
DataQuotaBytes int64
|
||||
QuotaAction string
|
||||
QuotaThrottleMbps int
|
||||
CreatedAt time.Time
|
||||
TotalUplinkBytes int64
|
||||
TotalDownlinkBytes int64
|
||||
@@ -35,6 +38,9 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
owner_username TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
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(),
|
||||
total_uplink_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
|
||||
)`,
|
||||
`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_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`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
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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, $8, $9, $10)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
|
||||
owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
|
||||
max_conns = EXCLUDED.max_conns,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -79,10 +92,13 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
|
||||
var expiresAt sql.NullTime
|
||||
var lastActive sql.NullTime
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM xray_clients WHERE uuid = $1`, uuid)
|
||||
if err == nil {
|
||||
xrayMgr.removeNativeQuotaPolicy(uuid)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
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)
|
||||
FROM xray_clients ORDER BY created_at DESC`)
|
||||
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) {
|
||||
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)
|
||||
FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
|
||||
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) {
|
||||
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)
|
||||
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
|
||||
if err != nil {
|
||||
@@ -148,7 +170,9 @@ func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
FROM xray_clients WHERE inbound_tag = $1 ORDER BY created_at DESC`, inboundTag)
|
||||
if err != nil {
|
||||
|
||||
+73
-12
@@ -259,8 +259,12 @@ type XrayManager struct {
|
||||
pollStarted bool
|
||||
|
||||
nativeDBMu sync.Mutex
|
||||
nativeTrafficPersistMu sync.Mutex
|
||||
nativeTrafficPending map[string]xrayPendingTraffic
|
||||
nativeStatsFlushStarted bool
|
||||
|
||||
nativeQuotaMu sync.RWMutex
|
||||
nativeQuotaByUUID map[string]*xrayNativeQuotaState
|
||||
}
|
||||
|
||||
type xrayTrafficCounters struct {
|
||||
@@ -296,6 +300,8 @@ func initXrayManager(cfg *XrayConfig) {
|
||||
}
|
||||
xrayMgr.mu.Unlock()
|
||||
|
||||
xrayMgr.reloadNativeQuotaPolicies()
|
||||
|
||||
// In native mode the in-process emulator records traffic directly, so the
|
||||
// external `xray api statsquery` poller is not started (it would overwrite
|
||||
// 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
|
||||
// queues DB persistence. Used by the native emulator instead of external
|
||||
// `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)
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
@@ -523,18 +529,14 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
|
||||
if email == "" || (up == 0 && down == 0) {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
m.statsMu.Lock()
|
||||
if m.statsByEmail == nil {
|
||||
m.statsByEmail = make(map[string]xrayRuntimeStat)
|
||||
state := m.nativeQuotaState(uuid)
|
||||
if state != nil {
|
||||
state.mu.Lock()
|
||||
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 != "" {
|
||||
m.nativeDBMu.Lock()
|
||||
@@ -548,6 +550,20 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
|
||||
m.nativeTrafficPending[uuid] = p
|
||||
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() {
|
||||
@@ -594,6 +610,8 @@ func (m *XrayManager) flushNativeStatsToDB() {
|
||||
if statsStore == nil {
|
||||
return
|
||||
}
|
||||
m.nativeTrafficPersistMu.Lock()
|
||||
defer m.nativeTrafficPersistMu.Unlock()
|
||||
m.nativeDBMu.Lock()
|
||||
pending := m.nativeTrafficPending
|
||||
m.nativeTrafficPending = nil
|
||||
@@ -2031,6 +2049,10 @@ type XrayClientInfo struct {
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
ExpirationDays int `json:"expiration_days"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
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"`
|
||||
}
|
||||
@@ -2338,6 +2360,10 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
Name: m.Name,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
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,
|
||||
UplinkBytes: m.TotalUplinkBytes,
|
||||
DownlinkBytes: m.TotalDownlinkBytes,
|
||||
@@ -2421,6 +2447,7 @@ func applyXrayRuntimeStats(c *XrayClientInfo) {
|
||||
c.DownlinkBytes = st.Downlink
|
||||
}
|
||||
c.TotalBytes = c.UplinkBytes + c.DownlinkBytes
|
||||
c.QuotaExceeded = c.DataQuotaBytes > 0 && c.TotalBytes >= c.DataQuotaBytes
|
||||
if st.ActiveConnections > c.ActiveConnections {
|
||||
c.ActiveConnections = st.ActiveConnections
|
||||
}
|
||||
@@ -2443,6 +2470,9 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty
|
||||
MaxConnections int `json:"max_connections"`
|
||||
DataQuotaBytes int64 `json:"data_quota_bytes"`
|
||||
QuotaAction string `json:"quota_action"`
|
||||
QuotaThrottleMbps int `json:"quota_throttle_mbps"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
@@ -2454,6 +2484,10 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -2549,6 +2583,9 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
InboundTag: req.InboundTag,
|
||||
OwnerUsername: ownerUsername,
|
||||
MaxConns: req.MaxConnections,
|
||||
DataQuotaBytes: req.DataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(req.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
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 {
|
||||
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
|
||||
} else {
|
||||
xrayMgr.setNativeQuotaPolicy(&meta)
|
||||
}
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
@@ -2584,6 +2623,10 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
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 {
|
||||
@@ -2594,6 +2637,10 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -2635,6 +2682,11 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
InboundTag: existing.InboundTag,
|
||||
OwnerUsername: existing.OwnerUsername,
|
||||
MaxConns: req.MaxConnections,
|
||||
DataQuotaBytes: req.DataQuotaBytes,
|
||||
QuotaAction: normalizeQuotaAction(req.QuotaAction),
|
||||
QuotaThrottleMbps: quotaThrottleMbpsOrDefault(req.QuotaThrottleMbps),
|
||||
TotalUplinkBytes: existing.TotalUplinkBytes,
|
||||
TotalDownlinkBytes: existing.TotalDownlinkBytes,
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
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)
|
||||
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 err := xrayMgr.UpdateXrayClientEmail(req.UUID, req.Email); err != nil {
|
||||
xrayLogf("xray: update config email for %s: %v", req.UUID, err)
|
||||
|
||||
+20
-15
@@ -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)
|
||||
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 _, 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
|
||||
defer wg.Done()
|
||||
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 {
|
||||
_ = 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
|
||||
defer wg.Done()
|
||||
defer closeAll()
|
||||
_, _ = copyWithRateLimit(meteredWriter{w: client, meter: downMeter}, backend, down)
|
||||
_, _ = copyWithRateLimit(xrayQuotaMeteredWriter{w: client, meter: downMeter}, backend, down)
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
@@ -704,11 +708,13 @@ type trafficMeter struct {
|
||||
email string
|
||||
uplink bool
|
||||
n int64
|
||||
quotaGeneration uint64
|
||||
}
|
||||
|
||||
const trafficFlushThreshold = 1024 * 1024
|
||||
|
||||
func (t *trafficMeter) add(n int) {
|
||||
t.syncQuotaGeneration()
|
||||
t.n += int64(n)
|
||||
if t.n >= trafficFlushThreshold {
|
||||
t.flush()
|
||||
@@ -716,29 +722,28 @@ func (t *trafficMeter) add(n int) {
|
||||
}
|
||||
|
||||
func (t *trafficMeter) flush() {
|
||||
t.syncQuotaGeneration()
|
||||
if t.n == 0 || t.email == "" {
|
||||
return
|
||||
}
|
||||
if t.uplink {
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0)
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, t.n, 0, t.quotaGeneration)
|
||||
} else {
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n)
|
||||
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n, t.quotaGeneration)
|
||||
}
|
||||
t.n = 0
|
||||
}
|
||||
|
||||
// meteredWriter counts bytes as they are written through to the wrapped writer.
|
||||
type meteredWriter struct {
|
||||
w io.Writer
|
||||
meter *trafficMeter
|
||||
}
|
||||
|
||||
func (mw meteredWriter) Write(p []byte) (int, error) {
|
||||
n, err := mw.w.Write(p)
|
||||
if n > 0 {
|
||||
mw.meter.add(n)
|
||||
func (t *trafficMeter) syncQuotaGeneration() {
|
||||
generation := xrayMgr.nativeQuotaGeneration(t.uuid)
|
||||
if t.quotaGeneration == 0 {
|
||||
t.quotaGeneration = generation
|
||||
return
|
||||
}
|
||||
if generation != t.quotaGeneration {
|
||||
t.n = 0
|
||||
t.quotaGeneration = generation
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) }
|
||||
|
||||
+37
-5
@@ -481,6 +481,16 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
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 err error
|
||||
@@ -492,6 +502,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
if isNativeDNSSinkTarget(item.host) || invalidNativeDestination(item.host, item.port) {
|
||||
// AdGuard/blocked endpoints must be ignored at the cheapest possible
|
||||
// point. Do not resolve, dial, log loudly, or keep the mux child busy.
|
||||
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)
|
||||
return true
|
||||
}
|
||||
@@ -503,6 +514,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
s.lastUDPPort = item.port
|
||||
s.lastUDPAddr = addr
|
||||
} 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)
|
||||
return true
|
||||
}
|
||||
@@ -512,9 +524,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
|
||||
if s.network == nativeMuxNetworkUDP && err == nil {
|
||||
_ = s.udp.SetReadDeadline(time.Now().Add(nativeMuxUDPIdleTimeout()))
|
||||
}
|
||||
if n > 0 {
|
||||
s.upMeter.add(n)
|
||||
}
|
||||
finishNativePacketQuota(s.upMeter, len(payload), n)
|
||||
if err != nil {
|
||||
xrayLogf("native xray: VLESS mux backend write failed session=%d: %v", s.id, err)
|
||||
return false
|
||||
@@ -571,14 +581,25 @@ func (s *nativeMuxSession) readTCPBackendLoop() {
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
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()
|
||||
werr := writeNativeMuxData(s.client, s.id, nativeMuxStatusKeep, buf[:n])
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
finishNativePacketQuota(s.downMeter, n, 0)
|
||||
xrayLogf("native xray: VLESS mux TCP client write failed session=%d: %v", s.id, werr)
|
||||
return
|
||||
}
|
||||
finishNativePacketQuota(s.downMeter, n, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +623,16 @@ func (s *nativeMuxSession) readUDPBackendLoop() bool {
|
||||
if err := s.waitDownRate(n); err != nil {
|
||||
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()
|
||||
// Include the UDP source endpoint on XUDP responses so clients that rely on
|
||||
// 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)
|
||||
s.writeMu.Unlock()
|
||||
if werr != nil {
|
||||
finishNativePacketQuota(s.downMeter, n, 0)
|
||||
xrayLogf("native xray: VLESS mux UDP client write failed session=%d: %v", s.id, werr)
|
||||
return true
|
||||
}
|
||||
finishNativePacketQuota(s.downMeter, n, n)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-8
@@ -60,10 +60,16 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
|
||||
if err := waitNativeRate(up, len(payload)); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(payload)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
quotaLimiter, err := reserveNativePacketQuota(upMeter, len(payload))
|
||||
if err != nil {
|
||||
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 {
|
||||
xrayLogf("native xray: VLESS UDP backend write failed: %v", err)
|
||||
return
|
||||
@@ -94,11 +100,20 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
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 {
|
||||
finishNativePacketQuota(downMeter, n, 0)
|
||||
xrayLogf("native xray: VLESS UDP client write failed: %v", err)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
n, err := backend.Write(pkt)
|
||||
if n > 0 {
|
||||
upMeter.add(n)
|
||||
quotaLimiter, err := reserveNativePacketQuota(upMeter, len(pkt))
|
||||
if err != nil {
|
||||
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 {
|
||||
xrayLogf("native xray: VMess UDP backend write failed: %v", err)
|
||||
return
|
||||
@@ -370,11 +391,20 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
|
||||
if err := waitNativeRate(down, n); err != nil {
|
||||
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 {
|
||||
finishNativePacketQuota(downMeter, n, 0)
|
||||
xrayLogf("native xray: VMess UDP client write failed: %v", err)
|
||||
return
|
||||
}
|
||||
downMeter.add(n)
|
||||
finishNativePacketQuota(downMeter, n, n)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+309
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user