diff --git a/admin/assets/app.js b/admin/assets/app.js index 9b5c8a5..88020b4 100644 --- a/admin/assets/app.js +++ b/admin/assets/app.js @@ -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 = `
${t("Loading…")}
`; 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 = ` + ${escapeHTML(s.name || "—")}${s.is_local ? ' master' : ""} + ${escapeHTML(s.base_url || "local")} + ${escapeHTML(opts)} + ${s.is_active ? 'active' : 'disabled'}`; + 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) }); diff --git a/admin/index.html b/admin/index.html index eb68072..1a74e64 100644 --- a/admin/index.html +++ b/admin/index.html @@ -52,6 +52,7 @@ + @@ -199,6 +200,18 @@ + +
@@ -290,6 +303,18 @@
+ +
@@ -552,6 +577,64 @@
+ +
+
+
+
+
+
Managed servers 0
+
+
+
+ + + +
NameURLOptionsStatusActions
+
+
Add slave nodes so the master can create SSH/Xray users remotely.
+
+ +
+
Add / edit server
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
+
+ + +
+
@@ -872,6 +955,6 @@
- + diff --git a/main.go b/main.go index b85d2fd..c531969 100644 --- a/main.go +++ b/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) diff --git a/managed_servers.go b/managed_servers.go new file mode 100644 index 0000000..15291f6 --- /dev/null +++ b/managed_servers.go @@ -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 +} diff --git a/xray_integration.go b/xray_integration.go index 3a2c472..289d8fc 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -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 {