diff --git a/README.md b/README.md index 561a451..4538ac3 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Ou gerar uma senha nova automaticamente: sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate ``` -O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins. +O script atualiza o usuário `admin` no PostgreSQL com hash bcrypt, ativa ele como `superadmin`, remove qualquer `ADMIN_PASSWORD` legado em texto puro de `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins. ### Atualização automática pelo Git @@ -779,7 +779,7 @@ Or generate a new password automatically: sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate ``` -The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded. +The script updates the `admin` user in PostgreSQL with a bcrypt hash, enables it as `superadmin`, removes any legacy plaintext `ADMIN_PASSWORD` from `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded. ### Automatic Git update diff --git a/admin/assets/app.css b/admin/assets/app.css index 7419ef5..651c41d 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -649,3 +649,53 @@ select:disabled { .update-check-time{font-size:.9rem!important;letter-spacing:0!important;} .update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;} .btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;} + +/* --- Bot sales workspace --- */ +#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);} +.bot-hero{ + position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px; + background: + radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%), + radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%), + linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98)); + box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04); +} +.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;} +.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;} +.bot-hero-copy{max-width:620px;} +.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;} +.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;} +.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;} +.bot-hero-copy p{margin-top:8px;} +.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;} +.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;} +.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;} +.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);} +.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);} +.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;} +.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);} +.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);} + +.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);} +.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;} +.bot-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);} +.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;} +.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;} +.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;} + +.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;} +.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);} +.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);} +.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;} +.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;} +.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;} + +.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);} +.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);} + +.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;} + +@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}} +@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}} +@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index 7700f3e..96c5063 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -1,6 +1,8 @@ // ─── State ─────────────────────────────────────────────────────────────────── -let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; +let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || ""; +if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken); +localStorage.removeItem("SESSION_TOKEN"); let currentRole = ""; let currentUser = ""; let statsTimer = null, usersTimer = null, xrayTimer = null; @@ -564,4 +566,3 @@ function patchRenderedInbounds(inbounds) { } return true; } - diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js index f974a98..27d40b2 100644 --- a/admin/assets/js/02-shell.js +++ b/admin/assets/js/02-shell.js @@ -56,7 +56,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); }); logoutBtn.addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); } catch {} sessionToken = ""; - localStorage.removeItem("SESSION_TOKEN"); + sessionStorage.removeItem("SESSION_TOKEN"); clearTimers(); mainApp.classList.add("hidden"); loginOverlay.classList.remove("hidden"); @@ -83,7 +83,7 @@ async function doLogin() { sessionToken = data.token; currentRole = data.role; currentUser = data.username; - localStorage.setItem("SESSION_TOKEN", sessionToken); + sessionStorage.setItem("SESSION_TOKEN", sessionToken); loginOverlay.classList.add("hidden"); mainApp.classList.remove("hidden"); initAfterLogin(); @@ -288,4 +288,3 @@ function refreshDashboard() { if (currentRole === "superadmin") loadStats(); if (currentRole === "reseller") loadMe(); } - diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 3d96204..d1310b5 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -161,7 +161,8 @@ function renderInbounds(inbounds, options = {}) { } inboundsContainer.innerHTML = ""; lastInboundsStructure = nextStructure; - inbounds.forEach(ib => { + inbounds.forEach((ib, inboundIndex) => { + const formKey = String(inboundIndex); const section = document.createElement("div"); section.dataset.inboundTag = String(ib.tag || ""); section.dataset.inboundProtocol = String(ib.protocol || ""); @@ -179,13 +180,18 @@ function renderInbounds(inbounds, options = {}) { ${escapeHTML(ib.tag || "untagged")} :${escapeHTML(ib.port ?? "?")} ${t("{count} online", {count: onlineCount})} - - `; + `; + const openButton = document.createElement("button"); + openButton.className = "btn btn-sm"; + openButton.type = "button"; + openButton.textContent = t("+ Add Client"); + openButton.addEventListener("click", () => openAddClient(ib.tag, formKey)); + hdr.appendChild(openButton); section.appendChild(hdr); // Add client mini-form (hidden by default) const addForm = document.createElement("div"); - addForm.id = `add-form-${ib.tag}`; + addForm.id = `add-form-${formKey}`; addForm.className = "hidden"; addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;"; addForm.innerHTML = ` @@ -193,19 +199,22 @@ function renderInbounds(inbounds, options = {}) {
- - + +
-
-
-
-
+
+
+
+
- - + +
`; + addForm.querySelector(`#genUUID-${formKey}`).addEventListener("click", () => { document.getElementById(`newUUID-${formKey}`).value = genUUID(); }); + addForm.querySelector(`#addClient-${formKey}`).addEventListener("click", () => addClient(ib.tag, formKey)); + addForm.querySelector(`#cancelAddClient-${formKey}`).addEventListener("click", () => addForm.classList.add("hidden")); section.appendChild(addForm); // Clients table @@ -262,19 +271,19 @@ function renderInbounds(inbounds, options = {}) { }); } -function openAddClient(tag) { - const form = document.getElementById(`add-form-${tag}`); +function openAddClient(tag, formKey = tag) { + const form = document.getElementById(`add-form-${formKey}`); if (form) { form.classList.remove("hidden"); } - const uuidField = document.getElementById(`newUUID-${tag}`); + const uuidField = document.getElementById(`newUUID-${formKey}`); if (uuidField && !uuidField.value) uuidField.value = genUUID(); } -async function addClient(tag) { - const uuidEl = document.getElementById(`newUUID-${tag}`); - const emailEl = document.getElementById(`newEmail-${tag}`); - const nameEl = document.getElementById(`newName-${tag}`); - const expiryEl = document.getElementById(`newExpiry-${tag}`); - const maxConnsEl = document.getElementById(`newMaxConns-${tag}`); +async function addClient(tag, formKey = tag) { + const uuidEl = document.getElementById(`newUUID-${formKey}`); + const emailEl = document.getElementById(`newEmail-${formKey}`); + const nameEl = document.getElementById(`newName-${formKey}`); + const expiryEl = document.getElementById(`newExpiry-${formKey}`); + const maxConnsEl = document.getElementById(`newMaxConns-${formKey}`); const uuid = (uuidEl?.value || "").trim(); const email = (emailEl?.value || "").trim(); const name = (nameEl?.value || "").trim(); @@ -349,4 +358,3 @@ async function loadXrayLogs() { xLogsBox.scrollTop = xLogsBox.scrollHeight; } catch (e) { if (e.message==="auth") doAuthError(); } } - diff --git a/admin/assets/js/05-resellers.js b/admin/assets/js/05-resellers.js index 3e87b78..24cc266 100644 --- a/admin/assets/js/05-resellers.js +++ b/admin/assets/js/05-resellers.js @@ -38,13 +38,13 @@ function renderResellers(list) { const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0; const tr = document.createElement("tr"); tr.innerHTML = ` - ${r.username} + ${escapeHTML(r.username)} ${used} / ${max || "∞"}
Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
- ${r.expires_at ? fmtDate(r.expires_at) : "—"} + ${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"} ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} `; const tdA = tr.lastElementChild; @@ -110,4 +110,3 @@ async function deleteReseller(username) { else resellerStatus.textContent = "Error deleting."; } } - diff --git a/admin/assets/js/07-stats-logs.js b/admin/assets/js/07-stats-logs.js index 34102a8..534690d 100644 --- a/admin/assets/js/07-stats-logs.js +++ b/admin/assets/js/07-stats-logs.js @@ -91,7 +91,7 @@ async function loadDnsttHealth() { const a = rows[i]; const b = rows[i + 1] || ["", ""]; const tr = document.createElement("tr"); - tr.innerHTML = `${a[0]}${fmtInt(a[1])}${b[0]}${b[0] ? fmtInt(b[1]) : ""}`; + tr.innerHTML = `${escapeHTML(a[0])}${escapeHTML(fmtInt(a[1]))}${escapeHTML(b[0])}${b[0] ? escapeHTML(fmtInt(b[1])) : ""}`; dnsttHealthBody.appendChild(tr); } } @@ -135,7 +135,7 @@ async function loadStats() { totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0); if (!ifaceBody) return; const tr = document.createElement("tr"); - tr.innerHTML = `${it.name}${fmtMbps(it.rx_mbps)}${fmtMbps(it.tx_mbps)}${fmtBytes(it.rx_bytes)}${fmtBytes(it.tx_bytes)}`; + tr.innerHTML = `${escapeHTML(it.name)}${escapeHTML(fmtMbps(it.rx_mbps))}${escapeHTML(fmtMbps(it.tx_mbps))}${escapeHTML(fmtBytes(it.rx_bytes))}${escapeHTML(fmtBytes(it.tx_bytes))}`; ifaceBody.appendChild(tr); }); if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; @@ -175,13 +175,13 @@ function renderVnstatRows(body, rows, emptyLabel) { body.innerHTML = ""; if (!rows.length) { const tr = document.createElement("tr"); - tr.innerHTML = `${emptyLabel}`; + tr.innerHTML = `${escapeHTML(emptyLabel)}`; body.appendChild(tr); return; } rows.forEach(r => { const tr = document.createElement("tr"); - tr.innerHTML = `${r.period || "--"}${r.iface || "--"}${fmtBytes(r.rx_bytes||0)}${fmtBytes(r.tx_bytes||0)}${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}`; + tr.innerHTML = `${escapeHTML(r.period || "--")}${escapeHTML(r.iface || "--")}${escapeHTML(fmtBytes(r.rx_bytes||0))}${escapeHTML(fmtBytes(r.tx_bytes||0))}${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))}`; body.appendChild(tr); }); } @@ -273,4 +273,3 @@ async function clearPanelLog() { else st.textContent = "Error cleaning panel log: " + e.message; } } - diff --git a/admin/assets/js/08-server-config.js b/admin/assets/js/08-server-config.js index 9b4d354..7108f3c 100644 --- a/admin/assets/js/08-server-config.js +++ b/admin/assets/js/08-server-config.js @@ -263,8 +263,8 @@ function renderTLSForwarders() { tlsForwardersState.forEach((fw, i) => { const row = document.createElement("div"); row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;"; - row.innerHTML = `${fw.listen} - ${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}`; + row.innerHTML = `${escapeHTML(fw.listen || "")} + ${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}`; const delBtn = document.createElement("button"); delBtn.className = "btn btn-danger btn-sm"; delBtn.textContent = "Remove"; @@ -446,4 +446,3 @@ async function loadDnsttPubkey() { else st.textContent = "Error: " + e.message; } } - diff --git a/admin/assets/js/09-xray-wizard.js b/admin/assets/js/09-xray-wizard.js index 8c67976..99931b4 100644 --- a/admin/assets/js/09-xray-wizard.js +++ b/admin/assets/js/09-xray-wizard.js @@ -113,9 +113,9 @@ function renderWzInbounds() { const sec = ss.security || ""; const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : ""; const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : ""; - row.innerHTML = `${ib.protocol} - ${ib.tag||"untagged"}${portStr} - ${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}`; + row.innerHTML = `${escapeHTML(ib.protocol || "")} + ${escapeHTML((ib.tag||"untagged")+portStr)} + ${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)}`; const clients = ib.settings?.clients; if (Array.isArray(clients) && clients.length) { const badge = document.createElement("span"); @@ -392,4 +392,3 @@ async function applyWizardConfig() { else if (st) st.textContent = "Error: " + e.message; } } - diff --git a/admin/assets/js/10-boot.js b/admin/assets/js/10-boot.js index 7e88f43..eb64c1f 100644 --- a/admin/assets/js/10-boot.js +++ b/admin/assets/js/10-boot.js @@ -1,7 +1,7 @@ // ─── Auth error ─────────────────────────────────────────────────────────────── function doAuthError() { sessionToken = ""; - localStorage.removeItem("SESSION_TOKEN"); + sessionStorage.removeItem("SESSION_TOKEN"); clearTimers(); mainApp.classList.add("hidden"); loginOverlay.classList.remove("hidden"); diff --git a/admin/assets/js/12-bot.js b/admin/assets/js/12-bot.js index e5c32b3..1ef6c4d 100644 --- a/admin/assets/js/12-bot.js +++ b/admin/assets/js/12-bot.js @@ -1,340 +1,615 @@ -// ─── Bot / Vendas (Telegram + Mercado Pago) ───────────────────────────────── -// Superadmin-only tab. Uses the shared api() helper from 01-core.js. +// Bot / Vendas — safe DOM rendering and sectioned management workspace. -function botEsc(s) { - return String(s == null ? "" : s) - .replace(/&/g, "&").replace(//g, ">") - .replace(/"/g, """).replace(/'/g, "'"); -} -function botStatus(id, msg, ok) { - const el = document.getElementById(id); - if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; } -} -function brl(cents) { - return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ","); -} +const botState = { + config: null, + plans: [], + packages: [], + users: [], + transactions: [], + section: sessionStorage.getItem("BOT_SECTION") || "config", +}; -async function loadBotTab() { - loadBotInbounds(); - const sel = document.getElementById("botSection"); - botShowSection(sel ? sel.value : "config"); -} - -// Show one section at a time and lazy-load its data. -function botShowSection(name) { - document.querySelectorAll("#tab-bot .bot-section").forEach(s => { s.style.display = "none"; }); - const el = document.getElementById("botSec-" + name); - if (el) el.style.display = ""; - switch (name) { - case "config": loadBotConfig(); break; - case "plans": loadBotPlans(); break; - case "packages": loadBotPkgs(); break; - case "messages": loadBotSettings(); break; - case "users": loadBotUsers(); break; - case "transactions": loadBotTxns(); break; +function botStatus(id, message, ok) { + const element = document.getElementById(id); + if (!element) return; + element.textContent = message; + if (id === "botConfigStatus") { + element.classList.toggle("is-ok", ok === true); + element.classList.toggle("is-error", ok === false); + } else { + element.style.color = ok === false ? "var(--danger)" : ""; } } -// ─── Config ─── +function botBRL(cents) { + return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100); +} + +function botNode(tag, options = {}, children = []) { + const element = document.createElement(tag); + if (options.className) element.className = options.className; + if (options.text != null) element.textContent = String(options.text); + if (options.title) element.title = options.title; + if (options.type) element.type = options.type; + for (const child of children) if (child) element.appendChild(child); + return element; +} + +function botCell(content, className = "") { + const cell = document.createElement("td"); + if (className) cell.className = className; + if (content instanceof Node) cell.appendChild(content); + else cell.textContent = String(content == null || content === "" ? "—" : content); + return cell; +} + +function botPrimaryCell(title, detail) { + const wrapper = botNode("div", { className: "bot-primary-cell" }); + wrapper.appendChild(botNode("strong", { text: title || "—" })); + if (detail) wrapper.appendChild(botNode("small", { text: detail })); + return wrapper; +} + +function botBadge(label, tone) { + return botNode("span", { className: "bot-status " + tone, text: label }); +} + +function botButton(label, handler, className = "btn btn-ghost btn-sm") { + const button = botNode("button", { className, text: label, type: "button" }); + button.addEventListener("click", handler); + return button; +} + +function botActions(buttons) { + return botNode("div", { className: "bot-row-actions" }, buttons); +} + +function botEmptyRow(body, columns, message) { + const row = botNode("tr", { className: "bot-empty-row" }); + const cell = botCell(message); + cell.colSpan = columns; + row.appendChild(cell); + body.replaceChildren(row); +} + +async function botRequest(path, options = {}) { + const response = await api(path, options); + if (!response.ok) { + const message = (await response.text()).trim(); + throw new Error(message || `HTTP ${response.status}`); + } + if (response.status === 204) return null; + return response.json(); +} + +function botHandleError(error, statusID, fallback) { + if (error.message === "auth") { + doAuthError(); + return; + } + botStatus(statusID, error.message || fallback, false); +} + +function botSetSection(section) { + const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]); + if (!allowedSections.has(section)) section = "config"; + botState.section = section; + sessionStorage.setItem("BOT_SECTION", section); + document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section)); + document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section)); + const select = document.getElementById("botSection"); + if (select) select.value = section; +} + +function botUpdateMetrics() { + const config = botState.config; + const stateMetric = document.getElementById("botMetricState"); + if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível"; + const plansMetric = document.getElementById("botMetricPlans"); + if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length); + const usersMetric = document.getElementById("botMetricUsers"); + if (usersMetric) usersMetric.textContent = String(botState.users.length); + const pendingMetric = document.getElementById("botMetricPending"); + if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length); +} + +async function loadBotTab() { + botSetSection(botState.section); + botStatus("botConfigStatus", "Atualizando dados…"); + await Promise.allSettled([ + loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(), + loadBotUsers(), loadBotTxns(), loadBotSettings(), + ]); + botUpdateMetrics(); +} + +// Configuration async function loadBotConfig() { try { - const res = await api("/api/bot/config"); - const c = await res.json(); - const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; - const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; }; - chk("botEnabled", c.enabled); - set("botMPConfirmMode", c.mp_confirm_mode); - set("botMPPollInterval", c.mp_poll_interval); - set("botPixExp", c.pix_expiration_minutes); - chk("botTrialEnabled", c.trial_enabled); - set("botTrialHours", c.trial_hours); - set("botTrialMaxConns", c.trial_max_connections); - set("botTrialKind", c.trial_kind); - set("botTrialInbound", c.trial_inbound_tag); - set("botAdminIDs", (c.admin_telegram_ids || []).join(",")); - set("botPublicHost", c.public_host); - set("botXrayPublicHost", c.xray_public_host); - const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; }; - hint("botHasTgToken", c.has_telegram_token); - hint("botHasMpToken", c.has_mp_access_token); - hint("botHasMpSecret", c.has_mp_webhook_secret); + const config = await botRequest("/api/bot/config"); + botState.config = config; + const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; }; + const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; }; + setChecked("botEnabled", config.enabled); + setValue("botMPConfirmMode", config.mp_confirm_mode); + setValue("botMPPollInterval", config.mp_poll_interval); + setValue("botPixExp", config.pix_expiration_minutes); + setChecked("botTrialEnabled", config.trial_enabled); + setValue("botTrialHours", config.trial_hours); + setValue("botTrialMaxConns", config.trial_max_connections); + setValue("botTrialKind", config.trial_kind); + setValue("botTrialInbound", config.trial_inbound_tag); + setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", ")); + setValue("botPublicHost", config.public_host); + setValue("botXrayPublicHost", config.xray_public_host); + botSetSecretState("botHasTgToken", config.has_telegram_token); + botSetSecretState("botHasMpToken", config.has_mp_access_token); + botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret); botToggleMPWebhookBox(); - botStatus("botConfigStatus", "Carregado."); - } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); } + botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true); + botUpdateMetrics(); + return config; + } catch (error) { + botState.config = null; + botHandleError(error, "botConfigStatus", "Erro ao carregar configuração."); + botUpdateMetrics(); + } +} + +function botSetSecretState(id, configured) { + const element = document.getElementById(id); + if (!element) return; + element.textContent = configured ? "● protegido" : "○ não configurado"; + element.classList.toggle("is-set", !!configured); + element.classList.toggle("is-missing", !configured); } function botToggleMPWebhookBox() { const mode = document.getElementById("botMPConfirmMode")?.value; - const box = document.getElementById("botMPWebhookBox"); - if (box) box.style.display = mode === "webhook" ? "" : "none"; + document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook"); const url = document.getElementById("botMPWebhookURL"); if (url) url.textContent = location.origin + "/api/mp/webhook"; } async function saveBotConfig() { - const val = id => (document.getElementById(id)?.value || "").trim(); - const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0; - const chk = id => !!document.getElementById(id)?.checked; - const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); - const body = { - enabled: chk("botEnabled"), - telegram_token: val("botTelegramToken"), - mp_access_token: val("botMPToken"), - mp_confirm_mode: val("botMPConfirmMode"), - mp_webhook_secret: val("botMPWebhookSecret"), - mp_poll_interval: val("botMPPollInterval"), - pix_expiration_minutes: num("botPixExp"), - trial_enabled: chk("botTrialEnabled"), - trial_hours: num("botTrialHours"), - trial_max_connections: num("botTrialMaxConns"), - trial_kind: val("botTrialKind"), - trial_inbound_tag: val("botTrialInbound"), - admin_telegram_ids: ids, - public_host: val("botPublicHost"), - xray_public_host: val("botXrayPublicHost"), + const value = id => (document.getElementById(id)?.value || "").trim(); + const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0; + const checked = id => !!document.getElementById(id)?.checked; + const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger); + const payload = { + enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"), + mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"), + mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"), + pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"), + trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"), + trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"), + admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"), }; + botStatus("botConfigStatus", "Salvando e reiniciando…"); try { - await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) }); - ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; }); - botStatus("botConfigStatus", "Configuração salva e bot reiniciado."); - loadBotConfig(); - } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); } + await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) }); + ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; }); + await loadBotConfig(); + botStatus("botConfigStatus", "Configuração salva", true); + } catch (error) { + botHandleError(error, "botConfigStatus", "Erro ao salvar configuração."); + } } async function testBot() { - botStatus("botConfigStatus", "Testando..."); - const body = { + botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…"); + const payload = { telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(), mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(), }; try { - const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) }); - const r = await res.json(); - const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`; - const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`; - botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok); - } catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); } + const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) }); + const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`; + const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`; + botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok); + } catch (error) { + botHandleError(error, "botConfigStatus", "Erro ao testar integrações."); + } +} + +async function botCopyWebhook() { + const value = document.getElementById("botMPWebhookURL")?.textContent || ""; + try { + await navigator.clipboard.writeText(value); + const button = document.getElementById("botCopyWebhookBtn"); + if (button) { + button.textContent = "Copiado"; + setTimeout(() => { button.textContent = "Copiar"; }, 1400); + } + } catch { + botStatus("botConfigStatus", "Não foi possível copiar a URL.", false); + } } -// ─── Inbounds datalist ─── async function loadBotInbounds() { try { - const res = await api("/api/xray/inbounds"); - const list = await res.json(); - const dl = document.getElementById("botInboundList"); - if (dl) dl.innerHTML = (list || []).map(ib => ``).join(""); - } catch (e) { /* xray may be off; ignore */ } + const inbounds = await botRequest("/api/xray/inbounds"); + const datalist = document.getElementById("botInboundList"); + if (!datalist) return; + datalist.replaceChildren(...(inbounds || []).map(inbound => { + const option = document.createElement("option"); + option.value = String(inbound.tag || ""); + option.textContent = String(inbound.protocol || ""); + return option; + })); + } catch (error) { + if (error.message === "auth") doAuthError(); + } } -// ─── Plans ─── +// Plans async function loadBotPlans() { try { - const res = await api("/api/bot/plans"); - const plans = await res.json() || []; - document.getElementById("botPlanCount").textContent = plans.length; - document.getElementById("botPlansBody").innerHTML = plans.map(p => ` - - ${botEsc(p.Name)}${botEsc(p.Kind)}${p.Days} - ${brl(p.PriceCents)}${p.CreditCost} - ${p.IsActive ? "✅" : "—"} - - - - - `).join(""); - botStatus("botPlansStatus", "Ready."); - } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao carregar.", false); } -} -function botEditPlan(p) { - const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; - set("planId", p.ID); set("planName", p.Name); set("planKind", p.Kind); set("planDays", p.Days); - set("planMaxConns", p.MaxConnections); set("planUpMbps", p.LimitMbpsUp); set("planDownMbps", p.LimitMbpsDown); - set("planInbound", p.XrayInboundTag); set("planProtocol", p.XrayProtocol); - set("planPrice", (p.PriceCents / 100).toFixed(2)); set("planCreditCost", p.CreditCost); - set("planServerId", p.ServerID); set("planSort", p.SortOrder); - document.getElementById("planActive").checked = !!p.IsActive; - document.getElementById("botPlanFormTitle").textContent = "Editar Plano #" + p.ID; -} -function botClearPlanForm() { - document.getElementById("botPlanForm").reset(); - document.getElementById("planId").value = ""; - document.getElementById("botPlanFormTitle").textContent = "Novo Plano"; -} -async function botSavePlan(ev) { - ev.preventDefault(); - const val = id => document.getElementById(id).value; - const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0; - const body = { - id: num("planId"), name: val("planName"), kind: val("planKind"), days: num("planDays"), - max_connections: num("planMaxConns"), limit_mbps_up: num("planUpMbps"), limit_mbps_down: num("planDownMbps"), - xray_inbound_tag: val("planInbound"), xray_protocol: val("planProtocol"), - price_cents: Math.round(parseFloat(val("planPrice") || "0") * 100), - credit_cost: num("planCreditCost"), server_id: val("planServerId"), sort_order: num("planSort"), - is_active: document.getElementById("planActive").checked, - }; - // map to Go struct JSON tags (exported field names) - const payload = { - ID: body.id, Name: body.name, Kind: body.kind, Days: body.days, MaxConnections: body.max_connections, - LimitMbpsUp: body.limit_mbps_up, LimitMbpsDown: body.limit_mbps_down, XrayInboundTag: body.xray_inbound_tag, - XrayProtocol: body.xray_protocol, PriceCents: body.price_cents, CreditCost: body.credit_cost, - ServerID: body.server_id, IsActive: body.is_active, SortOrder: body.sort_order, - }; - try { - await api("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) }); - botClearPlanForm(); loadBotPlans(); botStatus("botPlansStatus", "Plano salvo."); - } catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao salvar.", false); } -} -async function botDeletePlan(id) { - if (!confirm("Excluir este plano?")) return; - try { await api("/api/bot/plans?id=" + id, { method: "DELETE" }); loadBotPlans(); } - catch (e) { if (e.message !== "auth") botStatus("botPlansStatus", "Erro ao excluir.", false); } + const plans = await botRequest("/api/bot/plans"); + botState.plans = plans || []; + renderBotPlans(botState.plans); + botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true); + botUpdateMetrics(); + return plans; + } catch (error) { + botHandleError(error, "botPlansStatus", "Erro ao carregar planos."); + } } -// ─── Credit packages ─── +function renderBotPlans(plans) { + const body = document.getElementById("botPlansBody"); + if (!body) return; + document.getElementById("botPlanCount").textContent = String(plans.length); + if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado."); + const rows = plans.map(plan => { + const row = document.createElement("tr"); + const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH"; + const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`); + row.append( + botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery), + botCell(`${plan.Days} dias`), botCell(price), + botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")), + botCell(botActions([ + botButton("Editar", () => botEditPlan(plan)), + botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"), + ])), + ); + return row; + }); + body.replaceChildren(...rows); +} + +function botEditPlan(plan) { + const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; }; + set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days); + set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown); + set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2)); + set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder); + document.getElementById("planActive").checked = !!plan.IsActive; + document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`; + document.getElementById("planName")?.focus(); +} + +function botClearPlanForm() { + document.getElementById("botPlanForm")?.reset(); + document.getElementById("planId").value = ""; + document.getElementById("planActive").checked = true; + document.getElementById("botPlanFormTitle").textContent = "Novo plano"; +} + +async function botSavePlan(event) { + event.preventDefault(); + const value = id => document.getElementById(id).value.trim(); + const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0; + const payload = { + ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"), + MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"), + XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"), + PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"), + ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"), + }; + botStatus("botPlansStatus", "Salvando plano…"); + try { + await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) }); + botClearPlanForm(); + await loadBotPlans(); + botStatus("botPlansStatus", "Plano salvo.", true); + } catch (error) { + botHandleError(error, "botPlansStatus", "Erro ao salvar plano."); + } +} + +async function botDeletePlan(id) { + if (!confirm("Excluir este plano? Esta ação não pode ser desfeita.")) return; + try { + await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" }); + await loadBotPlans(); + } catch (error) { + botHandleError(error, "botPlansStatus", "Erro ao excluir plano."); + } +} + +// Credit packages async function loadBotPkgs() { try { - const res = await api("/api/bot/credit-packages"); - const pkgs = await res.json() || []; - document.getElementById("botPkgCount").textContent = pkgs.length; - document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => ` - - ${botEsc(p.Name)}${p.Credits}${brl(p.PriceCents)} - ${p.IsActive ? "✅" : "—"} - - - - - `).join(""); - botStatus("botPkgStatus", "Ready."); - } catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao carregar.", false); } -} -function botEditPkg(p) { - const set = (id, v) => { document.getElementById(id).value = v ?? ""; }; - set("pkgId", p.ID); set("pkgName", p.Name); set("pkgCredits", p.Credits); - set("pkgPrice", (p.PriceCents / 100).toFixed(2)); set("pkgSort", p.SortOrder); - document.getElementById("pkgActive").checked = !!p.IsActive; -} -function botClearPkgForm() { document.getElementById("botPkgForm").reset(); document.getElementById("pkgId").value = ""; } -async function botSavePkg(ev) { - ev.preventDefault(); - const val = id => document.getElementById(id).value; - const num = id => parseInt(document.getElementById(id).value || "0", 10) || 0; - const payload = { - ID: num("pkgId"), Name: val("pkgName"), Credits: num("pkgCredits"), - PriceCents: Math.round(parseFloat(val("pkgPrice") || "0") * 100), - SortOrder: num("pkgSort"), IsActive: document.getElementById("pkgActive").checked, - }; - try { await api("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) }); botClearPkgForm(); loadBotPkgs(); botStatus("botPkgStatus", "Pacote salvo."); } - catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao salvar.", false); } -} -async function botDeletePkg(id) { - if (!confirm("Excluir este pacote?")) return; - try { await api("/api/bot/credit-packages?id=" + id, { method: "DELETE" }); loadBotPkgs(); } - catch (e) { if (e.message !== "auth") botStatus("botPkgStatus", "Erro ao excluir.", false); } + const packages = await botRequest("/api/bot/credit-packages"); + botState.packages = packages || []; + renderBotPackages(botState.packages); + botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true); + return packages; + } catch (error) { + botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes."); + } } -// ─── Bot users ─── +function renderBotPackages(packages) { + const body = document.getElementById("botPkgsBody"); + if (!body) return; + document.getElementById("botPkgCount").textContent = String(packages.length); + if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado."); + body.replaceChildren(...packages.map(item => { + const row = document.createElement("tr"); + row.append( + botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)), + botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")), + botCell(botActions([ + botButton("Editar", () => botEditPkg(item)), + botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"), + ])), + ); + return row; + })); +} + +function botEditPkg(item) { + const set = (id, value) => { document.getElementById(id).value = value ?? ""; }; + set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits); + set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder); + document.getElementById("pkgActive").checked = !!item.IsActive; + document.getElementById("pkgName")?.focus(); +} + +function botClearPkgForm() { + document.getElementById("botPkgForm")?.reset(); + document.getElementById("pkgId").value = ""; + document.getElementById("pkgActive").checked = true; +} + +async function botSavePkg(event) { + event.preventDefault(); + const value = id => document.getElementById(id).value.trim(); + const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0; + const payload = { + ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"), + PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100), + SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked, + }; + botStatus("botPkgStatus", "Salvando pacote…"); + try { + await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) }); + botClearPkgForm(); + await loadBotPkgs(); + botStatus("botPkgStatus", "Pacote salvo.", true); + } catch (error) { + botHandleError(error, "botPkgStatus", "Erro ao salvar pacote."); + } +} + +async function botDeletePkg(id) { + if (!confirm("Excluir este pacote de créditos?")) return; + try { + await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" }); + await loadBotPkgs(); + } catch (error) { + botHandleError(error, "botPkgStatus", "Erro ao excluir pacote."); + } +} + +// Users async function loadBotUsers() { try { - const res = await api("/api/bot/users"); - const users = await res.json() || []; - document.getElementById("botUserCount").textContent = users.length; - document.getElementById("botUsersBody").innerHTML = users.map(u => ` - - ${u.TelegramID}${botEsc(u.FirstName)}${botEsc(u.Username)} - ${botEsc(u.Role)}${botEsc(u.LinkedAdminUsername)}${u.CreditBalance} - - - - - - `).join(""); - botStatus("botUsersStatus", "Ready."); - } catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro ao carregar.", false); } -} -async function botPromote(tid) { - const role = prompt("Papel (customer / reseller / blocked):", "reseller"); - if (!role) return; - let linked = ""; - if (role === "reseller") { linked = prompt("Username do revendedor (admin_users) vinculado:", "") || ""; } - try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "set_role", role, linked_admin_username: linked }) }); loadBotUsers(); } - catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); } -} -async function botCredit(tid) { - const v = prompt("Ajuste de créditos (use negativo para remover):", "10"); - if (v === null) return; - const n = parseInt(v, 10); if (isNaN(n)) return; - try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: "adjust_credits", credits: n }) }); loadBotUsers(); } - catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro (saldo insuficiente?).", false); } -} -async function botBlock(tid, isBlocked) { - try { await api("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: tid, action: isBlocked ? "unblock" : "block" }) }); loadBotUsers(); } - catch (e) { if (e.message !== "auth") botStatus("botUsersStatus", "Erro.", false); } + const users = await botRequest("/api/bot/users"); + botState.users = users || []; + renderBotUsers(botState.users); + botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true); + botUpdateMetrics(); + return users; + } catch (error) { + botHandleError(error, "botUsersStatus", "Erro ao carregar clientes."); + } } -// ─── Transactions ─── +function renderBotUsers(users) { + const body = document.getElementById("botUsersBody"); + if (!body) return; + document.getElementById("botUserCount").textContent = String(users.length); + if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda."); + body.replaceChildren(...users.map(user => { + const row = document.createElement("tr"); + const displayName = user.FirstName || user.Username || "Sem nome"; + const username = user.Username ? `@${user.Username}` : "Sem username"; + const isBlocked = user.Role === "blocked"; + row.append( + botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID), + botCell(botBadge(user.Role || "customer", user.Role || "customer")), + botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`), + botCell(botActions([ + botButton("Função", () => botOpenUserAction(user, "role")), + botButton("Saldo", () => botOpenUserAction(user, "credits")), + botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"), + ])), + ); + return row; + })); +} + +function botOpenUserAction(user, mode) { + document.getElementById("botActionTelegramID").value = String(user.TelegramID); + document.getElementById("botActionMode").value = mode; + document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos"; + document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`; + document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role"); + document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits"); + document.getElementById("botActionRole").value = user.Role || "customer"; + document.getElementById("botActionLinked").value = user.LinkedAdminUsername || ""; + document.getElementById("botActionCredits").value = ""; + botToggleLinkedAdminField(); + document.getElementById("botUserActionModal").classList.remove("hidden"); + document.body.classList.add("bot-modal-open"); + setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0); +} + +function botCloseUserAction() { + document.getElementById("botUserActionModal")?.classList.add("hidden"); + document.body.classList.remove("bot-modal-open"); +} + +function botToggleLinkedAdminField() { + const show = document.getElementById("botActionRole")?.value === "reseller"; + document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show); +} + +async function botSaveUserAction(event) { + event.preventDefault(); + const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10); + const mode = document.getElementById("botActionMode").value; + const payload = mode === "role" ? { + telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value, + linked_admin_username: document.getElementById("botActionLinked").value.trim(), + } : { + telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0, + }; + try { + await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) }); + botCloseUserAction(); + await loadBotUsers(); + botStatus("botUsersStatus", "Cliente atualizado.", true); + } catch (error) { + botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente."); + } +} + +async function botToggleBlock(user) { + const isBlocked = user.Role === "blocked"; + if (!confirm(isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?")) return; + try { + await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) }); + await loadBotUsers(); + } catch (error) { + botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio."); + } +} + +// Transactions async function loadBotTxns() { const filter = document.getElementById("botTxnFilter")?.value || ""; try { - const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter)); - const txns = await res.json() || []; - document.getElementById("botTxnCount").textContent = txns.length; - document.getElementById("botTxnsBody").innerHTML = txns.map(t => ` - - ${t.ID}${t.TelegramID}${botEsc(t.Type)}${brl(t.AmountCents)} - ${botEsc(t.Status)}${botEsc(t.TargetUsername)} - ${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))} - - ${t.Status === "pending" || t.Status === "approved" ? `` : ""} - ${t.Status !== "refunded" ? `` : ""} - - `).join(""); - botStatus("botTxnStatus", "Ready."); - } catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro ao carregar.", false); } -} -async function botReprocess(id) { - try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) }); botStatus("botTxnStatus", "Reprocessando #" + id + "..."); setTimeout(loadBotTxns, 1500); } - catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro (bot ativo?).", false); } -} -async function botRefund(id) { - if (!confirm("Marcar pagamento #" + id + " como estornado?")) return; - try { await api("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); loadBotTxns(); } - catch (e) { if (e.message !== "auth") botStatus("botTxnStatus", "Erro.", false); } + const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`); + botState.transactions = transactions || []; + renderBotTransactions(botState.transactions); + botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true); + botUpdateMetrics(); + return transactions; + } catch (error) { + botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos."); + } } -// ─── Settings (messages) ─── +function botTransactionType(type) { + return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—"; +} + +function renderBotTransactions(transactions) { + const body = document.getElementById("botTxnsBody"); + if (!body) return; + document.getElementById("botTxnCount").textContent = String(transactions.length); + if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro."); + body.replaceChildren(...transactions.map(transaction => { + const row = document.createElement("tr"); + const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—"; + const buttons = []; + if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID))); + if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm")); + row.append( + botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")), + botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)), + botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"), + botCell(createdAt), botCell(botActions(buttons)), + ); + return row; + })); +} + +async function botReprocess(id) { + try { + await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) }); + botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true); + setTimeout(loadBotTxns, 1400); + } catch (error) { + botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento."); + } +} + +async function botRefund(id) { + if (!confirm(`Isso apenas marca o pagamento #${id} como estornado no painel. Não envia um estorno financeiro ao Mercado Pago. Continuar?`)) return; + try { + await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) }); + await loadBotTxns(); + } catch (error) { + botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento."); + } +} + +// Messages async function loadBotSettings() { try { - const res = await api("/api/bot/settings"); - const s = await res.json() || {}; - const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; }; - set("setWelcome", s.welcome_text); set("setContact", s.contact_text); - set("setAppText", s.app_text); set("setAppUrl", s.app_url); - botStatus("botSettingsStatus", "Ready."); - } catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); } -} -async function saveBotSettings() { - const val = id => document.getElementById(id)?.value || ""; - const payload = { welcome_text: val("setWelcome"), contact_text: val("setContact"), app_text: val("setAppText"), app_url: val("setAppUrl") }; - try { await api("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); botStatus("botSettingsStatus", "Mensagens salvas."); } - catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao salvar.", false); } + const settings = await botRequest("/api/bot/settings"); + const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; }; + set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text); + set("setAppText", settings.app_text); set("setAppUrl", settings.app_url); + botStatus("botSettingsStatus", "Mensagens carregadas.", true); + return settings; + } catch (error) { + botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens."); + } } -// ─── Wiring ─── +async function saveBotSettings() { + const value = id => document.getElementById(id)?.value || ""; + const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() }; + botStatus("botSettingsStatus", "Salvando mensagens…"); + try { + await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); + botStatus("botSettingsStatus", "Mensagens salvas.", true); + } catch (error) { + botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens."); + } +} + +// Wiring +document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection))); +document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value)); document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig); -document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig); +document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab); document.getElementById("botTestBtn")?.addEventListener("click", testBot); document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox); -document.getElementById("botSection")?.addEventListener("change", e => botShowSection(e.target.value)); +document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook); document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans); document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan); document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs); document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm); +document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm); document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg); document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers); document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns); document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns); document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings); document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings); +document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction); +document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField); +document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction)); +document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); }); + +botSetSection(botState.section); diff --git a/admin/index.html b/admin/index.html index ae79ce1..8d38052 100644 --- a/admin/index.html +++ b/admin/index.html @@ -16,7 +16,7 @@ setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); })(); - +
@@ -951,234 +951,136 @@
- - -
-
-
🤖 Bot / Vendas
- +
+
+ Central de automação +

Bot de vendas

+

Telegram, PIX, planos e clientes em um único fluxo.

+
+ Aguardando dados + + +
+
+
OperaçãoCarregando
+
Planos0
+
Clientes0
+
$
PIX pendentes0
+
+
+ +
+ +
- -
- -
-
-
⚙️ Configuração do Bot
-
- Pronto. - - - -
-
-

1) Cole os tokens abaixo e clique Testar · 2) Crie os Planos · 3) Promova revendedores em Clientes. O Telegram funciona por long-polling — não precisa de domínio.

