Align native Xray with xray-core; drop dead knobs; split admin app.js
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>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
// ─── Xray Client Edit ─────────────────────────────────────────────────────────
|
||||
function openEditXrayClient(tag, client) {
|
||||
editingXrayClientId = client.id;
|
||||
document.getElementById("editClientUUID").textContent = client.id;
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
function closeEditXrayClient() {
|
||||
editingXrayClientId = null;
|
||||
document.getElementById("editXrayClientPanel").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function saveEditXrayClient() {
|
||||
if (!editingXrayClientId) return;
|
||||
const st = document.getElementById("editXrayClientStatus");
|
||||
st.textContent = "Saving…";
|
||||
const payload = {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
st.textContent = "Saved.";
|
||||
setTimeout(() => { closeEditXrayClient(); loadInbounds({ force: true }); }, 700);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Xray Config Wizard ────────────────────────────────────────────────────────
|
||||
function setXrayCfgMode(mode) {
|
||||
const wizPane = document.getElementById("xrayWizardPane");
|
||||
const jsonPane = document.getElementById("xrayCfgPaneJson");
|
||||
const wizBtn = document.getElementById("xrayWizardTabBtn");
|
||||
const jsonBtn = document.getElementById("xrayJsonTabBtn");
|
||||
if (mode === "wizard") {
|
||||
wizPane.classList.remove("hidden");
|
||||
jsonPane.classList.add("hidden");
|
||||
wizBtn.classList.remove("btn-ghost");
|
||||
jsonBtn.classList.add("btn-ghost");
|
||||
loadWizardFromConfig();
|
||||
} else {
|
||||
wizPane.classList.add("hidden");
|
||||
jsonPane.classList.remove("hidden");
|
||||
jsonBtn.classList.remove("btn-ghost");
|
||||
wizBtn.classList.add("btn-ghost");
|
||||
loadXrayCfg();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("wzLogLevel")?.addEventListener("change", () => { wzDirty = true; });
|
||||
|
||||
function cloneJsonSafe(obj) {
|
||||
return obj && typeof obj === "object" ? JSON.parse(JSON.stringify(obj)) : obj;
|
||||
}
|
||||
|
||||
function loadWizardFromConfig() {
|
||||
const serverID = selectedXrayServer();
|
||||
const target = selectedXrayServerLabel();
|
||||
const st = document.getElementById("wzStatus");
|
||||
wzLoadedServerID = null;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Loading config from ${target}...`;
|
||||
api(withServerParam("/api/xray/config", serverID)).then(async res => {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const raw = await res.text();
|
||||
const cfg = JSON.parse(raw);
|
||||
wzLoadedServerID = serverID || "local";
|
||||
wzLoadedConfigText = raw;
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
||||
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
renderWzInbounds();
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Config loaded from ${target}.`;
|
||||
}).catch(e => {
|
||||
wzLoadedServerID = null;
|
||||
wzLoadedConfigText = "";
|
||||
wzLoadedFullConfig = null;
|
||||
wzInbounds = [];
|
||||
renderWzInbounds();
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderWzInbounds() {
|
||||
const list = document.getElementById("wzInboundsList");
|
||||
if (!list) return;
|
||||
if (!wzInbounds.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:4px 0;">No inbounds. Click + Add to create one.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
wzInbounds.forEach((ib, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
const portStr = ib.port !== undefined ? `:${ib.port}` : "";
|
||||
const ss = ib.streamSettings || {};
|
||||
const net = ss.network || "";
|
||||
const sec = ss.security || "";
|
||||
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
|
||||
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
|
||||
row.innerHTML = `<span class="chip">${ib.protocol}</span>
|
||||
<span style="font-family:monospace;">${ib.tag||"untagged"}${portStr}</span>
|
||||
<span class="hint" style="flex:1;">${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}</span>`;
|
||||
const clients = ib.settings?.clients;
|
||||
if (Array.isArray(clients) && clients.length) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "chip green";
|
||||
badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
|
||||
row.appendChild(badge);
|
||||
}
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.onclick = () => { wzInbounds.splice(i,1); wzDirty = true; renderWzInbounds(); };
|
||||
row.appendChild(delBtn);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function wzToggleAddInbound() {
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.toggle("hidden");
|
||||
if (!form.classList.contains("hidden")) {
|
||||
onWzProtoChange(document.getElementById("wzProtocol").value);
|
||||
onWzNetworkChange(document.getElementById("wzNetwork").value);
|
||||
onWzTLSChange(document.getElementById("wzTLS").value);
|
||||
}
|
||||
}
|
||||
|
||||
function onWzProtoChange(val) {
|
||||
const usesClientTransport = val === "vless" || val === "vmess";
|
||||
document.getElementById("wzVlessFields").style.display = usesClientTransport ? "grid" : "none";
|
||||
document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
|
||||
document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
|
||||
|
||||
const tlsSel = document.getElementById("wzTLS");
|
||||
const realityOpt = document.querySelector("#wzTLS option[value='reality']");
|
||||
if (realityOpt) {
|
||||
realityOpt.disabled = val === "vmess";
|
||||
if (val === "vmess" && tlsSel.value === "reality") {
|
||||
tlsSel.value = "none";
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
}
|
||||
|
||||
const portMap = { vless:10086, vmess:10087, trojan:8443, shadowsocks:8388, socks:10808 };
|
||||
const tagMap = { vless:"vless-in", vmess:"vmess-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
|
||||
const portEl = document.getElementById("wzPort");
|
||||
const tagEl = document.getElementById("wzTag");
|
||||
const lisEl = document.getElementById("wzListenIP");
|
||||
const knownPorts = Object.values(portMap).map(String);
|
||||
const knownTags = Object.values(tagMap);
|
||||
if (!portEl.value || knownPorts.includes(portEl.value)) portEl.value = portMap[val] || "";
|
||||
if (!tagEl.value || knownTags.includes(tagEl.value)) tagEl.value = tagMap[val] || val+"-in";
|
||||
if (!lisEl.value || lisEl.value === "0.0.0.0" || lisEl.value === "127.0.0.1") {
|
||||
lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
function onWzNetworkChange(val) {
|
||||
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
||||
// WebSocket
|
||||
show("wzWSPathField", val === "ws");
|
||||
// XHTTP
|
||||
show("wzXHTTPPathField", val === "xhttp");
|
||||
show("wzXHTTPHostField", val === "xhttp");
|
||||
show("wzXHTTPModeField", val === "xhttp");
|
||||
// HTTPUpgrade
|
||||
show("wzHUPathField", val === "httpupgrade");
|
||||
show("wzHUHostField", val === "httpupgrade");
|
||||
// H2
|
||||
show("wzH2PathField", val === "h2");
|
||||
show("wzH2HostField", val === "h2");
|
||||
// gRPC
|
||||
show("wzGRPCServiceField", val === "grpc");
|
||||
show("wzGRPCMultiField", val === "grpc");
|
||||
// Auto-select TLS defaults
|
||||
const tlsSel = document.getElementById("wzTLS");
|
||||
if ((val === "h2" || val === "grpc") && tlsSel.value === "none") {
|
||||
tlsSel.value = "tls"; onWzTLSChange("tls");
|
||||
}
|
||||
}
|
||||
|
||||
function onWzTLSChange(val) {
|
||||
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
||||
show("wzTLSCertBlock", val === "tls");
|
||||
show("wzRealityDestField", val === "reality");
|
||||
show("wzRealitySNIField", val === "reality");
|
||||
show("wzRealityPrivField", val === "reality");
|
||||
show("wzRealityShortIDField",val === "reality");
|
||||
}
|
||||
|
||||
function wzSaveInbound() {
|
||||
const proto = document.getElementById("wzProtocol").value;
|
||||
const port = parseInt(document.getElementById("wzPort").value || "0", 10);
|
||||
const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
|
||||
const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
|
||||
if (!port) { alert("Port required."); return; }
|
||||
const ib = { tag, port, listen, protocol: proto, settings: {} };
|
||||
if (proto === "vless" || proto === "vmess") {
|
||||
ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] };
|
||||
const net = document.getElementById("wzNetwork").value;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
ib.streamSettings = { network: net };
|
||||
// Transport-specific settings
|
||||
switch (net) {
|
||||
case "ws":
|
||||
ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
|
||||
break;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
break;
|
||||
case "httpupgrade":
|
||||
ib.streamSettings.httpupgradeSettings = {
|
||||
path: document.getElementById("wzHUPath").value.trim() || "/",
|
||||
host: document.getElementById("wzHUHost").value.trim() || undefined,
|
||||
};
|
||||
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
|
||||
break;
|
||||
case "h2":
|
||||
ib.streamSettings.httpSettings = {
|
||||
path: document.getElementById("wzH2Path").value.trim() || "/",
|
||||
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
||||
};
|
||||
break;
|
||||
case "grpc":
|
||||
ib.streamSettings.grpcSettings = {
|
||||
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
||||
multiMode: document.getElementById("wzGRPCMulti").checked,
|
||||
};
|
||||
break;
|
||||
}
|
||||
// TLS / Reality
|
||||
if (tlsVal === "tls") {
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
} else if (tlsVal === "reality" && proto === "vless") {
|
||||
ib.streamSettings.security = "reality";
|
||||
ib.streamSettings.realitySettings = {
|
||||
dest: document.getElementById("wzRealityDest").value.trim(),
|
||||
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
|
||||
privateKey: document.getElementById("wzRealityPriv").value.trim(),
|
||||
shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
|
||||
};
|
||||
}
|
||||
} else if (proto === "trojan") {
|
||||
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
||||
ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} };
|
||||
} else if (proto === "shadowsocks") {
|
||||
ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" };
|
||||
} else if (proto === "socks") {
|
||||
ib.settings = { auth: "noauth", udp: true };
|
||||
ib.streamSettings = { network: "tcp" };
|
||||
}
|
||||
wzInbounds.push(ib);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
document.getElementById("wzAddInboundForm").classList.add("hidden");
|
||||
document.getElementById("wzPort").value = "";
|
||||
document.getElementById("wzTag").value = "";
|
||||
document.getElementById("wzListenIP").value = "";
|
||||
}
|
||||
|
||||
|
||||
function buildConfigFromVisualEditor() {
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
throw new Error("config for this server is not loaded yet");
|
||||
}
|
||||
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(wzLoadedConfigText);
|
||||
} catch (_) {
|
||||
cfg = cloneJsonSafe(wzLoadedFullConfig || {});
|
||||
}
|
||||
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
||||
throw new Error("loaded config is not an object");
|
||||
}
|
||||
|
||||
// Preserve the selected server's full JSON exactly as the base.
|
||||
// The visual tab is intentionally conservative: it only updates fields that
|
||||
// are visible here, so pressing Save cannot wipe routing/outbounds/policy/etc.
|
||||
cfg.log = cfg.log && typeof cfg.log === "object" ? cfg.log : {};
|
||||
cfg.log.loglevel = document.getElementById("wzLogLevel")?.value || cfg.log.loglevel || "warning";
|
||||
|
||||
const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : [];
|
||||
const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api");
|
||||
const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function updateFullConfigFromWizard() {
|
||||
const cfg = buildConfigFromVisualEditor();
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function applyWizardConfig() {
|
||||
const st = document.getElementById("wzStatus");
|
||||
const target = selectedXrayServerLabel();
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
|
||||
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
|
||||
if (st) st.textContent = `Reloading config from ${target} before saving...`;
|
||||
loadWizardFromConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
let cfg;
|
||||
try {
|
||||
cfg = buildConfigFromVisualEditor();
|
||||
} catch(e) {
|
||||
if (st) st.textContent = `Invalid visual config: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (st) st.textContent = `Saving config to ${target}...`;
|
||||
try {
|
||||
const body = JSON.stringify(cfg, null, 2);
|
||||
const res = await api(withServerParam("/api/xray/config", selectedID), { method:"POST", body });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
wzLoadedConfigText = body;
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
wzLoadedServerID = selectedID;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Saved on ${target}. Restarting Xray...`;
|
||||
await xrayCtrl("restart");
|
||||
if (st) st.textContent = `Config saved on ${target} and Xray restarted.`;
|
||||
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user