Fix the two reliability problems in the in-process Xray emulator by matching XTLS/Xray-core's transport semantics: - XHTTP upload queue: rewrite as a faithful port of xray-core's uploadQueue (bounded channel + sequence reorder heap). Packet-up POSTs are now acked immediately on buffering instead of blocking until the tunnel reader consumes them. The old block-until-consumed behavior throttled the uplink to the reassembly rate and deadlocked against the client's concurrent-POST limit, which showed up as "download a burst, stall, repeat" on video/large downloads. - Mux: dial the backend and pump uplink on a per-session goroutine fed by a bounded channel (mirrors xray-core's per-session buffered pipe). Previously the dial and backend writes ran inline in the shared read loop, so one slow target or backpressured session stalled every other muxed session. - XHTTP download writer: flush every write (matches httpServerConn.Write) instead of batching behind a 2ms/32KB window. - XHTTP: enforce a single download (stream-down) per session to stop two GETs from splitting the decoded stream and corrupting the tunnel. - Fix a close-of-closed-channel race in the mux session teardown (sync.Once). Remove the now-inert XHTTP tuning knobs (xhttp_queue_timeout_ms, xhttp_flush_ms, xhttp_flush_bytes) from the backend struct and the admin panel UI. Split admin/assets/app.js into ordered classic-script modules under admin/assets/js/ for maintainability. The concatenation is byte-identical to the old file and load order is preserved via defer, so behavior is unchanged. Add regression tests for the mux head-of-line stall and the out-of-order packet-up burst-stall; add golang.org/x/text to go.mod so tests build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
6.2 KiB
JavaScript
160 lines
6.2 KiB
JavaScript
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
|
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
|
newUserBtn.addEventListener("click", () => {
|
|
setFormCollapsed(false);
|
|
userForm.reset();
|
|
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
|
userStatus.textContent = t("New user.");
|
|
fUsername.focus();
|
|
});
|
|
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
|
|
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
|
|
document.getElementById("genTotpBtn").addEventListener("click", () => {
|
|
fTotpSecret.value = genBase32();
|
|
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
|
|
if (!fTotpWindow.value) fTotpWindow.value = 1;
|
|
if (!fTotpDigits.value) fTotpDigits.value = 6;
|
|
userStatus.textContent = t("TOTP secret generated.");
|
|
});
|
|
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
|
|
|
|
function setFormCollapsed(v) {
|
|
formCollapsed = v;
|
|
userFormWrap.classList.toggle("collapsed", v);
|
|
toggleFormBtn.textContent = v ? t("Show form") : t("Hide form");
|
|
}
|
|
|
|
async function loadUsers() {
|
|
userStatus.textContent = t("Loading…");
|
|
try {
|
|
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
|
const data = await res.json();
|
|
renderUsers(data || []);
|
|
userStatus.textContent = t("Loaded.");
|
|
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
|
|
} catch (e) {
|
|
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
|
|
}
|
|
}
|
|
async function loadUsersSilent() {
|
|
try {
|
|
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
|
const data = await res.json();
|
|
renderUsers(data || []);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
}
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
updateDashboardFromUsers(users);
|
|
const isSA = currentRole === "superadmin";
|
|
userCountChip.textContent = users.length;
|
|
if (isSA) ownerColHead.classList.remove("hidden");
|
|
usersBody.innerHTML = "";
|
|
let online = 0;
|
|
let expiredCount = 0;
|
|
users.forEach(u => {
|
|
const on = (u.active_conns || 0) > 0;
|
|
if (on) online++;
|
|
if (isExpiredDate(u.expires_at)) expiredCount++;
|
|
const tr = document.createElement("tr");
|
|
const cells = [
|
|
u.username,
|
|
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.active_conns ?? 0,
|
|
u.max_connections || 0,
|
|
u.limit_mbps_up || 0,
|
|
u.limit_mbps_down || 0,
|
|
u.expires_at ? fmtDate(u.expires_at) : "—",
|
|
];
|
|
if (isSA) cells.push(u.owner_username || "—");
|
|
cells.forEach((c, i) => {
|
|
const td = document.createElement("td");
|
|
if (i === 1) td.innerHTML = c; else td.textContent = c;
|
|
tr.appendChild(td);
|
|
});
|
|
const tdA = document.createElement("td");
|
|
const editBtn = Object.assign(document.createElement("button"), {
|
|
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
|
onclick: () => fillUserForm(u),
|
|
});
|
|
const delBtn = Object.assign(document.createElement("button"), {
|
|
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
|
style: "margin-left:4px;",
|
|
onclick: () => deleteUser(u.username),
|
|
});
|
|
tdA.append(editBtn, delBtn);
|
|
tr.appendChild(tdA);
|
|
usersBody.appendChild(tr);
|
|
});
|
|
const activeCount = Math.max(0, users.length - expiredCount);
|
|
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
|
}
|
|
|
|
function fillUserForm(u) {
|
|
setFormCollapsed(false);
|
|
fUsername.value = u.username || "";
|
|
fPassword.value = "";
|
|
fTotpSecret.value = u.totp_secret || "";
|
|
fTotpPeriod.value = u.totp_period || 60;
|
|
fTotpWindow.value = u.totp_window ?? 1;
|
|
fTotpDigits.value = u.totp_digits || 6;
|
|
fAllowStatic.checked = !!u.allow_static_password;
|
|
fMaxConn.value = u.max_connections || "";
|
|
fUp.value = u.limit_mbps_up || "";
|
|
fDown.value = u.limit_mbps_down || "";
|
|
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
|
userStatus.textContent = t("Editing {name}", {name: u.username});
|
|
}
|
|
|
|
userForm.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
saveUserBtn.disabled = true;
|
|
userStatus.textContent = t("Saving…");
|
|
const payload = {
|
|
username: fUsername.value.trim(),
|
|
password: fPassword.value || undefined,
|
|
totp_secret: fTotpSecret.value.trim(),
|
|
totp_period: parseInt(fTotpPeriod.value||"60",10),
|
|
totp_window: parseInt(fTotpWindow.value||"1",10),
|
|
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
|
allow_static_password: !!fAllowStatic.checked,
|
|
max_connections: parseInt(fMaxConn.value||"0",10),
|
|
expires_at: isoFromLocal(fExpires.value),
|
|
limit_mbps_up: parseInt(fUp.value||"0",10),
|
|
limit_mbps_down: parseInt(fDown.value||"0",10),
|
|
server_id: selectedSSHServer(),
|
|
};
|
|
try {
|
|
const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
userStatus.textContent = t("Saved.");
|
|
fPassword.value = "";
|
|
loadUsers();
|
|
if (currentRole === "reseller") loadMe();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else userStatus.textContent = t("Error: {error}", {error: e.message});
|
|
} finally {
|
|
saveUserBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function deleteUser(username) {
|
|
if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return;
|
|
userStatus.textContent = t("Deleting {name}…", {name: username});
|
|
try {
|
|
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
|
|
if (!res.ok && res.status !== 204) throw new Error("delete failed");
|
|
userStatus.textContent = t("Deleted.");
|
|
loadUsers();
|
|
if (currentRole === "reseller") loadMe();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else userStatus.textContent = t("Error deleting.");
|
|
}
|
|
}
|
|
|