Compare commits

...
17 Commits
Author SHA1 Message Date
penguinehis 44b2313299 Fix 2026-07-19 16:06:07 -03:00
penguinehis 37861cda22 Fix 2026-07-19 16:05:37 -03:00
penguinehis 5f43698e2b Fix bandwith counter 2026-07-15 00:27:12 -03:00
penguinehis ab6f1e1329 Tunning and memory control 2026-07-15 00:11:56 -03:00
penguinehis ff175174e4 quota reset button 2026-07-14 22:42:49 -03:00
penguinehis ed0e240241 Quota per user 2026-07-14 22:39:36 -03:00
penguinehis c6bfefe2fb Fix pam Data 2026-07-14 00:51:23 -03:00
penguinehis fa990c2094 fix user list 2026-07-14 00:48:57 -03:00
penguinehis 1797c50ea3 Pam cache fix 2026-07-14 00:41:55 -03:00
penguinehis 492a7f2002 Pam Test 40 2026-07-14 00:30:38 -03:00
penguinehis 5ddca88147 Pam diagnostic 2026-07-14 00:26:05 -03:00
penguinehis 8117f6ed11 Pam Fix 3.0 2026-07-14 00:20:42 -03:00
penguinehis 11cfd3f092 Pam DES support 2026-07-13 23:53:56 -03:00
penguinehis 628878e055 Pam Fix 2.0 2026-07-13 23:35:43 -03:00
penguinehis 7e0ec393a8 Fix pam 2026-07-13 23:25:12 -03:00
penguinehis 8a141ae86d fix update 2026-07-13 23:18:57 -03:00
penguinehis 2ff7976768 Support of PAM 2026-07-13 23:17:05 -03:00
33 changed files with 4003 additions and 294 deletions
+1
View File
@@ -1,3 +1,4 @@
/shell2.exe /shell2.exe
/BOT_PLAN.md /BOT_PLAN.md
/SECURITY_REVIEW.md /SECURITY_REVIEW.md
/DragonCoreSSH-NewWEB.zip
+24
View File
@@ -66,6 +66,18 @@ Para configurações XHTTP antigas, carregue a configuração visual e clique em
A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido. A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido.
### Cota de tráfego e proteção de recursos
Contas SSH e clientes VLESS/VMess do modo nativo podem usar `data_quota_bytes` com ação `block` ou `throttle`. O botão **Reset/Zerar tráfego** limpa apenas os contadores; não renova validade, senha ou configuração da conta. O valor `max_conns` é aplicado no momento em que o usuário VLESS/VMess é autenticado e vale em conjunto para TCP, UDP, WebSocket, XHTTP e conexões Mux (uma conexão Mux autenticada conta como uma conexão, independentemente dos streams filhos).
O runtime nativo também possui limites globais para impedir crescimento sem controle de sockets, goroutines e sessões HTTP:
- `max_concurrent_connections`: conexões de transporte TCP/TLS/WebSocket/XHTTP; padrão `32768`;
- `max_concurrent_xhttp_requests`: mantido apenas para compatibilidade de configuração; o limite de requisições web fica desativado (`-1`) no XHTTP;
- `xhttp_max_sessions`: sessões XHTTP ativas; padrão `32768`.
Esses campos ficam em **Configurações → Xray → Native Xray scale tuning**. O XHTTP é tratado como transporte VPN: rajadas de packet-up usam backpressure cancelável e buffers de bytes limitados, sem respostas `429` nem semântica de “too many requests”. Ao atingir o teto de transporte, novos sockets permanecem no backlog do kernel em vez de serem aceitos e resetados. Conexões HTTP/2 mantêm um limite de fluxo de 1024 streams simultâneos por conexão. Cada transporte Mux aceita no máximo 64 sessões filhas, com limite global padrão de 32768. Sockets WebSocket incompletos têm timeout de handshake, conexões HTTP ociosas têm timeout, e parar/reiniciar o Xray nativo fecha conexões e sessões existentes. Atualizações de tráfego e de conexões ativas são agregadas e persistidas em lote a cada cinco segundos, sem criar uma goroutine ou consulta PostgreSQL por conexão. Entradas pendentes de usuários removidos são descartadas para manter os mapas de retry limitados ao conjunto atual de contas.
### Requisitos ### Requisitos
- Servidor Linux com `systemd` - Servidor Linux com `systemd`
@@ -595,6 +607,18 @@ For older XHTTP configurations, load the visual configuration and click **Enable
The migration confirmation is rendered inside the panel. If saving fails, the temporary SSH inbound is removed from the draft and the old inbound remains intact, so the operation can be retried after fixing the displayed error. The migration confirmation is rendered inside the panel. If saving fails, the temporary SSH inbound is removed from the draft and the old inbound remains intact, so the operation can be retried after fixing the displayed error.
### Traffic quotas and resource protection
SSH accounts and native-mode VLESS/VMess clients can use `data_quota_bytes` with either the `block` or `throttle` action. The **Reset traffic** action clears only usage counters; it does not renew expiry, change a password, or alter account settings. `max_conns` is enforced when a native VLESS/VMess user is authenticated and is shared across TCP, UDP, WebSocket, XHTTP, and Mux transports (one authenticated Mux transport counts as one connection, regardless of its child streams).
The native runtime also has global ceilings that prevent unbounded socket, goroutine, and HTTP-session growth:
- `max_concurrent_connections`: TCP/TLS/WebSocket/XHTTP transport connections; default `32768`;
- `max_concurrent_xhttp_requests`: retained for configuration compatibility; the web-request cap is disabled (`-1`) for XHTTP;
- `xhttp_max_sessions`: active XHTTP sessions; default `32768`.
These fields are available under **Settings → Xray → Native Xray scale tuning**. XHTTP is treated as VPN transport traffic: packet-up bursts use cancelable backpressure and bounded byte buffers, with no `429` or “too many requests” behavior. At the transport ceiling, new sockets remain in the kernel backlog instead of being accepted and reset. HTTP/2 connections retain a 1024-stream flow-control guard per connection. Each Mux transport accepts at most 64 child sessions, with a default global ceiling of 32768. Incomplete WebSocket handshakes time out, idle HTTP connections time out, and stopping/restarting native Xray closes existing transports and XHTTP sessions. Traffic and active-connection changes are aggregated and written in five-second batches rather than creating a PostgreSQL query or goroutine for every connection. Pending retry entries for deleted clients are removed so retry maps stay bounded by the current account set.
### Requirements ### Requirements
- Linux server with `systemd` - Linux server with `systemd`
+7
View File
@@ -750,3 +750,10 @@ select:disabled {
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}} @media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}} @media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}} @media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
/* Sortable SSH user table headers */
th[data-sort-key]{cursor:pointer;user-select:none;white-space:nowrap;transition:color .12s ease;}
th[data-sort-key]:hover{color:var(--accent);}
th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.72em;opacity:.85;}
th[data-sort-key].sort-asc::after{content:" \25B2";}
th[data-sort-key].sort-desc::after{content:" \25BC";}
+24 -1
View File
@@ -172,6 +172,24 @@ Object.assign(I18N_TEXT["pt-BR"], {
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.", "Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros." "Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
}); });
Object.assign(I18N_TEXT["en-US"], {
"Reset":"Reset","Reset traffic":"Reset traffic","Reset SSH traffic":"Reset SSH traffic","Reset Xray traffic":"Reset Xray traffic",
"Reset traffic for user \"{name}\"?":"Reset traffic for user \"{name}\"?","Reset traffic for client {id}…?":"Reset traffic for client {id}…?",
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.",
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.",
"Resetting traffic for {name}…":"Resetting traffic for {name}…","Resetting traffic for client {id}…":"Resetting traffic for client {id}…",
"Traffic reset successfully.":"Traffic reset successfully.","Xray traffic reset successfully.":"Xray traffic reset successfully.",
"Could not reset traffic: {error}":"Could not reset traffic: {error}","Reset traffic counter":"Reset traffic counter"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Reset":"Zerar","Reset traffic":"Zerar tráfego","Reset SSH traffic":"Zerar tráfego SSH","Reset Xray traffic":"Zerar tráfego Xray",
"Reset traffic for user \"{name}\"?":"Zerar o tráfego do usuário \"{name}\"?","Reset traffic for client {id}…?":"Zerar o tráfego do cliente {id}…?",
"Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, senha, validade e cota não serão alteradas.",
"Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged.":"O consumo de upload e download voltará para zero. A conta, validade e cota não serão alteradas.",
"Resetting traffic for {name}…":"Zerando o tráfego de {name}…","Resetting traffic for client {id}…":"Zerando o tráfego do cliente {id}…",
"Traffic reset successfully.":"Tráfego zerado com sucesso.","Xray traffic reset successfully.":"Tráfego Xray zerado com sucesso.",
"Could not reset traffic: {error}":"Não foi possível zerar o tráfego: {error}","Reset traffic counter":"Zerar contador de tráfego"
});
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))])); const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
let currentLang = detectInitialLanguage(); let currentLang = detectInitialLanguage();
let i18nTranslating = false; let i18nTranslating = false;
@@ -683,7 +701,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) {
+140 -3
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");
@@ -57,7 +61,90 @@ async function loadUsersSilent() {
} }
} }
// ---- Column sorting (click a header to sort) ----
// Value extractor per sortable column. Numbers sort numerically, strings
// alphabetically; online counts as 1 so "status" groups online users together.
const USER_SORT_EXTRACT = {
username: u => String(u.username || "").toLowerCase(),
status: u => (u.active_conns || 0) > 0 ? 1 : 0,
auth: u => u.use_pam ? "pam" : (u.totp_enabled ? (u.allow_static_password ? "totp+pw" : "totp") : "password"),
conn: u => u.active_conns || 0,
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", "usage"]);
let userSort = { key: "username", dir: "asc" };
let lastUsersData = [];
function sortUsers(list) {
const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username;
const dir = userSort.dir === "desc" ? -1 : 1;
return list.slice().sort((a, b) => {
const va = ext(a), vb = ext(b);
let cmp;
if (typeof va === "number" && typeof vb === "number") cmp = va - vb;
else cmp = String(va).localeCompare(String(vb));
// Stable tie-break by username so equal rows never shuffle between polls.
if (cmp === 0) cmp = String(a.username || "").localeCompare(String(b.username || ""));
return cmp * dir;
});
}
function updateSortIndicators() {
const table = usersBody && usersBody.closest("table");
if (!table) return;
table.querySelectorAll("th[data-sort-key]").forEach(th => {
th.classList.remove("sort-asc", "sort-desc");
if (th.getAttribute("data-sort-key") === userSort.key) {
th.classList.add(userSort.dir === "asc" ? "sort-asc" : "sort-desc");
}
});
}
function setUserSort(key) {
if (!USER_SORT_EXTRACT[key]) return;
if (userSort.key === key) {
userSort.dir = userSort.dir === "asc" ? "desc" : "asc";
} else {
userSort.key = key;
userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc";
}
updateSortIndicators();
renderUsers(lastUsersData);
}
(function initUserSortHeaders() {
const table = usersBody && usersBody.closest("table");
if (!table) return;
table.querySelectorAll("th[data-sort-key]").forEach(th => {
th.addEventListener("click", () => setUserSort(th.getAttribute("data-sort-key")));
});
updateSortIndicators();
})();
function sshTrafficHTML(u) {
const up = Number(u.total_uplink_bytes || 0);
const down = Number(u.total_downlink_bytes || 0);
const total = Number(u.total_bytes || (up + down) || 0);
const quota = Number(u.data_quota_bytes || 0);
const quotaLabel = quota > 0 ? formatBytes(quota) : "∞";
const state = u.quota_exceeded
? (u.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 renderUsers(users) { function renderUsers(users) {
// Cache the raw list so a header click can re-sort without refetching, and
// order by the active column so rows don't shuffle on each live poll.
lastUsersData = users || [];
users = sortUsers(lastUsersData);
updateDashboardFromUsers(users); updateDashboardFromUsers(users);
const isSA = currentRole === "superadmin"; const isSA = currentRole === "superadmin";
userCountChip.textContent = users.length; userCountChip.textContent = users.length;
@@ -73,17 +160,19 @@ function renderUsers(users) {
const cells = [ const cells = [
u.username, u.username,
on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`, on ? `<span class="badge-on">${t("online")}</span>` : `<span class="badge-off">${t("idle")}</span>`,
u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password", u.use_pam ? "PAM" : (u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password"),
u.active_conns ?? 0, u.active_conns ?? 0,
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,
sshTrafficHTML(u),
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 || "—");
cells.forEach((c, i) => { cells.forEach((c, i) => {
const td = document.createElement("td"); const td = document.createElement("td");
if (i === 1) td.innerHTML = c; else td.textContent = c; if (i === 1 || i === 7) td.innerHTML = c; else td.textContent = c;
if (i === 7) td.style.fontSize = ".7rem";
tr.appendChild(td); tr.appendChild(td);
}); });
const tdA = document.createElement("td"); const tdA = document.createElement("td");
@@ -91,12 +180,18 @@ function renderUsers(users) {
className:"btn btn-ghost btn-sm", textContent:t("Edit"), className:"btn btn-ghost btn-sm", textContent:t("Edit"),
onclick: () => fillUserForm(u), onclick: () => fillUserForm(u),
}); });
const resetBtn = Object.assign(document.createElement("button"), {
className:"btn btn-warn btn-sm", textContent:t("Reset"),
style: "margin-left:4px;",
title: t("Reset traffic"),
onclick: () => resetUserTraffic(u.username, resetBtn),
});
const delBtn = Object.assign(document.createElement("button"), { const delBtn = Object.assign(document.createElement("button"), {
className:"btn btn-danger btn-sm", textContent:t("Del"), className:"btn btn-danger btn-sm", textContent:t("Del"),
style: "margin-left:4px;", style: "margin-left:4px;",
onclick: () => deleteUser(u.username), onclick: () => deleteUser(u.username),
}); });
tdA.append(editBtn, delBtn); tdA.append(editBtn, resetBtn, delBtn);
tr.appendChild(tdA); tr.appendChild(tdA);
usersBody.appendChild(tr); usersBody.appendChild(tr);
}); });
@@ -124,6 +219,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");
@@ -148,6 +249,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 {
@@ -155,6 +260,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"));
@@ -167,6 +273,37 @@ userForm.addEventListener("submit", async e => {
} }
}); });
async function resetUserTraffic(username, button) {
const accepted = await panelConfirm({
tone:"warning", icon:"↺", title:t("Reset SSH traffic"),
message:t("Reset traffic for user \"{name}\"?", {name: username}),
detail:t("Current uploaded and downloaded usage will return to zero. The account, password, expiry and quota remain unchanged."),
confirmLabel:t("Reset traffic"),
});
if (!accepted) return;
const previousDisabled = !!button?.disabled;
if (button) button.disabled = true;
userStatus.textContent = t("Resetting traffic for {name}…", {name: username});
try {
const res = await api("/api/users/reset-traffic", {
method:"POST",
body: JSON.stringify({ username, server_id:selectedSSHServer() }),
});
if (!res.ok) throw new Error((await res.text()) || "reset failed");
userStatus.textContent = t("Traffic reset successfully.");
showPanelToast(t("Traffic reset successfully."), "success", t("SSH / SlowDNS"));
await loadUsers();
} catch (e) {
if (e.message === "auth") doAuthError();
else {
userStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("SSH / SlowDNS"));
}
} finally {
if (button) button.disabled = previousDisabled;
}
}
async function deleteUser(username) { async function deleteUser(username) {
const accepted = await panelConfirm({ const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Delete SSH account"), tone:"danger", icon:"×", title:t("Delete SSH account"),
+54 -1
View File
@@ -244,12 +244,18 @@ function renderInbounds(inbounds, options = {}) {
editBtn.style.marginLeft = "4px"; editBtn.style.marginLeft = "4px";
editBtn.textContent = t("Edit"); editBtn.textContent = t("Edit");
editBtn.onclick = () => openEditXrayClient(ib.tag, c); editBtn.onclick = () => openEditXrayClient(ib.tag, c);
const resetBtn = document.createElement("button");
resetBtn.className = "btn btn-warn btn-sm";
resetBtn.style.marginLeft = "4px";
resetBtn.textContent = t("Reset");
resetBtn.title = t("Reset traffic");
resetBtn.onclick = () => resetXrayClientTraffic(c.id, resetBtn);
const delBtn = document.createElement("button"); const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm"; delBtn.className = "btn btn-danger btn-sm";
delBtn.style.marginLeft = "4px"; delBtn.style.marginLeft = "4px";
delBtn.textContent = t("Del"); delBtn.textContent = t("Del");
delBtn.onclick = () => removeClient(ib.tag, c.id); delBtn.onclick = () => removeClient(ib.tag, c.id);
actTd.append(copyBtn, editBtn, delBtn); actTd.append(copyBtn, editBtn, resetBtn, delBtn);
tr.appendChild(actTd); tr.appendChild(actTd);
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -325,6 +331,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 +363,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;
@@ -385,6 +400,44 @@ async function submitXrayClientCreator(event) {
} }
} }
async function resetXrayClientTraffic(uuid, button) {
const shortID = String(uuid || "").slice(0, 8);
const accepted = await panelConfirm({
tone:"warning", icon:"↺", title:t("Reset Xray traffic"),
message:t("Reset traffic for client {id}…?", {id:shortID}),
detail:t("Current uploaded and downloaded usage will return to zero. The account, expiry and quota remain unchanged."),
confirmLabel:t("Reset traffic"),
});
if (!accepted) return;
const previousDisabled = !!button?.disabled;
if (button) button.disabled = true;
xStatus.textContent = t("Resetting traffic for client {id}…", {id:shortID});
try {
const res = await api("/api/xray/clients/reset-traffic", {
method:"POST",
body:JSON.stringify({ uuid, server_id:selectedXrayServer() }),
});
if (!res.ok) throw new Error((await res.text()) || "reset failed");
xStatus.textContent = t("Xray traffic reset successfully.");
showPanelToast(t("Xray traffic reset successfully."), "success", t("Xray user"));
if (editingXrayClientId === uuid) {
const usage = document.getElementById("editXrayUsage");
if (usage) usage.value = "0 B (↑ 0 B · ↓ 0 B)";
const resetUsage = document.getElementById("editXrayResetUsage");
if (resetUsage) resetUsage.checked = false;
}
await loadInbounds({ force:true });
} catch (e) {
if (e.message === "auth") doAuthError();
else {
xStatus.textContent = t("Could not reset traffic: {error}", {error:e.message});
showPanelToast(t("Could not reset traffic: {error}", {error:e.message}), "error", t("Xray user"));
}
} finally {
if (button) button.disabled = previousDisabled;
}
}
async function removeClient(tag, uuid) { async function removeClient(tag, uuid) {
const accepted = await panelConfirm({ const accepted = await panelConfirm({
tone:"danger", icon:"×", title:t("Remove Xray client"), tone:"danger", icon:"×", title:t("Remove Xray client"),
+2
View File
@@ -418,6 +418,7 @@ async function loadManagedServerConfig(id) {
document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s"; document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
document.getElementById("managedCfgQuiet").checked = !!c.quiet; document.getElementById("managedCfgQuiet").checked = !!c.quiet;
document.getElementById("managedCfgUserCount").checked = !!c.user_count; document.getElementById("managedCfgUserCount").checked = !!c.user_count;
document.getElementById("managedCfgPamAuth").checked = !!c.pam_auth_enabled;
document.getElementById("managedCfgBanner").value = c.banner || ""; document.getElementById("managedCfgBanner").value = c.banner || "";
const hasDnstt = !!c.dnstt; const hasDnstt = !!c.dnstt;
@@ -487,6 +488,7 @@ function managedConfigFromForm() {
ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s", ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s",
quiet: document.getElementById("managedCfgQuiet").checked, quiet: document.getElementById("managedCfgQuiet").checked,
user_count: document.getElementById("managedCfgUserCount").checked, user_count: document.getElementById("managedCfgUserCount").checked,
pam_auth_enabled: document.getElementById("managedCfgPamAuth").checked,
banner: document.getElementById("managedCfgBanner").value, banner: document.getElementById("managedCfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt", banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? { dnstt: document.getElementById("managedCfgDnsttEnabled").checked ? {
+11
View File
@@ -36,11 +36,17 @@ const XRAY_NATIVE_TUNING_DEFAULTS = {
safe: { safe: {
runtime_gomaxprocs: 0, runtime_gomaxprocs: 0,
mux_global_sessions: 8192, mux_global_sessions: 8192,
max_concurrent_connections: 4096,
max_concurrent_xhttp_requests: 8192,
xhttp_max_sessions: 4096,
trace_packets: false, trace_packets: false,
}, },
"2k": { "2k": {
runtime_gomaxprocs: 0, runtime_gomaxprocs: 0,
mux_global_sessions: 32768, mux_global_sessions: 32768,
max_concurrent_connections: 8192,
max_concurrent_xhttp_requests: 16384,
xhttp_max_sessions: 8192,
trace_packets: false, trace_packets: false,
}, },
}; };
@@ -48,6 +54,9 @@ const XRAY_NATIVE_TUNING_DEFAULTS = {
const XRAY_NATIVE_TUNING_FIELDS = { const XRAY_NATIVE_TUNING_FIELDS = {
runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs", runtime_gomaxprocs: "cfgXrayRuntimeGomaxprocs",
mux_global_sessions: "cfgXrayMuxGlobalSessions", mux_global_sessions: "cfgXrayMuxGlobalSessions",
max_concurrent_connections: "cfgXrayMaxConnections",
max_concurrent_xhttp_requests: "cfgXrayMaxXHTTPRequests",
xhttp_max_sessions: "cfgXrayMaxXHTTPSessions",
}; };
function setXrayNativeTuningDefaults(profile = "2k") { function setXrayNativeTuningDefaults(profile = "2k") {
@@ -99,6 +108,7 @@ async function loadServerConfig() {
document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s"; document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
document.getElementById("cfgQuiet").checked = !!c.quiet; document.getElementById("cfgQuiet").checked = !!c.quiet;
document.getElementById("cfgUserCount").checked = !!c.user_count; document.getElementById("cfgUserCount").checked = !!c.user_count;
document.getElementById("cfgPamAuth").checked = !!c.pam_auth_enabled;
// Banner // Banner
document.getElementById("cfgBanner").value = c.banner || ""; document.getElementById("cfgBanner").value = c.banner || "";
@@ -182,6 +192,7 @@ async function saveServerConfig() {
ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s", ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s",
quiet: document.getElementById("cfgQuiet").checked, quiet: document.getElementById("cfgQuiet").checked,
user_count: document.getElementById("cfgUserCount").checked, user_count: document.getElementById("cfgUserCount").checked,
pam_auth_enabled: document.getElementById("cfgPamAuth").checked,
banner: document.getElementById("cfgBanner").value, banner: document.getElementById("cfgBanner").value,
banner_file: "/opt/sshpanel/banner.txt", banner_file: "/opt/sshpanel/banner.txt",
dnstt: document.getElementById("cfgDnsttEnabled").checked ? { dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
+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 {
+38 -18
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260713sections5"/> <link rel="stylesheet" href="assets/app.css?v=20260714pamfix1"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -272,9 +272,9 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr> <thead><tr>
<th>User</th><th>Status</th><th>Auth</th> <th data-sort-key="username">User</th><th data-sort-key="status">Status</th><th data-sort-key="auth">Auth</th>
<th>Conn</th><th>Max</th><th>Up</th><th>Dn</th><th>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">Traffic</th><th data-sort-key="expires">Expires</th>
<th id="ownerColHead" 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>
<tbody id="usersBody"></tbody> <tbody id="usersBody"></tbody>
@@ -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>
@@ -807,6 +820,7 @@
<div class="field"><label>SSH Idle Timeout <span class="hint">0s/off = disabled</span></label><input type="text" id="managedCfgSSHIdleTimeout" placeholder="0s" title="Keep disabled for VPN/XHTTP connections."/></div> <div class="field"><label>SSH Idle Timeout <span class="hint">0s/off = disabled</span></label><input type="text" id="managedCfgSSHIdleTimeout" placeholder="0s" title="Keep disabled for VPN/XHTTP connections."/></div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgQuiet"/> Quiet Logs</label> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgQuiet"/> Quiet Logs</label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgUserCount"/> User Count Display</label> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgUserCount"/> User Count Display</label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;" title="Verify SSH logins against the Linux system password (/etc/shadow); regular accounts (UID ≥ 1000) are auto-imported."><input type="checkbox" id="managedCfgPamAuth"/> Linux PAM Login (auto-import)</label>
</div> </div>
</div> </div>
@@ -1268,6 +1282,9 @@
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;">
<input type="checkbox" id="cfgUserCount"/> User Count Display <input type="checkbox" id="cfgUserCount"/> User Count Display
</label> </label>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;" title="Verify SSH logins against the Linux system password (/etc/shadow). Regular accounts (UID ≥ 1000) that log in successfully are auto-imported into the panel.">
<input type="checkbox" id="cfgPamAuth"/> Linux PAM Login (auto-import)
</label>
</div> </div>
</div> </div>
@@ -1492,13 +1509,16 @@
<summary style="cursor:pointer;font-size:.76rem;font-weight:700;color:var(--text);">Native Xray scale tuning</summary> <summary style="cursor:pointer;font-size:.76rem;font-weight:700;color:var(--text);">Native Xray scale tuning</summary>
<div class="grid2" style="margin-top:10px;gap:8px;"> <div class="grid2" style="margin-top:10px;gap:8px;">
<div class="field"><label>Go CPU threads (GOMAXPROCS)</label><input type="number" min="0" id="cfgXrayRuntimeGomaxprocs" placeholder="0 = all CPU cores"/></div> <div class="field"><label>Go CPU threads (GOMAXPROCS)</label><input type="number" min="0" id="cfgXrayRuntimeGomaxprocs" placeholder="0 = all CPU cores"/></div>
<div class="field"><label>Global mux backend sessions</label><input type="number" min="1" id="cfgXrayMuxGlobalSessions" placeholder="32768"/></div> <div class="field"><label>Global mux backend sessions</label><input type="number" min="1" id="cfgXrayMuxGlobalSessions" placeholder="8192"/></div>
<div class="field"><label>Global transport connections <span class="hint">0=4096, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxConnections" placeholder="4096"/></div>
<div class="field"><label>Concurrent XHTTP requests <span class="hint">0=8192, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPRequests" placeholder="8192"/></div>
<div class="field"><label>Active XHTTP sessions <span class="hint">0=4096, -1=unlimited</span></label><input type="number" min="-1" id="cfgXrayMaxXHTTPSessions" placeholder="4096"/></div>
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="cfgXrayTracePackets"/> Trace every XHTTP/mux packet <span class="hint">debug only, slows QUIC</span></label> <label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1"><input type="checkbox" id="cfgXrayTracePackets"/> Trace every XHTTP/mux packet <span class="hint">debug only, slows QUIC</span></label>
<div class="card-actions" style="grid-column:1/-1;"> <div class="card-actions" style="grid-column:1/-1;">
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('2k')">Apply 2K defaults</button> <button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('2k')">Apply 2K defaults</button>
<button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('safe')">Apply safe defaults</button> <button class="btn btn-ghost btn-sm" type="button" onclick="setXrayNativeTuningDefaults('safe')">Apply safe defaults</button>
</div> </div>
<div class="hint" style="grid-column:1/-1;margin-top:-4px;">Transport buffers (HTTP/2 flow control, XHTTP reorder buffer, mux/UDP buffers) are fixed to xray-core defaults and no longer tunable, so they can't be misconfigured. Go CPU threads = 0 means all detected cores. Saved in the panel config and applied live on restart/reload.</div> <div class="hint" style="grid-column:1/-1;margin-top:-4px;">The transport ceiling rejects sockets before native protocol/TLS work starts; the XHTTP ceilings bound concurrent handlers and session state. Keep the safe defaults unless load testing proves the VPS can sustain more; -1 disables an application ceiling and is not recommended on public listeners. HTTP/2 still keeps a 256-stream guard per connection. Transport buffers remain fixed to safe defaults. Saved in the panel config and applied live on restart/reload.</div>
</div> </div>
</details> </details>
</div> </div>
@@ -1538,17 +1558,17 @@
<!-- 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=20260713sections5"></script> <script defer src="assets/js/01-core.js?v=20260715quotareset1"></script>
<script defer src="assets/js/02-shell.js?v=20260713sections5"></script> <script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260713sections5"></script> <script defer src="assets/js/03-ssh-users.js?v=20260715sshtraffic1"></script>
<script defer src="assets/js/04-xray.js?v=20260713sections5"></script> <script defer src="assets/js/04-xray.js?v=20260715quotareset1"></script>
<script defer src="assets/js/05-resellers.js?v=20260713sections5"></script> <script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260713sections5"></script> <script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260713sections5"></script> <script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
<script defer src="assets/js/08-server-config.js?v=20260713sections5"></script> <script defer src="assets/js/08-server-config.js?v=20260715hardening1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260713sections5"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260714quota1"></script>
<script defer src="assets/js/11-update-status.js?v=20260713sections5"></script> <script defer src="assets/js/11-update-status.js?v=20260714pamfix1"></script>
<script defer src="assets/js/12-bot.js?v=20260713sections5"></script> <script defer src="assets/js/12-bot.js?v=20260714pamfix1"></script>
<script defer src="assets/js/10-boot.js?v=20260713sections5"></script> <script defer src="assets/js/10-boot.js?v=20260714pamfix1"></script>
</body> </body>
</html> </html>
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"errors"
"strings"
)
// Traditional DES-based crypt(3) — the 13-character, no-"$"-prefix hash used by
// old Linux/UNIX systems (e.g. accounts created with perl's crypt() or legacy
// SSH-account scripts). Pure Go; no dependency on libcrypt.
//
// Verified against the canonical vector crypt("rasmuslerdorf","rl") ==
// "rl.3StKT.4T8M" (see descrypt_test.go).
const cryptAlphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func crypt64Decode(c byte) int {
return strings.IndexByte(cryptAlphabet, c)
}
// ---- Standard DES permutation tables (1-indexed, MSB-first) ----
var ipTable = []int{
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
}
var fpTable = []int{
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
}
var eTable = []int{
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
}
var pTable = []int{
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
}
var pc1Table = []int{
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
}
var pc2Table = []int{
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10,
23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48,
44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
}
var shiftTable = []int{1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1}
var sBoxes = [8][64]int{
{14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13},
{15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9},
{10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12},
{7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14},
{2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3},
{12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13},
{4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12},
{13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11},
}
// permute selects bits from in (each element 0/1, MSB-first) per a 1-indexed table.
func permute(in []byte, table []int) []byte {
out := make([]byte, len(table))
for i, pos := range table {
out[i] = in[pos-1]
}
return out
}
func keySchedule(key64 []byte) [][]byte {
cd := permute(key64, pc1Table) // 56 bits
c := cd[:28]
d := cd[28:]
subkeys := make([][]byte, 16)
for i := 0; i < 16; i++ {
c = rotl(c, shiftTable[i])
d = rotl(d, shiftTable[i])
combined := append(append([]byte{}, c...), d...)
subkeys[i] = permute(combined, pc2Table) // 48 bits
}
return subkeys
}
func rotl(b []byte, n int) []byte {
out := make([]byte, len(b))
for i := range b {
out[i] = b[(i+n)%len(b)]
}
return out
}
// feistel computes f(R, K) with the salt-perturbed E expansion.
func feistel(r []byte, k []byte, saltMask [24]bool) []byte {
e := permute(r, eTable) // 48 bits
// Salt: for i in 0..23, if saltMask[i] swap E-output bits i and i+24.
for i := 0; i < 24; i++ {
if saltMask[i] {
e[i], e[i+24] = e[i+24], e[i]
}
}
x := make([]byte, 48)
for i := range x {
x[i] = e[i] ^ k[i]
}
out := make([]byte, 32)
for box := 0; box < 8; box++ {
off := box * 6
row := int(x[off])<<1 | int(x[off+5])
col := int(x[off+1])<<3 | int(x[off+2])<<2 | int(x[off+3])<<1 | int(x[off+4])
val := sBoxes[box][row*16+col]
for bit := 0; bit < 4; bit++ {
out[box*4+bit] = byte((val >> (3 - bit)) & 1)
}
}
return permute(out, pTable)
}
func desEncryptBlock(block []byte, subkeys [][]byte, saltMask [24]bool) []byte {
ip := permute(block, ipTable)
l := ip[:32]
r := ip[32:]
for i := 0; i < 16; i++ {
f := feistel(r, subkeys[i], saltMask)
newR := make([]byte, 32)
for j := 0; j < 32; j++ {
newR[j] = l[j] ^ f[j]
}
l = r
r = newR
}
pre := append(append([]byte{}, r...), l...) // R16 L16
return permute(pre, fpTable)
}
// desCrypt implements the traditional 13-char DES crypt. setting supplies the
// 2-char salt (its first two characters).
func desCrypt(password, setting string) (string, error) {
if len(setting) < 2 {
return "", errors.New("descrypt: salt too short")
}
s0 := crypt64Decode(setting[0])
s1 := crypt64Decode(setting[1])
if s0 < 0 || s1 < 0 {
return "", errors.New("descrypt: bad salt characters")
}
salt := s0 | (s1 << 6)
var saltMask [24]bool
for i := 0; i < 24; i++ {
if (salt>>i)&1 == 1 {
saltMask[i] = true
}
}
// Key: first 8 bytes of the password, each char<<1 forms a key byte.
key64 := make([]byte, 64)
for i := 0; i < 8; i++ {
var c byte
if i < len(password) {
c = password[i]
}
kb := c << 1
for bit := 0; bit < 8; bit++ {
key64[i*8+bit] = (kb >> (7 - bit)) & 1
}
}
subkeys := keySchedule(key64)
block := make([]byte, 64) // all zeros
for iter := 0; iter < 25; iter++ {
block = desEncryptBlock(block, subkeys, saltMask)
}
return string(setting[0]) + string(setting[1]) + encodeDESOutput(block), nil
}
// encodeDESOutput packs the 64-bit result (MSB-first bit array) into 11
// crypt-base64 characters: eleven 6-bit groups read most-significant-bit first,
// the last group zero-padded to 6 bits.
func encodeDESOutput(block []byte) string {
out := make([]byte, 0, 11)
for j := 0; j < 11; j++ {
v := 0
for k := 0; k < 6; k++ {
idx := j*6 + k
bit := 0
if idx < len(block) {
bit = int(block[idx])
}
v = (v << 1) | bit
}
out = append(out, cryptAlphabet[v])
}
return string(out)
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "testing"
func TestDESCryptCanonical(t *testing.T) {
got, err := desCrypt("rasmuslerdorf", "rl")
if err != nil {
t.Fatal(err)
}
want := "rl.3StKT.4T8M"
t.Logf("got=%q want=%q", got, want)
if got != want {
t.Errorf("desCrypt mismatch: got %q want %q", got, want)
}
}
+2
View File
@@ -3,7 +3,9 @@ module shell2
go 1.25.4 go 1.25.4
require ( require (
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/openwall/yescrypt-go v1.0.0
github.com/xtaci/kcp-go/v5 v5.6.61 github.com/xtaci/kcp-go/v5 v5.6.61
github.com/xtaci/smux v1.5.50 github.com/xtaci/smux v1.5.50
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
+8 -4
View File
@@ -1,10 +1,12 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -36,13 +38,15 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/openwall/yescrypt-go v1.0.0 h1:jsGk48zkFvtUjGVOhYPGh+CS595JmTRcKnpggK2AON4=
github.com/openwall/yescrypt-go v1.0.0/go.mod h1:e6CWtFizUEOUttaOjeVMiv1lJaJie3mfOtLJ9CCD6sA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/xtaci/kcp-go/v5 v5.6.61 h1:ajm12pGuWO+GWQNusPyPESC7Rq0yTC2rEXVYkM8ExOg= github.com/xtaci/kcp-go/v5 v5.6.61 h1:ajm12pGuWO+GWQNusPyPESC7Rq0yTC2rEXVYkM8ExOg=
+1
View File
@@ -335,6 +335,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown) setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout) setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections) setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections)
setPAMAuthEnabled(newCfg.PAMAuthEnabled)
// Quiet logging / user count display // Quiet logging / user count display
if newCfg.Quiet { if newCfg.Quiet {
+261 -24
View File
@@ -97,6 +97,13 @@ type Config struct {
UserCount bool `json:"user_count"` UserCount bool `json:"user_count"`
// PAMAuthEnabled turns on Linux system-password login for this server. When
// true, an SSH login with a username not present in the panel is verified
// against /etc/shadow; on success the account (regular users, UID >= 1000)
// is auto-imported into the panel. When false, only panel-managed accounts
// can log in and previously-imported PAM accounts are refused.
PAMAuthEnabled bool `json:"pam_auth_enabled"`
// SSHIdleTimeout controls how long an authenticated SSH connection may // SSHIdleTimeout controls how long an authenticated SSH connection may
// remain with no bytes moving in either direction before it is closed and // remain with no bytes moving in either direction before it is closed and
// released from the active user count. Empty, "0", or "0s" disables it. // released from the active user count. Empty, "0", or "0s" disables it.
@@ -351,6 +358,12 @@ type UserConfig struct {
// When false and totp_secret is set, only the TOTP code is accepted. // When false and totp_secret is set, only the TOTP code is accepted.
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
// UsePAM is a legacy opt-in: when true, the supplied SSH password is
// verified against the Linux PAM auth stack (auth phase only) for the
// system account matching this username, instead of the panel-managed
// Password/TOTP. New users leave this false and keep the script's own auth.
UsePAM bool `json:"use_pam"`
MaxConnections int `json:"max_connections"` MaxConnections int `json:"max_connections"`
ExpiresAt string `json:"expires_at"` // RFC3339 or empty ExpiresAt string `json:"expires_at"` // RFC3339 or empty
@@ -358,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"`
} }
@@ -370,6 +390,18 @@ 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
trafficMu sync.RWMutex
quotaLimiter *rate.Limiter
quotaLimiterMbps int
} }
type UserManager struct { type UserManager struct {
@@ -384,6 +416,19 @@ func (m *UserManager) Get(username string) (*UserState, bool) {
return u, ok return u, ok
} }
// AddIfAbsent inserts u only if no user with the same username exists yet, and
// reports whether it was added. Used by PAM auto-import to register a freshly
// authenticated system account without clobbering an existing runtime state.
func (m *UserManager) AddIfAbsent(u *UserState) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.users[u.Cfg.Username]; exists {
return false
}
m.users[u.Cfg.Username] = u
return true
}
func (m *UserManager) List() []*UserState { func (m *UserManager) List() []*UserState {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -533,12 +578,15 @@ var copyBufPool = sync.Pool{
// io.Copy, which allocates a fresh 32 KiB buffer per direction per channel and // io.Copy, which allocates a fresh 32 KiB buffer per direction per channel and
// never pools it — at thousands of channels that churn dominated GC pressure. // never pools it — at thousands of channels that churn dominated GC pressure.
func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) { func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
return copyWithRateLimitContext(context.Background(), dst, src, lim)
}
func copyWithRateLimitContext(ctx context.Context, dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
bufp := copyBufPool.Get().(*[]byte) bufp := copyBufPool.Get().(*[]byte)
buf := *bufp buf := *bufp
defer copyBufPool.Put(bufp) defer copyBufPool.Put(bufp)
var ctx context.Context if ctx == nil {
if lim != nil {
ctx = context.Background() ctx = context.Background()
} }
@@ -1347,17 +1395,29 @@ 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,
totp_digits INT NOT NULL DEFAULT 6, totp_digits INT NOT NULL DEFAULT 6,
allow_static_password BOOLEAN NOT NULL DEFAULT FALSE allow_static_password BOOLEAN NOT NULL DEFAULT FALSE,
use_pam BOOLEAN NOT NULL DEFAULT FALSE
)`, )`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60`,
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1`, `ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1`,
`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 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 {
@@ -1405,9 +1465,11 @@ 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(owner_username, '') COALESCE(use_pam, FALSE), COALESCE(owner_username, '')
FROM ssh_users`) FROM ssh_users`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1423,15 +1485,22 @@ 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
totpDigits int totpDigits int
allowStaticPassword bool allowStaticPassword bool
usePAM bool
ownerUsername string ownerUsername string
) )
if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown, if err := rows.Scan(&username, &password, &maxConnections, &expiresAt, &limitUp, &limitDown,
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil { &dataQuotaBytes, &quotaAction, &quotaThrottleMbps, &totalUplinkBytes, &totalDownlinkBytes,
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &usePAM, &ownerUsername); err != nil {
return nil, err return nil, err
} }
password, err = openSSHPassword(password) password, err = openSSHPassword(password)
@@ -1445,15 +1514,20 @@ 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,
TOTPDigits: totpDigits, TOTPDigits: totpDigits,
AllowStaticPassword: allowStaticPassword, AllowStaticPassword: allowStaticPassword,
UsePAM: usePAM,
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
} }
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 {
@@ -1480,23 +1554,29 @@ 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,
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username 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) 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,
totp_digits = EXCLUDED.totp_digits, totp_digits = EXCLUDED.totp_digits,
allow_static_password = EXCLUDED.allow_static_password`, allow_static_password = EXCLUDED.allow_static_password,
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.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername) u.DataQuotaBytes, normalizeQuotaAction(u.QuotaAction), quotaThrottleMbpsOrDefault(u.QuotaThrottleMbps),
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.UsePAM, u.OwnerUsername)
return err return err
} }
@@ -1612,6 +1692,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
// SSH user management (session required; role-filtered inside handlers) // SSH user management (session required; role-filtered inside handlers)
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers))) mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store)))) mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
mux.Handle("/api/users/reset-traffic", sessionMiddleware(http.HandlerFunc(handleResetUserTraffic(store))))
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
// Server stats: visible to authenticated sessions; reset remains superadmin-only. // Server stats: visible to authenticated sessions; reset remains superadmin-only.
@@ -1648,6 +1729,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/reset-traffic", sessionMiddleware(http.HandlerFunc(handleXrayClientResetTraffic)))
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
@@ -1708,11 +1790,19 @@ 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"`
TOTPDigits int `json:"totp_digits"` TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
UsePAM bool `json:"use_pam"`
TOTPEnabled bool `json:"totp_enabled"` TOTPEnabled bool `json:"totp_enabled"`
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"` ServerID string `json:"server_id,omitempty"`
@@ -1741,6 +1831,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 {
@@ -1754,11 +1847,19 @@ 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,
TOTPDigits: cfg.TOTPDigits, TOTPDigits: cfg.TOTPDigits,
AllowStaticPassword: cfg.AllowStaticPassword, AllowStaticPassword: cfg.AllowStaticPassword,
UsePAM: cfg.UsePAM,
TOTPEnabled: strings.TrimSpace(cfg.TOTPSecret) != "", TOTPEnabled: strings.TrimSpace(cfg.TOTPSecret) != "",
OwnerUsername: cfg.OwnerUsername, OwnerUsername: cfg.OwnerUsername,
}) })
@@ -1776,11 +1877,16 @@ 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"`
TOTPDigits int `json:"totp_digits"` TOTPDigits int `json:"totp_digits"`
AllowStaticPassword bool `json:"allow_static_password"` AllowStaticPassword bool `json:"allow_static_password"`
UsePAM bool `json:"use_pam"`
OwnerUsername string `json:"owner_username,omitempty"` OwnerUsername string `json:"owner_username,omitempty"`
ServerID string `json:"server_id,omitempty"` ServerID string `json:"server_id,omitempty"`
} }
@@ -1805,6 +1911,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 {
@@ -1882,7 +1992,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existing) ).Scan(&existing)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
if strings.TrimSpace(p.TOTPSecret) == "" { // PAM users authenticate against the system account, so they
// need neither a panel password nor a TOTP secret.
if strings.TrimSpace(p.TOTPSecret) == "" && !p.UsePAM {
http.Error(w, "password or totp_secret required for new user", http.StatusBadRequest) http.Error(w, "password or totp_secret required for new user", http.StatusBadRequest)
return return
} }
@@ -1928,11 +2040,15 @@ 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,
TOTPDigits: p.TOTPDigits, TOTPDigits: p.TOTPDigits,
AllowStaticPassword: p.AllowStaticPassword, AllowStaticPassword: p.AllowStaticPassword,
UsePAM: p.UsePAM,
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
} }
@@ -1941,15 +2057,89 @@ 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)
} }
} }
func handleResetUserTraffic(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if store == nil {
http.Error(w, "database not configured", http.StatusServiceUnavailable)
return
}
var req struct {
Username string `json:"username"`
ServerID string `json:"server_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, store, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, req.Username, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/reset-traffic", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
var owner string
if err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, req.Username).Scan(&owner); err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
} else {
http.Error(w, "db error", http.StatusInternalServerError)
}
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && strings.TrimSpace(owner) != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := resetSSHUserTrafficAccounting(ctx, store, req.Username); err != nil {
log.Printf("failed to reset SSH traffic for %s: %v", req.Username, err)
http.Error(w, "could not reset traffic", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": req.Username})
}
}
func handleDeleteUser(store *Store) http.HandlerFunc { func handleDeleteUser(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
@@ -2143,19 +2333,52 @@ func matchTOTPPassword(u *UserState, supplied string, now time.Time) bool {
// ---------- Auth callbacks ---------- // ---------- Auth callbacks ----------
func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { func passwordCallback(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
supplied := string(pass)
u, ok := userMgr.Get(meta.User()) u, ok := userMgr.Get(meta.User())
now := time.Now()
// Enforce panel policy (expiry / reseller owner) for known users up front,
// so neither PAM nor the static password can bypass it.
if ok {
if u.ExpiresAt != nil && now.After(*u.ExpiresAt) {
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)
}
}
// System (PAM) login. When enabled server-wide, the Linux system password
// (/etc/shadow) is accepted for any regular account (UID >= 1000) — whether
// or not it is already a panel user. Unknown accounts are auto-imported on
// success. Falls through to panel credentials if PAM does not accept.
if isPAMAuthEnabled() {
if isRegularLoginUser(meta.User()) {
if err := authenticatePAM(meta.User(), supplied); err == nil {
if !ok {
importPAMUser(meta.User())
}
pamLogf("PAM: %q authenticated against /etc/shadow", meta.User())
return nil, nil
} else {
pamLogf("PAM: %q rejected by /etc/shadow: %v", meta.User(), err)
}
} else if !ok {
pamLogf("PAM: %q is not a regular login account (needs an /etc/passwd entry with UID >= %d)", meta.User(), minLoginUID)
}
}
if !ok { if !ok {
pamLogf("auth: user %q rejected (no panel account and PAM did not accept it)", meta.User())
return nil, fmt.Errorf("authentication failed") return nil, fmt.Errorf("authentication failed")
} }
now := time.Now()
if u.ExpiresAt != nil && now.After(*u.ExpiresAt) { // Fall back to panel-managed credentials (TOTP and/or static password).
log.Printf("user %s tried to connect but account is expired", meta.User())
return nil, fmt.Errorf("account expired")
}
if err := ownerIsActive(u.Cfg.OwnerUsername); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
supplied := string(pass)
if strings.TrimSpace(u.Cfg.TOTPSecret) != "" { if strings.TrimSpace(u.Cfg.TOTPSecret) != "" {
if matchTOTPPassword(u, supplied, now) { if matchTOTPPassword(u, supplied, now) {
return nil, nil return nil, nil
@@ -2180,6 +2403,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)
} }
@@ -2311,6 +2537,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")
@@ -2339,9 +2569,14 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
// half-close that never completes), both sides are force-closed so the // half-close that never completes), both sides are force-closed so the
// other direction unblocks. Close is idempotent, so calling it from both // other direction unblocks. Close is idempotent, so calling it from both
// directions is safe and no separate waiter goroutine is needed. // directions is safe and no separate waiter goroutine is needed.
ctx, cancel := context.WithCancel(context.Background())
var closeOnce sync.Once
closeAll := func() { closeAll := func() {
_ = backend.Close() closeOnce.Do(func() {
_ = ch.Close() cancel()
_ = backend.Close()
_ = ch.Close()
})
} }
// Drain channel requests concurrently so the peer isn't left waiting. // Drain channel requests concurrently so the peer isn't left waiting.
@@ -2355,7 +2590,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) _, _ = copyWithRateLimitContext(ctx, sshQuotaWriter{w: backend, user: u, uplink: true, ctx: ctx}, 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()
@@ -2366,7 +2601,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) _, _ = copyWithRateLimitContext(ctx, sshQuotaWriter{w: ch, user: u, uplink: false, ctx: ctx}, backend, downLimiter)
closeAll() closeAll()
} }
@@ -3008,6 +3243,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)
@@ -3205,6 +3441,7 @@ func main() {
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown) setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout) setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
setMaxTotalConnsFromConfig(cfg.MaxTotalConnections) setMaxTotalConnsFromConfig(cfg.MaxTotalConnections)
setPAMAuthEnabled(cfg.PAMAuthEnabled)
// Initialise listener pools (used for initial startup and hot-reload alike). // Initialise listener pools (used for initial startup and hot-reload alike).
publicPool = newListenerPool(serveHTTP80) publicPool = newListenerPool(serveHTTP80)
+227
View File
@@ -0,0 +1,227 @@
package main
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/GehirnInc/crypt"
_ "github.com/GehirnInc/crypt/apr1_crypt"
_ "github.com/GehirnInc/crypt/md5_crypt"
_ "github.com/GehirnInc/crypt/sha256_crypt"
_ "github.com/GehirnInc/crypt/sha512_crypt"
"github.com/openwall/yescrypt-go"
"golang.org/x/crypto/bcrypt"
)
const (
shadowFile = "/etc/shadow"
passwdFile = "/etc/passwd"
// minLoginUID / nobodyUID bound the accounts eligible for auto-import.
// Regular human login accounts start at UID 1000 on Debian/Ubuntu; system
// and service accounts (and "nobody") are excluded.
minLoginUID = 1000
nobodyUID = 65534
)
var errNoSystemPassword = errors.New("account has no usable password")
// pamLogger writes PAM auth diagnostics straight to stderr (captured by
// journald) so they remain visible even when "Quiet Logs" redirects the default
// logger to io.Discard. Use pamLogf for anything an operator needs to see when
// debugging why a system login was accepted or refused.
var pamLogger = log.New(os.Stderr, "", log.LstdFlags)
func pamLogf(format string, args ...interface{}) { pamLogger.Printf(format, args...) }
// pamAuthEnabled mirrors Config.PAMAuthEnabled and is toggled live on config
// reload. Guarded atomically so passwordCallback can read it lock-free.
var pamAuthEnabled atomic.Bool
func setPAMAuthEnabled(v bool) {
pamAuthEnabled.Store(v)
state := "disabled"
if v {
state = "ENABLED"
}
pamLogf("PAM: system (Linux /etc/shadow) login is now %s", state)
}
func isPAMAuthEnabled() bool { return pamAuthEnabled.Load() }
// importPAMUser registers a freshly PAM-authenticated account in the running
// user manager and persists it (marked use_pam) so it shows up in the panel and
// later logins are re-verified against the system password. Idempotent: a
// second concurrent/subsequent login for the same user is a no-op.
func importPAMUser(username string) {
cfg := UserConfig{Username: username, UsePAM: true}
// Carry over the Linux account expiry (/etc/shadow field 8) so the panel's
// "Vence em" shows the real expiration instead of "—".
var expPtr *time.Time
if exp := shadowAccountExpiry(username); exp != nil {
cfg.ExpiresAt = exp.Format(time.RFC3339)
expPtr = exp
}
st := &UserState{Cfg: cfg, ExpiresAt: expPtr}
if !userMgr.AddIfAbsent(st) {
return // already present in memory
}
pamLogf("PAM: auto-imported system user %s into the panel", username)
if statsStore != nil {
if err := statsStore.UpsertUser(context.Background(), cfg); err != nil {
pamLogf("PAM: failed to persist auto-imported user %s: %v", username, err)
}
}
}
// isRegularLoginUser reports whether username is a regular human login account
// (UID >= 1000 and not "nobody"), by parsing /etc/passwd. System/service
// accounts and root are excluded from auto-import.
func isRegularLoginUser(username string) bool {
data, err := os.ReadFile(passwdFile)
if err != nil {
pamLogf("PAM: cannot read %s: %v", passwdFile, err)
return false
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimRight(line, "\r")
fields := strings.Split(line, ":")
if len(fields) < 3 || fields[0] != username {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
return false
}
return uid >= minLoginUID && uid != nobodyUID
}
return false
}
// shadowAccountExpiry returns the account expiration date from /etc/shadow
// field 8 (days since 1970-01-01), or nil if the account never expires (empty
// field) or the value is unusable. This is the `chage -E` / `useradd -e` date,
// which maps to the panel's per-user expiry.
func shadowAccountExpiry(username string) *time.Time {
data, err := os.ReadFile(shadowFile)
if err != nil {
return nil
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Split(strings.TrimRight(line, "\r"), ":")
if len(fields) < 8 || fields[0] != username {
continue
}
expStr := strings.TrimSpace(fields[7])
if expStr == "" {
return nil // no account expiry set
}
days, err := strconv.Atoi(expStr)
if err != nil || days <= 0 {
return nil
}
t := time.Unix(int64(days)*86400, 0).UTC()
return &t
}
return nil
}
// authenticatePAM verifies password against the Linux system account matching
// username. It reads the account's hash from /etc/shadow (the panel runs as
// root) and recomputes it with the same algorithm — this is the "just the auth"
// behaviour: the supplied password is checked exactly as the system would,
// with no account/session management and nothing to do with the SSH daemon.
//
// It is called "PAM" for continuity with the user-facing flag, but it does not
// link libpam; it verifies the crypt(3) hash directly. Supported hash formats:
// yescrypt ($y$), sha512-crypt ($6$), sha256-crypt ($5$), md5-crypt ($1$),
// apr1 ($apr1$) and bcrypt ($2a$/$2b$/$2y$). Returns nil on success.
func authenticatePAM(username, password string) error {
if username == "" {
return errors.New("shadow: empty username")
}
hash, err := lookupShadowHash(username)
if err != nil {
return err
}
return verifyCryptHash(hash, password)
}
// lookupShadowHash returns the password hash field for username from /etc/shadow.
func lookupShadowHash(username string) (string, error) {
data, err := os.ReadFile(shadowFile)
if err != nil {
return "", fmt.Errorf("read %s: %w", shadowFile, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
fields := strings.Split(line, ":")
if len(fields) < 2 || fields[0] != username {
continue
}
hash := fields[1]
// Empty, or locked/disabled accounts (! or * in the hash field) have no
// password that any input can match — reject rather than risk a match.
if hash == "" || strings.HasPrefix(hash, "!") || strings.HasPrefix(hash, "*") {
return "", errNoSystemPassword
}
return hash, nil
}
return "", fmt.Errorf("shadow: user %q not found", username)
}
// verifyCryptHash checks password against a crypt(3)-style hash string,
// dispatching on the hash prefix. Returns nil only on an exact match.
func verifyCryptHash(hash, password string) error {
switch {
case strings.HasPrefix(hash, "$y$"):
computed, err := yescrypt.Hash([]byte(password), []byte(hash))
if err != nil {
return fmt.Errorf("yescrypt: %w", err)
}
if subtle.ConstantTimeCompare(computed, []byte(hash)) == 1 {
return nil
}
return errors.New("password mismatch")
case strings.HasPrefix(hash, "$2a$"), strings.HasPrefix(hash, "$2b$"), strings.HasPrefix(hash, "$2y$"):
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
case crypt.IsHashSupported(hash):
return crypt.NewFromHash(hash).Verify(hash, []byte(password))
case isTraditionalDES(hash):
computed, err := desCrypt(password, hash)
if err != nil {
return fmt.Errorf("descrypt: %w", err)
}
if subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1 {
return nil
}
return errors.New("password mismatch")
default:
return fmt.Errorf("shadow: unsupported hash format")
}
}
// isTraditionalDES reports whether hash looks like a classic 13-character
// DES crypt(3) hash (2 salt chars + 11 hash chars, all from the crypt alphabet,
// no "$" scheme prefix). Used by old Linux/UNIX accounts.
func isTraditionalDES(hash string) bool {
if len(hash) != 13 {
return false
}
for i := 0; i < len(hash); i++ {
if crypt64Decode(hash[i]) < 0 {
return false
}
}
return true
}
+51
View File
@@ -0,0 +1,51 @@
package main
import "testing"
// Known crypt(3) test vectors covering the formats found in /etc/shadow across
// old and new Linux. verifyCryptHash must accept the right password and reject
// the wrong one for each.
func TestVerifyCryptHashVectors(t *testing.T) {
cases := []struct {
name string
hash string
pw string
}{
{
name: "sha512crypt $6$ (glibc, older Linux)",
// openssl passwd -6 -salt saltstring "Hello world!"
hash: "$6$saltstring$svn8UoSVapNtMuq1ukKS4tPQd8iKwSMHWjl/O817G3uBnIFNjnQJuesI68u4OTLiBFdcbYEdFCoEOfaS35inz1",
pw: "Hello world!",
},
{
name: "yescrypt $y$ (Debian 11+/Ubuntu 22.04+)",
hash: "$y$j9T$e8R9q85ZuzUkArEUurdtS.$esON.7y6H.u3UCPVCpbRFueRpAut2n2cMf1EhpjbuiC",
pw: "pleaseletmein",
},
{
// Real DES-crypt account from the production server's /etc/shadow.
name: "traditional DES (old Linux) — testedragon",
hash: "pae9A3UKpfaU6",
pw: "testedragon",
},
{
name: "traditional DES (old Linux) — ipv6dragon",
hash: "pa9eao3LI6u.6",
pw: "0tMGUL9chq8D",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := verifyCryptHash(c.hash, c.pw); err != nil {
t.Errorf("correct password REJECTED: %v", err)
}
// Build a wrong password that differs in the FIRST character, so the
// check is meaningful even for traditional DES (which only considers
// the first 8 bytes of the password).
wrong := "Z" + c.pw
if err := verifyCryptHash(c.hash, wrong); err == nil {
t.Errorf("wrong password ACCEPTED")
}
})
}
}
+361
View File
@@ -0,0 +1,361 @@
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
sshTrafficDirtyMu sync.Mutex
sshTrafficDirty = make(map[string]*UserState)
)
func markSSHUserTrafficDirty(u *UserState) {
if u == nil || strings.TrimSpace(u.Cfg.Username) == "" {
return
}
sshTrafficDirtyMu.Lock()
sshTrafficDirty[u.Cfg.Username] = u
sshTrafficDirtyMu.Unlock()
}
func takeSSHUserTrafficDirty() map[string]*UserState {
sshTrafficDirtyMu.Lock()
dirty := sshTrafficDirty
sshTrafficDirty = make(map[string]*UserState)
sshTrafficDirtyMu.Unlock()
return dirty
}
func clearSSHUserTrafficDirty(username string, u *UserState) {
sshTrafficDirtyMu.Lock()
if current := sshTrafficDirty[username]; u == nil || current == u {
delete(sshTrafficDirty, username)
}
sshTrafficDirtyMu.Unlock()
}
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 resetSSHRuntimeUsageLocked(u *UserState) {
if u == nil {
return
}
initSSHRuntimeUsage(u, 0, 0)
u.mu.Lock()
u.quotaLimiter = nil
u.quotaLimiterMbps = 0
u.mu.Unlock()
}
func resetSSHRuntimeUsage(username string) {
u, ok := userMgr.Get(username)
if !ok || u == nil {
return
}
u.trafficMu.Lock()
resetSSHRuntimeUsageLocked(u)
clearSSHUserTrafficDirty(username, u)
u.trafficMu.Unlock()
}
func resetSSHUserTrafficAccounting(ctx context.Context, store *Store, username string) error {
u, _ := userMgr.Get(username)
if u != nil {
u.trafficMu.Lock()
defer u.trafficMu.Unlock()
}
sshTrafficPersistenceMu.Lock()
defer sshTrafficPersistenceMu.Unlock()
if err := store.ResetSSHUserTraffic(ctx, username); err != nil {
return err
}
if u != nil {
resetSSHRuntimeUsageLocked(u)
clearSSHUserTrafficDirty(username, u)
}
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))
}
markSSHUserTrafficDirty(u)
}
type sshQuotaWriter struct {
w io.Writer
user *UserState
uplink bool
ctx context.Context
}
func (qw sshQuotaWriter) Write(p []byte) (int, error) {
if qw.user != nil {
qw.user.trafficMu.RLock()
defer qw.user.trafficMu.RUnlock()
}
allowed, quotaLimiter, stopAfter := reserveSSHUserBytes(qw.user, len(p))
if allowed <= 0 {
return 0, errDataQuotaExceeded
}
if quotaLimiter != nil {
ctx := qw.ctx
if ctx == nil {
ctx = context.Background()
}
if err := quotaLimiter.WaitN(ctx, 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 username, u := range takeSSHUserTrafficDirty() {
if u == nil || strings.TrimSpace(username) == "" {
continue
}
up := atomic.SwapInt64(&u.pendingUplinkBytes, 0)
down := atomic.SwapInt64(&u.pendingDownlinkBytes, 0)
if up == 0 && down == 0 {
continue
}
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)
markSSHUserTrafficDirty(u)
}
}
}
}
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
}
+607
View File
@@ -0,0 +1,607 @@
package main
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
)
func TestNativeClientMaxConnectionsAndBatchedActiveDelta(t *testing.T) {
oldStore := statsStore
statsStore = &Store{}
defer func() { statsStore = oldStore }()
const uuid = "11111111-1111-1111-1111-111111111111"
m := &XrayManager{
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{
uuid: {maxConns: 1, generation: 1},
},
}
state := m.nativeQuotaState(uuid)
release, acquiredState, ok := m.acquireNativeClientConnection(uuid, "user@example")
if !ok || release == nil {
t.Fatal("first native connection was rejected")
}
if acquiredState != state {
t.Fatal("connection lease did not retain the authenticated policy state")
}
if _, _, ok := m.acquireNativeClientConnection(uuid, "user@example"); ok {
t.Fatal("connection above max_conns was accepted")
}
m.nativeDBMu.Lock()
pending := m.nativeActivePending[uuid]
m.nativeDBMu.Unlock()
if pending.Delta != 1 || !pending.Connected || pending.State != state {
t.Fatalf("connect was not queued for batch persistence: %+v", pending)
}
release()
release() // idempotent release must not underflow counters.
m.nativeDBMu.Lock()
pending = m.nativeActivePending[uuid]
m.nativeDBMu.Unlock()
if pending.Delta != 0 || !pending.Connected {
t.Fatalf("connect/disconnect batch should net to zero and retain last-active: %+v", pending)
}
state.mu.Lock()
active := state.activeConns
state.mu.Unlock()
if active != 0 {
t.Fatalf("active connection count = %d, want 0", active)
}
release2, _, ok := m.acquireNativeClientConnection(uuid, "user@example")
if !ok {
t.Fatal("slot was not reusable after release")
}
release2()
}
func TestRemoveNativeQuotaPolicyPrunesPendingMaps(t *testing.T) {
m := &XrayManager{
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{
"gone": {generation: 1},
},
nativeTrafficPending: map[string]xrayPendingTraffic{
"gone": {Uplink: 10},
},
nativeActivePending: map[string]xrayPendingActive{
"gone": {Delta: 1},
},
}
m.removeNativeQuotaPolicy("gone")
if m.nativeQuotaState("gone") != nil {
t.Fatal("quota policy was not removed")
}
m.nativeDBMu.Lock()
_, trafficExists := m.nativeTrafficPending["gone"]
_, activeExists := m.nativeActivePending["gone"]
m.nativeDBMu.Unlock()
if trafficExists || activeExists {
t.Fatal("deleted UUID remained in a pending persistence map")
}
}
func TestNativeCounterIsBoundedAndReleaseIsIdempotent(t *testing.T) {
var active atomicInt64ForTest
release1, ok := acquireNativeCounter(&active.Int64, 2)
if !ok {
t.Fatal("first slot rejected")
}
release2, ok := acquireNativeCounter(&active.Int64, 2)
if !ok {
t.Fatal("second slot rejected")
}
if _, ok := acquireNativeCounter(&active.Int64, 2); ok {
t.Fatal("slot above limit accepted")
}
release1()
release1()
if got := active.Load(); got != 1 {
t.Fatalf("active after double release = %d, want 1", got)
}
release2()
if got := active.Load(); got != 0 {
t.Fatalf("active after releases = %d, want 0", got)
}
}
// Embedding keeps the test declaration readable while still passing the exact
// atomic.Int64 type required by acquireNativeCounter.
type atomicInt64ForTest struct{ Int64 atomic.Int64 }
func (a *atomicInt64ForTest) Load() int64 { return a.Int64.Load() }
func TestTrackedNativeConnectionsAreClosedOnShutdown(t *testing.T) {
oldAccepting := nativeTransportAccepting.Load()
defer nativeTransportAccepting.Store(oldAccepting)
beginNativeTransportAccepting()
before := nativeTransportConnections.Load()
serverSide, clientSide := net.Pipe()
defer clientSide.Close()
wrapped, ok := wrapTrackedNativeTransportConn(serverSide)
if !ok {
t.Fatal("tracked connection was rejected")
}
if got := nativeTransportConnections.Load(); got != before+1 {
t.Fatalf("transport count = %d, want %d", got, before+1)
}
stopNativeTransportAccepting()
closeAllNativeTransportConnections()
_ = clientSide.SetReadDeadline(time.Now().Add(time.Second))
if _, err := clientSide.Read(make([]byte, 1)); err == nil {
t.Fatal("peer remained open after native shutdown")
}
if err := wrapped.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
t.Fatalf("second close returned unexpected error: %v", err)
}
if got := nativeTransportConnections.Load(); got != before {
t.Fatalf("transport count after shutdown = %d, want %d", got, before)
}
}
func TestCloseAllXHTTPSessionsReleasesGlobalSlots(t *testing.T) {
oldLimit := nativeTuneXHTTPMaxSessions.Load()
nativeTuneXHTTPMaxSessions.Store(8)
defer nativeTuneXHTTPMaxSessions.Store(oldLimit)
before := nativeXHTTPSessions.Load()
ib := &nativeInbound{xhttpMaxBufferedPosts: 2}
for _, id := range []string{"one", "two"} {
if sess := ib.upsertXHTTPSession(httptest.NewRecorder(), id); sess == nil {
t.Fatalf("session %q was rejected", id)
}
}
if got := nativeXHTTPSessions.Load(); got != before+2 {
t.Fatalf("global XHTTP sessions = %d, want %d", got, before+2)
}
ib.closeAllXHTTPSessions()
if got := nativeXHTTPSessions.Load(); got != before {
t.Fatalf("global XHTTP sessions after close = %d, want %d", got, before)
}
ib.xhttpMu.Lock()
remaining := len(ib.xhttpSessions)
ib.xhttpMu.Unlock()
if remaining != 0 {
t.Fatalf("inbound retained %d XHTTP sessions", remaining)
}
}
func TestNegativeXHTTPSessionLimitMeansUnlimited(t *testing.T) {
old := nativeTuneXHTTPMaxSessions.Load()
nativeTuneXHTTPMaxSessions.Store(0)
defer nativeTuneXHTTPMaxSessions.Store(old)
if got := (&nativeInbound{}).xhttpMaxActiveSessions(); got != 0 {
t.Fatalf("unlimited XHTTP session limit normalized to %d", got)
}
}
func TestNativeProtocolGuardsRemainFinite(t *testing.T) {
oldRequests := nativeTuneMaxXHTTPRequests.Load()
defer nativeTuneMaxXHTTPRequests.Store(oldRequests)
// Zero is the internal representation of an explicitly disabled application
// request counter. HTTP/2 must still retain a finite per-connection guard.
nativeTuneMaxXHTTPRequests.Store(0)
if got := nativeHTTP2MaxConcurrentStreams(); got != defaultNativeHTTP2MaxStreams {
t.Fatalf("HTTP/2 stream guard = %d, want %d", got, defaultNativeHTTP2MaxStreams)
}
nativeTuneMaxXHTTPRequests.Store(32)
if got := nativeHTTP2MaxConcurrentStreams(); got != 32 {
t.Fatalf("HTTP/2 stream guard did not honor lower request cap: %d", got)
}
if got := nativeMuxMaxSessionLimit(); got != 64 {
t.Fatalf("per-transport Mux session guard = %d, want 64", got)
}
}
func TestXHTTPHandlerDoesNotApplyWebRequestCeiling(t *testing.T) {
oldLimit := nativeTuneMaxXHTTPRequests.Load()
oldActive := nativeXHTTPRequests.Load()
nativeTuneMaxXHTTPRequests.Store(1)
nativeXHTTPRequests.Store(1)
defer func() {
nativeTuneMaxXHTTPRequests.Store(oldLimit)
nativeXHTTPRequests.Store(oldActive)
}()
ib := &nativeInbound{transport: "xhttp", path: "/"}
req := httptest.NewRequest(http.MethodOptions, "/", nil)
rec := httptest.NewRecorder()
ib.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("XHTTP OPTIONS at legacy request ceiling = %d, want 200", rec.Code)
}
}
func TestLegacyXHTTPTuningMigratesToVPNDefaults(t *testing.T) {
got := normalizeNativeXrayTuning(&XrayNativeTuning{
MuxGlobalSessions: 8192,
MaxConcurrentConnections: 4096,
MaxConcurrentXHTTPRequests: 8192,
XHTTPMaxSessions: 4096,
})
if got.MuxGlobalSessions != defaultNativeMuxGlobalSessions ||
got.MaxConcurrentConnections != defaultNativeMaxConnections ||
got.MaxConcurrentXHTTPRequests != defaultNativeMaxXHTTPRequests ||
got.XHTTPMaxSessions != defaultNativeXHTTPMaxSessions {
t.Fatalf("legacy tuning was not migrated: %+v", got)
}
}
func TestXHTTPMetadataLengthIsBoundedBeforeSessionAllocation(t *testing.T) {
ib := &nativeInbound{transport: "xhttp", path: "/"}
req := httptest.NewRequest("GET", "/"+strings.Repeat("a", nativeXHTTPMaxSessionIDBytes+1), nil)
rec := httptest.NewRecorder()
ib.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("oversized XHTTP session id status = %d, want 400", rec.Code)
}
ib.xhttpMu.Lock()
sessions := len(ib.xhttpSessions)
ib.xhttpMu.Unlock()
if sessions != 0 {
t.Fatalf("oversized metadata allocated %d sessions", sessions)
}
}
func TestXHTTPUploadMemoryIsReleasedOnReadAndClose(t *testing.T) {
before := nativeXHTTPBufferedBytes.Load()
q := newNativeXHTTPUploadQueue(4, 8)
lease, ok := acquireNativeXHTTPMemory(8)
if !ok {
t.Fatal("failed to reserve XHTTP test memory")
}
lease.shrink(4)
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("test"), Seq: 0}, lease); err != nil {
lease.release()
t.Fatalf("queue push failed: %v", err)
}
lease.release() // transferred leases are a no-op for the producer.
if got := nativeXHTTPBufferedBytes.Load(); got != before+4 {
t.Fatalf("buffered bytes after push = %d, want %d", got, before+4)
}
buf := make([]byte, 4)
if n, err := q.Read(buf); err != nil || n != 4 || string(buf) != "test" {
t.Fatalf("queue read = (%d, %v, %q), want (4, nil, test)", n, err, string(buf))
}
if got := nativeXHTTPBufferedBytes.Load(); got != before {
t.Fatalf("buffered bytes after read = %d, want %d", got, before)
}
lease, ok = acquireNativeXHTTPMemory(3)
if !ok {
t.Fatal("failed to reserve second XHTTP test memory")
}
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("xyz"), Seq: 2}, lease); err != nil {
lease.release()
t.Fatalf("second queue push failed: %v", err)
}
lease.release()
q.close()
if got := nativeXHTTPBufferedBytes.Load(); got != before {
t.Fatalf("buffered bytes after close = %d, want %d", got, before)
}
}
func TestXHTTPUploadQueueEnforcesPerSessionByteBudget(t *testing.T) {
before := nativeXHTTPBufferedBytes.Load()
q := newNativeXHTTPUploadQueue(4, 4)
defer q.close()
lease, ok := acquireNativeXHTTPMemory(5)
if !ok {
t.Fatal("failed to reserve XHTTP test memory")
}
defer lease.release()
err := q.push(context.Background(), nativeXHTTPPacket{Payload: make([]byte, 5)}, lease)
if !errors.Is(err, errNativeXHTTPUploadBufferFull) {
t.Fatalf("oversized queue push error = %v, want buffer limit", err)
}
lease.release()
if got := nativeXHTTPBufferedBytes.Load(); got != before {
t.Fatalf("rejected payload retained %d bytes, baseline %d", got, before)
}
}
func TestXHTTPUploadQueueBackpressuresInsteadOfRejectingBurst(t *testing.T) {
before := nativeXHTTPBufferedBytes.Load()
q := newNativeXHTTPUploadQueue(2, 4)
defer q.close()
first, ok := acquireNativeXHTTPMemory(4)
if !ok {
t.Fatal("failed to reserve first XHTTP payload")
}
if err := q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("one!"), Seq: 0}, first); err != nil {
first.release()
t.Fatalf("first queue push failed: %v", err)
}
first.release()
second, ok := acquireNativeXHTTPMemory(4)
if !ok {
t.Fatal("failed to reserve second XHTTP payload")
}
done := make(chan error, 1)
go func() {
done <- q.push(context.Background(), nativeXHTTPPacket{Payload: []byte("two!"), Seq: 1}, second)
}()
select {
case err := <-done:
second.release()
t.Fatalf("second burst packet did not backpressure: %v", err)
case <-time.After(25 * time.Millisecond):
}
buf := make([]byte, 4)
if n, err := q.Read(buf); err != nil || n != 4 || string(buf) != "one!" {
second.release()
t.Fatalf("first queue read = (%d, %v, %q)", n, err, string(buf))
}
select {
case err := <-done:
if err != nil {
second.release()
t.Fatalf("backpressured packet failed after space released: %v", err)
}
second.release()
case <-time.After(time.Second):
second.release()
t.Fatal("backpressured packet did not resume")
}
q.close()
if got := nativeXHTTPBufferedBytes.Load(); got != before {
t.Fatalf("backpressure test leaked %d buffered bytes (baseline %d)", got, before)
}
}
func TestXHTTPBodyReservationUsesActualContentLength(t *testing.T) {
ib := &nativeInbound{xhttpMaxEachPostBytes: 1_000_000}
req := httptest.NewRequest(http.MethodPost, "/session/0", strings.NewReader("small"))
if got := ib.xhttpUploadReservationBytes(req); got != 5 {
t.Fatalf("body reservation = %d, want actual payload length 5", got)
}
req.ContentLength = -1
if got := ib.xhttpUploadReservationBytes(req); got != 1_000_000 {
t.Fatalf("chunked body reservation = %d, want configured maximum", got)
}
}
func TestNativeQuotaResetWaitsForInFlightTraffic(t *testing.T) {
const uuid = "22222222-2222-2222-2222-222222222222"
state := &xrayNativeQuotaState{usedBytes: 123, generation: 1}
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: state}}
state.trafficMu.RLock()
done := make(chan struct{})
go func() {
m.resetNativeQuotaUsage(uuid)
close(done)
}()
select {
case <-done:
state.trafficMu.RUnlock()
t.Fatal("traffic reset crossed an in-flight writer boundary")
case <-time.After(25 * time.Millisecond):
}
state.trafficMu.RUnlock()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("traffic reset did not complete after writer released")
}
state.mu.Lock()
used, generation := state.usedBytes, state.generation
state.mu.Unlock()
if used != 0 || generation != 2 {
t.Fatalf("reset state = used %d generation %d, want 0/2", used, generation)
}
}
func TestNativeRateWaitCanBeCanceled(t *testing.T) {
lim := rate.NewLimiter(1, 1)
if !lim.AllowN(time.Now(), 1) {
t.Fatal("failed to consume initial limiter token")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := waitNativeRate(ctx, lim, 1); !errors.Is(err, context.Canceled) {
t.Fatalf("waitNativeRate error = %v, want context.Canceled", err)
}
}
func TestSSHDirtyQueueDoesNotScanInactiveUsers(t *testing.T) {
sshTrafficDirtyMu.Lock()
old := sshTrafficDirty
sshTrafficDirty = make(map[string]*UserState)
sshTrafficDirtyMu.Unlock()
defer func() {
sshTrafficDirtyMu.Lock()
sshTrafficDirty = old
sshTrafficDirtyMu.Unlock()
}()
active := &UserState{Cfg: UserConfig{Username: "active"}}
inactive := &UserState{Cfg: UserConfig{Username: "inactive"}}
markSSHUserTrafficDirty(active)
dirty := takeSSHUserTrafficDirty()
if len(dirty) != 1 || dirty["active"] != active {
t.Fatalf("dirty queue = %#v", dirty)
}
if _, found := dirty[inactive.Cfg.Username]; found {
t.Fatal("inactive user appeared in dirty queue")
}
if next := takeSSHUserTrafficDirty(); len(next) != 0 {
t.Fatalf("dirty queue was not drained: %#v", next)
}
}
func TestOldNativeConnectionCannotDecrementReplacementAccount(t *testing.T) {
oldStore := statsStore
statsStore = &Store{}
defer func() { statsStore = oldStore }()
const uuid = "replacement-active-user"
oldState := &xrayNativeQuotaState{maxConns: 1, generation: 1}
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: oldState}}
release, acquiredState, ok := m.acquireNativeClientConnection(uuid, "old@example")
if !ok || acquiredState != oldState {
t.Fatal("failed to acquire old account connection")
}
// Discard the old account's successful connect delta so this assertion only
// measures what happens when that old connection later disconnects.
m.nativeDBMu.Lock()
m.nativeActivePending = nil
m.nativeDBMu.Unlock()
newState := &xrayNativeQuotaState{maxConns: 1, generation: 1}
m.nativeQuotaMu.Lock()
m.nativeQuotaByUUID[uuid] = newState
m.nativeQuotaMu.Unlock()
release()
m.nativeDBMu.Lock()
pending := m.nativeActivePending[uuid]
m.nativeDBMu.Unlock()
if pending.Delta != 0 || pending.State != nil {
t.Fatalf("old disconnect was queued against replacement account: %+v", pending)
}
newState.mu.Lock()
active := newState.activeConns
newState.mu.Unlock()
if active != 0 {
t.Fatalf("replacement account active count changed to %d", active)
}
}
func TestOldNativeTrafficCannotAttachToReplacementAccount(t *testing.T) {
oldStore := statsStore
statsStore = &Store{}
defer func() { statsStore = oldStore }()
const uuid = "replacement-traffic-user"
oldState := &xrayNativeQuotaState{generation: 1}
newState := &xrayNativeQuotaState{generation: 1}
m := &XrayManager{nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: oldState}}
meter := newTrafficMeter(uuid, "old@example", true, oldState)
meter.n = 1234
m.nativeQuotaMu.Lock()
m.nativeQuotaByUUID[uuid] = newState
m.nativeQuotaMu.Unlock()
oldMgr := xrayMgr
xrayMgr = m
defer func() { xrayMgr = oldMgr }()
meter.flush()
m.nativeDBMu.Lock()
pending := m.nativeTrafficPending[uuid]
m.nativeDBMu.Unlock()
if pending.Uplink != 0 || pending.Downlink != 0 || pending.State != nil {
t.Fatalf("old traffic was queued against replacement account: %+v", pending)
}
m.statsMu.RLock()
stat := m.statsByEmail["old@example"]
m.statsMu.RUnlock()
if stat.Uplink != 0 || stat.Downlink != 0 {
t.Fatalf("old traffic resurfaced in runtime stats: %+v", stat)
}
}
func TestNativeFlusherDropsMismatchedPolicyIdentity(t *testing.T) {
oldStore := statsStore
statsStore = &Store{}
defer func() { statsStore = oldStore }()
const uuid = "identity-prune-user"
oldState := &xrayNativeQuotaState{generation: 1}
newState := &xrayNativeQuotaState{generation: 1}
m := &XrayManager{
nativeQuotaByUUID: map[string]*xrayNativeQuotaState{uuid: newState},
nativeTrafficPending: map[string]xrayPendingTraffic{
uuid: {Email: "old@example", Uplink: 99, State: oldState},
},
nativeActivePending: map[string]xrayPendingActive{
uuid: {Email: "old@example", Delta: -1, State: oldState},
},
}
m.flushNativeStatsToDB()
m.nativeDBMu.Lock()
defer m.nativeDBMu.Unlock()
if len(m.nativeTrafficPending) != 0 || len(m.nativeActivePending) != 0 {
t.Fatalf("mismatched pending deltas survived prune: traffic=%v active=%v", m.nativeTrafficPending, m.nativeActivePending)
}
}
func TestNativeMuxFinishRunsOnce(t *testing.T) {
var calls atomic.Int32
s := &nativeMuxSession{
closed: make(chan struct{}),
uplink: make(chan nativeMuxUplinkItem),
onClose: func(*nativeMuxSession) {
calls.Add(1)
},
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s.finish()
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("mux onClose called %d times, want 1", got)
}
}
type closeTrackingReader struct {
closed atomic.Bool
}
func (r *closeTrackingReader) Read([]byte) (int, error) { return 0, io.EOF }
func (r *closeTrackingReader) Close() error {
r.closed.Store(true)
return nil
}
func TestNativeXHTTPQueueCloseClosesQueuedStreamReader(t *testing.T) {
q := newNativeXHTTPUploadQueue(1, 1024)
r := &closeTrackingReader{}
if err := q.push(context.Background(), nativeXHTTPPacket{Reader: r}, nil); err != nil {
t.Fatalf("queue stream reader: %v", err)
}
q.close()
if !r.closed.Load() {
t.Fatal("queued stream reader was not closed during queue shutdown")
}
}
+9 -2
View File
@@ -20,8 +20,15 @@ func securePanelHandler(next http.Handler) http.Handler {
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || r.URL.Path == "/index.html" { // The panel is deployed in-place by update.sh. Do not let browsers or
w.Header().Set("Cache-Control", "no-store") // reverse proxies keep an older JavaScript bundle after an update, because
// stale form serializers can silently omit newly-added config fields.
if strings.HasPrefix(r.URL.Path, "/api/") ||
r.URL.Path == "/" || r.URL.Path == "/index.html" ||
strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
} }
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody) r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody)
+14
View File
@@ -87,6 +87,17 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge) http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
return return
} }
// Keep track of optional field presence separately from its boolean value.
// This protects a newly-added setting from being reset by a stale cached
// panel bundle that does not know how to send the field yet.
var fieldPresence struct {
PAMAuthEnabled *bool `json:"pam_auth_enabled"`
}
if err := json.Unmarshal(body, &fieldPresence); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
var newCfg Config var newCfg Config
if err := json.Unmarshal(body, &newCfg); err != nil { if err := json.Unmarshal(body, &newCfg); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -104,6 +115,9 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
globalCfgMu.RLock() globalCfgMu.RLock()
if globalCfg != nil { if globalCfg != nil {
newCfg.Users = globalCfg.Users newCfg.Users = globalCfg.Users
if fieldPresence.PAMAuthEnabled == nil {
newCfg.PAMAuthEnabled = globalCfg.PAMAuthEnabled
}
} }
globalCfgMu.RUnlock() globalCfgMu.RUnlock()
+81 -18
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
} }
@@ -96,13 +112,22 @@ 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 {
// Serialize deletion with the native stats flusher. Otherwise a batch that
// was swapped out just before DELETE could finish afterward and, if the same
// UUID is recreated quickly, apply stale traffic/active deltas to the new row.
xrayMgr.nativeTrafficPersistMu.Lock()
defer xrayMgr.nativeTrafficPersistMu.Unlock()
_, 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 +139,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 +158,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 +175,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 {
@@ -204,19 +233,38 @@ func (s *Store) AddXrayClientTrafficBatch(ctx context.Context, deltas map[string
return tx.Commit() return tx.Commit()
} }
// UpdateXrayClientActive adjusts the native online connection counter. // AddXrayClientActiveBatch persists native online-counter deltas without
func (s *Store) UpdateXrayClientActive(ctx context.Context, uuid, email string, delta int) error { // launching a database goroutine/query for every connect and disconnect.
if uuid == "" || delta == 0 { func (s *Store) AddXrayClientActiveBatch(ctx context.Context, deltas map[string]xrayPendingActive) error {
if len(deltas) == 0 {
return nil return nil
} }
_, err := s.db.ExecContext(ctx, ` tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `
UPDATE xray_clients SET UPDATE xray_clients SET
email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END, email = CASE WHEN email = '' AND $2 <> '' THEN $2 ELSE email END,
name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END, name = CASE WHEN name = '' AND $2 <> '' THEN $2 ELSE name END,
last_active = CASE WHEN $3::INT > 0 THEN NOW() ELSE last_active END, last_active = CASE WHEN $4::BOOLEAN THEN NOW() ELSE last_active END,
active_connections = GREATEST(active_connections + $3::INT, 0) active_connections = GREATEST(active_connections + $3::INT, 0)
WHERE uuid = $1`, uuid, email, delta) WHERE uuid = $1`)
return err if err != nil {
_ = tx.Rollback()
return err
}
defer stmt.Close()
for uuid, d := range deltas {
if uuid == "" || (d.Delta == 0 && !d.Connected) {
continue
}
if _, err := stmt.ExecContext(ctx, uuid, d.Email, d.Delta, d.Connected); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
} }
func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int { func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
@@ -304,3 +352,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 {
+314 -79
View File
@@ -259,8 +259,13 @@ type XrayManager struct {
pollStarted bool pollStarted bool
nativeDBMu sync.Mutex nativeDBMu sync.Mutex
nativeTrafficPersistMu sync.Mutex
nativeTrafficPending map[string]xrayPendingTraffic nativeTrafficPending map[string]xrayPendingTraffic
nativeActivePending map[string]xrayPendingActive
nativeStatsFlushStarted bool nativeStatsFlushStarted bool
nativeQuotaMu sync.RWMutex
nativeQuotaByUUID map[string]*xrayNativeQuotaState
} }
type xrayTrafficCounters struct { type xrayTrafficCounters struct {
@@ -280,6 +285,14 @@ type xrayPendingTraffic struct {
Email string Email string
Uplink int64 Uplink int64
Downlink int64 Downlink int64
State *xrayNativeQuotaState
}
type xrayPendingActive struct {
Email string
Delta int
Connected bool
State *xrayNativeQuotaState
} }
var xrayMgr = &XrayManager{} var xrayMgr = &XrayManager{}
@@ -296,6 +309,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).
@@ -449,7 +464,7 @@ func (m *XrayManager) Restart() error {
// recordNativeConnect marks a native client stream as online immediately. This // recordNativeConnect marks a native client stream as online immediately. This
// is more accurate than external Xray's Stats API polling because it knows when // is more accurate than external Xray's Stats API polling because it knows when
// the decoded VMess/VLESS stream is authenticated and opened. // the decoded VMess/VLESS stream is authenticated and opened.
func (m *XrayManager) recordNativeConnect(uuid, email string) { func (m *XrayManager) recordNativeConnect(uuid, email string, state *xrayNativeQuotaState) {
uuid = strings.TrimSpace(uuid) uuid = strings.TrimSpace(uuid)
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
if email == "" { if email == "" {
@@ -470,18 +485,10 @@ func (m *XrayManager) recordNativeConnect(uuid, email string) {
m.statsByEmail[email] = st m.statsByEmail[email] = st
m.statsMu.Unlock() m.statsMu.Unlock()
if statsStore != nil && uuid != "" { m.queueNativeActiveDelta(uuid, email, 1, true, state)
xrayGo("native xray stats active increment", func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, 1); err != nil {
xrayLogf("xray native stats: active +1 for %s failed: %v", uuid, err)
}
})
}
} }
func (m *XrayManager) recordNativeDisconnect(uuid, email string) { func (m *XrayManager) recordNativeDisconnect(uuid, email string, state *xrayNativeQuotaState) {
uuid = strings.TrimSpace(uuid) uuid = strings.TrimSpace(uuid)
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
if email == "" { if email == "" {
@@ -500,21 +507,44 @@ func (m *XrayManager) recordNativeDisconnect(uuid, email string) {
} }
m.statsMu.Unlock() m.statsMu.Unlock()
if statsStore != nil && uuid != "" { m.queueNativeActiveDelta(uuid, email, -1, false, state)
xrayGo("native xray stats active decrement", func() { }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() func (m *XrayManager) queueNativeActiveDelta(uuid, email string, delta int, connected bool, state *xrayNativeQuotaState) {
if err := statsStore.UpdateXrayClientActive(ctx, uuid, email, -1); err != nil { if statsStore == nil || uuid == "" || delta == 0 || state == nil {
xrayLogf("xray native stats: active -1 for %s failed: %v", uuid, err) return
}
})
} }
// Keep the policy identity stable until the delta is queued. A UUID can be
// deleted and later recreated; an old connection must never decrement or add
// traffic to the replacement account merely because the string key matches.
m.nativeQuotaMu.RLock()
if m.nativeQuotaByUUID[uuid] != state {
m.nativeQuotaMu.RUnlock()
return
}
m.nativeDBMu.Lock()
if m.nativeActivePending == nil {
m.nativeActivePending = make(map[string]xrayPendingActive)
}
p := m.nativeActivePending[uuid]
if p.State != nil && p.State != state {
p = xrayPendingActive{}
}
if p.Email == "" {
p.Email = email
}
p.Delta += delta
p.Connected = p.Connected || connected
p.State = state
m.nativeActivePending[uuid] = p
m.nativeDBMu.Unlock()
m.nativeQuotaMu.RUnlock()
} }
// 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, state *xrayNativeQuotaState) {
uuid = strings.TrimSpace(uuid) uuid = strings.TrimSpace(uuid)
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
if email == "" { if email == "" {
@@ -523,6 +553,43 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
if email == "" || (up == 0 && down == 0) { if email == "" || (up == 0 && down == 0) {
return return
} }
if state != nil {
// Keep generation validation and queuing in the same critical section as
// resetNativeTrafficAccounting. Otherwise an old meter can validate just
// before a reset and enqueue its bytes immediately after the DB was zeroed.
state.mu.Lock()
defer state.mu.Unlock()
if generation != state.generation {
return
}
}
// Only DB-backed clients have a native policy state. Config-only clients are
// still shown in runtime stats, but queuing UPDATEs for rows that do not exist
// can make the retry map grow during a database outage.
if statsStore != nil && uuid != "" && state != nil {
m.nativeQuotaMu.RLock()
if m.nativeQuotaByUUID[uuid] != state {
m.nativeQuotaMu.RUnlock()
return
}
m.nativeDBMu.Lock()
if m.nativeTrafficPending == nil {
m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
}
p := m.nativeTrafficPending[uuid]
if p.State != nil && p.State != state {
p = xrayPendingTraffic{}
}
p.Email = email
p.Uplink += up
p.Downlink += down
p.State = state
m.nativeTrafficPending[uuid] = p
m.nativeDBMu.Unlock()
m.nativeQuotaMu.RUnlock()
}
now := time.Now() now := time.Now()
m.statsMu.Lock() m.statsMu.Lock()
if m.statsByEmail == nil { if m.statsByEmail == nil {
@@ -536,18 +603,6 @@ func (m *XrayManager) recordNativeTraffic(uuid, email string, up, down int64) {
m.statsByEmail[email] = st m.statsByEmail[email] = st
m.statsMu.Unlock() m.statsMu.Unlock()
if statsStore != nil && uuid != "" {
m.nativeDBMu.Lock()
if m.nativeTrafficPending == nil {
m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
}
p := m.nativeTrafficPending[uuid]
p.Email = email
p.Uplink += up
p.Downlink += down
m.nativeTrafficPending[uuid] = p
m.nativeDBMu.Unlock()
}
} }
func (m *XrayManager) startNativeStatsFlusher() { func (m *XrayManager) startNativeStatsFlusher() {
@@ -594,35 +649,108 @@ func (m *XrayManager) flushNativeStatsToDB() {
if statsStore == nil { if statsStore == nil {
return return
} }
m.nativeTrafficPersistMu.Lock()
defer m.nativeTrafficPersistMu.Unlock()
persistent := m.nativePersistentStates()
m.nativeDBMu.Lock() m.nativeDBMu.Lock()
pending := m.nativeTrafficPending for uuid, pending := range m.nativeTrafficPending {
if persistent[uuid] != pending.State {
delete(m.nativeTrafficPending, uuid)
}
}
for uuid, pending := range m.nativeActivePending {
if persistent[uuid] != pending.State {
delete(m.nativeActivePending, uuid)
}
}
pendingTraffic := m.nativeTrafficPending
pendingActive := m.nativeActivePending
m.nativeTrafficPending = nil m.nativeTrafficPending = nil
m.nativeActivePending = nil
m.nativeDBMu.Unlock() m.nativeDBMu.Unlock()
if len(pending) == 0 { if len(pendingTraffic) == 0 && len(pendingActive) == 0 {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() var trafficErr error
if err := statsStore.AddXrayClientTrafficBatch(ctx, pending); err != nil { if len(pendingTraffic) > 0 {
xrayLogf("xray native stats: db traffic flush failed: %v", err) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Put deltas back so a transient DB failure does not lose accounting. trafficErr = statsStore.AddXrayClientTrafficBatch(ctx, pendingTraffic)
cancel()
}
var activeErr error
if len(pendingActive) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
activeErr = statsStore.AddXrayClientActiveBatch(ctx, pendingActive)
cancel()
}
if trafficErr != nil {
xrayLogf("xray native stats: db traffic flush failed: %v", trafficErr)
}
if activeErr != nil {
xrayLogf("xray native stats: db active flush failed: %v", activeErr)
}
if trafficErr != nil || activeErr != nil {
// Put only failed batches back so a successful write is never duplicated.
persistent = m.nativePersistentStates()
m.nativeDBMu.Lock() m.nativeDBMu.Lock()
if m.nativeTrafficPending == nil { if trafficErr != nil && m.nativeTrafficPending == nil {
m.nativeTrafficPending = make(map[string]xrayPendingTraffic) m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
} }
for uuid, d := range pending { if trafficErr != nil {
p := m.nativeTrafficPending[uuid] for uuid, d := range pendingTraffic {
if p.Email == "" { if persistent[uuid] != d.State {
p.Email = d.Email continue
}
p := m.nativeTrafficPending[uuid]
if p.State != nil && p.State != d.State {
p = xrayPendingTraffic{}
}
if p.Email == "" {
p.Email = d.Email
}
p.Uplink += d.Uplink
p.Downlink += d.Downlink
p.State = d.State
m.nativeTrafficPending[uuid] = p
}
}
if activeErr != nil && m.nativeActivePending == nil {
m.nativeActivePending = make(map[string]xrayPendingActive)
}
if activeErr != nil {
for uuid, d := range pendingActive {
if persistent[uuid] != d.State {
continue
}
p := m.nativeActivePending[uuid]
if p.State != nil && p.State != d.State {
p = xrayPendingActive{}
}
if p.Email == "" {
p.Email = d.Email
}
p.Delta += d.Delta
p.Connected = p.Connected || d.Connected
p.State = d.State
m.nativeActivePending[uuid] = p
} }
p.Uplink += d.Uplink
p.Downlink += d.Downlink
m.nativeTrafficPending[uuid] = p
} }
m.nativeDBMu.Unlock() m.nativeDBMu.Unlock()
} }
} }
func (m *XrayManager) nativePersistentStates() map[string]*xrayNativeQuotaState {
m.nativeQuotaMu.RLock()
out := make(map[string]*xrayNativeQuotaState, len(m.nativeQuotaByUUID))
for uuid, state := range m.nativeQuotaByUUID {
out[uuid] = state
}
m.nativeQuotaMu.RUnlock()
return out
}
// XrayStatusDTO is returned by /api/xray/status. // XrayStatusDTO is returned by /api/xray/status.
type XrayStatusDTO struct { type XrayStatusDTO struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -2027,12 +2155,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 +2470,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 +2557,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 +2574,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 +2594,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 +2687,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 +2712,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 +2728,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 +2747,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 +2786,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 +2810,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)
@@ -2658,6 +2829,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func handleXrayClientResetTraffic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var req struct {
UUID string `json:"uuid"`
ServerID string `json:"server_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
req.UUID = strings.TrimSpace(req.UUID)
if req.UUID == "" {
http.Error(w, "uuid required", http.StatusBadRequest)
return
}
ctx := r.Context()
if ms, remote, err := managedServerFromID(ctx, statsStore, req.ServerID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
} else if remote {
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(ctx, ms, req.UUID, sess.Username) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
req.ServerID = ""
body, _ := json.Marshal(req)
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/reset-traffic", body, "application/json")
if err != nil {
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
return
}
writeProxyResponse(w, status, data, ct)
return
}
if statsStore == nil {
http.Error(w, "storage not available", http.StatusInternalServerError)
return
}
existing, err := statsStore.GetXrayClientMeta(ctx, req.UUID)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "client not found", http.StatusNotFound)
} else {
http.Error(w, "database error", http.StatusInternalServerError)
}
return
}
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := xrayMgr.resetNativeTrafficAccounting(ctx, statsStore, req.UUID, existing.Email); err != nil {
http.Error(w, "usage reset failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "uuid": req.UUID})
}
func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) { func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
+97 -43
View File
@@ -138,6 +138,14 @@ func (s *nativeXrayServer) start(configFile string) error {
if s.running { if s.running {
return fmt.Errorf("native xray already running") return fmt.Errorf("native xray already running")
} }
beginNativeTransportAccepting()
started := false
defer func() {
if !started {
stopNativeTransportAccepting()
closeAllNativeTransportConnections()
}
}()
if configFile == "" { if configFile == "" {
return fmt.Errorf("native xray: no config file configured") return fmt.Errorf("native xray: no config file configured")
} }
@@ -196,9 +204,11 @@ func (s *nativeXrayServer) start(configFile string) error {
} }
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err) return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
} }
serveLn := net.Listener(ln) // Apply the global pre-authentication ceiling before net/http can spawn a
// goroutine or begin a TLS handshake for the accepted socket.
serveLn := limitNativeListener(ln)
if group.security == "tls" { if group.security == "tls" {
serveLn = tls.NewListener(ln, group.tlsConfig) serveLn = tls.NewListener(serveLn, group.tlsConfig)
} }
opened = append(opened, serveLn) opened = append(opened, serveLn)
xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) }) xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) })
@@ -212,21 +222,34 @@ func (s *nativeXrayServer) start(configFile string) error {
s.inboundsByTag = active s.inboundsByTag = active
s.running = true s.running = true
s.startTime = time.Now() s.startTime = time.Now()
started = true
return nil return nil
} }
func (s *nativeXrayServer) stop() { func (s *nativeXrayServer) stop() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock()
if !s.running && len(s.listeners) == 0 { if !s.running && len(s.listeners) == 0 {
s.mu.Unlock()
return return
} }
for _, l := range s.listeners { stopNativeTransportAccepting()
_ = l.Close() listeners := append([]net.Listener(nil), s.listeners...)
inbounds := make([]*nativeInbound, 0, len(s.inboundsByTag))
for _, ib := range s.inboundsByTag {
inbounds = append(inbounds, ib)
} }
s.listeners = nil s.listeners = nil
s.inboundsByTag = nil s.inboundsByTag = nil
s.running = false s.running = false
s.mu.Unlock()
for _, l := range listeners {
_ = l.Close()
}
closeAllNativeTransportConnections()
for _, ib := range inbounds {
ib.closeAllXHTTPSessions()
}
xrayLogf("native xray: stopped") xrayLogf("native xray: stopped")
} }
@@ -241,6 +264,12 @@ func (ib *nativeInbound) acceptLoop(ln net.Listener) {
xrayLogf("native xray: accept error on %s: %v", ln.Addr(), err) xrayLogf("native xray: accept error on %s: %v", ln.Addr(), err)
continue continue
} }
counted, ok := waitWrapTrackedNativeTransportConn(c)
if !ok {
time.Sleep(nativeOverloadBackoff)
continue
}
c = counted
xrayGo(fmt.Sprintf("native xray connection remote=%s", c.RemoteAddr()), func() { ib.serve(c) }) xrayGo(fmt.Sprintf("native xray connection remote=%s", c.RemoteAddr()), func() { ib.serve(c) })
} }
} }
@@ -262,7 +291,7 @@ func (ib *nativeInbound) serve(raw net.Conn) {
tconn := tls.Server(raw, ib.tlsConfig) tconn := tls.Server(raw, ib.tlsConfig)
_ = tconn.SetDeadline(time.Now().Add(tlsHandshakeTimeout)) _ = tconn.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
if err := tconn.Handshake(); err != nil { if err := tconn.Handshake(); err != nil {
xrayLogf("native xray: tls handshake from %s failed: %v", raw.RemoteAddr(), err) logNativePreAuthRejection("native xray: tls handshake from %s failed: %v", raw.RemoteAddr(), err)
return return
} }
_ = tconn.SetDeadline(time.Time{}) _ = tconn.SetDeadline(time.Time{})
@@ -275,11 +304,13 @@ func (ib *nativeInbound) serve(raw net.Conn) {
case "tcp", "raw", "": case "tcp", "raw", "":
// stream is already the protocol stream // stream is already the protocol stream
case "ws", "websocket": case "ws", "websocket":
_ = conn.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
ws, err := wsServerHandshake(conn, ib.path) ws, err := wsServerHandshake(conn, ib.path)
if err != nil { if err != nil {
xrayLogf("native xray: ws handshake from %s failed: %v", raw.RemoteAddr(), err) logNativePreAuthRejection("native xray: ws handshake from %s failed: %v", raw.RemoteAddr(), err)
return return
} }
_ = conn.SetDeadline(time.Time{})
stream = ws stream = ws
case "xhttp", "splithttp": case "xhttp", "splithttp":
xrayLogf("native xray: inbound %q got raw connection for XHTTP; this transport is served by http.Server", ib.tag) xrayLogf("native xray: inbound %q got raw connection for XHTTP; this transport is served by http.Server", ib.tag)
@@ -331,11 +362,7 @@ const (
func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) { func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
defer xrayRecover(fmt.Sprintf("native xray VLESS inbound=%q remote=%s", ib.tag, remote)) defer xrayRecover(fmt.Sprintf("native xray VLESS inbound=%q remote=%s", ib.tag, remote))
if ib.isXHTTP() { xrayTracef("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
xrayTracef("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
} else {
xrayLogf("native xray: vless handshake start inbound=%q transport=%s remote=%s", ib.tag, ib.transport, remote)
}
_ = stream.SetReadDeadline(time.Now().Add(30 * time.Second)) _ = stream.SetReadDeadline(time.Now().Add(30 * time.Second))
head := make([]byte, 1+16+1) // version + uuid + addonLen head := make([]byte, 1+16+1) // version + uuid + addonLen
@@ -349,7 +376,11 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
client := ib.getNativeClient(id) client := ib.getNativeClient(id)
if client == nil { if client == nil {
xrayLogf("native xray: inbound %q rejected unknown VLESS uuid from %s", ib.tag, remote) logNativePreAuthRejection("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 return
} }
@@ -396,6 +427,18 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
} }
_ = stream.SetReadDeadline(time.Time{}) _ = stream.SetReadDeadline(time.Time{})
switch cmd[0] {
case vlessCmdTCP, vlessCmdUDP, vlessCmdMux:
default:
xrayLogf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0])
return
}
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email)
if !ok {
return
}
defer releaseConnection()
// VLESS response header must be sent before relaying payload. CommandMux is // VLESS response header must be sent before relaying payload. CommandMux is
// special: official Xray does not read a target from the VLESS header for it; // special: official Xray does not read a target from the VLESS header for it;
// the following bytes are Mux.Cool/XUDP frames. Reading port/address here // the following bytes are Mux.Cool/XUDP frames. Reading port/address here
@@ -413,7 +456,7 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
return return
} }
ib.nativeSuccessLogf("native xray: vless/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) ib.nativeSuccessLogf("native xray: vless/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
nativeTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) nativeTunnel(stream, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
case vlessCmdUDP: case vlessCmdUDP:
backend, target, err := ib.nativeDialUDP(host, port) backend, target, err := ib.nativeDialUDP(host, port)
if err != nil { if err != nil {
@@ -421,12 +464,10 @@ func (ib *nativeInbound) handleVLESS(stream net.Conn, remote net.Addr) {
return return
} }
ib.nativeSuccessLogf("native xray: vless/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) ib.nativeSuccessLogf("native xray: vless/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
nativeVLESSUDPTunnel(stream, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) nativeVLESSUDPTunnel(stream, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
case vlessCmdMux: case vlessCmdMux:
ib.nativeSuccessLogf("native xray: vless/mux user=%s remote=%s (inbound %q)", client.email, remote, ib.tag) ib.nativeSuccessLogf("native xray: vless/mux user=%s remote=%s (inbound %q)", client.email, remote, ib.tag)
ib.nativeVLESSMuxTunnel(stream, client.uuid, client.email) ib.nativeVLESSMuxTunnel(stream, client.uuid, client.email, quotaState)
default:
xrayLogf("native xray: inbound %q VLESS command %d not supported yet", ib.tag, cmd[0])
} }
} }
@@ -440,7 +481,7 @@ func (ib *nativeInbound) logVLESSReadFailure(stage string, remote net.Addr, emai
return return
} }
if email == "" { if email == "" {
xrayLogf("native xray: vless %s failed inbound=%q transport=%s remote=%s: %v", stage, ib.tag, ib.transport, remote, err) logNativePreAuthRejection("native xray: vless %s failed inbound=%q transport=%s remote=%s: %v", stage, ib.tag, ib.transport, remote, err)
} else { } else {
xrayLogf("native xray: vless %s failed inbound=%q transport=%s user=%s remote=%s: %v", stage, ib.tag, ib.transport, email, remote, err) xrayLogf("native xray: vless %s failed inbound=%q transport=%s user=%s remote=%s: %v", stage, ib.tag, ib.transport, email, remote, err)
} }
@@ -657,18 +698,18 @@ func normalizeNativeTargetHost(raw string) string {
// backend, applying per-direction rate limits and accounting traffic against // backend, applying per-direction rate limits and accounting traffic against
// the client's email so the panel's online detection keeps working. It mirrors // the client's email so the panel's online detection keeps working. It mirrors
// handleDirectTCPIP in main.go. // handleDirectTCPIP in main.go.
func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) { func nativeTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
xrayMgr.recordNativeConnect(uuid, email)
defer xrayMgr.recordNativeDisconnect(uuid, email)
defer xrayRecover(fmt.Sprintf("native xray TCP tunnel user=%s", email)) defer xrayRecover(fmt.Sprintf("native xray TCP tunnel user=%s", email))
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true} upMeter := newTrafficMeter(uuid, email, true, quotaState)
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false} downMeter := newTrafficMeter(uuid, email, false, quotaState)
var wg sync.WaitGroup var wg sync.WaitGroup
var closeOnce sync.Once var closeOnce sync.Once
ctx, cancel := context.WithCancel(context.Background())
closeAll := func() { closeAll := func() {
closeOnce.Do(func() { closeOnce.Do(func() {
cancel()
_ = backend.Close() _ = backend.Close()
_ = client.Close() _ = client.Close()
}) })
@@ -678,7 +719,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) _, _ = copyWithRateLimitContext(ctx, xrayQuotaMeteredWriter{w: backend, meter: upMeter, ctx: ctx}, client, up)
if cw, ok := backend.(interface{ CloseWrite() error }); ok { if cw, ok := backend.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite() _ = cw.CloseWrite()
} }
@@ -688,7 +729,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) _, _ = copyWithRateLimitContext(ctx, xrayQuotaMeteredWriter{w: client, meter: downMeter, ctx: ctx}, backend, down)
}) })
wg.Wait() wg.Wait()
@@ -700,15 +741,24 @@ 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
state *xrayNativeQuotaState
} }
const trafficFlushThreshold = 1024 * 1024 const trafficFlushThreshold = 1024 * 1024
func newTrafficMeter(uuid, email string, uplink bool, state *xrayNativeQuotaState) *trafficMeter {
t := &trafficMeter{uuid: uuid, email: email, uplink: uplink, state: state}
t.syncQuotaGeneration()
return t
}
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 +766,33 @@ 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, t.state)
} else { } else {
xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n) xrayMgr.recordNativeTraffic(t.uuid, t.email, 0, t.n, t.quotaGeneration, t.state)
} }
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 { var generation uint64
w io.Writer if t.state != nil {
meter *trafficMeter t.state.mu.Lock()
} generation = t.state.generation
t.state.mu.Unlock()
func (mw meteredWriter) Write(p []byte) (int, error) { }
n, err := mw.w.Write(p) if t.quotaGeneration == 0 {
if n > 0 { t.quotaGeneration = generation
mw.meter.add(n) return
}
if generation != t.quotaGeneration {
t.n = 0
t.quotaGeneration = generation
} }
return n, err
} }
func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) } func (ib *nativeInbound) upLimiter() *rate.Limiter { return newByteLimiter(ib.upBytesPerSec) }
+184 -32
View File
@@ -54,7 +54,11 @@ type nativeMuxUplinkItem struct {
port uint16 port uint16
} }
const nativeMuxUplinkQueue = 64 const (
nativeMuxUplinkQueue = 16
nativeMuxMaxBufferedBytesPerSession = 1 * 1024 * 1024
nativeMuxMaxBufferedBytesGlobal = 128 * 1024 * 1024
)
var nativeMuxFramePool = sync.Pool{ var nativeMuxFramePool = sync.Pool{
New: func() any { New: func() any {
@@ -91,6 +95,11 @@ type nativeMuxSession struct {
uplink chan nativeMuxUplinkItem uplink chan nativeMuxUplinkItem
closed chan struct{} closed chan struct{}
closeOnce sync.Once closeOnce sync.Once
finishOnce sync.Once
enqueueMu sync.Mutex
enqueueWG sync.WaitGroup
enqueueDone bool
buffered atomic.Int64
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
onClose func(*nativeMuxSession) onClose func(*nativeMuxSession)
@@ -98,7 +107,11 @@ type nativeMuxSession struct {
globalID [8]byte globalID [8]byte
} }
var nativeMuxGlobalActive atomic.Int64 var (
nativeMuxGlobalActive atomic.Int64
nativeMuxBufferedBytes atomic.Int64
nativeMuxBufferRejected atomic.Int64
)
func acquireNativeMuxGlobalSlot() (func(), bool) { func acquireNativeMuxGlobalSlot() (func(), bool) {
limit := int64(nativeMuxGlobalSessionLimit()) limit := int64(nativeMuxGlobalSessionLimit())
@@ -117,15 +130,73 @@ func acquireNativeMuxGlobalSlot() (func(), bool) {
} }
} }
func reserveNativeMuxBufferedBytes(s *nativeMuxSession, n int64) bool {
if s == nil || n <= 0 {
return true
}
for {
current := s.buffered.Load()
if current > nativeMuxMaxBufferedBytesPerSession-n {
logNativeLimitRejection("mux session buffered bytes", &nativeMuxBufferRejected, nativeMuxMaxBufferedBytesPerSession)
return false
}
if s.buffered.CompareAndSwap(current, current+n) {
break
}
}
for {
current := nativeMuxBufferedBytes.Load()
if current > nativeMuxMaxBufferedBytesGlobal-n {
s.buffered.Add(-n)
logNativeLimitRejection("mux global buffered bytes", &nativeMuxBufferRejected, nativeMuxMaxBufferedBytesGlobal)
return false
}
if nativeMuxBufferedBytes.CompareAndSwap(current, current+n) {
return true
}
}
}
func releaseNativeMuxBufferedBytes(s *nativeMuxSession, n int64) {
if s == nil || n <= 0 {
return
}
for {
current := s.buffered.Load()
release := n
if release > current {
release = current
}
if s.buffered.CompareAndSwap(current, current-release) {
releaseNativeAtomicBytes(&nativeMuxBufferedBytes, release)
return
}
}
}
func releaseNativeAtomicBytes(counter *atomic.Int64, n int64) {
if counter == nil || n <= 0 {
return
}
for {
current := counter.Load()
next := current - n
if next < 0 {
next = 0
}
if counter.CompareAndSwap(current, next) {
return
}
}
}
// nativeVLESSMuxTunnel implements the server side of Xray's Mux.Cool framing // nativeVLESSMuxTunnel implements the server side of Xray's Mux.Cool framing
// for VLESS CommandMux. CommandMux does not carry a VLESS target address; every // for VLESS CommandMux. CommandMux does not carry a VLESS target address; every
// child TCP/UDP request is described by mux frame metadata. UDP is treated as a // child TCP/UDP request is described by mux frame metadata. UDP is treated as a
// packet protocol, not as a byte stream, and XUDP-style GlobalID/endpoint // packet protocol, not as a byte stream, and XUDP-style GlobalID/endpoint
// metadata is accepted for full-cone friendly clients. // metadata is accepted for full-cone friendly clients.
func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, email string) { func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, email string, quotaState *xrayNativeQuotaState) {
defer xrayRecover(fmt.Sprintf("native xray VLESS mux user=%s", email)) defer xrayRecover(fmt.Sprintf("native xray VLESS mux user=%s", email))
xrayMgr.recordNativeConnect(uuid, email)
defer xrayMgr.recordNativeDisconnect(uuid, email)
writeMu := &sync.Mutex{} writeMu := &sync.Mutex{}
sessions := make(map[uint16]*nativeMuxSession) sessions := make(map[uint16]*nativeMuxSession)
@@ -256,7 +327,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
} }
} }
s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, removeSession) s, target, err := ib.newNativeMuxSession(meta.sessionID, meta.network, targetHost, targetPort, isXUDP, meta.globalID, stream, writeMu, uuid, email, quotaState, removeSession)
if err != nil { if err != nil {
xrayLogf("native xray: VLESS mux session %s setup failed: %v", target, err) xrayLogf("native xray: VLESS mux session %s setup failed: %v", target, err)
writeMu.Lock() writeMu.Lock()
@@ -281,8 +352,11 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP) xrayTracef("native xray: vless/mux %s user=%s -> %s session=%d xudp=%v", nativeMuxNetworkName(meta.network), email, target, meta.sessionID, isXUDP)
ib2, host2, port2 := ib, targetHost, targetPort ib2, host2, port2 := ib, targetHost, targetPort
xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) }) xrayGo(fmt.Sprintf("native xray mux session=%d", s.id), func() { s.run(ib2, host2, port2) })
if len(pkt.payload) > 0 { if len(pkt.payload) > 0 && !s.enqueueUplink(pkt.payload, pkt.host, pkt.port) {
s.enqueueUplink(pkt.payload, pkt.host, pkt.port) closeSession(s.id)
writeMu.Lock()
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
writeMu.Unlock()
} }
case nativeMuxStatusKeep: case nativeMuxStatusKeep:
@@ -319,8 +393,11 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
pkt.host = meta.host pkt.host = meta.host
pkt.port = meta.port pkt.port = meta.port
} }
if len(pkt.payload) > 0 { if len(pkt.payload) > 0 && !s.enqueueUplink(pkt.payload, pkt.host, pkt.port) {
s.enqueueUplink(pkt.payload, pkt.host, pkt.port) closeSession(s.id)
writeMu.Lock()
_ = writeNativeMuxEnd(stream, meta.sessionID, true)
writeMu.Unlock()
} }
default: default:
@@ -332,7 +409,7 @@ func (ib *nativeInbound) nativeVLESSMuxTunnel(stream io.ReadWriteCloser, uuid, e
} }
} }
func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) { func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host string, port uint16, xudp bool, globalID [8]byte, client io.Writer, writeMu *sync.Mutex, uuid, email string, quotaState *xrayNativeQuotaState, onClose func(*nativeMuxSession)) (*nativeMuxSession, string, error) {
target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port))) target := net.JoinHostPort(normalizeNativeTargetHost(host), strconv.Itoa(int(port)))
if invalidNativeDestination(host, port) { if invalidNativeDestination(host, port) {
return nil, target, fmt.Errorf("invalid destination") return nil, target, fmt.Errorf("invalid destination")
@@ -351,8 +428,8 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin
email: email, email: email,
upLimiter: ib.upLimiter(), upLimiter: ib.upLimiter(),
downLimiter: ib.downLimiter(), downLimiter: ib.downLimiter(),
upMeter: &trafficMeter{uuid: uuid, email: email, uplink: true}, upMeter: newTrafficMeter(uuid, email, true, quotaState),
downMeter: &trafficMeter{uuid: uuid, email: email, uplink: false}, downMeter: newTrafficMeter(uuid, email, false, quotaState),
uplink: make(chan nativeMuxUplinkItem, nativeMuxUplinkQueue), uplink: make(chan nativeMuxUplinkItem, nativeMuxUplinkQueue),
closed: make(chan struct{}), closed: make(chan struct{}),
onClose: onClose, onClose: onClose,
@@ -365,6 +442,7 @@ func (ib *nativeInbound) newNativeMuxSession(id uint16, network byte, host strin
func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) { func (s *nativeMuxSession) run(ib *nativeInbound, host string, port uint16) {
defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id)) defer xrayRecover(fmt.Sprintf("native xray mux run session=%d", s.id))
defer s.finish()
select { select {
case <-s.closed: case <-s.closed:
@@ -410,24 +488,61 @@ func (s *nativeMuxSession) failInit(notifyClient bool) {
_ = writeNativeMuxEnd(s.client, s.id, true) _ = writeNativeMuxEnd(s.client, s.id, true)
s.writeMu.Unlock() s.writeMu.Unlock()
} }
if s.onClose != nil { s.finish()
s.onClose(s)
}
s.closeBackend()
} }
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) { // finish is the single lifecycle exit for a mux child. The backend reader,
// uplink loop, parent mux stream, and initialization path can all detect the
// terminal condition concurrently, so both cleanup and map removal must be
// exactly-once operations.
func (s *nativeMuxSession) finish() {
s.finishOnce.Do(func() {
s.closeBackend()
if s.onClose != nil {
s.onClose(s)
}
})
}
func (s *nativeMuxSession) beginEnqueue() bool {
s.enqueueMu.Lock()
defer s.enqueueMu.Unlock()
if s.enqueueDone {
return false
}
s.enqueueWG.Add(1)
return true
}
func (s *nativeMuxSession) enqueueUplink(payload []byte, host string, port uint16) bool {
if len(payload) == 0 { if len(payload) == 0 {
return return true
}
if !s.beginEnqueue() {
return false
}
defer s.enqueueWG.Done()
bytes := int64(len(payload))
if !reserveNativeMuxBufferedBytes(s, bytes) {
return false
} }
cp := make([]byte, len(payload)) cp := make([]byte, len(payload))
copy(cp, payload) copy(cp, payload)
select { select {
case s.uplink <- nativeMuxUplinkItem{payload: cp, host: host, port: port}: case s.uplink <- nativeMuxUplinkItem{payload: cp, host: host, port: port}:
return true
case <-s.closed: case <-s.closed:
releaseNativeMuxBufferedBytes(s, bytes)
return false
} }
} }
func (s *nativeMuxSession) processUplinkItem(item nativeMuxUplinkItem) bool {
defer releaseNativeMuxBufferedBytes(s, int64(len(item.payload)))
return s.writeBackendItem(item)
}
func (s *nativeMuxSession) uplinkLoop() { func (s *nativeMuxSession) uplinkLoop() {
defer s.upMeter.flush() defer s.upMeter.flush()
for { for {
@@ -435,7 +550,7 @@ func (s *nativeMuxSession) uplinkLoop() {
case <-s.closed: case <-s.closed:
return return
case item := <-s.uplink: case item := <-s.uplink:
if !s.writeBackendItem(item) { if !s.processUplinkItem(item) {
s.closeBackend() s.closeBackend()
return return
} }
@@ -481,6 +596,13 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
return false return false
} }
} }
quotaReservation, quotaErr := reserveNativePacketQuota(s.upMeter, len(payload))
if quotaErr != nil {
return false
}
if err := quotaReservation.wait(s.ctx); err != nil {
return false
}
var n int var n int
var err error var err error
@@ -492,6 +614,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.
quotaReservation.finish(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 +626,7 @@ func (s *nativeMuxSession) writeBackendItem(item nativeMuxUplinkItem) bool {
s.lastUDPPort = item.port s.lastUDPPort = item.port
s.lastUDPAddr = addr s.lastUDPAddr = addr
} else { } else {
quotaReservation.finish(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 +636,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 { quotaReservation.finish(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
@@ -532,10 +654,7 @@ func (s *nativeMuxSession) readBackendLoop() {
_ = writeNativeMuxEnd(s.client, s.id, false) _ = writeNativeMuxEnd(s.client, s.id, false)
s.writeMu.Unlock() s.writeMu.Unlock()
} }
if s.onClose != nil { s.finish()
s.onClose(s)
}
s.closeBackend()
}() }()
if s.network == nativeMuxNetworkTCP { if s.network == nativeMuxNetworkTCP {
@@ -571,14 +690,22 @@ func (s *nativeMuxSession) readTCPBackendLoop() {
if err := s.waitDownRate(n); err != nil { if err := s.waitDownRate(n); err != nil {
return return
} }
s.downMeter.add(n) quotaReservation, quotaErr := reserveNativePacketQuota(s.downMeter, n)
if quotaErr != nil {
return
}
if err := quotaReservation.wait(s.ctx); err != nil {
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 {
quotaReservation.finish(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
} }
quotaReservation.finish(n)
} }
} }
@@ -602,7 +729,13 @@ 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) quotaReservation, quotaErr := reserveNativePacketQuota(s.downMeter, n)
if quotaErr != nil {
return true
}
if err := quotaReservation.wait(s.ctx); err != nil {
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,27 +743,46 @@ 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 {
quotaReservation.finish(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
} }
quotaReservation.finish(n)
} }
} }
func (s *nativeMuxSession) closeBackend() { func (s *nativeMuxSession) closeBackend() {
s.closeOnce.Do(func() { s.closeOnce.Do(func() {
s.enqueueMu.Lock()
s.enqueueDone = true
close(s.closed) close(s.closed)
s.enqueueMu.Unlock()
if s.cancel != nil { if s.cancel != nil {
s.cancel() s.cancel()
} }
if s.releaseSlot != nil {
s.releaseSlot()
}
if s.tcp != nil { if s.tcp != nil {
_ = s.tcp.Close() _ = s.tcp.Close()
} }
if s.udp != nil { if s.udp != nil {
_ = s.udp.Close() _ = s.udp.Close()
} }
// Wait for producers that passed beginEnqueue before the close flag, then
// discard any payloads the consumer did not take. This returns every byte
// reservation even when shutdown races a full queue.
s.enqueueWG.Wait()
for {
select {
case item := <-s.uplink:
releaseNativeMuxBufferedBytes(s, int64(len(item.payload)))
item.payload = nil
default:
if s.releaseSlot != nil {
s.releaseSlot()
}
return
}
}
}) })
} }
+275 -1
View File
@@ -1,6 +1,14 @@
package main package main
import "runtime/debug" import (
"net"
"runtime/debug"
"sync"
"sync/atomic"
"time"
)
const nativeOverloadBackoff = 10 * time.Millisecond
// xrayRecover prevents a bad client packet, closed HTTP stream, or mux/session // xrayRecover prevents a bad client packet, closed HTTP stream, or mux/session
// race from taking down the whole sshpanel process. A panic should only kill the // race from taking down the whole sshpanel process. A panic should only kill the
@@ -18,3 +26,269 @@ func xrayGo(where string, fn func()) {
fn() fn()
}() }()
} }
func init() {
// Direct native-inbound tests and embedders may run an accept loop without
// the singleton server start method. Production stop() flips this to false.
nativeTransportAccepting.Store(true)
}
var (
nativeTransportConnections atomic.Int64
nativeTransportRejected atomic.Int64
nativeXHTTPRequests atomic.Int64
nativeXHTTPRequestsRejected atomic.Int64
nativeXHTTPSessions atomic.Int64
nativeXHTTPSessionsRejected atomic.Int64
nativeClientConnsRejected atomic.Int64
nativePreAuthRejected atomic.Int64
nativeTransportAccepting atomic.Bool
nativeTransportRegistry = struct {
sync.Mutex
conns map[*nativeCountedConn]struct{}
}{conns: make(map[*nativeCountedConn]struct{})}
)
// acquireNativeCounter reserves one slot without blocking. Blocking the accept
// loop or an HTTP handler when the process is already at its safety ceiling
// would retain yet more sockets/goroutines, so overload is rejected promptly.
func acquireNativeCounter(active *atomic.Int64, limit int) (func(), bool) {
for {
current := active.Load()
if limit > 0 && current >= int64(limit) {
return nil, false
}
if active.CompareAndSwap(current, current+1) {
var once sync.Once
return func() {
once.Do(func() {
if active.Add(-1) < 0 {
active.Store(0)
}
})
}, true
}
}
}
func shouldLogNativeSample(counter *atomic.Int64) (n int64, ok bool) {
n = counter.Add(1)
// Keep attacks visible without allowing logging itself to become a CPU/disk
// amplifier. The first event and one event per 1024 repetitions are logged.
return n, n == 1 || n%1024 == 0
}
func logNativeLimitRejection(kind string, rejected *atomic.Int64, limit int) {
n, ok := shouldLogNativeSample(rejected)
if ok {
xrayLogf("native xray: rejected %s at safety limit=%d (rejected=%d)", kind, limit, n)
}
}
func logNativeClientLimitRejection(email string, limit int) {
n, ok := shouldLogNativeSample(&nativeClientConnsRejected)
if ok {
xrayLogf("native xray: rejected authenticated user %s at max_conns=%d (rejected=%d)", email, limit, n)
}
}
func logNativePreAuthRejection(format string, args ...interface{}) {
if _, ok := shouldLogNativeSample(&nativePreAuthRejected); ok {
xrayLogf(format, args...)
}
}
func acquireNativeTransportConnection() (func(), bool) {
limit := nativeMaxConnectionLimit()
release, ok := acquireNativeCounter(&nativeTransportConnections, limit)
if !ok {
logNativeLimitRejection("transport connection", &nativeTransportRejected, limit)
}
return release, ok
}
func acquireNativeXHTTPRequest() (func(), bool) {
limit := nativeMaxXHTTPRequestLimit()
release, ok := acquireNativeCounter(&nativeXHTTPRequests, limit)
if !ok {
logNativeLimitRejection("XHTTP request", &nativeXHTTPRequestsRejected, limit)
}
return release, ok
}
func acquireNativeXHTTPSession() (func(), bool) {
limit := nativeXHTTPMaxSessionLimit()
release, ok := acquireNativeCounter(&nativeXHTTPSessions, limit)
if !ok {
logNativeLimitRejection("XHTTP session", &nativeXHTTPSessionsRejected, limit)
}
return release, ok
}
func configureNativeTransportSocket(c net.Conn) {
if tc, ok := c.(*net.TCPConn); ok {
_ = tc.SetKeepAlive(true)
_ = tc.SetKeepAlivePeriod(30 * time.Second)
_ = tc.SetNoDelay(true)
}
}
// nativeCountedConn releases its global transport slot and unregisters itself
// exactly once, even when several tunnel paths race to close the same socket.
type nativeCountedConn struct {
net.Conn
release func()
onClose func()
closeOnce sync.Once
closeErr error
}
func (c *nativeCountedConn) Close() error {
c.closeOnce.Do(func() {
c.closeErr = c.Conn.Close()
if c.release != nil {
c.release()
}
if c.onClose != nil {
c.onClose()
}
})
return c.closeErr
}
// wrapNativeTransportConn applies only the global counter. It is useful for
// focused tests and for callers that own connection lifetime themselves.
func wrapNativeTransportConn(c net.Conn) (net.Conn, bool) {
if c == nil {
return nil, false
}
configureNativeTransportSocket(c)
release, ok := acquireNativeTransportConnection()
if !ok {
_ = c.Close()
return nil, false
}
return &nativeCountedConn{Conn: c, release: release}, true
}
// wrapTrackedNativeTransportConn additionally registers the accepted socket so
// stopping/restarting native Xray closes established raw, WebSocket, TLS, HTTP/1
// and HTTP/2 transports instead of leaving tunnel goroutines alive.
func wrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
if c == nil {
return nil, false
}
configureNativeTransportSocket(c)
if !nativeTransportAccepting.Load() {
_ = c.Close()
return nil, false
}
release, ok := acquireNativeTransportConnection()
if !ok {
_ = c.Close()
return nil, false
}
return registerTrackedNativeTransportConn(c, release)
}
// registerTrackedNativeTransportConn finishes registration when the caller has
// already reserved a transport slot. Keeping reservation and Accept separate is
// what lets the production listener apply kernel/socket backpressure instead of
// accepting and immediately resetting connections at capacity.
func registerTrackedNativeTransportConn(c net.Conn, release func()) (net.Conn, bool) {
counted := &nativeCountedConn{Conn: c, release: release}
counted.onClose = func() {
nativeTransportRegistry.Lock()
delete(nativeTransportRegistry.conns, counted)
nativeTransportRegistry.Unlock()
}
nativeTransportRegistry.Lock()
if !nativeTransportAccepting.Load() {
nativeTransportRegistry.Unlock()
_ = counted.Close()
return nil, false
}
nativeTransportRegistry.conns[counted] = struct{}{}
nativeTransportRegistry.Unlock()
return counted, true
}
// waitWrapTrackedNativeTransportConn is used by raw native accept loops. It
// holds at most one already-accepted socket while capacity is busy, leaving the
// rest in the kernel backlog instead of creating origin-side resets/502s.
func waitWrapTrackedNativeTransportConn(c net.Conn) (net.Conn, bool) {
if c == nil {
return nil, false
}
configureNativeTransportSocket(c)
for nativeTransportAccepting.Load() {
release, ok := acquireNativeTransportConnection()
if ok {
return registerTrackedNativeTransportConn(c, release)
}
time.Sleep(nativeOverloadBackoff)
}
_ = c.Close()
return nil, false
}
func beginNativeTransportAccepting() {
nativeTransportAccepting.Store(true)
}
func stopNativeTransportAccepting() {
nativeTransportAccepting.Store(false)
}
func closeAllNativeTransportConnections() {
nativeTransportRegistry.Lock()
conns := make([]*nativeCountedConn, 0, len(nativeTransportRegistry.conns))
for c := range nativeTransportRegistry.conns {
conns = append(conns, c)
}
nativeTransportRegistry.Unlock()
for _, c := range conns {
_ = c.Close()
}
}
// nativeLimitedListener applies the same pre-authentication ceiling to XHTTP
// listeners. net/http receives only sockets that own a slot; when capacity is
// busy, new sockets remain in the kernel backlog until a slot becomes available.
type nativeLimitedListener struct {
net.Listener
}
func (l nativeLimitedListener) Accept() (net.Conn, error) {
for {
// Reserve before accepting. When the transport is at capacity, connections
// remain queued by the kernel rather than being accepted and reset, which is
// the behavior CDNs commonly report as an origin 502.
release, ok := acquireNativeTransportConnection()
if !ok {
time.Sleep(nativeOverloadBackoff)
continue
}
c, err := l.Listener.Accept()
if err != nil {
release()
return nil, err
}
configureNativeTransportSocket(c)
if counted, ok := registerTrackedNativeTransportConn(c, release); ok {
return counted, nil
}
// Shutdown may race Accept. Registration closes the socket and releases the
// slot; the next Accept observes the listener close.
time.Sleep(nativeOverloadBackoff)
}
}
func limitNativeListener(ln net.Listener) net.Listener {
if ln == nil {
return nil
}
return nativeLimitedListener{Listener: ln}
}
+65 -7
View File
@@ -7,22 +7,35 @@ import (
) )
type XrayNativeTuning struct { type XrayNativeTuning struct {
RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"` RuntimeGOMAXPROCS int `json:"runtime_gomaxprocs,omitempty"`
MuxGlobalSessions int `json:"mux_global_sessions,omitempty"` MuxGlobalSessions int `json:"mux_global_sessions,omitempty"`
TracePackets bool `json:"trace_packets,omitempty"` MaxConcurrentConnections int `json:"max_concurrent_connections,omitempty"`
MaxConcurrentXHTTPRequests int `json:"max_concurrent_xhttp_requests,omitempty"`
XHTTPMaxSessions int `json:"xhttp_max_sessions,omitempty"`
TracePackets bool `json:"trace_packets,omitempty"`
} }
const ( const (
defaultNativeRuntimeGOMAXPROCS = 0 defaultNativeRuntimeGOMAXPROCS = 0
defaultNativeMuxGlobalSessions = 32768 defaultNativeMuxGlobalSessions = 32768
defaultNativeMaxConnections = 32768
// XHTTP packet handlers are governed by HTTP/2 flow control and bounded byte
// queues, not a website-style request ceiling. A negative configured value is
// normalized to the internal unlimited representation.
defaultNativeMaxXHTTPRequests = -1
fixedNativeMuxMaxSessions = 128 fixedNativeMuxMaxSessions = 64
fixedNativeMuxUDPIdleMS = 120000 fixedNativeMuxUDPIdleMS = 120000
fixedNativeMuxUDPReadBuffer = 256 * 1024 fixedNativeMuxUDPReadBuffer = 256 * 1024
fixedNativeMuxUDPWriteBuffer = 256 * 1024 fixedNativeMuxUDPWriteBuffer = 256 * 1024
defaultNativeXHTTPMaxSessions = 16384 defaultNativeXHTTPMaxSessions = 32768
defaultNativeXHTTPBufferedPosts = 512 defaultNativeHTTP2MaxStreams = 1024
// Packet-up posts are also protected by byte budgets in xray_xhttp.go. Keep
// the default reorder queue modest so thousands of unauthenticated sessions
// cannot consume large amounts of memory merely by allocating empty channel
// buffers. Operators may request more, up to the hard cap enforced there.
defaultNativeXHTTPBufferedPosts = 64
// Do not impose an application-level lifetime on a connected XHTTP VPN // Do not impose an application-level lifetime on a connected XHTTP VPN
// session. The official Xray server keeps a connected session for the // session. The official Xray server keeps a connected session for the
@@ -35,6 +48,9 @@ const (
var ( var (
nativeTuneRuntimeGOMAXPROCS atomic.Int64 nativeTuneRuntimeGOMAXPROCS atomic.Int64
nativeTuneMuxGlobalSessions atomic.Int64 nativeTuneMuxGlobalSessions atomic.Int64
nativeTuneMaxConnections atomic.Int64
nativeTuneMaxXHTTPRequests atomic.Int64
nativeTuneXHTTPMaxSessions atomic.Int64
nativeTuneTracePackets atomic.Bool nativeTuneTracePackets atomic.Bool
) )
@@ -47,12 +63,33 @@ func normalizeNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
t = &XrayNativeTuning{} t = &XrayNativeTuning{}
} }
out := *t out := *t
// Migrate the two profiles written by older panel builds. Those defaults were
// sized like a web service (4K/8K sessions and a global request cap) and cause
// valid high-volume XHTTP VPN traffic to be rejected after an upgrade unless
// the persisted values are translated here.
legacySafe := out.MaxConcurrentConnections == 4096 && out.MaxConcurrentXHTTPRequests == 8192 && out.XHTTPMaxSessions == 4096
legacy2K := out.MaxConcurrentConnections == 8192 && out.MaxConcurrentXHTTPRequests == 16384 && out.XHTTPMaxSessions == 8192
if legacySafe || legacy2K {
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
out.MaxConcurrentConnections = defaultNativeMaxConnections
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
}
if out.RuntimeGOMAXPROCS < 0 { if out.RuntimeGOMAXPROCS < 0 {
out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS out.RuntimeGOMAXPROCS = defaultNativeRuntimeGOMAXPROCS
} }
if out.MuxGlobalSessions <= 0 { if out.MuxGlobalSessions <= 0 {
out.MuxGlobalSessions = defaultNativeMuxGlobalSessions out.MuxGlobalSessions = defaultNativeMuxGlobalSessions
} }
if out.MaxConcurrentConnections == 0 {
out.MaxConcurrentConnections = defaultNativeMaxConnections
}
if out.MaxConcurrentXHTTPRequests == 0 {
out.MaxConcurrentXHTTPRequests = defaultNativeMaxXHTTPRequests
}
if out.XHTTPMaxSessions == 0 {
out.XHTTPMaxSessions = defaultNativeXHTTPMaxSessions
}
return out return out
} }
@@ -68,19 +105,40 @@ func applyNativeXrayTuning(t *XrayNativeTuning) XrayNativeTuning {
runtime.GOMAXPROCS(gomax) runtime.GOMAXPROCS(gomax)
nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax)) nativeTuneRuntimeGOMAXPROCS.Store(int64(gomax))
nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions)) nativeTuneMuxGlobalSessions.Store(int64(out.MuxGlobalSessions))
nativeTuneMaxConnections.Store(nativeLimitValue(out.MaxConcurrentConnections))
nativeTuneMaxXHTTPRequests.Store(nativeLimitValue(out.MaxConcurrentXHTTPRequests))
nativeTuneXHTTPMaxSessions.Store(nativeLimitValue(out.XHTTPMaxSessions))
nativeTuneTracePackets.Store(out.TracePackets) nativeTuneTracePackets.Store(out.TracePackets)
return out return out
} }
// Native tuning limits use zero internally for unlimited. In configuration,
// zero means "use the safe default" and any negative value disables the cap.
func nativeLimitValue(v int) int64 {
if v < 0 {
return 0
}
return int64(v)
}
func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) } func nativeRuntimeGOMAXPROCS() int { return int(nativeTuneRuntimeGOMAXPROCS.Load()) }
func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) } func nativeMuxGlobalSessionLimit() int { return int(nativeTuneMuxGlobalSessions.Load()) }
func nativeMaxConnectionLimit() int { return int(nativeTuneMaxConnections.Load()) }
func nativeMaxXHTTPRequestLimit() int { return int(nativeTuneMaxXHTTPRequests.Load()) }
func nativeXHTTPMaxSessionLimit() int { return int(nativeTuneXHTTPMaxSessions.Load()) }
func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() } func nativeTracePacketsEnabled() bool { return nativeTuneTracePackets.Load() }
func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions } func nativeMuxMaxSessionLimit() int { return fixedNativeMuxMaxSessions }
func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer } func nativeMuxUDPReadBufferSize() int { return fixedNativeMuxUDPReadBuffer }
func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer } func nativeMuxUDPWriteBufferSize() int { return fixedNativeMuxUDPWriteBuffer }
func nativeXHTTPMaxSessionLimit() int { return defaultNativeXHTTPMaxSessions }
func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts } func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts }
func nativeHTTP2MaxConcurrentStreams() uint32 {
limit := nativeMaxXHTTPRequestLimit()
if limit <= 0 || limit > defaultNativeHTTP2MaxStreams {
return defaultNativeHTTP2MaxStreams
}
return uint32(limit)
}
func nativeMuxUDPIdleTimeout() time.Duration { func nativeMuxUDPIdleTimeout() time.Duration {
return fixedNativeMuxUDPIdleMS * time.Millisecond return fixedNativeMuxUDPIdleMS * time.Millisecond
} }
+53 -24
View File
@@ -25,18 +25,18 @@ const (
// check and caused the server to block waiting for a fake second payload. // check and caused the server to block waiting for a fake second payload.
// XUDP belongs to VLESS CommandMux and is handled separately when Mux support // XUDP belongs to VLESS CommandMux and is handled separately when Mux support
// is implemented. // is implemented.
func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, up, down *rate.Limiter) { func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
xrayMgr.recordNativeConnect(uuid, email)
defer xrayMgr.recordNativeDisconnect(uuid, email)
defer xrayRecover(fmt.Sprintf("native xray VLESS UDP tunnel user=%s", email)) defer xrayRecover(fmt.Sprintf("native xray VLESS UDP tunnel user=%s", email))
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true} upMeter := newTrafficMeter(uuid, email, true, quotaState)
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false} downMeter := newTrafficMeter(uuid, email, false, quotaState)
var wg sync.WaitGroup var wg sync.WaitGroup
var closeOnce sync.Once var closeOnce sync.Once
ctx, cancel := context.WithCancel(context.Background())
closeAll := func() { closeAll := func() {
closeOnce.Do(func() { closeOnce.Do(func() {
cancel()
_ = backend.Close() _ = backend.Close()
_ = client.Close() _ = client.Close()
}) })
@@ -57,13 +57,18 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
if len(payload) == 0 { if len(payload) == 0 {
continue continue
} }
if err := waitNativeRate(up, len(payload)); err != nil { if err := waitNativeRate(ctx, up, len(payload)); err != nil {
return
}
quotaReservation, err := reserveNativePacketQuota(upMeter, len(payload))
if err != nil {
return
}
if err := quotaReservation.wait(ctx); err != nil {
return return
} }
n, err := backend.Write(payload) n, err := backend.Write(payload)
if n > 0 { quotaReservation.finish(n)
upMeter.add(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
@@ -91,14 +96,22 @@ func nativeVLESSUDPTunnel(client io.ReadWriteCloser, backend net.Conn, uuid, ema
if n <= 0 { if n <= 0 {
continue continue
} }
if err := waitNativeRate(down, n); err != nil { if err := waitNativeRate(ctx, down, n); err != nil {
return
}
quotaReservation, err := reserveNativePacketQuota(downMeter, n)
if err != nil {
return
}
if err := quotaReservation.wait(ctx); err != nil {
return return
} }
if err := writeVLESSLengthPacket(client, buf[:n]); err != nil { if err := writeVLESSLengthPacket(client, buf[:n]); err != nil {
quotaReservation.finish(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) quotaReservation.finish(n)
} }
}) })
@@ -301,18 +314,18 @@ func writeVLESSXUDPPacket(w io.Writer, payload []byte) error {
// nativeVMessUDPTunnel maps one VMess body chunk to one UDP datagram. VMess AEAD // nativeVMessUDPTunnel maps one VMess body chunk to one UDP datagram. VMess AEAD
// chunking already preserves packet boundaries, so no extra VLESS length prefix // chunking already preserves packet boundaries, so no extra VLESS length prefix
// is added inside the encrypted body. // is added inside the encrypted body.
func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, up, down *rate.Limiter) { func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, email string, quotaState *xrayNativeQuotaState, up, down *rate.Limiter) {
xrayMgr.recordNativeConnect(uuid, email)
defer xrayMgr.recordNativeDisconnect(uuid, email)
defer xrayRecover(fmt.Sprintf("native xray VMess UDP tunnel user=%s", email)) defer xrayRecover(fmt.Sprintf("native xray VMess UDP tunnel user=%s", email))
upMeter := &trafficMeter{uuid: uuid, email: email, uplink: true} upMeter := newTrafficMeter(uuid, email, true, quotaState)
downMeter := &trafficMeter{uuid: uuid, email: email, uplink: false} downMeter := newTrafficMeter(uuid, email, false, quotaState)
var wg sync.WaitGroup var wg sync.WaitGroup
var closeOnce sync.Once var closeOnce sync.Once
ctx, cancel := context.WithCancel(context.Background())
closeAll := func() { closeAll := func() {
closeOnce.Do(func() { closeOnce.Do(func() {
cancel()
_ = backend.Close() _ = backend.Close()
_ = client.Close() _ = client.Close()
}) })
@@ -333,13 +346,18 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
if len(pkt) == 0 { if len(pkt) == 0 {
continue continue
} }
if err := waitNativeRate(up, len(pkt)); err != nil { if err := waitNativeRate(ctx, up, len(pkt)); err != nil {
return
}
quotaReservation, err := reserveNativePacketQuota(upMeter, len(pkt))
if err != nil {
return
}
if err := quotaReservation.wait(ctx); err != nil {
return return
} }
n, err := backend.Write(pkt) n, err := backend.Write(pkt)
if n > 0 { quotaReservation.finish(n)
upMeter.add(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
@@ -367,14 +385,22 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
if n <= 0 { if n <= 0 {
continue continue
} }
if err := waitNativeRate(down, n); err != nil { if err := waitNativeRate(ctx, down, n); err != nil {
return
}
quotaReservation, err := reserveNativePacketQuota(downMeter, n)
if err != nil {
return
}
if err := quotaReservation.wait(ctx); err != nil {
return return
} }
if err := client.WritePacket(buf[:n]); err != nil { if err := client.WritePacket(buf[:n]); err != nil {
quotaReservation.finish(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) quotaReservation.finish(n)
} }
}) })
@@ -384,9 +410,12 @@ func nativeVMessUDPTunnel(client nativeVMessStream, backend net.Conn, uuid, emai
closeAll() closeAll()
} }
func waitNativeRate(lim *rate.Limiter, n int) error { func waitNativeRate(ctx context.Context, lim *rate.Limiter, n int) error {
if lim == nil || n <= 0 { if lim == nil || n <= 0 {
return nil return nil
} }
return lim.WaitN(context.Background(), n) if ctx == nil {
ctx = context.Background()
}
return lim.WaitN(ctx, n)
} }
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"context"
"io"
"strings"
"sync"
"golang.org/x/time/rate"
)
type xrayNativeQuotaState struct {
// trafficMu establishes a clean reset boundary. Native stream and packet
// writers hold a read lock from quota reservation through the actual write
// and metering; traffic resets take the write lock. This prevents an
// in-flight pre-reset reservation from being accounted in the new period or
// subtracting from freshly reset usage.
trafficMu sync.RWMutex
mu sync.Mutex
usedBytes int64
quotaBytes int64
action string
throttleMbps int
limiter *rate.Limiter
generation uint64
maxConns int
activeConns int
}
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,
maxConns: normalizeXrayMaxConns(meta.MaxConns),
}
}
func normalizeXrayMaxConns(v int) int {
if v < 0 {
return 0
}
return v
}
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.maxConns = normalizeXrayMaxConns(meta.MaxConns)
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()
// Do not retain failed traffic/active deltas for a client that no longer
// exists. This also bounds the pending maps during a prolonged DB outage.
m.nativeDBMu.Lock()
delete(m.nativeTrafficPending, uuid)
delete(m.nativeActivePending, uuid)
m.nativeDBMu.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.trafficMu.Lock()
defer state.trafficMu.Unlock()
state.mu.Lock()
state.usedBytes = 0
state.limiter = nil
state.generation++
if state.generation == 0 {
state.generation = 1
}
state.mu.Unlock()
}
func (m *XrayManager) resetNativeTrafficAccounting(ctx context.Context, store *Store, uuid, email string) error {
state := m.nativeQuotaState(uuid)
if state != nil {
state.trafficMu.Lock()
defer state.trafficMu.Unlock()
state.mu.Lock()
defer state.mu.Unlock()
}
m.nativeTrafficPersistMu.Lock()
defer m.nativeTrafficPersistMu.Unlock()
// Remove this client's queued pre-reset delta while holding only the short
// map mutex. The database call may take seconds during an outage; keeping
// nativeDBMu locked across it would stall traffic/accounting updates for
// every other native user and could amplify a slow database into a goroutine
// pile-up.
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)
}
m.nativeDBMu.Unlock()
err := store.ResetXrayClientTraffic(ctx, uuid)
if err != nil && hadPending && pending.State == state {
m.nativeDBMu.Lock()
if m.nativeTrafficPending == nil {
m.nativeTrafficPending = make(map[string]xrayPendingTraffic)
}
current := m.nativeTrafficPending[key]
if current.State != nil && current.State != state {
current = xrayPendingTraffic{}
}
if current.Email == "" {
current.Email = pending.Email
}
current.Uplink += pending.Uplink
current.Downlink += pending.Downlink
current.State = state
m.nativeTrafficPending[key] = current
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
}
// acquireNativeClientConnection enforces the DB-backed max_conns policy across
// every native inbound and transport. The returned release function is safe to
// call more than once and keeps runtime/DB online counters in sync.
func (m *XrayManager) acquireNativeClientConnection(uuid, email string) (func(), *xrayNativeQuotaState, bool) {
state := m.nativeQuotaState(uuid)
if state != nil {
state.mu.Lock()
if state.maxConns > 0 && state.activeConns >= state.maxConns {
limit := state.maxConns
state.mu.Unlock()
logNativeClientLimitRejection(email, limit)
return nil, state, false
}
state.activeConns++
state.mu.Unlock()
}
m.recordNativeConnect(uuid, email, state)
var once sync.Once
return func() {
once.Do(func() {
if state != nil {
state.mu.Lock()
if state.activeConns > 0 {
state.activeConns--
}
state.mu.Unlock()
}
m.recordNativeDisconnect(uuid, email, state)
})
}, state, true
}
func (m *XrayManager) nativeQuotaBlocked(uuid string) bool {
return nativeQuotaStateBlocked(m.nativeQuotaState(uuid))
}
func nativeQuotaStateBlocked(state *xrayNativeQuotaState) bool {
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(state *xrayNativeQuotaState, requested int) (allowed int, limiter *rate.Limiter, stopAfter bool) {
if requested <= 0 {
return 0, nil, false
}
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(state *xrayNativeQuotaState, reserved, written int) {
if reserved <= 0 || written >= reserved {
return
}
if written < 0 {
written = 0
}
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
ctx context.Context
}
func (mw xrayQuotaMeteredWriter) Write(p []byte) (int, error) {
if mw.meter == nil {
return mw.w.Write(p)
}
state := mw.meter.state
if state != nil {
state.trafficMu.RLock()
defer state.trafficMu.RUnlock()
}
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(state, len(p))
if allowed <= 0 {
return 0, errDataQuotaExceeded
}
if limiter != nil {
ctx := mw.ctx
if ctx == nil {
ctx = context.Background()
}
if err := limiter.WaitN(ctx, allowed); err != nil {
xrayMgr.finishNativeQuotaReservation(state, allowed, 0)
return 0, err
}
}
n, err := mw.w.Write(p[:allowed])
xrayMgr.finishNativeQuotaReservation(state, allowed, n)
if n > 0 {
mw.meter.add(n)
}
if err != nil {
return n, err
}
if stopAfter || allowed < len(p) || nativeQuotaStateBlocked(state) {
return n, errDataQuotaExceeded
}
return n, nil
}
type nativePacketQuotaReservation struct {
meter *trafficMeter
state *xrayNativeQuotaState
limiter *rate.Limiter
reserved int
finished bool
}
func reserveNativePacketQuota(meter *trafficMeter, n int) (nativePacketQuotaReservation, error) {
if meter == nil || n <= 0 {
return nativePacketQuotaReservation{}, nil
}
state := meter.state
if state != nil {
state.trafficMu.RLock()
}
allowed, limiter, stopAfter := xrayMgr.reserveNativeQuota(state, n)
if allowed != n || stopAfter {
if allowed > 0 {
xrayMgr.finishNativeQuotaReservation(state, allowed, 0)
}
if state != nil {
state.trafficMu.RUnlock()
}
return nativePacketQuotaReservation{}, errDataQuotaExceeded
}
return nativePacketQuotaReservation{
meter: meter,
state: state,
limiter: limiter,
reserved: n,
}, nil
}
func (r *nativePacketQuotaReservation) wait(ctx context.Context) error {
if r == nil || r.finished || r.limiter == nil || r.reserved <= 0 {
return nil
}
if err := waitNativeRate(ctx, r.limiter, r.reserved); err != nil {
r.finish(0)
return err
}
return nil
}
func (r *nativePacketQuotaReservation) finish(written int) {
if r == nil || r.finished {
return
}
r.finished = true
if r.meter == nil || r.reserved <= 0 {
if r.state != nil {
r.state.trafficMu.RUnlock()
}
return
}
xrayMgr.finishNativeQuotaReservation(r.state, r.reserved, written)
if written > 0 {
r.meter.add(written)
}
if r.state != nil {
r.state.trafficMu.RUnlock()
}
}
+12 -3
View File
@@ -670,7 +670,11 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
} }
client := ib.matchVMess(authid, time.Now().Unix()) client := ib.matchVMess(authid, time.Now().Unix())
if client == nil { if client == nil {
log.Printf("native xray: inbound %q rejected unknown/expired VMess auth id from %s", ib.tag, remote) logNativePreAuthRejection("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 return
} }
@@ -690,6 +694,11 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command) log.Printf("native xray: inbound %q VMess command %d not supported yet", ib.tag, req.command)
return return
} }
releaseConnection, quotaState, ok := xrayMgr.acquireNativeClientConnection(client.uuid, client.email)
if !ok {
return
}
defer releaseConnection()
respBodyKey := sha256.Sum256(req.bodyKey[:]) respBodyKey := sha256.Sum256(req.bodyKey[:])
respBodyIV := sha256.Sum256(req.bodyIV[:]) respBodyIV := sha256.Sum256(req.bodyIV[:])
@@ -715,7 +724,7 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
return return
} }
log.Printf("native xray: vmess/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) log.Printf("native xray: vmess/tcp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
nativeTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) nativeTunnel(vc, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
case vmessCmdUDP: case vmessCmdUDP:
backend, target, err := ib.nativeDialUDP(req.host, req.port) backend, target, err := ib.nativeDialUDP(req.host, req.port)
if err != nil { if err != nil {
@@ -723,6 +732,6 @@ func (ib *nativeInbound) handleVMess(stream net.Conn, remote net.Addr) {
return return
} }
log.Printf("native xray: vmess/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag) log.Printf("native xray: vmess/udp user=%s src=%s -> %s (inbound %q)", client.email, backend.LocalAddr(), target, ib.tag)
nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, ib.upLimiter(), ib.downLimiter()) nativeVMessUDPTunnel(vc, backend, client.uuid, client.email, quotaState, ib.upLimiter(), ib.downLimiter())
} }
} }
+392 -33
View File
@@ -14,12 +14,35 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/net/http2/h2c" "golang.org/x/net/http2/h2c"
) )
const nativeXHTTPServerIdleTimeout = 90 * time.Second
const (
nativeXHTTPMaxSessionIDBytes = 256
nativeXHTTPMaxSequenceBytes = 20
nativeXHTTPHardMaxHeaderBytes = 256 * 1024
nativeXHTTPHardMaxPostBytes int64 = 16 * 1024 * 1024
nativeXHTTPMaxBufferedPosts = 512
nativeXHTTPMaxBufferedSessionBytes = 16 * 1024 * 1024
nativeXHTTPMaxBufferedGlobalBytes = 128 * 1024 * 1024
)
var (
nativeXHTTPBufferedBytes atomic.Int64
nativeXHTTPBufferRejected atomic.Int64
errNativeXHTTPUploadBufferFull = errors.New("xhttp upload buffer limit reached")
nativeXHTTPMemoryWait = struct {
sync.Mutex
changed chan struct{}
}{changed: make(chan struct{})}
)
const ( const (
xhttpPlacementPath = "path" xhttpPlacementPath = "path"
xhttpPlacementQuery = "query" xhttpPlacementQuery = "query"
@@ -157,7 +180,10 @@ func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
func (g *nativeXHTTPListener) serve(ln net.Listener) { func (g *nativeXHTTPListener) serve(ln net.Listener) {
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr())) defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
h2s := &http2.Server{} h2s := &http2.Server{
IdleTimeout: nativeXHTTPServerIdleTimeout,
MaxConcurrentStreams: nativeHTTP2MaxConcurrentStreams(),
}
handler := http.Handler(g) handler := http.Handler(g)
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP // Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without // listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
@@ -169,6 +195,7 @@ func (g *nativeXHTTPListener) serve(ln net.Listener) {
srv := &http.Server{ srv := &http.Server{
Handler: handler, Handler: handler,
ReadHeaderTimeout: 4 * time.Second, ReadHeaderTimeout: 4 * time.Second,
IdleTimeout: nativeXHTTPServerIdleTimeout,
MaxHeaderBytes: g.headerSize, MaxHeaderBytes: g.headerSize,
} }
if g.security == "tls" && g.tlsConfig != nil { if g.security == "tls" && g.tlsConfig != nil {
@@ -258,6 +285,9 @@ func (ib *nativeInbound) reapStaleXHTTPSessions(idle time.Duration) {
func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int { func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
if ib.xhttpMaxHeaderBytes > 0 { if ib.xhttpMaxHeaderBytes > 0 {
if ib.xhttpMaxHeaderBytes > nativeXHTTPHardMaxHeaderBytes {
return nativeXHTTPHardMaxHeaderBytes
}
return ib.xhttpMaxHeaderBytes return ib.xhttpMaxHeaderBytes
} }
// Xray defaults to 8192. Keep a little room for custom headers/cookies used // Xray defaults to 8192. Keep a little room for custom headers/cookies used
@@ -269,19 +299,25 @@ func (ib *nativeInbound) xhttpServerMaxHeaderBytes() int {
// byte stream to the VLESS/VMess handlers as a net.Conn. // byte stream to the VLESS/VMess handlers as a net.Conn.
func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP request inbound=%q method=%s path=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.RemoteAddr)) defer xrayRecover(fmt.Sprintf("native xray XHTTP request inbound=%q method=%s path=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.RemoteAddr))
// XHTTP is a VPN transport, not a web API. A single connected user keeps a
// long-lived download handler and can generate many short packet-up handlers.
// Rejecting handlers at an application request ceiling turns normal tunnel
// bursts into 429s and, through CDNs/reverse proxies, intermittent 502s.
// HTTP/2 flow control plus the bounded, cancelable upload queues below provide
// backpressure without applying website rate-limit semantics to tunnel traffic.
if !ib.isXHTTP() { if !ib.isXHTTP() {
xrayLogf("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr) logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=not-xhttp method=%s path=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, r.RemoteAddr)
xhttpBadRequest(w) xhttpBadRequest(w)
return return
} }
if !ib.xhttpHostAllowed(r.Host) { if !ib.xhttpHostAllowed(r.Host) {
xrayLogf("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr) logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=host method=%s path=%q host=%q want=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), r.Host, ib.xhttpHost, r.RemoteAddr)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
base, ok := ib.matchXHTTPPath(r.URL.Path) base, ok := ib.matchXHTTPPath(r.URL.Path)
if !ok { if !ok {
xrayLogf("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr) logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=path method=%s path=%q want=%q host=%q remote=%s", ib.tag, r.Method, r.URL.RequestURI(), ib.path, r.Host, r.RemoteAddr)
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
@@ -293,6 +329,11 @@ func (ib *nativeInbound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
sessionID, seqStr := ib.extractXHTTPMeta(r, base) sessionID, seqStr := ib.extractXHTTPMeta(r, base)
if len(sessionID) > nativeXHTTPMaxSessionIDBytes || len(seqStr) > nativeXHTTPMaxSequenceBytes {
logNativePreAuthRejection("native xray: xhttp reject inbound=%q reason=metadata-size remote=%s", ib.tag, r.RemoteAddr)
xhttpBadRequest(w)
return
}
mode := ib.normalizedXHTTPMode() mode := ib.normalizedXHTTPMode()
xrayTracef("native xray: xhttp request inbound=%q method=%s proto=%s path=%q host=%q session=%q seq=%q len=%d mode=%s remote=%s", ib.tag, r.Method, r.Proto, r.URL.RequestURI(), r.Host, sessionID, seqStr, r.ContentLength, mode, r.RemoteAddr) xrayTracef("native xray: xhttp request inbound=%q method=%s proto=%s path=%q host=%q session=%q seq=%q len=%d mode=%s remote=%s", ib.tag, r.Method, r.Proto, r.URL.RequestURI(), r.Host, sessionID, seqStr, r.ContentLength, mode, r.RemoteAddr)
@@ -520,17 +561,22 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
return s return s
} }
if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max { if max := ib.xhttpMaxActiveSessions(); max > 0 && len(ib.xhttpSessions) >= max {
// XHTTP uses many HTTP requests/sessions by design. Returning HTTP 429 http.Error(w, "native XHTTP session capacity reached", http.StatusServiceUnavailable)
// makes Xray clients tear down active tunnels, which is worse than allowing logNativePreAuthRejection("native xray: xhttp session rejected inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max)
// a short soft-limit overflow and relying on stale-session cleanup. return nil
xrayTracef("native xray: xhttp session soft limit exceeded inbound=%q active=%d limit=%d", ib.tag, len(ib.xhttpSessions), max) }
releaseSlot, ok := acquireNativeXHTTPSession()
if !ok {
http.Error(w, "native XHTTP global session capacity reached", http.StatusServiceUnavailable)
return nil
} }
s := &nativeXHTTPSession{ s := &nativeXHTTPSession{
id: id, id: id,
queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts), queue: newNativeXHTTPUploadQueue(ib.xhttpMaxBufferedPosts, nativeXHTTPMaxBufferedSessionBytes),
done: make(chan struct{}), done: make(chan struct{}),
connectedCh: make(chan struct{}), connectedCh: make(chan struct{}),
lastSeen: time.Now(), lastSeen: time.Now(),
releaseSlot: releaseSlot,
} }
ib.xhttpSessions[id] = s ib.xhttpSessions[id] = s
xrayTracef("native xray: xhttp session created inbound=%q session=%q active=%d", ib.tag, id, len(ib.xhttpSessions)) xrayTracef("native xray: xhttp session created inbound=%q session=%q active=%d", ib.tag, id, len(ib.xhttpSessions))
@@ -539,10 +585,9 @@ func (ib *nativeInbound) upsertXHTTPSession(w http.ResponseWriter, id string) *n
} }
func (ib *nativeInbound) xhttpMaxActiveSessions() int { func (ib *nativeInbound) xhttpMaxActiveSessions() int {
if nativeXHTTPMaxSessionLimit() > 0 { // normalizeNativeXrayTuning already installs the safe default. A zero value
return nativeXHTTPMaxSessionLimit() // here therefore intentionally means the operator configured -1 (unlimited).
} return nativeXHTTPMaxSessionLimit()
return defaultNativeXHTTPMaxSessions
} }
func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) { func (ib *nativeInbound) reapUnconnectedXHTTPSession(id string, s *nativeXHTTPSession) {
@@ -570,6 +615,19 @@ func (ib *nativeInbound) deleteXHTTPSession(id string, s *nativeXHTTPSession) {
} }
} }
func (ib *nativeInbound) closeAllXHTTPSessions() {
ib.xhttpMu.Lock()
sessions := make([]*nativeXHTTPSession, 0, len(ib.xhttpSessions))
for id, session := range ib.xhttpSessions {
delete(ib.xhttpSessions, id)
sessions = append(sessions, session)
}
ib.xhttpMu.Unlock()
for _, session := range sessions {
session.close()
}
}
func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession) { func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.Request, sess *nativeXHTTPSession) {
sess.touch() sess.touch()
xrayTracef("native xray: xhttp stream-up inbound=%q session=%q len=%d remote=%s", ib.tag, sess.id, r.ContentLength, r.RemoteAddr) xrayTracef("native xray: xhttp stream-up inbound=%q session=%q len=%d remote=%s", ib.tag, sess.id, r.ContentLength, r.RemoteAddr)
@@ -577,7 +635,7 @@ func (ib *nativeInbound) handleXHTTPStreamUpload(w http.ResponseWriter, r *http.
http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest) http.Error(w, "xhttp stream-up mode is not allowed", http.StatusBadRequest)
return return
} }
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}); err != nil { if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Reader: r.Body}, nil); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return return
} }
@@ -605,16 +663,38 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
http.Error(w, "bad xhttp sequence", http.StatusBadRequest) http.Error(w, "bad xhttp sequence", http.StatusBadRequest)
return return
} }
// Reserve the expected payload rather than the configured maximum. Normal
// XHTTP body uploads have a Content-Length, so small packets no longer each
// consume a full 1 MB reservation. Unknown/chunked or metadata-carried uploads
// still reserve the maximum before decoding to preserve the hard memory bound.
memory, err := acquireNativeXHTTPMemoryContext(r.Context(), ib.xhttpUploadReservationBytes(r))
if err != nil {
// If the client/CDN canceled while waiting for backpressure, there is no
// useful HTTP error to send. Returning also releases every reservation.
return
}
defer memory.release()
payload, err := ib.readXHTTPPayload(r) payload, err := ib.readXHTTPPayload(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
memory.shrink(int64(len(payload)))
xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr) xrayTracef("native xray: xhttp packet-up inbound=%q session=%q seq=%d payload=%d remote=%s", ib.tag, sess.id, seq, len(payload), r.RemoteAddr)
if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}); err != nil { if err := sess.queue.push(r.Context(), nativeXHTTPPacket{Payload: payload, Seq: seq}, memory); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return return
} }
if errors.Is(err, io.ErrClosedPipe) {
// A packet can race the stream-down request closing. Acknowledge the late
// upload instead of leaking an origin 500/502 into the reconnect path.
w.WriteHeader(http.StatusOK)
return
}
if errors.Is(err, errNativeXHTTPUploadBufferFull) {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
return
}
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err) xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -719,11 +799,30 @@ func (ib *nativeInbound) readXHTTPBodyPayload(r *http.Request) ([]byte, error) {
func (ib *nativeInbound) xhttpMaxPostBytes() int64 { func (ib *nativeInbound) xhttpMaxPostBytes() int64 {
if ib.xhttpMaxEachPostBytes > 0 { if ib.xhttpMaxEachPostBytes > 0 {
if ib.xhttpMaxEachPostBytes > nativeXHTTPHardMaxPostBytes {
return nativeXHTTPHardMaxPostBytes
}
return ib.xhttpMaxEachPostBytes return ib.xhttpMaxEachPostBytes
} }
return 1_000_000 return 1_000_000
} }
// xhttpUploadReservationBytes returns a safe pre-read reservation. Body-mode
// clients normally send Content-Length, which lets thousands of small packets
// share the global budget. Header/cookie/auto and chunked bodies reserve the
// configured maximum because their decoded size is not known until parsed.
func (ib *nativeInbound) xhttpUploadReservationBytes(r *http.Request) int64 {
maxBytes := ib.xhttpMaxPostBytes()
placement := firstNonEmpty(ib.xhttpUplinkDataPlacement, xhttpPlacementBody)
if placement == xhttpPlacementBody && r.ContentLength >= 0 {
if r.ContentLength > maxBytes {
return maxBytes
}
return r.ContentLength
}
return maxBytes
}
func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) { func (ib *nativeInbound) handleXHTTPStreamOne(w http.ResponseWriter, r *http.Request) {
defer xrayRecover(fmt.Sprintf("native xray XHTTP stream-one inbound=%q remote=%s", ib.tag, r.RemoteAddr)) defer xrayRecover(fmt.Sprintf("native xray XHTTP stream-one inbound=%q remote=%s", ib.tag, r.RemoteAddr))
xrayTracef("native xray: xhttp stream-one inbound=%q len=%d remote=%s", ib.tag, r.ContentLength, r.RemoteAddr) xrayTracef("native xray: xhttp stream-one inbound=%q len=%d remote=%s", ib.tag, r.ContentLength, r.RemoteAddr)
@@ -794,7 +893,7 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ
// The stream-down HTTP request is the lifetime owner of an XHTTP // The stream-down HTTP request is the lifetime owner of an XHTTP
// session. Log the actual transport cancellation so a CDN/proxy // session. Log the actual transport cancellation so a CDN/proxy
// timeout can be distinguished from a server idle policy. // timeout can be distinguished from a server idle policy.
xrayLogf("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v", xrayTracef("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
ib.tag, sessionID, r.RemoteAddr, r.Context().Err()) ib.tag, sessionID, r.RemoteAddr, r.Context().Err())
_ = xc.Close() _ = xc.Close()
case <-sess.done: case <-sess.done:
@@ -859,6 +958,7 @@ type nativeXHTTPSession struct {
mu sync.Mutex mu sync.Mutex
connected bool connected bool
lastSeen time.Time lastSeen time.Time
releaseSlot func()
} }
func (s *nativeXHTTPSession) touch() { func (s *nativeXHTTPSession) touch() {
@@ -879,6 +979,9 @@ func (s *nativeXHTTPSession) close() {
s.closeOnce.Do(func() { s.closeOnce.Do(func() {
close(s.done) close(s.done)
s.queue.close() s.queue.close()
if s.releaseSlot != nil {
s.releaseSlot()
}
}) })
} }
@@ -980,6 +1083,108 @@ func (w *nativeXHTTPResponseWriter) close() {
w.mu.Unlock() w.mu.Unlock()
} }
// nativeXHTTPMemoryLease reserves from a process-wide byte budget before a
// packet-up handler allocates its payload. The same lease is transferred to the
// session queue, so active request bodies and queued reassembly data share one
// hard ceiling instead of each having an independent amplification window.
type nativeXHTTPMemoryLease struct {
bytes int64
}
func acquireNativeXHTTPMemory(n int64) (*nativeXHTTPMemoryLease, bool) {
if n <= 0 {
return &nativeXHTTPMemoryLease{}, true
}
for {
current := nativeXHTTPBufferedBytes.Load()
if current > nativeXHTTPMaxBufferedGlobalBytes-n {
logNativeLimitRejection("XHTTP buffered upload bytes", &nativeXHTTPBufferRejected, nativeXHTTPMaxBufferedGlobalBytes)
return nil, false
}
if nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
return &nativeXHTTPMemoryLease{bytes: n}, true
}
}
}
// acquireNativeXHTTPMemoryContext applies process-wide memory backpressure.
// Unlike the old fail-fast admission path, a legitimate tunnel burst waits for
// queued bytes to be consumed and remains cancelable if its HTTP request ends.
func acquireNativeXHTTPMemoryContext(ctx context.Context, n int64) (*nativeXHTTPMemoryLease, error) {
if n <= 0 {
return &nativeXHTTPMemoryLease{}, nil
}
if n > nativeXHTTPMaxBufferedGlobalBytes {
return nil, errNativeXHTTPUploadBufferFull
}
for {
current := nativeXHTTPBufferedBytes.Load()
if current <= nativeXHTTPMaxBufferedGlobalBytes-n && nativeXHTTPBufferedBytes.CompareAndSwap(current, current+n) {
return &nativeXHTTPMemoryLease{bytes: n}, nil
}
nativeXHTTPMemoryWait.Lock()
// Recheck while holding the generation lock so a release cannot happen
// between the failed check and subscribing to the notification channel.
current = nativeXHTTPBufferedBytes.Load()
if current <= nativeXHTTPMaxBufferedGlobalBytes-n {
nativeXHTTPMemoryWait.Unlock()
continue
}
changed := nativeXHTTPMemoryWait.changed
nativeXHTTPMemoryWait.Unlock()
select {
case <-changed:
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func releaseNativeXHTTPMemory(n int64) {
if n <= 0 {
return
}
for {
current := nativeXHTTPBufferedBytes.Load()
next := current - n
if next < 0 {
next = 0
}
if nativeXHTTPBufferedBytes.CompareAndSwap(current, next) {
nativeXHTTPMemoryWait.Lock()
close(nativeXHTTPMemoryWait.changed)
nativeXHTTPMemoryWait.changed = make(chan struct{})
nativeXHTTPMemoryWait.Unlock()
return
}
}
}
func (l *nativeXHTTPMemoryLease) shrink(n int64) {
if l == nil {
return
}
if n < 0 {
n = 0
}
if n >= l.bytes {
return
}
release := l.bytes - n
l.bytes = n
releaseNativeXHTTPMemory(release)
}
func (l *nativeXHTTPMemoryLease) release() {
if l == nil || l.bytes <= 0 {
return
}
n := l.bytes
l.bytes = 0
releaseNativeXHTTPMemory(n)
}
type nativeXHTTPPacket struct { type nativeXHTTPPacket struct {
Reader io.ReadCloser Reader io.ReadCloser
Payload []byte Payload []byte
@@ -989,44 +1194,148 @@ type nativeXHTTPPacket struct {
type nativeXHTTPUploadQueue struct { type nativeXHTTPUploadQueue struct {
pushedPackets chan nativeXHTTPPacket pushedPackets chan nativeXHTTPPacket
maxPackets int maxPackets int
maxBytes int64
mu sync.Mutex // readMu serializes the single decoded stream reader with close-time queue
reader io.ReadCloser // cleanup. pushWG lets close wait until every producer that started before
heap nativeXHTTPHeap // closedFlag was set has either transferred or released its memory lease.
nextSeq uint64 readMu sync.Mutex
readDeadline time.Time pushWG sync.WaitGroup
mu sync.Mutex
reader io.ReadCloser
readerQueued bool
heap nativeXHTTPHeap
nextSeq uint64
readDeadline time.Time
bufferedBytes int64
closedFlag bool
spaceChanged chan struct{}
closed chan struct{} closed chan struct{}
closeOnce sync.Once closeOnce sync.Once
} }
func newNativeXHTTPUploadQueue(maxPackets int) *nativeXHTTPUploadQueue { func newNativeXHTTPUploadQueue(maxPackets int, maxBytes int64) *nativeXHTTPUploadQueue {
if maxPackets <= 0 { if maxPackets <= 0 {
maxPackets = defaultNativeXHTTPBufferedPosts maxPackets = defaultNativeXHTTPBufferedPosts
} }
if maxPackets > nativeXHTTPMaxBufferedPosts {
maxPackets = nativeXHTTPMaxBufferedPosts
}
if maxBytes <= 0 || maxBytes > nativeXHTTPMaxBufferedSessionBytes {
maxBytes = nativeXHTTPMaxBufferedSessionBytes
}
return &nativeXHTTPUploadQueue{ return &nativeXHTTPUploadQueue{
pushedPackets: make(chan nativeXHTTPPacket, maxPackets), pushedPackets: make(chan nativeXHTTPPacket, maxPackets),
maxPackets: maxPackets, maxPackets: maxPackets,
maxBytes: maxBytes,
closed: make(chan struct{}), closed: make(chan struct{}),
spaceChanged: make(chan struct{}),
} }
} }
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket) error { func (q *nativeXHTTPUploadQueue) beginPush() bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.closedFlag {
return false
}
q.pushWG.Add(1)
return true
}
func (q *nativeXHTTPUploadQueue) adoptPayloadMemory(ctx context.Context, memory *nativeXHTTPMemoryLease, n int64) error {
if n <= 0 {
return nil
}
if memory == nil || memory.bytes != n {
return errNativeXHTTPUploadBufferFull
}
if n > q.maxBytes {
return errNativeXHTTPUploadBufferFull
}
for {
q.mu.Lock()
if q.closedFlag {
q.mu.Unlock()
return io.ErrClosedPipe
}
if q.bufferedBytes <= q.maxBytes-n {
q.bufferedBytes += n
memory.bytes = 0
q.mu.Unlock()
return nil
}
changed := q.spaceChanged
q.mu.Unlock()
select {
case <-changed:
case <-q.closed:
return io.ErrClosedPipe
case <-ctx.Done():
return ctx.Err()
}
}
}
func (q *nativeXHTTPUploadQueue) releasePayloadMemory(n int64) {
if n <= 0 {
return
}
q.mu.Lock()
release := n
if release > q.bufferedBytes {
release = q.bufferedBytes
}
q.bufferedBytes -= release
close(q.spaceChanged)
q.spaceChanged = make(chan struct{})
q.mu.Unlock()
releaseNativeXHTTPMemory(release)
}
func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket, memory *nativeXHTTPMemoryLease) error {
if !q.beginPush() {
return io.ErrClosedPipe
}
defer q.pushWG.Done()
readerReserved := false
if p.Reader != nil { if p.Reader != nil {
q.mu.Lock() q.mu.Lock()
if q.reader != nil { if q.reader != nil || q.readerQueued || q.closedFlag {
q.mu.Unlock() q.mu.Unlock()
return errors.New("xhttp upload reader already exists") return errors.New("xhttp upload reader already exists")
} }
q.readerQueued = true
readerReserved = true
q.mu.Unlock() q.mu.Unlock()
defer func() {
if readerReserved {
q.mu.Lock()
q.readerQueued = false
q.mu.Unlock()
}
}()
}
payloadBytes := int64(len(p.Payload))
if err := q.adoptPayloadMemory(ctx, memory, payloadBytes); err != nil {
return err
}
transferred := payloadBytes > 0
if transferred {
defer func() {
if payloadBytes > 0 {
q.releasePayloadMemory(payloadBytes)
}
}()
} }
select { select {
case q.pushedPackets <- p: case q.pushedPackets <- p:
select { // Ownership has moved to the queue. close() waits for this producer and
case <-q.closed: // then drains/releases anything not consumed by the stream reader.
return io.ErrClosedPipe payloadBytes = 0
default: readerReserved = false
}
return nil return nil
case <-q.closed: case <-q.closed:
return io.ErrClosedPipe return io.ErrClosedPipe
@@ -1037,13 +1346,41 @@ func (q *nativeXHTTPUploadQueue) push(ctx context.Context, p nativeXHTTPPacket)
func (q *nativeXHTTPUploadQueue) close() { func (q *nativeXHTTPUploadQueue) close() {
q.closeOnce.Do(func() { q.closeOnce.Do(func() {
close(q.closed)
q.mu.Lock() q.mu.Lock()
q.closedFlag = true
reader := q.reader reader := q.reader
close(q.closed)
q.mu.Unlock() q.mu.Unlock()
if reader != nil { if reader != nil {
_ = reader.Close() _ = reader.Close()
} }
q.pushWG.Wait()
q.readMu.Lock()
// No producers or readers can now change the queue. Drop references to
// buffered payloads promptly and return their exact byte reservation.
for {
select {
case p := <-q.pushedPackets:
if p.Reader != nil {
_ = p.Reader.Close()
}
p.Payload = nil
default:
goto drained
}
}
drained:
q.mu.Lock()
remaining := q.bufferedBytes
q.bufferedBytes = 0
for i := range q.heap {
q.heap[i].Payload = nil
}
q.heap = nil
q.mu.Unlock()
q.readMu.Unlock()
releaseNativeXHTTPMemory(remaining)
}) })
} }
@@ -1085,6 +1422,9 @@ func (q *nativeXHTTPUploadQueue) recv() (nativeXHTTPPacket, error) {
} }
func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) { func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
q.readMu.Lock()
defer q.readMu.Unlock()
if reader := q.loadReader(); reader != nil { if reader := q.loadReader(); reader != nil {
return reader.Read(b) return reader.Read(b)
} }
@@ -1101,9 +1441,18 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
return 0, err return 0, err
} }
if p.Reader != nil { if p.Reader != nil {
q.setReader(p.Reader) if !q.setReader(p.Reader) {
_ = p.Reader.Close()
return 0, io.EOF
}
return p.Reader.Read(b) return p.Reader.Read(b)
} }
select {
case <-q.closed:
q.releasePayloadMemory(int64(len(p.Payload)))
return 0, io.EOF
default:
}
heap.Push(&q.heap, p) heap.Push(&q.heap, p)
} }
@@ -1112,6 +1461,7 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
if packet.Seq == q.nextSeq { if packet.Seq == q.nextSeq {
n := copy(b, packet.Payload) n := copy(b, packet.Payload)
q.releasePayloadMemory(int64(n))
if n < len(packet.Payload) { if n < len(packet.Payload) {
packet.Payload = packet.Payload[n:] packet.Payload = packet.Payload[n:]
heap.Push(&q.heap, packet) heap.Push(&q.heap, packet)
@@ -1131,10 +1481,15 @@ func (q *nativeXHTTPUploadQueue) Read(b []byte) (int, error) {
return 0, err return 0, err
} }
if p.Reader != nil { if p.Reader != nil {
_ = p.Reader.Close()
return 0, errors.New("xhttp mixed stream-up and packet-up upload") return 0, errors.New("xhttp mixed stream-up and packet-up upload")
} }
heap.Push(&q.heap, p) heap.Push(&q.heap, p)
continue
} }
// A duplicate/late packet is discarded; release the bytes it owned.
q.releasePayloadMemory(int64(len(packet.Payload)))
} }
return 0, nil return 0, nil
@@ -1146,10 +1501,14 @@ func (q *nativeXHTTPUploadQueue) loadReader() io.ReadCloser {
return q.reader return q.reader
} }
func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) { func (q *nativeXHTTPUploadQueue) setReader(r io.ReadCloser) bool {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
if q.closedFlag {
return false
}
q.reader = r q.reader = r
q.mu.Unlock() return true
} }
type nativeXHTTPHeap []nativeXHTTPPacket type nativeXHTTPHeap []nativeXHTTPPacket