Mult server
This commit is contained in:
@@ -12,6 +12,10 @@ let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
|
||||
let currentTab = "dashboard";
|
||||
let inboundsRefreshInFlight = false;
|
||||
let lastInboundsStructure = "";
|
||||
let serversCache = [];
|
||||
let selectedSSHServerID = localStorage.getItem("SSH_SERVER_ID") || "local";
|
||||
let selectedXrayServerID = localStorage.getItem("XRAY_SERVER_ID") || "local";
|
||||
let configuringServerID = "";
|
||||
|
||||
|
||||
// ─── Language / i18n ─────────────────────────────────────────────────────────
|
||||
@@ -264,6 +268,12 @@ const xCfgEditor = document.getElementById("xCfgEditor");
|
||||
const xCfgStatus = document.getElementById("xCfgStatus");
|
||||
const xLogsBox = document.getElementById("xLogsBox");
|
||||
const inboundsContainer = document.getElementById("inboundsContainer");
|
||||
const sshServerPickerCard = document.getElementById("sshServerPickerCard");
|
||||
const xrayServerPickerCard = document.getElementById("xrayServerPickerCard");
|
||||
const sshServerSelect = document.getElementById("sshServerSelect");
|
||||
const xrayServerSelect = document.getElementById("xrayServerSelect");
|
||||
const sshServerHint = document.getElementById("sshServerHint");
|
||||
const xrayServerHint = document.getElementById("xrayServerHint");
|
||||
|
||||
// Resellers
|
||||
const resellersBody = document.getElementById("resellersBody");
|
||||
@@ -277,6 +287,27 @@ const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
const serversBody = document.getElementById("serversBody");
|
||||
const serversCountChip = document.getElementById("serversCountChip");
|
||||
const serversStatus = document.getElementById("serversStatus");
|
||||
const serverForm = document.getElementById("serverForm");
|
||||
const serverFormTitle = document.getElementById("serverFormTitle");
|
||||
const srvID = document.getElementById("srvID");
|
||||
const srvName = document.getElementById("srvName");
|
||||
const srvBaseURL = document.getElementById("srvBaseURL");
|
||||
const srvAdminUser = document.getElementById("srvAdminUser");
|
||||
const srvAdminKey = document.getElementById("srvAdminKey");
|
||||
const srvEnableSSH = document.getElementById("srvEnableSSH");
|
||||
const srvEnableXray = document.getElementById("srvEnableXray");
|
||||
const srvIsActive = document.getElementById("srvIsActive");
|
||||
const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigEditor = document.getElementById("managedConfigEditor");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
|
||||
// Stats
|
||||
const cpuVal = document.getElementById("cpuVal");
|
||||
const cpuBar = document.getElementById("cpuBar");
|
||||
@@ -309,6 +340,14 @@ async function api(path, opts = {}) {
|
||||
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
serverID = serverID || "local";
|
||||
if (!serverID || serverID === "local") return path;
|
||||
return path + (path.includes("?") ? "&" : "?") + "server_id=" + encodeURIComponent(serverID);
|
||||
}
|
||||
function selectedSSHServer() { return sshServerSelect?.value || selectedSSHServerID || "local"; }
|
||||
function selectedXrayServer() { return xrayServerSelect?.value || selectedXrayServerID || "local"; }
|
||||
function serverByID(id) { return serversCache.find(s => String(s.id) === String(id)); }
|
||||
|
||||
// ─── Formatters ──────────────────────────────────────────────────────────────
|
||||
const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%";
|
||||
@@ -450,6 +489,7 @@ const tabTitles = {
|
||||
ssh: ["Accounts", "SSH / SlowDNS"],
|
||||
xray: ["Accounts", "Xray Users"],
|
||||
resellers: ["Administration", "Resellers"],
|
||||
servers: ["Administration", "Servers"],
|
||||
stats: ["Server", "Monitoring"],
|
||||
vnstat: ["Traffic", "VnStat"],
|
||||
logs: ["System", "Logs"],
|
||||
@@ -481,6 +521,7 @@ function selectTab(tab) {
|
||||
}
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
|
||||
@@ -563,6 +604,7 @@ function initAfterLogin() {
|
||||
dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
|
||||
|
||||
selectTab("dashboard");
|
||||
loadServers();
|
||||
|
||||
if (currentRole === "superadmin") {
|
||||
loadDashboardStats();
|
||||
@@ -756,7 +798,7 @@ function setFormCollapsed(v) {
|
||||
async function loadUsers() {
|
||||
userStatus.textContent = t("Loading…");
|
||||
try {
|
||||
const res = await api("/api/users");
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
renderUsers(data || []);
|
||||
userStatus.textContent = t("Loaded.");
|
||||
@@ -767,7 +809,7 @@ async function loadUsers() {
|
||||
}
|
||||
async function loadUsersSilent() {
|
||||
try {
|
||||
const res = await api("/api/users");
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
renderUsers(data || []);
|
||||
} catch (e) {
|
||||
@@ -854,6 +896,7 @@ userForm.addEventListener("submit", async e => {
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
server_id: selectedSSHServer(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
@@ -874,7 +917,7 @@ 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(`/api/users/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
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();
|
||||
@@ -899,7 +942,7 @@ document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
|
||||
|
||||
async function loadXrayStatus() {
|
||||
try {
|
||||
const res = await api("/api/xray/status");
|
||||
const res = await api(withServerParam("/api/xray/status", selectedXrayServer()));
|
||||
const s = await res.json();
|
||||
const run = !!s.running;
|
||||
xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled"));
|
||||
@@ -924,8 +967,8 @@ async function loadXrayStatus() {
|
||||
} else if (xStatus) {
|
||||
xStatus.textContent = s.api_server ? t("Counters API ready at {server}.", {server: s.api_server}) : t("Counters API ready.");
|
||||
}
|
||||
if (dashServers) dashServers.textContent = s.enabled ? "1" : "0";
|
||||
if (dashServerStatus) dashServerStatus.textContent = run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled"));
|
||||
if (dashServers) dashServers.textContent = String((serversCache || []).filter(n => n.is_active !== false).length || (s.enabled ? 1 : 0));
|
||||
if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled")));
|
||||
renderDashboardCounters();
|
||||
if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error});
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
@@ -936,7 +979,7 @@ async function repairXrayStats() {
|
||||
if (btn) btn.disabled = true;
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Verificando e reparando a API de contadores do Xray…" : "Checking and repairing Xray counters API…";
|
||||
try {
|
||||
const res = await api("/api/xray/stats/repair", { method:"POST" });
|
||||
const res = await api(withServerParam("/api/xray/stats/repair", selectedXrayServer()), { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const d = await res.json().catch(() => ({}));
|
||||
xStatus.textContent = d.changed
|
||||
@@ -955,7 +998,7 @@ async function repairXrayStats() {
|
||||
async function xrayCtrl(action) {
|
||||
xStatus.textContent = (currentLang === "pt-BR" ? "Processando Xray…" : action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…");
|
||||
try {
|
||||
const res = await api(`/api/xray/${action}`, { method:"POST" });
|
||||
const res = await api(withServerParam(`/api/xray/${action}`, selectedXrayServer()), { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
@@ -973,7 +1016,7 @@ async function loadInbounds(options = {}) {
|
||||
if (!silent) inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("Loading…")}</div>`;
|
||||
else inboundsContainer.classList.add("xray-refreshing");
|
||||
try {
|
||||
const res = await api("/api/xray/inbounds");
|
||||
const res = await api(withServerParam("/api/xray/inbounds", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const inbounds = await res.json();
|
||||
renderInbounds(inbounds || [], { silent, force });
|
||||
@@ -1146,7 +1189,7 @@ async function addClient(tag) {
|
||||
try {
|
||||
const res = await api("/api/xray/clients/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns }),
|
||||
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client {id}… added. Restarting Xray…", {id: uuid.slice(0,8)});
|
||||
@@ -1160,7 +1203,7 @@ async function addClient(tag) {
|
||||
async function removeClient(tag, uuid) {
|
||||
if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return;
|
||||
try {
|
||||
const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" });
|
||||
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client removed. Restarting Xray…");
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
@@ -1172,7 +1215,7 @@ async function removeClient(tag, uuid) {
|
||||
|
||||
async function loadXrayCfg() {
|
||||
try {
|
||||
const res = await api("/api/xray/config");
|
||||
const res = await api(withServerParam("/api/xray/config", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const text = await res.text();
|
||||
try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
|
||||
@@ -1189,7 +1232,7 @@ async function saveXrayCfg() {
|
||||
try { JSON.parse(text); } catch(e) { xCfgStatus.textContent = t("Invalid JSON: {error}", {error: e.message}); return; }
|
||||
xCfgStatus.textContent = t("Saving…");
|
||||
try {
|
||||
const res = await api("/api/xray/config", { method:"POST", body: text });
|
||||
const res = await api(withServerParam("/api/xray/config", selectedXrayServer()), { method:"POST", body: text });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xCfgStatus.textContent = t("Saved. Restarting Xray…");
|
||||
await xrayCtrl("restart");
|
||||
@@ -1201,7 +1244,7 @@ async function saveXrayCfg() {
|
||||
|
||||
async function loadXrayLogs() {
|
||||
try {
|
||||
const res = await api("/api/xray/logs");
|
||||
const res = await api(withServerParam("/api/xray/logs", selectedXrayServer()));
|
||||
const data = await res.json();
|
||||
xLogsBox.textContent = (data.lines||[]).join("\n");
|
||||
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
||||
@@ -1321,6 +1364,250 @@ async function deleteReseller(username) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Managed Servers ─────────────────────────────────────────────────────────
|
||||
sshServerSelect?.addEventListener("change", () => {
|
||||
selectedSSHServerID = selectedSSHServer();
|
||||
localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID);
|
||||
loadUsers();
|
||||
});
|
||||
xrayServerSelect?.addEventListener("change", () => {
|
||||
selectedXrayServerID = selectedXrayServer();
|
||||
localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID);
|
||||
lastInboundsStructure = "";
|
||||
closeEditXrayClient?.();
|
||||
loadXrayStatus();
|
||||
loadInbounds({ force: true });
|
||||
});
|
||||
document.getElementById("reloadServersBtn")?.addEventListener("click", loadServers);
|
||||
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
|
||||
document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers);
|
||||
document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm);
|
||||
document.getElementById("testServerBtn")?.addEventListener("click", testServerForm);
|
||||
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
|
||||
document.getElementById("loadManagedConfigBtn")?.addEventListener("click", () => loadManagedServerConfig(configuringServerID));
|
||||
document.getElementById("saveManagedConfigBtn")?.addEventListener("click", saveManagedServerConfig);
|
||||
serverForm?.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
await saveServerForm();
|
||||
});
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const res = await api("/api/servers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serversCache = await res.json() || [];
|
||||
} catch (e) {
|
||||
serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }];
|
||||
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
|
||||
if (e.message === "auth") doAuthError();
|
||||
}
|
||||
renderServerSelectors();
|
||||
renderServersTable();
|
||||
}
|
||||
|
||||
function renderServerSelectors() {
|
||||
const active = serversCache.filter(s => s.is_active !== false);
|
||||
const sshServers = active.filter(s => s.enable_ssh || s.is_local);
|
||||
const xrayServers = active.filter(s => s.enable_xray || s.is_local);
|
||||
populateServerSelect(sshServerSelect, sshServers, selectedSSHServerID, "ssh");
|
||||
populateServerSelect(xrayServerSelect, xrayServers, selectedXrayServerID, "xray");
|
||||
const hasMultiSSH = sshServers.length > 1;
|
||||
const hasMultiXray = xrayServers.length > 1;
|
||||
sshServerPickerCard?.classList.toggle("hidden", !hasMultiSSH);
|
||||
xrayServerPickerCard?.classList.toggle("hidden", !hasMultiXray);
|
||||
if (sshServerHint) sshServerHint.textContent = hasMultiSSH ? "Choose where SSH users are created and listed." : "Only the master node is available for SSH.";
|
||||
if (xrayServerHint) xrayServerHint.textContent = hasMultiXray ? "Choose where Xray clients are created and listed." : "Only the master node is available for Xray.";
|
||||
if (dashServers) dashServers.textContent = String(active.length || 1);
|
||||
if (dashServerStatus) dashServerStatus.textContent = active.length > 1 ? `${active.length} nodes configured` : "master only";
|
||||
}
|
||||
|
||||
function populateServerSelect(select, list, selected, kind) {
|
||||
if (!select) return;
|
||||
const current = String(selected || select.value || "local");
|
||||
select.innerHTML = "";
|
||||
list.forEach(s => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(s.id);
|
||||
opt.textContent = `${s.name || s.base_url || s.id}${s.is_local ? " (master)" : ""}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
const allowed = list.some(s => String(s.id) === current);
|
||||
select.value = allowed ? current : "local";
|
||||
if (kind === "ssh") {
|
||||
selectedSSHServerID = select.value || "local";
|
||||
localStorage.setItem("SSH_SERVER_ID", selectedSSHServerID);
|
||||
} else {
|
||||
selectedXrayServerID = select.value || "local";
|
||||
localStorage.setItem("XRAY_SERVER_ID", selectedXrayServerID);
|
||||
}
|
||||
}
|
||||
|
||||
function renderServersTable() {
|
||||
if (!serversBody) return;
|
||||
const rows = serversCache || [];
|
||||
serversCountChip && (serversCountChip.textContent = String(Math.max(0, rows.length - 1)));
|
||||
serversBody.innerHTML = "";
|
||||
rows.forEach(s => {
|
||||
const tr = document.createElement("tr");
|
||||
const opts = `${s.enable_ssh ? "SSH" : ""}${s.enable_ssh && s.enable_xray ? " / " : ""}${s.enable_xray ? "Xray" : ""}` || "disabled";
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHTML(s.name || "—")}${s.is_local ? ' <span class="chip">master</span>' : ""}</td>
|
||||
<td style="font-family:monospace;font-size:.68rem;">${escapeHTML(s.base_url || "local")}</td>
|
||||
<td>${escapeHTML(opts)}</td>
|
||||
<td>${s.is_active ? '<span class="badge-on">active</span>' : '<span class="badge-off">disabled</span>'}</td>`;
|
||||
const td = document.createElement("td");
|
||||
td.style.whiteSpace = "nowrap";
|
||||
const cfgBtn = document.createElement("button");
|
||||
cfgBtn.className = "btn btn-ghost btn-sm";
|
||||
cfgBtn.textContent = "Configure";
|
||||
cfgBtn.onclick = () => openManagedServerConfig(String(s.id));
|
||||
td.appendChild(cfgBtn);
|
||||
if (!s.is_local) {
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-warn btn-sm";
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.onclick = () => fillServerForm(s);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = "Del";
|
||||
delBtn.onclick = () => deleteServer(s);
|
||||
td.append(editBtn, delBtn);
|
||||
}
|
||||
tr.appendChild(td);
|
||||
serversBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function clearServerForm() {
|
||||
if (!serverForm) return;
|
||||
srvID.value = "";
|
||||
srvName.value = "";
|
||||
srvBaseURL.value = "";
|
||||
srvAdminUser.value = "admin";
|
||||
srvAdminKey.value = "";
|
||||
srvEnableSSH.checked = true;
|
||||
srvEnableXray.checked = true;
|
||||
srvIsActive.checked = true;
|
||||
if (serverFormTitle) serverFormTitle.textContent = "Add / edit server";
|
||||
if (serverFormStatus) serverFormStatus.textContent = "";
|
||||
}
|
||||
|
||||
function fillServerForm(s) {
|
||||
srvID.value = s.id || "";
|
||||
srvName.value = s.name || "";
|
||||
srvBaseURL.value = s.base_url || "";
|
||||
srvAdminUser.value = s.admin_username || "admin";
|
||||
srvAdminKey.value = "";
|
||||
srvEnableSSH.checked = !!s.enable_ssh;
|
||||
srvEnableXray.checked = !!s.enable_xray;
|
||||
srvIsActive.checked = s.is_active !== false;
|
||||
if (serverFormTitle) serverFormTitle.textContent = "Edit: " + (s.name || s.base_url);
|
||||
if (serverFormStatus) serverFormStatus.textContent = "Leave admin key blank to keep the saved key.";
|
||||
}
|
||||
|
||||
function serverPayloadFromForm() {
|
||||
return {
|
||||
id: srvID?.value || "",
|
||||
name: srvName?.value.trim() || "",
|
||||
base_url: srvBaseURL?.value.trim() || "",
|
||||
admin_username: srvAdminUser?.value.trim() || "admin",
|
||||
admin_key: srvAdminKey?.value || "",
|
||||
enable_ssh: !!srvEnableSSH?.checked,
|
||||
enable_xray: !!srvEnableXray?.checked,
|
||||
is_active: !!srvIsActive?.checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveServerForm() {
|
||||
if (!serverFormStatus) return;
|
||||
serverFormStatus.textContent = "Saving…";
|
||||
try {
|
||||
const res = await api("/api/servers", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serverFormStatus.textContent = "Saved.";
|
||||
clearServerForm();
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serverFormStatus.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testServerForm() {
|
||||
if (!serverFormStatus) return;
|
||||
serverFormStatus.textContent = "Testing remote login…";
|
||||
try {
|
||||
const res = await api("/api/servers/test", { method:"POST", body: JSON.stringify(serverPayloadFromForm()) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serverFormStatus.textContent = "Connection OK.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serverFormStatus.textContent = "Test failed: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServer(s) {
|
||||
if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return;
|
||||
try {
|
||||
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else serversStatus && (serversStatus.textContent = "Delete failed: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showServerListView() {
|
||||
serversListView?.classList.remove("hidden");
|
||||
serverConfigSubpage?.classList.add("hidden");
|
||||
configuringServerID = "";
|
||||
}
|
||||
|
||||
function openManagedServerConfig(id) {
|
||||
configuringServerID = id || "local";
|
||||
const srv = serverByID(configuringServerID) || { name: "Master node" };
|
||||
if (cfgServerName) cfgServerName.textContent = srv.name || srv.base_url || configuringServerID;
|
||||
serversListView?.classList.add("hidden");
|
||||
serverConfigSubpage?.classList.remove("hidden");
|
||||
loadManagedServerConfig(configuringServerID);
|
||||
}
|
||||
|
||||
async function loadManagedServerConfig(id) {
|
||||
if (!id) return;
|
||||
managedConfigStatus.textContent = "Loading config…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/servers/config", id));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const text = await res.text();
|
||||
try { managedConfigEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch { managedConfigEditor.value = text; }
|
||||
managedConfigStatus.textContent = "Config loaded.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else managedConfigStatus.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManagedServerConfig() {
|
||||
if (!configuringServerID) return;
|
||||
const text = managedConfigEditor.value.trim();
|
||||
try { JSON.parse(text); } catch (e) { managedConfigStatus.textContent = "Invalid JSON: " + e.message; return; }
|
||||
managedConfigStatus.textContent = "Saving config…";
|
||||
try {
|
||||
const res = await api(withServerParam("/api/servers/config", configuringServerID), { method:"POST", body: text });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
managedConfigStatus.textContent = "Saved and applied.";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else managedConfigStatus.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
||||
|
||||
@@ -1880,6 +2167,7 @@ async function saveEditXrayClient() {
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<button class="tab-btn" data-tab="xray"><span class="nav-icon">◇</span><span>Xray Users</span></button>
|
||||
<div class="nav-group-label superadmin-only hidden">Administração</div>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Servidores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="stats"><span class="nav-icon">📊</span><span>Servidor</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="vnstat"><span class="nav-icon">⇅</span><span>Tráfego</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon">☰</span><span>Logs</span></button>
|
||||
@@ -199,6 +200,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sshServerPickerCard" class="card hidden" style="margin-bottom:12px;">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Target server</div>
|
||||
<span class="chip">master/slave</span>
|
||||
</div>
|
||||
<div class="form-grid" style="grid-template-columns:1fr auto;align-items:end;">
|
||||
<div class="field"><label>Create/list SSH users on</label><select id="sshServerSelect"></select></div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="reloadServersBtn">Reload servers</button>
|
||||
</div>
|
||||
<div class="hint" id="sshServerHint">Servers with SSH enabled are available here.</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- Users list -->
|
||||
<div class="card">
|
||||
@@ -290,6 +303,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="xrayServerPickerCard" class="card hidden" style="margin-bottom:12px;">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Target server</div>
|
||||
<span class="chip">master/slave</span>
|
||||
</div>
|
||||
<div class="form-grid" style="grid-template-columns:1fr auto;align-items:end;">
|
||||
<div class="field"><label>Create/list Xray users on</label><select id="xrayServerSelect"></select></div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="reloadServersBtn2">Reload servers</button>
|
||||
</div>
|
||||
<div class="hint" id="xrayServerHint">Servers with Xray enabled are available here.</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -552,6 +577,64 @@
|
||||
</div>
|
||||
</div><!-- /tab-resellers -->
|
||||
|
||||
<!-- ═══════════ Servers Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers">
|
||||
<div id="serversListView">
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Managed servers <span class="chip" id="serversCountChip">0</span></div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="refreshServersBtn" type="button">Reload</button></div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>URL</th><th>Options</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="serversBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="serversStatus">Add slave nodes so the master can create SSH/Xray users remotely.</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title" id="serverFormTitle">Add / edit server</div></div>
|
||||
<form id="serverForm">
|
||||
<input type="hidden" id="srvID"/>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Name</label><input id="srvName" placeholder="Brazil Node 01"/></div>
|
||||
<div class="field"><label>Base URL</label><input id="srvBaseURL" placeholder="https://node.example.com:8080"/></div>
|
||||
<div class="field"><label>Admin username</label><input id="srvAdminUser" placeholder="admin"/></div>
|
||||
<div class="field"><label>Admin key / password</label><input id="srvAdminKey" type="password" placeholder="leave blank to keep saved key"/></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="srvEnableSSH" checked/> SSH enabled</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="srvEnableXray" checked/> Xray enabled</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="srvIsActive" checked/> Active</label>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;">
|
||||
<button class="btn" type="submit" id="saveServerBtn">Save server</button>
|
||||
<button class="btn btn-ghost" type="button" id="testServerBtn">Test</button>
|
||||
<button class="btn btn-ghost" type="button" id="clearServerFormBtn">New</button>
|
||||
</div>
|
||||
<div id="serverFormStatus" class="hint" style="margin-top:6px;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="serverConfigSubpage" class="hidden">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Configure server <span class="chip" id="cfgServerName">--</span></div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="backToServersBtn" type="button">Back</button>
|
||||
<button class="btn btn-ghost btn-sm" id="loadManagedConfigBtn" type="button">Reload config</button>
|
||||
<button class="btn btn-sm" id="saveManagedConfigBtn" type="button">Save config</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="managedConfigEditor" rows="24" style="width:100%;box-sizing:border-box;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:inherit;padding:10px;font-family:monospace;font-size:.75rem;resize:vertical;"></textarea>
|
||||
<div class="statusbar"><span id="managedConfigStatus">Select Configure on a server to edit that node config.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-servers -->
|
||||
|
||||
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-stats">
|
||||
<div class="grid2">
|
||||
@@ -872,6 +955,6 @@
|
||||
</div><!-- /shell -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<script defer src="assets/app.js?v=20260510black2"></script>
|
||||
<script defer src="assets/app.js?v=20260511servers1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
60
main.go
60
main.go
@@ -17,6 +17,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -1114,6 +1115,9 @@ func NewStore(dsn string) (*Store, error) {
|
||||
if err := store.EnsureAdminUsersSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.EnsureManagedServersSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -1367,6 +1371,12 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
|
||||
// Master/slave server management. Superadmins can add slave nodes; all authenticated
|
||||
// users can read the enabled server list to pick where accounts are created.
|
||||
mux.Handle("/api/servers", sessionMiddleware(http.HandlerFunc(handleServers(store))))
|
||||
mux.Handle("/api/servers/test", saSession(http.HandlerFunc(handleServerTest(store))))
|
||||
mux.Handle("/api/servers/config", saSession(http.HandlerFunc(handleManagedServerConfig(store))))
|
||||
|
||||
// Xray-core management. Service/config/log actions are superadmin-only;
|
||||
// authenticated resellers may list inbounds and manage their own Xray clients.
|
||||
mux.Handle("/api/xray/status", sessionMiddleware(http.HandlerFunc(handleXrayStatus)))
|
||||
@@ -1425,6 +1435,7 @@ type UserDTO struct {
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1434,6 +1445,13 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
filterOwner := ""
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
filterOwner = sess.Username
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/users", nil, filterOwner) {
|
||||
return
|
||||
}
|
||||
states := userMgr.List()
|
||||
out := make([]UserDTO, 0, len(states))
|
||||
for _, u := range states {
|
||||
@@ -1483,6 +1501,8 @@ type UserPayload struct {
|
||||
TOTPWindow int `json:"totp_window"`
|
||||
TOTPDigits int `json:"totp_digits"`
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
@@ -1507,6 +1527,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableSSH {
|
||||
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
// - if payload has non-empty password -> use it
|
||||
@@ -1557,6 +1598,8 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
}
|
||||
|
||||
cfg := UserConfig{
|
||||
@@ -1606,6 +1649,23 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); 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, username, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
// Resellers may only delete their own users
|
||||
sess := sessionFromCtx(ctx)
|
||||
|
||||
596
managed_servers.go
Normal file
596
managed_servers.go
Normal file
@@ -0,0 +1,596 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
BaseURL string
|
||||
AdminUsername string
|
||||
AdminKey string
|
||||
EnableSSH bool
|
||||
EnableXray bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type ManagedServerDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
EnableSSH bool `json:"enable_ssh"`
|
||||
EnableXray bool `json:"enable_xray"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ManagedServerPayload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AdminUsername string `json:"admin_username"`
|
||||
AdminKey string `json:"admin_key"`
|
||||
EnableSSH bool `json:"enable_ssh"`
|
||||
EnableXray bool `json:"enable_xray"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS managed_servers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL UNIQUE,
|
||||
admin_username TEXT NOT NULL DEFAULT 'admin',
|
||||
admin_key TEXT NOT NULL DEFAULT '',
|
||||
enable_ssh BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
enable_xray BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
|
||||
FROM managed_servers ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*ManagedServer
|
||||
for rows.Next() {
|
||||
ms := &ManagedServer{}
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, error) {
|
||||
ms := &ManagedServer{}
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active, created_at, updated_at
|
||||
FROM managed_servers WHERE id=$1`, id).
|
||||
Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, fmt.Errorf("invalid server id")
|
||||
}
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetManagedServer(ctx, id)
|
||||
}
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
name=EXCLUDED.name,
|
||||
admin_username=EXCLUDED.admin_username,
|
||||
admin_key=EXCLUDED.admin_key,
|
||||
enable_ssh=EXCLUDED.enable_ssh,
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetManagedServer(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteManagedServer(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM managed_servers WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func managedServerToDTO(ms *ManagedServer) ManagedServerDTO {
|
||||
return ManagedServerDTO{
|
||||
ID: strconv.Itoa(ms.ID),
|
||||
Name: ms.Name,
|
||||
BaseURL: ms.BaseURL,
|
||||
AdminUsername: ms.AdminUsername,
|
||||
EnableSSH: ms.EnableSSH,
|
||||
EnableXray: ms.EnableXray,
|
||||
IsActive: ms.IsActive,
|
||||
CreatedAt: ms.CreatedAt,
|
||||
UpdatedAt: ms.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func localManagedServerDTO() ManagedServerDTO {
|
||||
cfg := getGlobalCfg()
|
||||
xrayEnabled := cfg != nil && cfg.Xray != nil && cfg.Xray.Enabled
|
||||
return ManagedServerDTO{
|
||||
ID: "local",
|
||||
Name: "Master node",
|
||||
BaseURL: "local",
|
||||
EnableSSH: true,
|
||||
EnableXray: xrayEnabled,
|
||||
IsActive: true,
|
||||
IsLocal: true,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("server_id"))
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(r.URL.Query().Get("server"))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func managedServerFromID(ctx context.Context, store *Store, id string) (*ManagedServer, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == "local" || id == "0" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if store == nil {
|
||||
return nil, false, fmt.Errorf("database not configured")
|
||||
}
|
||||
n, err := strconv.Atoi(id)
|
||||
if err != nil || n <= 0 {
|
||||
return nil, false, fmt.Errorf("invalid server id")
|
||||
}
|
||||
ms, err := store.GetManagedServer(ctx, n)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ms == nil {
|
||||
return nil, false, fmt.Errorf("server not found")
|
||||
}
|
||||
if !ms.IsActive {
|
||||
return nil, false, fmt.Errorf("server is disabled")
|
||||
}
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
|
||||
return "", fmt.Errorf("remote login returned no token")
|
||||
}
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path string, body []byte, contentType string) (int, []byte, string, error) {
|
||||
token, err := remoteLoginToken(ctx, ms)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, ms.BaseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
return resp.StatusCode, data, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if status == 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
return false
|
||||
}
|
||||
if remotePath == "" {
|
||||
remotePath = r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
q := r.URL.Query()
|
||||
q.Del("server_id")
|
||||
q.Del("server")
|
||||
if enc := q.Encode(); enc != "" {
|
||||
remotePath += "?" + enc
|
||||
}
|
||||
}
|
||||
}
|
||||
if body == nil && r.Body != nil && r.Method != http.MethodGet {
|
||||
body, _ = io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
if filtered, ok := filterRemoteOwnerJSON(remotePath, data, filterOwner); ok {
|
||||
data = filtered
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return true
|
||||
}
|
||||
|
||||
func filterRemoteOwnerJSON(path string, data []byte, owner string) ([]byte, bool) {
|
||||
if owner == "" || len(data) == 0 {
|
||||
return data, false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/users") {
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return data, false
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) == owner {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
filtered, _ := json.Marshal(out)
|
||||
return filtered, true
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/xray/inbounds") {
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return data, false
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
filtered := make([]interface{}, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(m["owner_username"])) == owner {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
ib["clients"] = filtered
|
||||
}
|
||||
filtered, _ := json.Marshal(inbounds)
|
||||
return filtered, true
|
||||
}
|
||||
return data, false
|
||||
}
|
||||
|
||||
func handleServers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rows, err := store.ListManagedServers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := []ManagedServerDTO{localManagedServerDTO()}
|
||||
for _, ms := range rows {
|
||||
if sess != nil && sess.Role == RoleReseller && !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
dto := managedServerToDTO(ms)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
dto.AdminUsername = ""
|
||||
}
|
||||
out = append(out, dto)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
case http.MethodPost:
|
||||
if sess == nil || sess.Role != RoleSuperAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var p ManagedServerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(managedServerToDTO(ms))
|
||||
case http.MethodDelete:
|
||||
if sess == nil || sess.Role != RoleSuperAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
idStr := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteManagedServer(r.Context(), id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleServerTest(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 p ManagedServerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ms := &ManagedServer{Name: p.Name, BaseURL: normalizeManagedServerBaseURL(p.BaseURL), AdminUsername: strings.TrimSpace(p.AdminUsername), AdminKey: p.AdminKey, EnableSSH: p.EnableSSH, EnableXray: p.EnableXray, IsActive: true}
|
||||
if p.ID != "" && p.ID != "local" && (ms.BaseURL == "" || ms.AdminKey == "") {
|
||||
id, _ := strconv.Atoi(p.ID)
|
||||
if id > 0 {
|
||||
stored, err := store.GetManagedServer(r.Context(), id)
|
||||
if err == nil && stored != nil {
|
||||
if ms.BaseURL == "" {
|
||||
ms.BaseURL = stored.BaseURL
|
||||
}
|
||||
if ms.AdminUsername == "" {
|
||||
ms.AdminUsername = stored.AdminUsername
|
||||
}
|
||||
if ms.AdminKey == "" {
|
||||
ms.AdminKey = stored.AdminKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms.AdminUsername == "" {
|
||||
ms.AdminUsername = "admin"
|
||||
}
|
||||
if ms.BaseURL == "" || ms.AdminKey == "" {
|
||||
http.Error(w, "base url and admin key/password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "remote login ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := requestedServerID(r)
|
||||
if id == "" || id == "local" || id == "0" {
|
||||
handleServerConfig(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body := []byte(nil)
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
body, err = io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
handleServerConfig(w, r)
|
||||
return
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
if owner == "" || username == "" {
|
||||
return false
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
if owner == "" || uuid == "" {
|
||||
return false
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
@@ -1100,6 +1101,9 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/status", nil, "") {
|
||||
return
|
||||
}
|
||||
// Be practical for old/manual configs: if the panel detects missing Stats API
|
||||
// pieces or missing per-client stat labels, repair once automatically. This
|
||||
// avoids a dashboard that says "OK" but continues to show zero Xray online
|
||||
@@ -1136,6 +1140,9 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/start", nil, "") {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1148,6 +1155,9 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/stop", nil, "") {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1160,6 +1170,9 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/restart", nil, "") {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1168,6 +1181,20 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if requestedServerID(r) != "" && requestedServerID(r) != "local" && requestedServerID(r) != "0" {
|
||||
body := []byte(nil)
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
body, err = io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/config", body, "") {
|
||||
return
|
||||
}
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := xrayMgr.GetConfig()
|
||||
@@ -1200,6 +1227,9 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/stats/repair", nil, "") {
|
||||
return
|
||||
}
|
||||
wasRunning := xrayMgr.isRunningSnapshot()
|
||||
changed, err := xrayMgr.EnsureStatsAPIConfig()
|
||||
if err != nil {
|
||||
@@ -1230,6 +1260,9 @@ func handleXrayLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/logs", nil, "") {
|
||||
return
|
||||
}
|
||||
lines := xrayLogBuf.snapshot()
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
@@ -1449,6 +1482,13 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
filterOwner := ""
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
filterOwner = sess.Username
|
||||
}
|
||||
if proxyManagedServerFromRequest(w, r, statsStore, "/api/xray/inbounds", nil, filterOwner) {
|
||||
return
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -1560,6 +1600,8 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty
|
||||
MaxConnections int `json:"max_connections"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
@@ -1569,6 +1611,27 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableXray {
|
||||
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
req.OwnerUsername = sess.Username
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Email == "" {
|
||||
req.Email = strings.TrimSpace(req.Name)
|
||||
@@ -1594,6 +1657,8 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
}
|
||||
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
@@ -1643,6 +1708,7 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
@@ -1652,6 +1718,24 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", 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
|
||||
@@ -1702,6 +1786,23 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
|
||||
Reference in New Issue
Block a user