-
+
+
01 · Integrações

Conecte os serviços

Tokens ficam criptografados. Campos vazios mantêm o valor atual.

+
+
+

Telegram

Canal de atendimento e entrega

+
+
Separe mais de um ID por vírgula.
+
-
- -
-
✈️ Telegram
- -
-
-
- - -
-
💠 Mercado Pago (PIX)
-
-
-
-
-
-
- -
-
- -
- -
-
⏳ Teste Grátis
- -
-
-
-
-
-
-
- - -
-
🌐 Host de entrega
-
-
-

Enviado ao cliente nas credenciais após o pagamento.

-
-
-
- - -
+ +
+
02 · Catálogo

Planos de acesso

Configure preço, validade, protocolo e limites.

+
+
Planos cadastrados0
PlanoEntregaValidadePreçoStatus
Aguardando.
+
Novo plano

Defina como a conta será criada.

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
03 · Revendedores

Pacotes de créditos

Crie recargas para revendedores comprarem planos.

+
+
Pacotes cadastrados0
PacoteCréditosPreçoStatus
Aguardando.
+
Editar pacote

Defina o saldo entregue e o preço PIX.

+
+
+
+
+ +
+
04 · Conteúdo

Mensagens do bot

Personalize os textos vistos pelo cliente.

+
Use {name} para inserir o primeiro nome.
Somente links HTTP ou HTTPS.
Aguardando.
+
+ +
+
05 · Relacionamento

Clientes do Telegram

Gerencie funções, vínculos e saldo sem sair da lista.

+
Base de clientes Ações administrativas são registradas no livro de créditos.
0
ClienteTelegram IDFunçãoRevenda vinculadaSaldo
Aguardando.
+
+ +
+
06 · Financeiro

Pagamentos PIX

Acompanhe confirmações e reprocessamentos.

+
Histórico recente0
PagamentoClienteTipoValorStatusEntregaData
Aguardando.
+
+ + -
- - - - - - - - - - - - -
@@ -1494,17 +1396,17 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/auth.go b/auth.go index 930853c..44309fa 100644 --- a/auth.go +++ b/auth.go @@ -4,22 +4,32 @@ import ( "context" "crypto/rand" "crypto/sha256" + "crypto/subtle" "database/sql" "encoding/hex" "encoding/json" "fmt" "log" + "net" "net/http" + "regexp" + "strconv" + "strings" "sync" "time" + + "golang.org/x/crypto/bcrypt" ) const ( - RoleSuperAdmin = "superadmin" - RoleReseller = "reseller" - sessionTTL = 12 * time.Hour + RoleSuperAdmin = "superadmin" + RoleReseller = "reseller" + sessionTTL = 12 * time.Hour + adminBcryptCost = 12 ) +var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + // ---------- AdminUser ---------- type AdminUser struct { @@ -50,9 +60,11 @@ type sessionStoreT struct { var sessions = &sessionStoreT{m: make(map[string]*AdminSession)} -func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession { +func (s *sessionStoreT) Create(userID int, username, role string) (*AdminSession, error) { b := make([]byte, 32) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("generate session token: %w", err) + } tok := hex.EncodeToString(b) sess := &AdminSession{ Token: tok, @@ -64,7 +76,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession s.mu.Lock() s.m[tok] = sess s.mu.Unlock() - return sess + return sess, nil } func (s *sessionStoreT) Get(token string) *AdminSession { @@ -86,6 +98,16 @@ func (s *sessionStoreT) Delete(token string) { s.mu.Unlock() } +func (s *sessionStoreT) DeleteUser(userID int) { + s.mu.Lock() + defer s.mu.Unlock() + for token, sess := range s.m { + if sess.UserID == userID { + delete(s.m, token) + } + } +} + func (s *sessionStoreT) cleanup() { s.mu.Lock() defer s.mu.Unlock() @@ -170,6 +192,15 @@ func sessionMiddleware(next http.Handler) http.Handler { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + // Re-check the account on every request. This immediately revokes sessions + // after an account is suspended, expired, deleted, or has its role changed. + u, ok := adminUsers.get(s.Username) + if !ok || u.ID != s.UserID || !u.IsActive || u.Role != s.Role || + (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) { + sessions.Delete(token) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s))) }) } @@ -194,11 +225,112 @@ func saSession(next http.Handler) http.Handler { // ---------- Password hashing ---------- -func hashAdminPassword(pw string) string { +func legacyAdminPasswordHash(pw string) string { h := sha256.Sum256([]byte(pw)) return hex.EncodeToString(h[:]) } +func hashAdminPassword(pw string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(pw), adminBcryptCost) + if err != nil { + return "", fmt.Errorf("hash admin password: %w", err) + } + return string(hash), nil +} + +// verifyAdminPassword accepts bcrypt and the legacy unsalted SHA-256 format. +// Legacy hashes are upgraded immediately after a successful login. +func verifyAdminPassword(storedHash, password string) (valid bool, needsUpgrade bool) { + if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") { + if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) != nil { + return false, false + } + cost, err := bcrypt.Cost([]byte(storedHash)) + return true, err != nil || cost < adminBcryptCost + } + if len(storedHash) != sha256.Size*2 { + return false, false + } + expected := legacyAdminPasswordHash(password) + return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expected)) == 1, true +} + +func validateAdminPassword(password string) error { + if len(password) < 10 { + return fmt.Errorf("password must contain at least 10 characters") + } + if len(password) > 1024 { + return fmt.Errorf("password is too long") + } + return nil +} + +func validateAdminUsername(username string) error { + if !adminUsernamePattern.MatchString(username) { + return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, or hyphen") + } + return nil +} + +// ---------- Login throttling ---------- + +type loginAttempt struct { + Failures int + FirstSeen time.Time + BlockedTo time.Time +} + +type loginThrottleT struct { + mu sync.Mutex + attempts map[string]loginAttempt +} + +var loginThrottle = &loginThrottleT{attempts: make(map[string]loginAttempt)} + +func loginAttemptKey(r *http.Request, username string) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + return host + "\x00" + strings.ToLower(username) +} + +func (l *loginThrottleT) retryAfter(key string, now time.Time) time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + a, ok := l.attempts[key] + if !ok { + return 0 + } + if !a.BlockedTo.IsZero() && now.Before(a.BlockedTo) { + return time.Until(a.BlockedTo) + } + if now.Sub(a.FirstSeen) > 15*time.Minute { + delete(l.attempts, key) + } + return 0 +} + +func (l *loginThrottleT) fail(key string, now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + a := l.attempts[key] + if a.FirstSeen.IsZero() || now.Sub(a.FirstSeen) > 15*time.Minute { + a = loginAttempt{FirstSeen: now} + } + a.Failures++ + if a.Failures >= 5 { + a.BlockedTo = now.Add(15 * time.Minute) + } + l.attempts[key] = a +} + +func (l *loginThrottleT) success(key string) { + l.mu.Lock() + delete(l.attempts, key) + l.mu.Unlock() +} + // ---------- DB methods on Store ---------- func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error { @@ -286,6 +418,11 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error { return err } +func (s *Store) UpdateAdminPasswordHash(ctx context.Context, id int, passwordHash string) error { + _, err := s.db.ExecContext(ctx, `UPDATE admin_users SET password_hash=$2 WHERE id=$1`, id, passwordHash) + return err +} + func (s *Store) DeleteAdminUser(ctx context.Context, username string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM admin_users WHERE username=$1`, username) return err @@ -352,11 +489,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) { return "", nil } b := make([]byte, 10) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate bootstrap password: %w", err) + } pw := hex.EncodeToString(b) + passwordHash, err := hashAdminPassword(pw) + if err != nil { + return "", err + } u := &AdminUser{ Username: "admin", - PasswordHash: hashAdminPassword(pw), + PasswordHash: passwordHash, Role: RoleSuperAdmin, MaxUsers: 0, IsActive: true, @@ -474,18 +617,33 @@ func handleLogin(store *Store) http.HandlerFunc { w.WriteHeader(http.StatusMethodNotAllowed) return } + if store == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) var req struct { Username string `json:"username"` Password string `json:"password"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } + req.Username = strings.TrimSpace(req.Username) if req.Username == "" || req.Password == "" { http.Error(w, "username and password required", http.StatusBadRequest) return } + key := loginAttemptKey(r, req.Username) + now := time.Now() + if retry := loginThrottle.retryAfter(key, now); retry > 0 { + w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds())))) + http.Error(w, "too many login attempts", http.StatusTooManyRequests) + return + } u, err := store.GetAdminUserByUsername(r.Context(), req.Username) if err != nil { @@ -493,7 +651,16 @@ func handleLogin(store *Store) http.HandlerFunc { http.Error(w, "server error", http.StatusInternalServerError) return } - if u == nil || u.PasswordHash != hashAdminPassword(req.Password) { + valid := false + needsUpgrade := false + if u != nil { + valid, needsUpgrade = verifyAdminPassword(u.PasswordHash, req.Password) + } else { + // Keep roughly the same CPU cost for unknown users to reduce account probing. + _, _ = hashAdminPassword(req.Password) + } + if !valid { + loginThrottle.fail(key, now) http.Error(w, "invalid credentials", http.StatusUnauthorized) return } @@ -506,7 +673,23 @@ func handleLogin(store *Store) http.HandlerFunc { return } - sess := sessions.Create(u.ID, u.Username, u.Role) + if needsUpgrade { + if upgradedHash, hashErr := hashAdminPassword(req.Password); hashErr == nil { + if updateErr := store.UpdateAdminPasswordHash(r.Context(), u.ID, upgradedHash); updateErr != nil { + log.Printf("upgrade admin password hash for %s: %v", u.Username, updateErr) + } else { + u.PasswordHash = upgradedHash + adminUsers.set(u) + } + } + } + loginThrottle.success(key) + sess, err := sessions.Create(u.ID, u.Username, u.Role) + if err != nil { + log.Printf("create admin session: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "token": sess.Token, @@ -614,8 +797,13 @@ func handleCreateReseller(store *Store) http.HandlerFunc { http.Error(w, "invalid json", http.StatusBadRequest) return } - if p.Username == "" { - http.Error(w, "username required", http.StatusBadRequest) + p.Username = strings.TrimSpace(p.Username) + if err := validateAdminUsername(p.Username); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if p.MaxUsers < 0 || p.MaxUsers > 1000000 { + http.Error(w, "max_users must be between 0 and 1000000", http.StatusBadRequest) return } @@ -638,7 +826,16 @@ func handleCreateReseller(store *Store) http.HandlerFunc { } if p.Password != "" { - u.PasswordHash = hashAdminPassword(p.Password) + if err := validateAdminPassword(p.Password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + passwordHash, err := hashAdminPassword(p.Password) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + u.PasswordHash = passwordHash } u.MaxUsers = p.MaxUsers u.IsActive = p.IsActive @@ -658,6 +855,9 @@ func handleCreateReseller(store *Store) http.HandlerFunc { return } adminUsers.set(u) + if p.Password != "" && existing != nil { + sessions.DeleteUser(u.ID) + } if u.Role == RoleReseller { if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) { @@ -676,12 +876,17 @@ func handleDeleteReseller(store *Store) http.HandlerFunc { w.WriteHeader(http.StatusMethodNotAllowed) return } - username := r.URL.Query().Get("username") - if username == "" { - http.Error(w, "username required", http.StatusBadRequest) + username := strings.TrimSpace(r.URL.Query().Get("username")) + if err := validateAdminUsername(username); err != nil { + http.Error(w, "invalid username", http.StatusBadRequest) return } ctx := r.Context() + u, _ := store.GetAdminUserByUsername(ctx, username) + if u != nil && u.Role == RoleSuperAdmin { + http.Error(w, "superadmin accounts cannot be deleted from the reseller endpoint", http.StatusForbidden) + return + } if err := store.DeleteAdminUser(ctx, username); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -689,6 +894,9 @@ func handleDeleteReseller(store *Store) http.HandlerFunc { disconnectOwnerUsers(username) removeOwnerXrayClients(ctx, store, username) adminUsers.delete(username) + if u != nil { + sessions.DeleteUser(u.ID) + } w.WriteHeader(http.StatusNoContent) } } diff --git a/bot_api.go b/bot_api.go index 9dc8251..bd4a5f2 100644 --- a/bot_api.go +++ b/bot_api.go @@ -4,11 +4,73 @@ package main import ( "encoding/json" + "fmt" "net/http" + "net/url" "strconv" "strings" + "time" + "unicode" ) +var botSettingKeys = map[string]int{ + "welcome_text": 4096, + "contact_text": 4096, + "app_text": 4096, + "app_url": 2048, +} + +func botHasControlCharacters(value string) bool { + return strings.IndexFunc(value, func(r rune) bool { + return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' + }) >= 0 +} + +func botHasAnyControlCharacters(value string) bool { + return strings.IndexFunc(value, unicode.IsControl) >= 0 +} + +func validateBotPlan(p *BotPlan) error { + p.Name = strings.TrimSpace(p.Name) + p.Kind = strings.ToLower(strings.TrimSpace(p.Kind)) + p.XrayProtocol = strings.ToLower(strings.TrimSpace(p.XrayProtocol)) + p.XrayInboundTag = strings.TrimSpace(p.XrayInboundTag) + p.ServerID = strings.TrimSpace(p.ServerID) + if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) { + return fmt.Errorf("plan name must contain 1-120 safe characters") + } + if p.Kind != "ssh" && p.Kind != "xray" { + return fmt.Errorf("plan kind must be ssh or xray") + } + if p.Days < 1 || p.Days > 3650 || p.MaxConnections < 0 || p.MaxConnections > 10000 { + return fmt.Errorf("invalid plan duration or connection limit") + } + if p.LimitMbpsUp < 0 || p.LimitMbpsUp > 1000000 || p.LimitMbpsDown < 0 || p.LimitMbpsDown > 1000000 { + return fmt.Errorf("invalid bandwidth limit") + } + if p.PriceCents < 0 || p.PriceCents > 1000000000 || p.CreditCost < 0 || p.CreditCost > 1000000000 { + return fmt.Errorf("invalid plan price or credit cost") + } + if p.Kind == "xray" && p.XrayProtocol != "" && p.XrayProtocol != "vless" && p.XrayProtocol != "vmess" && p.XrayProtocol != "trojan" { + return fmt.Errorf("invalid Xray protocol") + } + if len(p.XrayInboundTag) > 128 || len(p.ServerID) > 128 { + return fmt.Errorf("inbound tag or server id is too long") + } + return nil +} + +func validateBotPackage(p *BotCreditPackage) error { + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) { + return fmt.Errorf("package name must contain 1-120 safe characters") + } + if p.Credits < 1 || p.Credits > 1000000000 || p.PriceCents < 0 || p.PriceCents > 1000000000 { + return fmt.Errorf("invalid package credits or price") + } + return nil +} + func botWriteJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) @@ -80,10 +142,69 @@ func handleBotConfig(store *Store) http.HandlerFunc { }) case http.MethodPost: var dto botConfigDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + r.Body = http.MaxBytesReader(w, r.Body, 64*1024) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&dto); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } + dto.MPConfirmMode = strings.ToLower(strings.TrimSpace(dto.MPConfirmMode)) + if dto.MPConfirmMode != "polling" && dto.MPConfirmMode != "webhook" { + http.Error(w, "confirmation mode must be polling or webhook", http.StatusBadRequest) + return + } + pollInterval, err := time.ParseDuration(strings.TrimSpace(dto.MPPollInterval)) + if err != nil || pollInterval < 5*time.Second || pollInterval > 5*time.Minute { + http.Error(w, "poll interval must be between 5s and 5m", http.StatusBadRequest) + return + } + if dto.PixExpirationMinutes < 5 || dto.PixExpirationMinutes > 1440 || dto.TrialHours < 1 || dto.TrialHours > 720 || dto.TrialMaxConnections < 1 || dto.TrialMaxConnections > 1000 { + http.Error(w, "invalid PIX expiration or trial limits", http.StatusBadRequest) + return + } + dto.TrialKind = strings.ToLower(strings.TrimSpace(dto.TrialKind)) + if dto.TrialKind != "ssh" && dto.TrialKind != "xray" { + http.Error(w, "trial kind must be ssh or xray", http.StatusBadRequest) + return + } + if len(dto.AdminTelegramIDs) > 100 { + http.Error(w, "too many admin Telegram IDs", http.StatusBadRequest) + return + } + for _, id := range dto.AdminTelegramIDs { + if id <= 0 { + http.Error(w, "admin Telegram IDs must be positive", http.StatusBadRequest) + return + } + } + for _, value := range []string{dto.TelegramToken, dto.MPAccessToken, dto.MPWebhookSecret, dto.PublicHost, dto.XrayPublicHost, dto.TrialInboundTag} { + if len(value) > 2048 || botHasAnyControlCharacters(value) { + http.Error(w, "configuration contains an invalid value", http.StatusBadRequest) + return + } + } + existing, err := LoadBotConfig(ctx, store) + if err != nil { + http.Error(w, "load existing config", http.StatusInternalServerError) + return + } + effectiveTelegramToken := strings.TrimSpace(dto.TelegramToken) + if effectiveTelegramToken == "" { + effectiveTelegramToken = existing.TelegramToken + } + if dto.Enabled && effectiveTelegramToken == "" { + http.Error(w, "Telegram token is required before enabling the bot", http.StatusBadRequest) + return + } + effectiveWebhookSecret := strings.TrimSpace(dto.MPWebhookSecret) + if effectiveWebhookSecret == "" { + effectiveWebhookSecret = existing.MPWebhookSecret + } + if dto.MPConfirmMode == "webhook" && len(effectiveWebhookSecret) < 16 { + http.Error(w, "a webhook secret of at least 16 characters is required", http.StatusBadRequest) + return + } cfg := &BotConfig{ Enabled: dto.Enabled, TelegramToken: strings.TrimSpace(dto.TelegramToken), @@ -139,6 +260,10 @@ func handleBotPlans(store *Store) http.HandlerFunc { if p.Kind == "" { p.Kind = "ssh" } + if err := validateBotPlan(&p); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if err := store.UpsertPlan(ctx, &p); err != nil { http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) return @@ -183,6 +308,10 @@ func handleBotCreditPackages(store *Store) http.HandlerFunc { http.Error(w, "invalid json", http.StatusBadRequest) return } + if err := validateBotPackage(&p); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if err := store.UpsertCreditPackage(ctx, &p); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -238,6 +367,21 @@ func handleBotUsers(store *Store) http.HandlerFunc { if req.Role == "" { req.Role = "customer" } + req.Role = strings.ToLower(strings.TrimSpace(req.Role)) + if req.Role != "customer" && req.Role != "reseller" && req.Role != "blocked" { + http.Error(w, "role must be customer, reseller, or blocked", http.StatusBadRequest) + return + } + req.LinkedAdminUsername = strings.TrimSpace(req.LinkedAdminUsername) + if req.Role == "reseller" { + linked, err := store.GetAdminUserByUsername(ctx, req.LinkedAdminUsername) + if err != nil || linked == nil || linked.Role != RoleReseller { + http.Error(w, "linked reseller account not found", http.StatusBadRequest) + return + } + } else { + req.LinkedAdminUsername = "" + } if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -253,6 +397,10 @@ func handleBotUsers(store *Store) http.HandlerFunc { return } case "adjust_credits": + if req.Credits == 0 || req.Credits < -1000000000 || req.Credits > 1000000000 { + http.Error(w, "invalid credit adjustment", http.StatusBadRequest) + return + } if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil { http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest) return @@ -278,8 +426,18 @@ func handleBotTransactions(store *Store) http.HandlerFunc { ctx := r.Context() switch r.Method { case http.MethodGet: - status := r.URL.Query().Get("status") + status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status"))) + if status != "" && status != "pending" && status != "approved" && status != "expired" && status != "refunded" && status != "error" { + http.Error(w, "invalid status", http.StatusBadRequest) + return + } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 200 + } + if limit > 500 { + limit = 500 + } txns, err := store.ListTransactions(ctx, status, limit) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) @@ -342,6 +500,18 @@ func handleBotSettings(store *Store) http.HandlerFunc { return } for k, v := range kv { + maxLen, ok := botSettingKeys[k] + if !ok || len(v) > maxLen || botHasControlCharacters(v) { + http.Error(w, "invalid bot setting", http.StatusBadRequest) + return + } + if k == "app_url" && strings.TrimSpace(v) != "" { + u, err := url.ParseRequestURI(strings.TrimSpace(v)) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + http.Error(w, "app_url must be an http or https URL", http.StatusBadRequest) + return + } + } if err := store.SetSetting(ctx, k, v); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return diff --git a/bot_mercadopago.go b/bot_mercadopago.go index 5386d6d..250ad60 100644 --- a/bot_mercadopago.go +++ b/bot_mercadopago.go @@ -129,7 +129,7 @@ func (c *mpClient) do(ctx context.Context, method, path string, body interface{} // Manifest: "id:;request-id:;ts:;" HMAC-SHA256(secret). func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool { if secret == "" { - return true // validation disabled + return false } var ts, v1 string for _, part := range strings.Split(xSignature, ",") { @@ -147,7 +147,18 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool { if ts == "" || v1 == "" { return false } - manifest := fmt.Sprintf("id:%s;request-id:%s;ts:%s;", strings.ToLower(dataID), xRequestID, ts) + if timestamp, err := strconv.ParseInt(ts, 10, 64); err != nil || timestamp <= 0 { + return false + } + parts := make([]string, 0, 3) + if dataID != "" { + parts = append(parts, "id:"+strings.ToLower(dataID)) + } + if xRequestID != "" { + parts = append(parts, "request-id:"+xRequestID) + } + parts = append(parts, "ts:"+ts) + manifest := strings.Join(parts, ";") + ";" mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(manifest)) expected := hex.EncodeToString(mac.Sum(nil)) @@ -157,6 +168,10 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool { // handleMPWebhook is the public endpoint Mercado Pago calls on payment events. // It never trusts the body: it re-fetches the payment and fulfills idempotently. func handleMPWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } b := currentBot() if b == nil { w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries @@ -173,6 +188,7 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) { if dataID == "" { dataID = r.URL.Query().Get("id") } + signatureDataID := dataID var payload struct { Type string `json:"type"` Action string `json:"action"` @@ -180,7 +196,12 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) { ID json.Number `json:"id"` } `json:"data"` } - body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "invalid webhook body", http.StatusRequestEntityTooLarge) + return + } if len(body) > 0 { _ = json.Unmarshal(body, &payload) if dataID == "" { @@ -191,8 +212,18 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) return } + if len(dataID) > 32 { + w.WriteHeader(http.StatusBadRequest) + return + } + for _, char := range dataID { + if char < '0' || char > '9' { + w.WriteHeader(http.StatusBadRequest) + return + } + } - if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), dataID, b.cfg.MPWebhookSecret) { + if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), signatureDataID, b.cfg.MPWebhookSecret) { log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID) w.WriteHeader(http.StatusUnauthorized) return diff --git a/change_admin_password.sh b/change_admin_password.sh index 24e4641..e236919 100644 --- a/change_admin_password.sh +++ b/change_admin_password.sh @@ -104,18 +104,15 @@ get_env_value() { ' "$ENV_FILE" } -update_env_password() { - local new_password="$1" +remove_legacy_env_password() { local tmp tmp="$(mktemp)" - awk -v line="ADMIN_PASSWORD=${new_password}" ' - BEGIN { done = 0 } - /^ADMIN_PASSWORD=/ { print line; done = 1; next } + awk ' + /^ADMIN_PASSWORD=/ { next } { print } - END { if (!done) print line } ' "$ENV_FILE" > "$tmp" - cat "$tmp" > "$ENV_FILE" - rm -f "$tmp" + install -m 600 "$tmp" "$ENV_FILE" + rm -f -- "$tmp" chmod 600 "$ENV_FILE" 2>/dev/null || true } @@ -135,15 +132,8 @@ generate_password() { hash_password() { local pw="$1" - if command -v sha256sum >/dev/null 2>&1; then - printf '%s' "$pw" | sha256sum | awk '{print $1}' - elif command -v shasum >/dev/null 2>&1; then - printf '%s' "$pw" | shasum -a 256 | awk '{print $1}' - elif command -v openssl >/dev/null 2>&1; then - printf '%s' "$pw" | openssl dgst -sha256 -r | awk '{print $1}' - else - error "No SHA-256 tool found. Install coreutils or openssl." - fi + [[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel" + printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null } PG_DSN="$(get_env_value PG_DSN)" @@ -168,12 +158,12 @@ elif [[ -z "$NEW_PASSWORD" ]]; then fi [[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty." -if [[ ${#NEW_PASSWORD} -lt 8 ]]; then - error "Password must have at least 8 characters." +if [[ ${#NEW_PASSWORD} -lt 10 ]]; then + error "Password must have at least 10 characters." fi PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")" -[[ ${#PASSWORD_HASH} -eq 64 ]] || error "Failed to generate valid SHA-256 password hash." +[[ "$PASSWORD_HASH" == \$2* ]] || error "Failed to generate a valid bcrypt password hash." info "Updating admin user '${ADMIN_USER}' in PostgreSQL..." psql "$PG_DSN" -v ON_ERROR_STOP=1 \ @@ -201,10 +191,8 @@ ON CONFLICT (username) DO UPDATE SET SQL if [[ "$ADMIN_USER" == "admin" ]]; then - update_env_password "$NEW_PASSWORD" - info "Updated ADMIN_PASSWORD inside $ENV_FILE" -else - warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'." + remove_legacy_env_password + info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE" fi if ! $NO_RESTART; then diff --git a/dnstt_integration.go b/dnstt_integration.go index 8f0bdbe..bc69d9b 100644 --- a/dnstt_integration.go +++ b/dnstt_integration.go @@ -1532,7 +1532,6 @@ func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncoded atomic.AddUint64(&dnsttStats.RespSent, 1) atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf))) } - return nil } // computeMaxEncodedPayload computes the maximum amount of downstream TXT RR diff --git a/install.sh b/install.sh index 717ba4e..dabfac9 100644 --- a/install.sh +++ b/install.sh @@ -493,7 +493,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true) if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N) fi -ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}') +ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null) +[[ "$ADMIN_PASSWORD_HASH" == \$2* ]] || error "Failed to generate admin bcrypt password hash" su -c "psql -d ${DB_NAME}" postgres < "$INSTALL_DIR/.env" < '' AND password NOT LIKE 'enc:v1:ssh:%'`) + if err != nil { + return err + } + type legacyPassword struct { + username string + password string + } + var legacy []legacyPassword + for rows.Next() { + var item legacyPassword + if err := rows.Scan(&item.username, &item.password); err != nil { + rows.Close() + return err + } + legacy = append(legacy, item) + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range legacy { + sealed, err := sealSSHPassword(item.password) + if err != nil { + return fmt.Errorf("encrypt SSH password for %s: %w", item.username, err) + } + if _, err := s.db.ExecContext(ctx, `UPDATE ssh_users SET password=$2 WHERE username=$1`, item.username, sealed); err != nil { + return err + } + } return nil } @@ -1391,6 +1434,10 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { &totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil { return nil, err } + password, err = openSSHPassword(password) + if err != nil { + return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err) + } cfg := UserConfig{ Username: username, @@ -1426,7 +1473,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) { // UpsertUser creates or updates a row in ssh_users. func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error { - _, err := s.db.ExecContext(ctx, ` + storedPassword, err := sealSSHPassword(u.Password) + if err != nil { + return fmt.Errorf("encrypt SSH password: %w", err) + } + _, err = s.db.ExecContext(ctx, ` INSERT INTO ssh_users ( username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username @@ -1444,7 +1495,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error { totp_digits = EXCLUDED.totp_digits, allow_static_password = EXCLUDED.allow_static_password`, // owner_username is intentionally excluded from UPDATE — ownership is set at creation only. - u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown, + u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown, u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername) return err } @@ -1634,7 +1685,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) { go func() { log.Printf("Admin HTTP (panel + API) listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + server := &http.Server{ + Addr: addr, + Handler: securePanelHandler(mux), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 2 * time.Minute, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 1 << 20, + } + if err := server.ListenAndServe(); err != nil { log.Printf("admin http error: %v", err) } }() @@ -1756,6 +1816,27 @@ func handleCreateUser(store *Store) http.HandlerFunc { return } if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller { + currentOwner, exists, ownerErr := remoteSSHUserOwner(ctx, ms, p.Username) + if ownerErr != nil { + http.Error(w, "could not verify remote ownership", http.StatusBadGateway) + return + } + if exists && currentOwner != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if !exists { + owner, ok := adminUsers.get(sess.Username) + used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username) + if quotaErr != nil { + http.Error(w, "could not verify reseller quota", http.StatusBadGateway) + return + } + if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { + http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) + return + } + } p.OwnerUsername = sess.Username } p.ServerID = "" @@ -1769,6 +1850,20 @@ func handleCreateUser(store *Store) http.HandlerFunc { return } + sess := sessionFromCtx(ctx) + if sess != nil && sess.Role == RoleReseller { + var existingOwner string + err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, p.Username).Scan(&existingOwner) + if err != nil && err != sql.ErrNoRows { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if err == nil && existingOwner != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + // Decide what password to use: // - if payload has non-empty password -> use it // - else try to read existing password from DB @@ -1802,7 +1897,6 @@ func handleCreateUser(store *Store) http.HandlerFunc { } // Determine owner and enforce reseller quota - sess := sessionFromCtx(ctx) ownerUsername := "" if sess != nil && sess.Role == RoleReseller { ownerUsername = sess.Username @@ -1813,7 +1907,12 @@ func handleCreateUser(store *Store) http.HandlerFunc { ).Scan(&existsInDB) if !existsInDB { owner, ok := adminUsers.get(sess.Username) - if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers { + used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username) + if quotaErr != nil { + http.Error(w, "could not verify reseller quota", http.StatusBadGateway) + return + } + if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) return } @@ -2218,7 +2317,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi return } - target := fmt.Sprintf("%s:%d", req.Host, req.Port) + target := net.JoinHostPort(req.Host, strconv.FormatUint(uint64(req.Port), 10)) log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d", u.Cfg.Username, target, req.OriginAddr, req.OriginPort) @@ -2841,7 +2940,23 @@ func main() { configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)") quietFlag := flag.Bool("quiet", false, "override config and disable logs") userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)") + hashAdminPasswordStdin := flag.Bool("hash-admin-password-stdin", false, "read an admin password from stdin and print a bcrypt hash") flag.Parse() + if *hashAdminPasswordStdin { + password, readErr := io.ReadAll(io.LimitReader(os.Stdin, 1025)) + if readErr != nil { + log.Fatalf("read admin password: %v", readErr) + } + if err := validateAdminPassword(string(password)); err != nil { + log.Fatal(err) + } + hash, hashErr := hashAdminPassword(string(password)) + if hashErr != nil { + log.Fatal(hashErr) + } + fmt.Println(hash) + return + } resolvedConfigPath := resolveMainConfigPath(*configPath) cfg, userMap, err := loadConfig(resolvedConfigPath) diff --git a/managed_servers.go b/managed_servers.go index 7a54706..536065c 100644 --- a/managed_servers.go +++ b/managed_servers.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "net/url" "strconv" @@ -15,6 +16,25 @@ import ( "time" ) +const managedCredentialPrefix = "enc:v1:" + +func sealManagedCredential(plain string) (string, error) { + return sealCredential(managedCredentialPrefix, plain) +} + +func openManagedCredential(stored string) (string, error) { + return openCredential(managedCredentialPrefix, stored) +} + +func managedServerHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + type ManagedServer struct { ID int Name string @@ -66,7 +86,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`) - return err + if err != nil { + return err + } + return s.migrateManagedServerCredentials(ctx) +} + +func (s *Store) migrateManagedServerCredentials(ctx context.Context) error { + rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`) + if err != nil { + return err + } + type legacyCredential struct { + id int + key string + } + var legacy []legacyCredential + for rows.Next() { + var item legacyCredential + if err := rows.Scan(&item.id, &item.key); err != nil { + rows.Close() + return err + } + legacy = append(legacy, item) + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range legacy { + sealed, err := sealManagedCredential(item.key) + if err != nil { + return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err) + } + if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil { + return err + } + } + return nil } func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) { @@ -83,6 +139,11 @@ func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error 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 } + plainKey, err := openManagedCredential(ms.AdminKey) + if err != nil { + return nil, err + } + ms.AdminKey = plainKey out = append(out, ms) } return out, rows.Err() @@ -100,12 +161,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e if err != nil { return nil, err } + plainKey, err := openManagedCredential(ms.AdminKey) + if err != nil { + return nil, err + } + ms.AdminKey = plainKey return ms, nil } func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) { name := strings.TrimSpace(p.Name) - baseURL := normalizeManagedServerBaseURL(p.BaseURL) + baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL) adminUsername := strings.TrimSpace(p.AdminUsername) if adminUsername == "" { adminUsername = "admin" @@ -113,8 +179,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) if name == "" { return nil, fmt.Errorf("server name required") } - if baseURL == "" { - return nil, fmt.Errorf("base url required") + if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return nil, fmt.Errorf("invalid server name") + } + if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return nil, fmt.Errorf("invalid admin username") + } + if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 { + return nil, fmt.Errorf("invalid admin credential") + } + if baseURLErr != nil { + return nil, baseURLErr } if p.ID != "" && p.ID != "local" { id, err := strconv.Atoi(p.ID) @@ -127,10 +202,14 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) 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 { + sealedKey, sealErr := sealManagedCredential(p.AdminKey) + if sealErr != nil { + return nil, fmt.Errorf("encrypt admin credential: %w", sealErr) + } _, 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) + WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive) } if err != nil { return nil, err @@ -140,8 +219,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) if strings.TrimSpace(p.AdminKey) == "" { return nil, fmt.Errorf("admin key/password required") } + sealedKey, err := sealManagedCredential(p.AdminKey) + if err != nil { + return nil, fmt.Errorf("encrypt admin credential: %w", err) + } var id int - err := s.db.QueryRowContext(ctx, ` + 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 @@ -152,7 +235,7 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) 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) + RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id) if err != nil { return nil, err } @@ -193,21 +276,39 @@ func localManagedServerDTO() ManagedServerDTO { } func normalizeManagedServerBaseURL(raw string) string { + normalized, _ := validateManagedServerBaseURL(raw) + return normalized +} + +func validateManagedServerBaseURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { - return "" + return "", fmt.Errorf("base url required") } - if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + lowerRaw := strings.ToLower(raw) + if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") { raw = "http://" + raw } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { - return "" + return "", fmt.Errorf("invalid base url") } - u.Path = strings.TrimRight(u.Path, "/") + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("base url must use http or https") + } + if u.User != nil { + return "", fmt.Errorf("base url must not contain credentials") + } + if u.Path != "" && u.Path != "/" { + return "", fmt.Errorf("base url must not contain a path") + } + if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) { + return "", fmt.Errorf("base url uses a forbidden address") + } + u.Path = "" u.RawQuery = "" u.Fragment = "" - return strings.TrimRight(u.String(), "/") + return strings.TrimRight(u.String(), "/"), nil } func requestedServerID(r *http.Request) string { @@ -250,7 +351,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) { return "", err } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 15 * time.Second} + client := managedServerHTTPClient(15 * time.Second) resp, err := client.Do(req) if err != nil { return "", err @@ -286,7 +387,7 @@ func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path str } req.Header.Set("Content-Type", contentType) req.Header.Set("X-Session-Token", token) - client := &http.Client{Timeout: 30 * time.Second} + client := managedServerHTTPClient(30 * time.Second) resp, err := client.Do(req) if err != nil { return 0, nil, "", err @@ -560,46 +661,119 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc { } } -func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool { - if owner == "" || username == "" { - return false +func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) { + if username == "" { + return "", false, nil } status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json") if err != nil || status < 200 || status >= 300 { - return false + if err == nil { + err = fmt.Errorf("remote users returned HTTP %d", status) + } + return "", false, err } var rows []map[string]interface{} if err := json.Unmarshal(data, &rows); err != nil { - return false + return "", false, err } for _, row := range rows { - if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner { - return true + if fmt.Sprint(row["username"]) == username { + return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil } } - return false + return "", false, nil } -func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool { - if owner == "" || uuid == "" { - return false +func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool { + actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username) + return err == nil && exists && actualOwner == owner +} + +func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) { + if uuid == "" { + return "", false, nil } status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json") if err != nil || status < 200 || status >= 300 { - return false + if err == nil { + err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status) + } + return "", false, err } var inbounds []map[string]interface{} if err := json.Unmarshal(data, &inbounds); err != nil { - return false + return "", false, err } 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 + if fmt.Sprint(m["id"]) == uuid { + return strings.TrimSpace(fmt.Sprint(m["owner_username"])), true, nil } } } - return false + return "", false, nil +} + +func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool { + actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid) + return err == nil && exists && actualOwner == owner +} + +func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) { + if store == nil || owner == "" { + return 0, nil + } + total := countOwnedQuota(ctx, store, owner) + servers, err := store.ListManagedServers(ctx) + if err != nil { + return 0, err + } + for _, ms := range servers { + if !ms.IsActive { + continue + } + if ms.EnableSSH { + status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json") + if err != nil || status < 200 || status >= 300 { + if err == nil { + err = fmt.Errorf("remote users returned HTTP %d", status) + } + return 0, err + } + var users []map[string]interface{} + if err := json.Unmarshal(data, &users); err != nil { + return 0, err + } + for _, user := range users { + if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner { + total++ + } + } + } + if ms.EnableXray { + status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json") + if err != nil || status < 200 || status >= 300 { + if err == nil { + err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status) + } + return 0, err + } + var inbounds []map[string]interface{} + if err := json.Unmarshal(data, &inbounds); err != nil { + return 0, err + } + for _, inbound := range inbounds { + clients, _ := inbound["clients"].([]interface{}) + for _, client := range clients { + item, _ := client.(map[string]interface{}) + if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner { + total++ + } + } + } + } + } + return total, nil } diff --git a/server_config_api.go b/server_config_api.go index 15c3de8..9c044f9 100644 --- a/server_config_api.go +++ b/server_config_api.go @@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "config path not set", http.StatusInternalServerError) return } - body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024)) + body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024+1)) if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } + if len(body) > 512*1024 { + http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge) + return + } var newCfg Config if err := json.Unmarshal(body, &newCfg); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -110,7 +114,7 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) { http.Error(w, "marshal error", http.StatusInternalServerError) return } - if err := os.WriteFile(globalCfgPath, out, 0o644); err != nil { + if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil { http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError) return } diff --git a/tls_api.go b/tls_api.go index a6f67aa..b663371 100644 --- a/tls_api.go +++ b/tls_api.go @@ -4,21 +4,57 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "math/big" + "net" "net/http" + "net/mail" "os" "os/exec" "path/filepath" + "regexp" + "strings" "time" ) const tlsCertsDir = "/opt/sshpanel/certs" +var ( + tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) + tlsStoreNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +) + +func normalizeTLSDomain(raw string, allowWildcard bool) (domain string, dirName string, err error) { + domain = strings.TrimSuffix(strings.TrimSpace(raw), ".") + if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/\\\x00\r\n") { + return "", "", fmt.Errorf("invalid domain") + } + if ip := net.ParseIP(domain); ip != nil { + return domain, strings.ReplaceAll(domain, ":", "_"), nil + } + if strings.HasPrefix(domain, "*.") && !allowWildcard { + return "", "", fmt.Errorf("wildcard domains are not supported by this operation") + } + if !tlsDNSNamePattern.MatchString(domain) { + return "", "", fmt.Errorf("invalid domain") + } + dirName = strings.ReplaceAll(domain, "*", "_wildcard_") + return domain, dirName, nil +} + +func normalizeTLSStoreName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if !tlsStoreNamePattern.MatchString(name) { + return "", fmt.Errorf("name must use only letters, numbers, dot, underscore, or hyphen") + } + return name, nil +} + // handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the // given domain, writes it to /opt/sshpanel/certs//, and returns the paths. func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) { @@ -29,12 +65,18 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) { var req struct { Domain string `json:"domain"` } + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" { http.Error(w, "domain required", http.StatusBadRequest) return } + domain, dirName, err := normalizeTLSDomain(req.Domain, true) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - certDir := filepath.Join(tlsCertsDir, req.Domain) + certDir := filepath.Join(tlsCertsDir, dirName) if err := os.MkdirAll(certDir, 0o700); err != nil { http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError) return @@ -47,40 +89,45 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) { http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError) return } + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + http.Error(w, "serial generation failed", http.StatusInternalServerError) + return + } tmpl := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: req.Domain}, + SerialNumber: serialNumber, + Subject: pkix.Name{CommonName: domain}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{req.Domain}, + DNSNames: []string{domain}, + } + if net.ParseIP(domain) != nil { + tmpl.DNSNames = nil + tmpl.IPAddresses = []net.IP{net.ParseIP(domain)} } der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) if err != nil { http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError) return } - cf, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError) - return - } - _ = pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der}) - cf.Close() - privDER, err := x509.MarshalECPrivateKey(priv) if err != nil { http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError) return } - kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER}) + if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil { + http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError) + return + } + if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil { http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError) return } - _ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER}) - kf.Close() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ @@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) { Domain string `json:"domain"` Email string `json:"email"` } + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" { http.Error(w, "domain and email required", http.StatusBadRequest) return } + domain, _, err := normalizeTLSDomain(req.Domain, false) + if err != nil || net.ParseIP(domain) != nil { + http.Error(w, "a valid DNS domain is required", http.StatusBadRequest) + return + } + email := strings.TrimSpace(req.Email) + parsedEmail, err := mail.ParseAddress(email) + if err != nil || parsedEmail.Address != email || len(email) > 254 { + http.Error(w, "valid email required", http.StatusBadRequest) + return + } cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive", - "--agree-tos", "-m", req.Email, "-d", req.Domain) + "--agree-tos", "-m", email, "-d", domain) out, err := cmd.CombinedOutput() if err != nil { http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError) return } - certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem" - keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem" + certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem" + keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem" w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ @@ -136,13 +195,27 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) { Cert string `json:"cert"` Key string `json:"key"` } + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Cert == "" || req.Key == "" { http.Error(w, "name, cert, and key required", http.StatusBadRequest) return } - name := filepath.Base(req.Name) - if name == "." || name == "/" || name == "" { - http.Error(w, "invalid name", http.StatusBadRequest) + name, err := normalizeTLSStoreName(req.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.Cert) > 1024*1024 || len(req.Key) > 1024*1024 { + http.Error(w, "certificate or key is too large", http.StatusRequestEntityTooLarge) + return + } + pair, err := tls.X509KeyPair([]byte(req.Cert), []byte(req.Key)) + if err != nil || len(pair.Certificate) == 0 { + http.Error(w, "certificate and private key are invalid or do not match", http.StatusBadRequest) + return + } + if _, err := x509.ParseCertificate(pair.Certificate[0]); err != nil { + http.Error(w, "invalid leaf certificate", http.StatusBadRequest) return } certDir := filepath.Join(tlsCertsDir, name) @@ -152,11 +225,11 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) { } certFile := filepath.Join(certDir, "cert.pem") keyFile := filepath.Join(certDir, "key.pem") - if err := os.WriteFile(certFile, []byte(req.Cert), 0o600); err != nil { + if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil { http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError) return } - if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil { + if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil { http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError) return } diff --git a/xray_integration.go b/xray_integration.go index 661ab47..d8ea48f 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -2458,6 +2458,25 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { return } if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller { + _, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID) + if ownerErr != nil { + http.Error(w, "could not verify remote ownership", http.StatusBadGateway) + return + } + if exists { + http.Error(w, "UUID already exists", http.StatusConflict) + return + } + owner, ok := adminUsers.get(sess.Username) + used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username) + if quotaErr != nil { + http.Error(w, "could not verify reseller quota", http.StatusBadGateway) + return + } + if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers { + http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) + return + } req.OwnerUsername = sess.Username } req.ServerID = "" @@ -2491,7 +2510,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) { http.Error(w, "reseller account suspended or expired", http.StatusForbidden) return } - if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers { + used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username) + if quotaErr != nil { + http.Error(w, "could not verify reseller quota", http.StatusBadGateway) + return + } + if owner.MaxUsers > 0 && used >= owner.MaxUsers { http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden) return } diff --git a/xray_native_test.go b/xray_native_test.go index c111dc6..b2321c1 100644 --- a/xray_native_test.go +++ b/xray_native_test.go @@ -1068,18 +1068,30 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) { t.Fatalf("write fast mux frame: %v", err) } - meta, err := readNativeMuxMetadata(conn) - if err != nil { - t.Fatalf("read fast session response meta (head-of-line stall?): %v", err) - } - if meta.sessionID != 2 || meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 { - t.Fatalf("expected session 2 keep-data frame, got: %#v", meta) - } - got, err := readNativeMuxDataBlock(conn) - if err != nil { - t.Fatalf("read fast session payload: %v", err) - } - if !bytes.Equal(got, want) { - t.Fatalf("fast session echo mismatch: got %q want %q", got, want) + for { + meta, err := readNativeMuxMetadata(conn) + if err != nil { + t.Fatalf("read fast session response meta (head-of-line stall?): %v", err) + } + var got []byte + if meta.option&nativeMuxOptionData != 0 { + got, err = readNativeMuxDataBlock(conn) + if err != nil { + t.Fatalf("read mux response payload: %v", err) + } + } + // Some networks reject TEST-NET immediately instead of letting the dial + // time out. Ignore that session-1 error frame and keep waiting for the + // independent fast session. + if meta.sessionID != 2 { + continue + } + if meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 { + t.Fatalf("expected session 2 keep-data frame, got: %#v", meta) + } + if !bytes.Equal(got, want) { + t.Fatalf("fast session echo mismatch: got %q want %q", got, want) + } + break } }