security fix

This commit is contained in:
2026-07-13 00:57:28 -03:00
parent ba5b581aaf
commit 9001b47204
24 changed files with 1706 additions and 677 deletions
+2 -2
View File
@@ -268,7 +268,7 @@ Ou gerar uma senha nova automaticamente:
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate 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 ### 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 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 ### Automatic Git update
+50
View File
@@ -649,3 +649,53 @@ select:disabled {
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;} .update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;} .update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;} .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%;}}
+3 -2
View File
@@ -1,6 +1,8 @@
// ─── State ─────────────────────────────────────────────────────────────────── // ─── 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 currentRole = "";
let currentUser = ""; let currentUser = "";
let statsTimer = null, usersTimer = null, xrayTimer = null; let statsTimer = null, usersTimer = null, xrayTimer = null;
@@ -564,4 +566,3 @@ function patchRenderedInbounds(inbounds) {
} }
return true; return true;
} }
+2 -3
View File
@@ -56,7 +56,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
logoutBtn.addEventListener("click", async () => { logoutBtn.addEventListener("click", async () => {
try { await api("/api/auth/logout", { method: "POST" }); } catch {} try { await api("/api/auth/logout", { method: "POST" }); } catch {}
sessionToken = ""; sessionToken = "";
localStorage.removeItem("SESSION_TOKEN"); sessionStorage.removeItem("SESSION_TOKEN");
clearTimers(); clearTimers();
mainApp.classList.add("hidden"); mainApp.classList.add("hidden");
loginOverlay.classList.remove("hidden"); loginOverlay.classList.remove("hidden");
@@ -83,7 +83,7 @@ async function doLogin() {
sessionToken = data.token; sessionToken = data.token;
currentRole = data.role; currentRole = data.role;
currentUser = data.username; currentUser = data.username;
localStorage.setItem("SESSION_TOKEN", sessionToken); sessionStorage.setItem("SESSION_TOKEN", sessionToken);
loginOverlay.classList.add("hidden"); loginOverlay.classList.add("hidden");
mainApp.classList.remove("hidden"); mainApp.classList.remove("hidden");
initAfterLogin(); initAfterLogin();
@@ -288,4 +288,3 @@ function refreshDashboard() {
if (currentRole === "superadmin") loadStats(); if (currentRole === "superadmin") loadStats();
if (currentRole === "reseller") loadMe(); if (currentRole === "reseller") loadMe();
} }
+30 -22
View File
@@ -161,7 +161,8 @@ function renderInbounds(inbounds, options = {}) {
} }
inboundsContainer.innerHTML = ""; inboundsContainer.innerHTML = "";
lastInboundsStructure = nextStructure; lastInboundsStructure = nextStructure;
inbounds.forEach(ib => { inbounds.forEach((ib, inboundIndex) => {
const formKey = String(inboundIndex);
const section = document.createElement("div"); const section = document.createElement("div");
section.dataset.inboundTag = String(ib.tag || ""); section.dataset.inboundTag = String(ib.tag || "");
section.dataset.inboundProtocol = String(ib.protocol || ""); section.dataset.inboundProtocol = String(ib.protocol || "");
@@ -179,13 +180,18 @@ function renderInbounds(inbounds, options = {}) {
${escapeHTML(ib.tag || "untagged")} ${escapeHTML(ib.tag || "untagged")}
<span class="hint">:${escapeHTML(ib.port ?? "?")}</span> <span class="hint">:${escapeHTML(ib.port ?? "?")}</span>
<span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span> <span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span>
</div> </div>`;
<button class="btn btn-sm" onclick="openAddClient('${ib.tag}')">${t("+ Add Client")}</button>`; 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); section.appendChild(hdr);
// Add client mini-form (hidden by default) // Add client mini-form (hidden by default)
const addForm = document.createElement("div"); const addForm = document.createElement("div");
addForm.id = `add-form-${ib.tag}`; addForm.id = `add-form-${formKey}`;
addForm.className = "hidden"; 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.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
addForm.innerHTML = ` addForm.innerHTML = `
@@ -193,19 +199,22 @@ function renderInbounds(inbounds, options = {}) {
<div class="field"> <div class="field">
<label>UUID</label> <label>UUID</label>
<div class="field-row"> <div class="field-row">
<input id="newUUID-${ib.tag}" placeholder="auto-generate" style="border-radius:6px;"/> <input id="newUUID-${formKey}" placeholder="auto-generate" style="border-radius:6px;"/>
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('newUUID-${ib.tag}').value=genUUID()">Gen</button> <button class="btn btn-ghost btn-sm" id="genUUID-${formKey}" type="button">Gen</button>
</div> </div>
</div> </div>
<div class="field"><label>${t("Email / label")}</label><input id="newEmail-${ib.tag}" placeholder="user@example" style="border-radius:6px;"/></div> <div class="field"><label>${t("Email / label")}</label><input id="newEmail-${formKey}" placeholder="user@example" style="border-radius:6px;"/></div>
<div class="field"><label>${t("Display Name")}</label><input id="newName-${ib.tag}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div> <div class="field"><label>${t("Display Name")}</label><input id="newName-${formKey}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div>
<div class="field"><label>${t("Expiry Date")}</label><input type="datetime-local" id="newExpiry-${ib.tag}" style="border-radius:6px;color-scheme:dark;"/></div> <div class="field"><label>${t("Expiry Date")}</label><input type="datetime-local" id="newExpiry-${formKey}" style="border-radius:6px;color-scheme:dark;"/></div>
<div class="field"><label>${t("Max Connections")} <span class="hint">${t("(0 = unlimited)")}</span></label><input type="number" min="0" id="newMaxConns-${ib.tag}" placeholder="0" style="border-radius:6px;"/></div> <div class="field"><label>${t("Max Connections")} <span class="hint">${t("(0 = unlimited)")}</span></label><input type="number" min="0" id="newMaxConns-${formKey}" placeholder="0" style="border-radius:6px;"/></div>
</div> </div>
<div class="form-actions" style="margin-top:6px;"> <div class="form-actions" style="margin-top:6px;">
<button class="btn btn-sm" onclick="addClient('${ib.tag}')">${t("Add")}</button> <button class="btn btn-sm" id="addClient-${formKey}" type="button">${t("Add")}</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('add-form-${ib.tag}').classList.add('hidden')">${t("Cancel")}</button> <button class="btn btn-ghost btn-sm" id="cancelAddClient-${formKey}" type="button">${t("Cancel")}</button>
</div>`; </div>`;
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); section.appendChild(addForm);
// Clients table // Clients table
@@ -262,19 +271,19 @@ function renderInbounds(inbounds, options = {}) {
}); });
} }
function openAddClient(tag) { function openAddClient(tag, formKey = tag) {
const form = document.getElementById(`add-form-${tag}`); const form = document.getElementById(`add-form-${formKey}`);
if (form) { form.classList.remove("hidden"); } 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(); if (uuidField && !uuidField.value) uuidField.value = genUUID();
} }
async function addClient(tag) { async function addClient(tag, formKey = tag) {
const uuidEl = document.getElementById(`newUUID-${tag}`); const uuidEl = document.getElementById(`newUUID-${formKey}`);
const emailEl = document.getElementById(`newEmail-${tag}`); const emailEl = document.getElementById(`newEmail-${formKey}`);
const nameEl = document.getElementById(`newName-${tag}`); const nameEl = document.getElementById(`newName-${formKey}`);
const expiryEl = document.getElementById(`newExpiry-${tag}`); const expiryEl = document.getElementById(`newExpiry-${formKey}`);
const maxConnsEl = document.getElementById(`newMaxConns-${tag}`); const maxConnsEl = document.getElementById(`newMaxConns-${formKey}`);
const uuid = (uuidEl?.value || "").trim(); const uuid = (uuidEl?.value || "").trim();
const email = (emailEl?.value || "").trim(); const email = (emailEl?.value || "").trim();
const name = (nameEl?.value || "").trim(); const name = (nameEl?.value || "").trim();
@@ -349,4 +358,3 @@ async function loadXrayLogs() {
xLogsBox.scrollTop = xLogsBox.scrollHeight; xLogsBox.scrollTop = xLogsBox.scrollHeight;
} catch (e) { if (e.message==="auth") doAuthError(); } } catch (e) { if (e.message==="auth") doAuthError(); }
} }
+2 -3
View File
@@ -38,13 +38,13 @@ function renderResellers(list) {
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0; const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${r.username}</td> <td>${escapeHTML(r.username)}</td>
<td> <td>
<strong>${used} / ${max || "∞"}</strong> <strong>${used} / ${max || "∞"}</strong>
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div> <div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
<div class="table-meter"><span style="width:${pct}%"></span></div> <div class="table-meter"><span style="width:${pct}%"></span></div>
</td> </td>
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td> <td>${r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td> <td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
<td></td>`; <td></td>`;
const tdA = tr.lastElementChild; const tdA = tr.lastElementChild;
@@ -110,4 +110,3 @@ async function deleteReseller(username) {
else resellerStatus.textContent = "Error deleting."; else resellerStatus.textContent = "Error deleting.";
} }
} }
+4 -5
View File
@@ -91,7 +91,7 @@ async function loadDnsttHealth() {
const a = rows[i]; const a = rows[i];
const b = rows[i + 1] || ["", ""]; const b = rows[i + 1] || ["", ""];
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`; tr.innerHTML = `<td>${escapeHTML(a[0])}</td><td>${escapeHTML(fmtInt(a[1]))}</td><td>${escapeHTML(b[0])}</td><td>${b[0] ? escapeHTML(fmtInt(b[1])) : ""}</td>`;
dnsttHealthBody.appendChild(tr); dnsttHealthBody.appendChild(tr);
} }
} }
@@ -135,7 +135,7 @@ async function loadStats() {
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0); totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
if (!ifaceBody) return; if (!ifaceBody) return;
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = `<td>${it.name}</td><td>${fmtMbps(it.rx_mbps)}</td><td>${fmtMbps(it.tx_mbps)}</td><td>${fmtBytes(it.rx_bytes)}</td><td>${fmtBytes(it.tx_bytes)}</td>`; tr.innerHTML = `<td>${escapeHTML(it.name)}</td><td>${escapeHTML(fmtMbps(it.rx_mbps))}</td><td>${escapeHTML(fmtMbps(it.tx_mbps))}</td><td>${escapeHTML(fmtBytes(it.rx_bytes))}</td><td>${escapeHTML(fmtBytes(it.tx_bytes))}</td>`;
ifaceBody.appendChild(tr); ifaceBody.appendChild(tr);
}); });
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
@@ -175,13 +175,13 @@ function renderVnstatRows(body, rows, emptyLabel) {
body.innerHTML = ""; body.innerHTML = "";
if (!rows.length) { if (!rows.length) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = `<td colspan="5" class="hint">${emptyLabel}</td>`; tr.innerHTML = `<td colspan="5" class="hint">${escapeHTML(emptyLabel)}</td>`;
body.appendChild(tr); body.appendChild(tr);
return; return;
} }
rows.forEach(r => { rows.forEach(r => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = `<td>${r.period || "--"}</td><td>${r.iface || "--"}</td><td>${fmtBytes(r.rx_bytes||0)}</td><td>${fmtBytes(r.tx_bytes||0)}</td><td>${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}</td>`; tr.innerHTML = `<td>${escapeHTML(r.period || "--")}</td><td>${escapeHTML(r.iface || "--")}</td><td>${escapeHTML(fmtBytes(r.rx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.tx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))}</td>`;
body.appendChild(tr); body.appendChild(tr);
}); });
} }
@@ -273,4 +273,3 @@ async function clearPanelLog() {
else st.textContent = "Error cleaning panel log: " + e.message; else st.textContent = "Error cleaning panel log: " + e.message;
} }
} }
+2 -3
View File
@@ -263,8 +263,8 @@ function renderTLSForwarders() {
tlsForwardersState.forEach((fw, i) => { tlsForwardersState.forEach((fw, i) => {
const row = document.createElement("div"); 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.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
row.innerHTML = `<span style="flex:1;font-family:monospace;">${fw.listen}</span> row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
<span class="hint">${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}</span>`; <span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
const delBtn = document.createElement("button"); const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger btn-sm"; delBtn.className = "btn btn-danger btn-sm";
delBtn.textContent = "Remove"; delBtn.textContent = "Remove";
@@ -446,4 +446,3 @@ async function loadDnsttPubkey() {
else st.textContent = "Error: " + e.message; else st.textContent = "Error: " + e.message;
} }
} }
+3 -4
View File
@@ -113,9 +113,9 @@ function renderWzInbounds() {
const sec = ss.security || ""; const sec = ss.security || "";
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : ""; const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : ""; const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
row.innerHTML = `<span class="chip">${ib.protocol}</span> row.innerHTML = `<span class="chip">${escapeHTML(ib.protocol || "")}</span>
<span style="font-family:monospace;">${ib.tag||"untagged"}${portStr}</span> <span style="font-family:monospace;">${escapeHTML((ib.tag||"untagged")+portStr)}</span>
<span class="hint" style="flex:1;">${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}</span>`; <span class="hint" style="flex:1;">${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)}</span>`;
const clients = ib.settings?.clients; const clients = ib.settings?.clients;
if (Array.isArray(clients) && clients.length) { if (Array.isArray(clients) && clients.length) {
const badge = document.createElement("span"); const badge = document.createElement("span");
@@ -392,4 +392,3 @@ async function applyWizardConfig() {
else if (st) st.textContent = "Error: " + e.message; else if (st) st.textContent = "Error: " + e.message;
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// ─── Auth error ─────────────────────────────────────────────────────────────── // ─── Auth error ───────────────────────────────────────────────────────────────
function doAuthError() { function doAuthError() {
sessionToken = ""; sessionToken = "";
localStorage.removeItem("SESSION_TOKEN"); sessionStorage.removeItem("SESSION_TOKEN");
clearTimers(); clearTimers();
mainApp.classList.add("hidden"); mainApp.classList.add("hidden");
loginOverlay.classList.remove("hidden"); loginOverlay.classList.remove("hidden");
+547 -272
View File
@@ -1,340 +1,615 @@
// ─── Bot / Vendas (Telegram + Mercado Pago) ───────────────────────────────── // Bot / Vendas — safe DOM rendering and sectioned management workspace.
// Superadmin-only tab. Uses the shared api() helper from 01-core.js.
function botEsc(s) { const botState = {
return String(s == null ? "" : s) config: null,
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") plans: [],
.replace(/"/g, "&quot;").replace(/'/g, "&#39;"); packages: [],
} users: [],
function botStatus(id, msg, ok) { transactions: [],
const el = document.getElementById(id); section: sessionStorage.getItem("BOT_SECTION") || "config",
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(".", ",");
}
async function loadBotTab() { function botStatus(id, message, ok) {
loadBotInbounds(); const element = document.getElementById(id);
const sel = document.getElementById("botSection"); if (!element) return;
botShowSection(sel ? sel.value : "config"); element.textContent = message;
} if (id === "botConfigStatus") {
element.classList.toggle("is-ok", ok === true);
// Show one section at a time and lazy-load its data. element.classList.toggle("is-error", ok === false);
function botShowSection(name) { } else {
document.querySelectorAll("#tab-bot .bot-section").forEach(s => { s.style.display = "none"; }); element.style.color = ok === false ? "var(--danger)" : "";
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;
} }
} }
// ─── 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() { async function loadBotConfig() {
try { try {
const res = await api("/api/bot/config"); const config = await botRequest("/api/bot/config");
const c = await res.json(); botState.config = config;
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; }; const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; }; const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
chk("botEnabled", c.enabled); setChecked("botEnabled", config.enabled);
set("botMPConfirmMode", c.mp_confirm_mode); setValue("botMPConfirmMode", config.mp_confirm_mode);
set("botMPPollInterval", c.mp_poll_interval); setValue("botMPPollInterval", config.mp_poll_interval);
set("botPixExp", c.pix_expiration_minutes); setValue("botPixExp", config.pix_expiration_minutes);
chk("botTrialEnabled", c.trial_enabled); setChecked("botTrialEnabled", config.trial_enabled);
set("botTrialHours", c.trial_hours); setValue("botTrialHours", config.trial_hours);
set("botTrialMaxConns", c.trial_max_connections); setValue("botTrialMaxConns", config.trial_max_connections);
set("botTrialKind", c.trial_kind); setValue("botTrialKind", config.trial_kind);
set("botTrialInbound", c.trial_inbound_tag); setValue("botTrialInbound", config.trial_inbound_tag);
set("botAdminIDs", (c.admin_telegram_ids || []).join(",")); setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
set("botPublicHost", c.public_host); setValue("botPublicHost", config.public_host);
set("botXrayPublicHost", c.xray_public_host); setValue("botXrayPublicHost", config.xray_public_host);
const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; }; botSetSecretState("botHasTgToken", config.has_telegram_token);
hint("botHasTgToken", c.has_telegram_token); botSetSecretState("botHasMpToken", config.has_mp_access_token);
hint("botHasMpToken", c.has_mp_access_token); botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
hint("botHasMpSecret", c.has_mp_webhook_secret);
botToggleMPWebhookBox(); botToggleMPWebhookBox();
botStatus("botConfigStatus", "Carregado."); botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); } 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() { function botToggleMPWebhookBox() {
const mode = document.getElementById("botMPConfirmMode")?.value; const mode = document.getElementById("botMPConfirmMode")?.value;
const box = document.getElementById("botMPWebhookBox"); document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
if (box) box.style.display = mode === "webhook" ? "" : "none";
const url = document.getElementById("botMPWebhookURL"); const url = document.getElementById("botMPWebhookURL");
if (url) url.textContent = location.origin + "/api/mp/webhook"; if (url) url.textContent = location.origin + "/api/mp/webhook";
} }
async function saveBotConfig() { async function saveBotConfig() {
const val = id => (document.getElementById(id)?.value || "").trim(); const value = id => (document.getElementById(id)?.value || "").trim();
const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0; const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
const chk = id => !!document.getElementById(id)?.checked; const checked = id => !!document.getElementById(id)?.checked;
const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
const body = { const payload = {
enabled: chk("botEnabled"), enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
telegram_token: val("botTelegramToken"), mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
mp_access_token: val("botMPToken"), mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
mp_confirm_mode: val("botMPConfirmMode"), pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
mp_webhook_secret: val("botMPWebhookSecret"), trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
mp_poll_interval: val("botMPPollInterval"), trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
pix_expiration_minutes: num("botPixExp"), admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
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"),
}; };
botStatus("botConfigStatus", "Salvando e reiniciando…");
try { try {
await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) }); await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; }); ["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
botStatus("botConfigStatus", "Configuração salva e bot reiniciado."); await loadBotConfig();
loadBotConfig(); botStatus("botConfigStatus", "Configuração salva", true);
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); } } catch (error) {
botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
}
} }
async function testBot() { async function testBot() {
botStatus("botConfigStatus", "Testando..."); botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
const body = { const payload = {
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(), telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(), mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
}; };
try { try {
const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) }); const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
const r = await res.json(); const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`; const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`; botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok); } catch (error) {
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); } 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() { async function loadBotInbounds() {
try { try {
const res = await api("/api/xray/inbounds"); const inbounds = await botRequest("/api/xray/inbounds");
const list = await res.json(); const datalist = document.getElementById("botInboundList");
const dl = document.getElementById("botInboundList"); if (!datalist) return;
if (dl) dl.innerHTML = (list || []).map(ib => `<option value="${botEsc(ib.tag)}">${botEsc(ib.protocol)}</option>`).join(""); datalist.replaceChildren(...(inbounds || []).map(inbound => {
} catch (e) { /* xray may be off; ignore */ } 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() { async function loadBotPlans() {
try { try {
const res = await api("/api/bot/plans"); const plans = await botRequest("/api/bot/plans");
const plans = await res.json() || []; botState.plans = plans || [];
document.getElementById("botPlanCount").textContent = plans.length; renderBotPlans(botState.plans);
document.getElementById("botPlansBody").innerHTML = plans.map(p => ` botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
<tr> botUpdateMetrics();
<td>${botEsc(p.Name)}</td><td>${botEsc(p.Kind)}</td><td>${p.Days}</td> return plans;
<td>${brl(p.PriceCents)}</td><td>${p.CreditCost}</td> } catch (error) {
<td>${p.IsActive ? "✅" : "—"}</td> botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
<td> }
<button class="btn btn-ghost btn-sm" onclick='botEditPlan(${JSON.stringify(p)})'>Editar</button>
<button class="btn btn-ghost btn-sm" onclick='botDeletePlan(${p.ID})'>Excluir</button>
</td>
</tr>`).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); }
} }
// ─── 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() { async function loadBotPkgs() {
try { try {
const res = await api("/api/bot/credit-packages"); const packages = await botRequest("/api/bot/credit-packages");
const pkgs = await res.json() || []; botState.packages = packages || [];
document.getElementById("botPkgCount").textContent = pkgs.length; renderBotPackages(botState.packages);
document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => ` botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
<tr> return packages;
<td>${botEsc(p.Name)}</td><td>${p.Credits}</td><td>${brl(p.PriceCents)}</td> } catch (error) {
<td>${p.IsActive ? "✅" : "—"}</td> botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
<td> }
<button class="btn btn-ghost btn-sm" onclick='botEditPkg(${JSON.stringify(p)})'>Editar</button>
<button class="btn btn-ghost btn-sm" onclick='botDeletePkg(${p.ID})'>Excluir</button>
</td>
</tr>`).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); }
} }
// ─── 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() { async function loadBotUsers() {
try { try {
const res = await api("/api/bot/users"); const users = await botRequest("/api/bot/users");
const users = await res.json() || []; botState.users = users || [];
document.getElementById("botUserCount").textContent = users.length; renderBotUsers(botState.users);
document.getElementById("botUsersBody").innerHTML = users.map(u => ` botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
<tr> botUpdateMetrics();
<td>${u.TelegramID}</td><td>${botEsc(u.FirstName)}</td><td>${botEsc(u.Username)}</td> return users;
<td>${botEsc(u.Role)}</td><td>${botEsc(u.LinkedAdminUsername)}</td><td>${u.CreditBalance}</td> } catch (error) {
<td> botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
<button class="btn btn-ghost btn-sm" onclick='botPromote(${u.TelegramID})'>Papel</button> }
<button class="btn btn-ghost btn-sm" onclick='botCredit(${u.TelegramID})'>Créditos</button>
<button class="btn btn-ghost btn-sm" onclick='botBlock(${u.TelegramID}, ${u.Role === "blocked"})'>${u.Role === "blocked" ? "Desbloq." : "Bloquear"}</button>
</td>
</tr>`).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); }
} }
// ─── 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() { async function loadBotTxns() {
const filter = document.getElementById("botTxnFilter")?.value || ""; const filter = document.getElementById("botTxnFilter")?.value || "";
try { try {
const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter)); const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
const txns = await res.json() || []; botState.transactions = transactions || [];
document.getElementById("botTxnCount").textContent = txns.length; renderBotTransactions(botState.transactions);
document.getElementById("botTxnsBody").innerHTML = txns.map(t => ` botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
<tr> botUpdateMetrics();
<td>${t.ID}</td><td>${t.TelegramID}</td><td>${botEsc(t.Type)}</td><td>${brl(t.AmountCents)}</td> return transactions;
<td>${botEsc(t.Status)}</td><td>${botEsc(t.TargetUsername)}</td> } catch (error) {
<td>${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))}</td> botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
<td> }
${t.Status === "pending" || t.Status === "approved" ? `<button class="btn btn-ghost btn-sm" onclick='botReprocess(${t.ID})'>Reprocessar</button>` : ""}
${t.Status !== "refunded" ? `<button class="btn btn-ghost btn-sm" onclick='botRefund(${t.ID})'>Estornar</button>` : ""}
</td>
</tr>`).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); }
} }
// ─── 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() { async function loadBotSettings() {
try { try {
const res = await api("/api/bot/settings"); const settings = await botRequest("/api/bot/settings");
const s = await res.json() || {}; const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; }; set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
set("setWelcome", s.welcome_text); set("setContact", s.contact_text); set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
set("setAppText", s.app_text); set("setAppUrl", s.app_url); botStatus("botSettingsStatus", "Mensagens carregadas.", true);
botStatus("botSettingsStatus", "Ready."); return settings;
} catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); } } catch (error) {
} botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
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); }
} }
// ─── 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("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig); document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
document.getElementById("botTestBtn")?.addEventListener("click", testBot); document.getElementById("botTestBtn")?.addEventListener("click", testBot);
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox); 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("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm); document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan); document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs); document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm); document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg); document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers); document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns); document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns); document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings); document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings); 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);
+134 -232
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260713bot3"/> <link rel="stylesheet" href="assets/app.css?v=20260713secure1"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -951,234 +951,136 @@
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ --> <!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
<div class="tab-pane" id="tab-bot"> <div class="tab-pane" id="tab-bot">
<section class="bot-hero">
<!-- Section switcher --> <div class="bot-hero-copy">
<div class="card"> <span class="bot-eyebrow">Central de automação</span>
<div class="card-hdr" style="align-items:center;"> <h2>Bot de vendas</h2>
<div class="card-title">🤖 Bot / Vendas</div> <p>Telegram, PIX, planos e clientes em um único fluxo.</p>
<select id="botSection" class="btn-sm" style="min-width:230px;font-weight:600;">
<option value="config">⚙️ Configuração</option>
<option value="plans">💎 Planos</option>
<option value="packages">💳 Pacotes de Crédito</option>
<option value="messages">✉️ Mensagens</option>
<option value="users">👥 Clientes</option>
<option value="transactions">💰 Pagamentos</option>
</select>
</div> </div>
<div class="bot-hero-actions">
<span id="botConfigStatus" class="bot-live-status">Aguardando dados</span>
<button class="btn btn-ghost btn-sm" id="botTestBtn" type="button">Testar integrações</button>
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn" type="button">Atualizar tudo</button>
</div>
<div class="bot-overview-grid">
<article class="bot-overview-card"><span class="bot-overview-icon"></span><div><small>Operação</small><strong id="botMetricState">Carregando</strong></div></article>
<article class="bot-overview-card"><span class="bot-overview-icon bot-purple"></span><div><small>Planos</small><strong id="botMetricPlans">0</strong></div></article>
<article class="bot-overview-card"><span class="bot-overview-icon bot-green"></span><div><small>Clientes</small><strong id="botMetricUsers">0</strong></div></article>
<article class="bot-overview-card"><span class="bot-overview-icon bot-amber">$</span><div><small>PIX pendentes</small><strong id="botMetricPending">0</strong></div></article>
</div>
</section>
<div class="bot-nav-shell">
<nav class="bot-section-nav" id="botSectionNav" aria-label="Áreas do bot">
<button class="active" type="button" data-bot-section="config"><span></span> Integrações</button>
<button type="button" data-bot-section="plans"><span></span> Planos</button>
<button type="button" data-bot-section="packages"><span></span> Créditos</button>
<button type="button" data-bot-section="messages"><span></span> Mensagens</button>
<button type="button" data-bot-section="users"><span></span> Clientes</button>
<button type="button" data-bot-section="transactions"><span>$</span> Pagamentos</button>
</nav>
<select id="botSection" class="bot-section-select" aria-label="Área do bot">
<option value="config">Integrações</option><option value="plans">Planos</option><option value="packages">Créditos</option>
<option value="messages">Mensagens</option><option value="users">Clientes</option><option value="transactions">Pagamentos</option>
</select>
</div> </div>
<!-- ═══ Section: Config ═══ --> <section class="bot-section active" id="botSec-config" data-bot-panel="config">
<div class="bot-section" id="botSec-config"> <div class="bot-section-heading"><div><span>01 · Integrações</span><h3>Conecte os serviços</h3><p>Tokens ficam criptografados. Campos vazios mantêm o valor atual.</p></div><button class="btn btn-sm" id="botConfigSaveBtn" type="button">Salvar e reiniciar bot</button></div>
<!-- Config: sticky action bar --> <div class="bot-config-grid">
<div class="card"> <article class="card bot-integration-card">
<div class="card-hdr"> <div class="bot-card-heading"><div class="bot-service-icon telegram"></div><div><h4>Telegram</h4><p>Canal de atendimento e entrega</p></div><label class="bot-switch"><input id="botEnabled" type="checkbox"/><span></span></label></div>
<div class="card-title">⚙️ Configuração do Bot</div> <div class="field"><label>Token do @BotFather <span class="bot-secret-state" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="Em branco para manter o token salvo"/></div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;"> <div class="field"><label>IDs dos administradores</label><input id="botAdminIDs" inputmode="numeric" placeholder="111111111, 222222222"/><span class="hint">Separe mais de um ID por vírgula.</span></div>
<span id="botConfigStatus" class="hint">Pronto.</span> </article>
<button class="btn btn-ghost btn-sm" id="botTestBtn">Testar conexão</button>
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn">Recarregar</button>
<button class="btn btn-sm" id="botConfigSaveBtn">Salvar configuração</button>
</div>
</div>
<p class="hint" style="margin:2px 0 0;">1) Cole os tokens abaixo e clique <b>Testar</b> · 2) Crie os <b>Planos</b> · 3) Promova revendedores em <b>Clientes</b>. O Telegram funciona por long-polling — não precisa de domínio.</p>
</div>
<div class="grid2"> <article class="card bot-integration-card">
<!-- Telegram --> <div class="bot-card-heading"><div class="bot-service-icon mercado">MP</div><div><h4>Mercado Pago</h4><p>Recebimentos PIX automáticos</p></div></div>
<div class="card"> <div class="field"><label>Access Token <span class="bot-secret-state" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-… (vazio mantém o atual)"/></div>
<div class="card-hdr"><div class="card-title">✈️ Telegram</div></div>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
<input id="botEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Bot ativo</span>
</label>
<div class="field"><label>Token do bot <span class="hint" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="cole o token do @BotFather (em branco = manter)"/></div>
<div class="field"><label>IDs de admin <span class="hint">separados por vírgula</span></label><input id="botAdminIDs" placeholder="111111111,222222222"/></div>
</div>
<!-- Mercado Pago -->
<div class="card">
<div class="card-hdr"><div class="card-title">💠 Mercado Pago (PIX)</div></div>
<div class="field"><label>Access Token <span class="hint" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-... (em branco = manter)"/></div>
<div class="form-grid">
<div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling (sem domínio)</option><option value="webhook">Webhook</option></select></div>
<div class="field"><label>Intervalo do polling</label><input id="botMPPollInterval" placeholder="20s"/></div>
<div class="field"><label>Expiração do PIX (min)</label><input id="botPixExp" type="number" min="1" placeholder="30"/></div>
</div>
<div id="botMPWebhookBox" style="display:none;margin-top:6px;">
<div class="field"><label>Webhook Secret <span class="hint" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="(em branco = manter)"/></div>
<p class="hint" style="margin:4px 0 0;">No Mercado Pago, cadastre a URL de notificação:<br><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code></p>
</div>
</div>
</div>
<div class="grid2">
<!-- Trial -->
<div class="card">
<div class="card-hdr"><div class="card-title">⏳ Teste Grátis</div></div>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:12px;cursor:pointer;">
<input id="botTrialEnabled" type="checkbox" style="width:16px;height:16px;"/> <span>Oferecer teste grátis</span>
</label>
<div class="form-grid">
<div class="field"><label>Duração (horas)</label><input id="botTrialHours" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" placeholder="1"/></div>
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
<div class="field"><label>Inbound (se Xray)</label><input id="botTrialInbound" list="botInboundList" placeholder="tag"/></div>
</div>
</div>
<!-- Delivery host -->
<div class="card">
<div class="card-hdr"><div class="card-title">🌐 Host de entrega</div></div>
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="seu.dominio ou IP público"/></div>
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="seu.dominio (vless/vmess)"/></div>
<p class="hint" style="margin:4px 0 0;">Enviado ao cliente nas credenciais após o pagamento.</p>
</div>
</div>
</div><!-- /botSec-config -->
<!-- ═══ Section: Plans ═══ -->
<div class="bot-section" id="botSec-plans" style="display:none;">
<div class="grid2">
<!-- Plans -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💎 Planos <span class="chip" id="botPlanCount">0</span></div>
<div style="display:flex;gap:5px;">
<button class="btn btn-ghost btn-sm" id="botNewPlanBtn">+ Novo</button>
<button class="btn btn-ghost btn-sm" id="botReloadPlansBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Nome</th><th>Tipo</th><th>Dias</th><th>Preço</th><th>Créd.</th><th>Ativo</th><th>Ações</th></tr></thead>
<tbody id="botPlansBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botPlansStatus">Ready.</span></div>
</div>
<!-- Plan form -->
<div class="card">
<div class="card-hdr">
<div class="card-title" id="botPlanFormTitle">Novo Plano</div>
<button class="btn btn-ghost btn-sm" id="botCancelPlanBtn">Limpar</button>
</div>
<form id="botPlanForm">
<input type="hidden" id="planId"/>
<div class="form-grid"> <div class="form-grid">
<div class="field"><label>Nome</label><input id="planName" required/></div> <div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling — recomendado</option><option value="webhook">Webhook assinado</option></select></div>
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div> <div class="field"><label>Intervalo</label><input id="botMPPollInterval" value="20s" placeholder="20s"/></div>
<div class="field"><label>Dias de validade</label><input id="planDays" type="number" min="1" value="30"/></div> <div class="field"><label>Validade do PIX</label><div class="bot-input-suffix"><input id="botPixExp" type="number" min="5" max="1440" value="30"/><span>min</span></div></div>
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" value="1"/></div>
<div class="field"><label>Limite Mbps ↑ (SSH)</label><input id="planUpMbps" type="number" min="0" value="0"/></div>
<div class="field"><label>Limite Mbps ↓ (SSH)</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag (se Xray)"/><datalist id="botInboundList"></datalist></div>
<div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">-</option><option value="vless">vless</option><option value="vmess">vmess</option><option value="trojan">trojan</option></select></div>
<div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div>
<div class="field"><label>Server ID <span class="hint">(vazio=local)</span></label><input id="planServerId"/></div>
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div>
<div class="field"><label>Ativo</label><input id="planActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
</div> </div>
<div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div> <div id="botMPWebhookBox" class="bot-webhook-box hidden">
<div class="field"><label>Webhook Secret <span class="bot-secret-state" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="Mínimo de 16 caracteres"/></div>
<div class="bot-copy-row"><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code><button class="btn btn-ghost btn-sm" id="botCopyWebhookBtn" type="button">Copiar</button></div>
</div>
</article>
<article class="card bot-integration-card">
<div class="bot-card-heading"><div class="bot-service-icon trial"></div><div><h4>Teste grátis</h4><p>Entrada controlada para novos clientes</p></div><label class="bot-switch"><input id="botTrialEnabled" type="checkbox"/><span></span></label></div>
<div class="form-grid">
<div class="field"><label>Duração</label><div class="bot-input-suffix"><input id="botTrialHours" type="number" min="1" max="720" value="1"/><span>h</span></div></div>
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" max="1000" value="1"/></div>
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
<div class="field"><label>Inbound Xray</label><input id="botTrialInbound" list="botInboundList" placeholder="Somente para Xray"/></div>
</div>
</article>
<article class="card bot-integration-card">
<div class="bot-card-heading"><div class="bot-service-icon host"></div><div><h4>Entrega</h4><p>Endereços enviados após a compra</p></div></div>
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="vpn.seudominio.com ou IP"/></div>
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="xray.seudominio.com"/></div>
<div class="bot-note">Esses endereços aparecem nas credenciais entregues ao cliente.</div>
</article>
</div>
</section>
<section class="bot-section" id="botSec-plans" data-bot-panel="plans">
<div class="bot-section-heading"><div><span>02 · Catálogo</span><h3>Planos de acesso</h3><p>Configure preço, validade, protocolo e limites.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPlansBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPlanBtn" type="button">Novo plano</button></div></div>
<div class="bot-master-detail">
<article class="card"><div class="bot-list-heading"><strong>Planos cadastrados</strong><span class="chip" id="botPlanCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Plano</th><th>Entrega</th><th>Validade</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPlansBody"></tbody></table></div><div class="statusbar"><span id="botPlansStatus">Aguardando.</span></div></article>
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title" id="botPlanFormTitle">Novo plano</div><p class="hint">Defina como a conta será criada.</p></div><button class="btn btn-ghost btn-sm" id="botCancelPlanBtn" type="button">Limpar</button></div>
<form id="botPlanForm"><input type="hidden" id="planId"/><div class="form-grid">
<div class="field bot-span-2"><label>Nome do plano</label><input id="planName" maxlength="120" required placeholder="Ex.: Premium 30 dias"/></div>
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div><div class="field"><label>Dias</label><input id="planDays" type="number" min="1" max="3650" value="30"/></div>
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" max="10000" value="1"/></div><div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
<div class="field"><label>Upload Mbps</label><input id="planUpMbps" type="number" min="0" value="0"/></div><div class="field"><label>Download Mbps</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag do inbound"/><datalist id="botInboundList"></datalist></div><div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">Automático</option><option value="vless">VLESS</option><option value="vmess">VMess</option><option value="trojan">Trojan</option></select></div>
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div><div class="field"><label>Servidor</label><input id="planServerId" placeholder="Vazio = local"/></div>
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div><label class="bot-check-field"><input id="planActive" type="checkbox" checked/><span>Plano visível no bot</span></label>
</div><div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div></form>
</article>
</div>
</section>
<section class="bot-section" id="botSec-packages" data-bot-panel="packages">
<div class="bot-section-heading"><div><span>03 · Revendedores</span><h3>Pacotes de créditos</h3><p>Crie recargas para revendedores comprarem planos.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPkgBtn" type="button">Novo pacote</button></div></div>
<div class="bot-master-detail">
<article class="card"><div class="bot-list-heading"><strong>Pacotes cadastrados</strong><span class="chip" id="botPkgCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pacote</th><th>Créditos</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPkgsBody"></tbody></table></div><div class="statusbar"><span id="botPkgStatus">Aguardando.</span></div></article>
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title">Editar pacote</div><p class="hint">Defina o saldo entregue e o preço PIX.</p></div></div><form id="botPkgForm"><input type="hidden" id="pkgId"/><div class="form-grid">
<div class="field bot-span-2"><label>Nome</label><input id="pkgName" maxlength="120" required placeholder="Ex.: Recarga 50 créditos"/></div><div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div><div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div><div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div><label class="bot-check-field"><input id="pkgActive" type="checkbox" checked/><span>Pacote visível</span></label>
</div><div class="form-actions"><button class="btn" type="submit">Salvar pacote</button><button class="btn btn-ghost" id="botClearPkgBtn" type="button">Limpar</button></div></form></article>
</div>
</section>
<section class="bot-section" id="botSec-messages" data-bot-panel="messages">
<div class="bot-section-heading"><div><span>04 · Conteúdo</span><h3>Mensagens do bot</h3><p>Personalize os textos vistos pelo cliente.</p></div><button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn" type="button">Restaurar salvo</button></div>
<article class="card bot-message-editor"><div class="bot-message-grid"><div class="field"><label>Mensagem de boas-vindas</label><textarea id="setWelcome" rows="7" maxlength="4096" placeholder="Olá, {name}! Escolha uma opção abaixo."></textarea><span class="hint">Use <code>{name}</code> para inserir o primeiro nome.</span></div><div class="field"><label>Contato e suporte</label><textarea id="setContact" rows="7" maxlength="4096" placeholder="Explique como falar com o suporte."></textarea></div><div class="field"><label>Texto para baixar o app</label><textarea id="setAppText" rows="5" maxlength="4096" placeholder="Baixe nosso aplicativo para conectar."></textarea></div><div class="field"><label>Link do aplicativo</label><input id="setAppUrl" type="url" maxlength="2048" placeholder="https://..."/><span class="hint">Somente links HTTP ou HTTPS.</span></div></div><div class="bot-save-row"><span id="botSettingsStatus" class="hint">Aguardando.</span><button class="btn" id="botSaveSettingsBtn" type="button">Salvar mensagens</button></div></article>
</section>
<section class="bot-section" id="botSec-users" data-bot-panel="users">
<div class="bot-section-heading"><div><span>05 · Relacionamento</span><h3>Clientes do Telegram</h3><p>Gerencie funções, vínculos e saldo sem sair da lista.</p></div><button class="btn btn-ghost btn-sm" id="botReloadUsersBtn" type="button">Atualizar clientes</button></div>
<article class="card"><div class="bot-list-heading"><div><strong>Base de clientes</strong><span class="hint"> Ações administrativas são registradas no livro de créditos.</span></div><span class="chip" id="botUserCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Cliente</th><th>Telegram ID</th><th>Função</th><th>Revenda vinculada</th><th>Saldo</th><th></th></tr></thead><tbody id="botUsersBody"></tbody></table></div><div class="statusbar"><span id="botUsersStatus">Aguardando.</span></div></article>
</section>
<section class="bot-section" id="botSec-transactions" data-bot-panel="transactions">
<div class="bot-section-heading"><div><span>06 · Financeiro</span><h3>Pagamentos PIX</h3><p>Acompanhe confirmações e reprocessamentos.</p></div><div class="card-actions"><select id="botTxnFilter" class="input-sm"><option value="">Todos os status</option><option value="pending">Pendentes</option><option value="approved">Aprovados</option><option value="expired">Expirados</option><option value="refunded">Marcados como estornados</option><option value="error">Com erro</option></select><button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn" type="button">Atualizar</button></div></div>
<article class="card"><div class="bot-list-heading"><strong>Histórico recente</strong><span class="chip" id="botTxnCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pagamento</th><th>Cliente</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Entrega</th><th>Data</th><th></th></tr></thead><tbody id="botTxnsBody"></tbody></table></div><div class="statusbar"><span id="botTxnStatus">Aguardando.</span></div></article>
</section>
<div class="bot-modal hidden" id="botUserActionModal" role="dialog" aria-modal="true" aria-labelledby="botUserActionTitle">
<div class="bot-modal-backdrop" data-bot-modal-close></div>
<div class="bot-modal-card">
<div class="card-hdr"><div><div class="card-title" id="botUserActionTitle">Editar cliente</div><p class="hint" id="botUserActionSubtitle"></p></div><button class="btn btn-ghost btn-sm" type="button" data-bot-modal-close>Fechar</button></div>
<form id="botUserActionForm"><input type="hidden" id="botActionTelegramID"/><input type="hidden" id="botActionMode"/>
<div id="botRoleFields"><div class="field"><label>Função</label><select id="botActionRole"><option value="customer">Cliente</option><option value="reseller">Revendedor</option><option value="blocked">Bloqueado</option></select></div><div class="field" id="botActionLinkedField"><label>Usuário da revenda vinculada</label><input id="botActionLinked" placeholder="username em Revendedores"/></div></div>
<div id="botCreditFields" class="hidden"><div class="field"><label>Ajuste de créditos</label><input id="botActionCredits" type="number" placeholder="Ex.: 10 ou -5"/><span class="hint">Use número negativo para remover saldo.</span></div></div>
<div class="form-actions"><button class="btn" type="submit">Confirmar alteração</button></div>
</form> </form>
</div> </div>
</div> </div>
</div><!-- /botSec-plans -->
<!-- ═══ Section: Credit packages ═══ -->
<div class="bot-section" id="botSec-packages" style="display:none;">
<!-- Credit packages -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💳 Pacotes de Crédito <span class="chip" id="botPkgCount">0</span></div>
<div style="display:flex;gap:5px;">
<button class="btn btn-ghost btn-sm" id="botNewPkgBtn">+ Novo</button>
<button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Nome</th><th>Créditos</th><th>Preço</th><th>Ativo</th><th>Ações</th></tr></thead>
<tbody id="botPkgsBody"></tbody>
</table>
</div>
<form id="botPkgForm" style="margin-top:10px;">
<input type="hidden" id="pkgId"/>
<div class="form-grid">
<div class="field"><label>Nome</label><input id="pkgName" required/></div>
<div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div>
<div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div>
<div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div>
<div class="field"><label>Ativo</label><input id="pkgActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
</div>
<div class="form-actions"><button class="btn" type="submit">Salvar pacote</button></div>
</form>
<div class="statusbar"><span id="botPkgStatus">Ready.</span></div>
</div>
</div><!-- /botSec-packages -->
<!-- ═══ Section: Messages ═══ -->
<div class="bot-section" id="botSec-messages" style="display:none;">
<!-- Messages -->
<div class="card">
<div class="card-hdr">
<div class="card-title">✉️ Mensagens do Bot</div>
<button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn">Recarregar</button>
</div>
<div class="field"><label>Boas-vindas <span class="hint">({name} = nome)</span></label><textarea id="setWelcome" rows="4"></textarea></div>
<div class="field"><label>Texto de Contato</label><textarea id="setContact" rows="3"></textarea></div>
<div class="field"><label>Texto Baixar APP</label><textarea id="setAppText" rows="2"></textarea></div>
<div class="field"><label>URL do APP <span class="hint">(botão vira link)</span></label><input id="setAppUrl" placeholder="https://..."/></div>
<div class="form-actions"><button class="btn" id="botSaveSettingsBtn">Salvar mensagens</button></div>
<div class="statusbar"><span id="botSettingsStatus">Ready.</span></div>
</div>
</div><!-- /botSec-messages -->
<!-- ═══ Section: Users ═══ -->
<div class="bot-section" id="botSec-users" style="display:none;">
<!-- Bot users -->
<div class="card">
<div class="card-hdr">
<div class="card-title">👥 Clientes do Bot <span class="chip" id="botUserCount">0</span></div>
<button class="btn btn-ghost btn-sm" id="botReloadUsersBtn">Recarregar</button>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Telegram ID</th><th>Nome</th><th>@user</th><th>Papel</th><th>Revenda</th><th>Créditos</th><th>Ações</th></tr></thead>
<tbody id="botUsersBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botUsersStatus">Ready.</span></div>
</div>
</div><!-- /botSec-users -->
<!-- ═══ Section: Transactions ═══ -->
<div class="bot-section" id="botSec-transactions" style="display:none;">
<!-- Transactions -->
<div class="card">
<div class="card-hdr">
<div class="card-title">💰 Pagamentos <span class="chip" id="botTxnCount">0</span></div>
<div style="display:flex;gap:5px;align-items:center;">
<select id="botTxnFilter" class="btn-sm">
<option value="">Todos</option>
<option value="pending">Pendentes</option>
<option value="approved">Aprovados</option>
<option value="expired">Expirados</option>
<option value="refunded">Estornados</option>
</select>
<button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn">Recarregar</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>Telegram</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Conta</th><th>Data</th><th>Ações</th></tr></thead>
<tbody id="botTxnsBody"></tbody>
</table>
</div>
<div class="statusbar"><span id="botTxnStatus">Ready.</span></div>
</div>
</div><!-- /botSec-transactions -->
</div><!-- /tab-bot --> </div><!-- /tab-bot -->
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ --> <!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
@@ -1494,17 +1396,17 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. --> so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260713bot3"></script> <script defer src="assets/js/01-core.js?v=20260713secure1"></script>
<script defer src="assets/js/02-shell.js?v=20260713bot3"></script> <script defer src="assets/js/02-shell.js?v=20260713secure1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260713bot3"></script> <script defer src="assets/js/03-ssh-users.js?v=20260713secure1"></script>
<script defer src="assets/js/04-xray.js?v=20260713bot3"></script> <script defer src="assets/js/04-xray.js?v=20260713secure1"></script>
<script defer src="assets/js/05-resellers.js?v=20260713bot3"></script> <script defer src="assets/js/05-resellers.js?v=20260713secure1"></script>
<script defer src="assets/js/06-servers.js?v=20260713bot3"></script> <script defer src="assets/js/06-servers.js?v=20260713secure1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260713bot3"></script> <script defer src="assets/js/07-stats-logs.js?v=20260713secure1"></script>
<script defer src="assets/js/08-server-config.js?v=20260713bot3"></script> <script defer src="assets/js/08-server-config.js?v=20260713secure1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260713bot3"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260713secure1"></script>
<script defer src="assets/js/11-update-status.js?v=20260713bot3"></script> <script defer src="assets/js/11-update-status.js?v=20260713secure1"></script>
<script defer src="assets/js/12-bot.js?v=20260713bot3"></script> <script defer src="assets/js/12-bot.js?v=20260713secure1"></script>
<script defer src="assets/js/10-boot.js?v=20260713bot3"></script> <script defer src="assets/js/10-boot.js?v=20260713secure1"></script>
</body> </body>
</html> </html>
+226 -18
View File
@@ -4,22 +4,32 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
"regexp"
"strconv"
"strings"
"sync" "sync"
"time" "time"
"golang.org/x/crypto/bcrypt"
) )
const ( const (
RoleSuperAdmin = "superadmin" RoleSuperAdmin = "superadmin"
RoleReseller = "reseller" RoleReseller = "reseller"
sessionTTL = 12 * time.Hour sessionTTL = 12 * time.Hour
adminBcryptCost = 12
) )
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// ---------- AdminUser ---------- // ---------- AdminUser ----------
type AdminUser struct { type AdminUser struct {
@@ -50,9 +60,11 @@ type sessionStoreT struct {
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)} 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) 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) tok := hex.EncodeToString(b)
sess := &AdminSession{ sess := &AdminSession{
Token: tok, Token: tok,
@@ -64,7 +76,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession
s.mu.Lock() s.mu.Lock()
s.m[tok] = sess s.m[tok] = sess
s.mu.Unlock() s.mu.Unlock()
return sess return sess, nil
} }
func (s *sessionStoreT) Get(token string) *AdminSession { func (s *sessionStoreT) Get(token string) *AdminSession {
@@ -86,6 +98,16 @@ func (s *sessionStoreT) Delete(token string) {
s.mu.Unlock() 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() { func (s *sessionStoreT) cleanup() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -170,6 +192,15 @@ func sessionMiddleware(next http.Handler) http.Handler {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return 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))) next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
}) })
} }
@@ -194,11 +225,112 @@ func saSession(next http.Handler) http.Handler {
// ---------- Password hashing ---------- // ---------- Password hashing ----------
func hashAdminPassword(pw string) string { func legacyAdminPasswordHash(pw string) string {
h := sha256.Sum256([]byte(pw)) h := sha256.Sum256([]byte(pw))
return hex.EncodeToString(h[:]) 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 ---------- // ---------- DB methods on Store ----------
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error { func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
@@ -286,6 +418,11 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
return err 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 { func (s *Store) DeleteAdminUser(ctx context.Context, username string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM admin_users WHERE username=$1`, username) _, err := s.db.ExecContext(ctx, `DELETE FROM admin_users WHERE username=$1`, username)
return err return err
@@ -352,11 +489,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
return "", nil return "", nil
} }
b := make([]byte, 10) 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) pw := hex.EncodeToString(b)
passwordHash, err := hashAdminPassword(pw)
if err != nil {
return "", err
}
u := &AdminUser{ u := &AdminUser{
Username: "admin", Username: "admin",
PasswordHash: hashAdminPassword(pw), PasswordHash: passwordHash,
Role: RoleSuperAdmin, Role: RoleSuperAdmin,
MaxUsers: 0, MaxUsers: 0,
IsActive: true, IsActive: true,
@@ -474,18 +617,33 @@ func handleLogin(store *Store) http.HandlerFunc {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return 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 { var req struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` 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) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
http.Error(w, "username and password required", http.StatusBadRequest) http.Error(w, "username and password required", http.StatusBadRequest)
return 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) u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
if err != nil { if err != nil {
@@ -493,7 +651,16 @@ func handleLogin(store *Store) http.HandlerFunc {
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
return 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) http.Error(w, "invalid credentials", http.StatusUnauthorized)
return return
} }
@@ -506,7 +673,23 @@ func handleLogin(store *Store) http.HandlerFunc {
return 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") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{ _ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": sess.Token, "token": sess.Token,
@@ -614,8 +797,13 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
if p.Username == "" { p.Username = strings.TrimSpace(p.Username)
http.Error(w, "username required", http.StatusBadRequest) 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 return
} }
@@ -638,7 +826,16 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
} }
if p.Password != "" { 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.MaxUsers = p.MaxUsers
u.IsActive = p.IsActive u.IsActive = p.IsActive
@@ -658,6 +855,9 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
return return
} }
adminUsers.set(u) adminUsers.set(u)
if p.Password != "" && existing != nil {
sessions.DeleteUser(u.ID)
}
if u.Role == RoleReseller { if u.Role == RoleReseller {
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) { 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) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
username := r.URL.Query().Get("username") username := strings.TrimSpace(r.URL.Query().Get("username"))
if username == "" { if err := validateAdminUsername(username); err != nil {
http.Error(w, "username required", http.StatusBadRequest) http.Error(w, "invalid username", http.StatusBadRequest)
return return
} }
ctx := r.Context() 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 { if err := store.DeleteAdminUser(ctx, username); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@@ -689,6 +894,9 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
disconnectOwnerUsers(username) disconnectOwnerUsers(username)
removeOwnerXrayClients(ctx, store, username) removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username) adminUsers.delete(username)
if u != nil {
sessions.DeleteUser(u.ID)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
+172 -2
View File
@@ -4,11 +4,73 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "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{}) { func botWriteJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
@@ -80,10 +142,69 @@ func handleBotConfig(store *Store) http.HandlerFunc {
}) })
case http.MethodPost: case http.MethodPost:
var dto botConfigDTO 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) http.Error(w, "invalid json", http.StatusBadRequest)
return 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{ cfg := &BotConfig{
Enabled: dto.Enabled, Enabled: dto.Enabled,
TelegramToken: strings.TrimSpace(dto.TelegramToken), TelegramToken: strings.TrimSpace(dto.TelegramToken),
@@ -139,6 +260,10 @@ func handleBotPlans(store *Store) http.HandlerFunc {
if p.Kind == "" { if p.Kind == "" {
p.Kind = "ssh" 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 { if err := store.UpsertPlan(ctx, &p); err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return return
@@ -183,6 +308,10 @@ func handleBotCreditPackages(store *Store) http.HandlerFunc {
http.Error(w, "invalid json", http.StatusBadRequest) http.Error(w, "invalid json", http.StatusBadRequest)
return return
} }
if err := validateBotPackage(&p); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := store.UpsertCreditPackage(ctx, &p); err != nil { if err := store.UpsertCreditPackage(ctx, &p); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@@ -238,6 +367,21 @@ func handleBotUsers(store *Store) http.HandlerFunc {
if req.Role == "" { if req.Role == "" {
req.Role = "customer" 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 { if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
@@ -253,6 +397,10 @@ func handleBotUsers(store *Store) http.HandlerFunc {
return return
} }
case "adjust_credits": 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 { if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil {
http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest) http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest)
return return
@@ -278,8 +426,18 @@ func handleBotTransactions(store *Store) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
switch r.Method { switch r.Method {
case http.MethodGet: 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")) 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) txns, err := store.ListTransactions(ctx, status, limit)
if err != nil { if err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
@@ -342,6 +500,18 @@ func handleBotSettings(store *Store) http.HandlerFunc {
return return
} }
for k, v := range kv { 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 { if err := store.SetSetting(ctx, k, v); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
+35 -4
View File
@@ -129,7 +129,7 @@ func (c *mpClient) do(ctx context.Context, method, path string, body interface{}
// Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret). // Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret).
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool { func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
if secret == "" { if secret == "" {
return true // validation disabled return false
} }
var ts, v1 string var ts, v1 string
for _, part := range strings.Split(xSignature, ",") { for _, part := range strings.Split(xSignature, ",") {
@@ -147,7 +147,18 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
if ts == "" || v1 == "" { if ts == "" || v1 == "" {
return false 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 := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(manifest)) mac.Write([]byte(manifest))
expected := hex.EncodeToString(mac.Sum(nil)) 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. // handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
// It never trusts the body: it re-fetches the payment and fulfills idempotently. // It never trusts the body: it re-fetches the payment and fulfills idempotently.
func handleMPWebhook(w http.ResponseWriter, r *http.Request) { func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
b := currentBot() b := currentBot()
if b == nil { if b == nil {
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
@@ -173,6 +188,7 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
if dataID == "" { if dataID == "" {
dataID = r.URL.Query().Get("id") dataID = r.URL.Query().Get("id")
} }
signatureDataID := dataID
var payload struct { var payload struct {
Type string `json:"type"` Type string `json:"type"`
Action string `json:"action"` Action string `json:"action"`
@@ -180,7 +196,12 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
ID json.Number `json:"id"` ID json.Number `json:"id"`
} `json:"data"` } `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 { if len(body) > 0 {
_ = json.Unmarshal(body, &payload) _ = json.Unmarshal(body, &payload)
if dataID == "" { if dataID == "" {
@@ -191,8 +212,18 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return 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) log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
+12 -24
View File
@@ -104,18 +104,15 @@ get_env_value() {
' "$ENV_FILE" ' "$ENV_FILE"
} }
update_env_password() { remove_legacy_env_password() {
local new_password="$1"
local tmp local tmp
tmp="$(mktemp)" tmp="$(mktemp)"
awk -v line="ADMIN_PASSWORD=${new_password}" ' awk '
BEGIN { done = 0 } /^ADMIN_PASSWORD=/ { next }
/^ADMIN_PASSWORD=/ { print line; done = 1; next }
{ print } { print }
END { if (!done) print line }
' "$ENV_FILE" > "$tmp" ' "$ENV_FILE" > "$tmp"
cat "$tmp" > "$ENV_FILE" install -m 600 "$tmp" "$ENV_FILE"
rm -f "$tmp" rm -f -- "$tmp"
chmod 600 "$ENV_FILE" 2>/dev/null || true chmod 600 "$ENV_FILE" 2>/dev/null || true
} }
@@ -135,15 +132,8 @@ generate_password() {
hash_password() { hash_password() {
local pw="$1" local pw="$1"
if command -v sha256sum >/dev/null 2>&1; then [[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel"
printf '%s' "$pw" | sha256sum | awk '{print $1}' printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null
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
} }
PG_DSN="$(get_env_value PG_DSN)" PG_DSN="$(get_env_value PG_DSN)"
@@ -168,12 +158,12 @@ elif [[ -z "$NEW_PASSWORD" ]]; then
fi fi
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty." [[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
if [[ ${#NEW_PASSWORD} -lt 8 ]]; then if [[ ${#NEW_PASSWORD} -lt 10 ]]; then
error "Password must have at least 8 characters." error "Password must have at least 10 characters."
fi fi
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")" 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..." info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
psql "$PG_DSN" -v ON_ERROR_STOP=1 \ psql "$PG_DSN" -v ON_ERROR_STOP=1 \
@@ -201,10 +191,8 @@ ON CONFLICT (username) DO UPDATE SET
SQL SQL
if [[ "$ADMIN_USER" == "admin" ]]; then if [[ "$ADMIN_USER" == "admin" ]]; then
update_env_password "$NEW_PASSWORD" remove_legacy_env_password
info "Updated ADMIN_PASSWORD inside $ENV_FILE" info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE"
else
warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'."
fi fi
if ! $NO_RESTART; then if ! $NO_RESTART; then
-1
View File
@@ -1532,7 +1532,6 @@ func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncoded
atomic.AddUint64(&dnsttStats.RespSent, 1) atomic.AddUint64(&dnsttStats.RespSent, 1)
atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf))) atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf)))
} }
return nil
} }
// computeMaxEncodedPayload computes the maximum amount of downstream TXT RR // computeMaxEncodedPayload computes the maximum amount of downstream TXT RR
+3 -3
View File
@@ -493,7 +493,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true)
if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then
ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N) ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N)
fi 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 <<SQL su -c "psql -d ${DB_NAME}" postgres <<SQL
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active) INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE) VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE)
@@ -509,7 +510,6 @@ SQL
cat > "$INSTALL_DIR/.env" <<EOF cat > "$INSTALL_DIR/.env" <<EOF
PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable
ADMIN_TOKEN=${ADMIN_TOKEN} ADMIN_TOKEN=${ADMIN_TOKEN}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
ADMIN_HTTP_ADDR=0.0.0.0:9090 ADMIN_HTTP_ADDR=0.0.0.0:9090
EOF EOF
chmod 600 "$INSTALL_DIR/.env" chmod 600 "$INSTALL_DIR/.env"
@@ -703,7 +703,7 @@ echo -e " Admin login : ${YELLOW}admin${NC}"
echo -e " Admin password: ${YELLOW}${ADMIN_PASSWORD}${NC}" echo -e " Admin password: ${YELLOW}${ADMIN_PASSWORD}${NC}"
echo -e " Admin token : ${YELLOW}${ADMIN_TOKEN}${NC}" echo -e " Admin token : ${YELLOW}${ADMIN_TOKEN}${NC}"
echo "" echo ""
echo -e " Token + DB creds stored in: ${INSTALL_DIR}/.env" echo -e " API token + DB credentials stored in: ${INSTALL_DIR}/.env"
echo -e " Logs: journalctl -u ${SERVICE_NAME} -f" echo -e " Logs: journalctl -u ${SERVICE_NAME} -f"
echo -e " tail -f ${INSTALL_DIR}/logs/panel.log" echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
echo "" echo ""
+121 -6
View File
@@ -1328,6 +1328,16 @@ func NewStore(dsn string) (*Store, error) {
return store, nil return store, nil
} }
const sshPasswordPrefix = "enc:v1:ssh:"
func sealSSHPassword(password string) (string, error) {
return sealCredential(sshPasswordPrefix, password)
}
func openSSHPassword(password string) (string, error) {
return openCredential(sshPasswordPrefix, password)
}
func (s *Store) EnsureUsersSchema(ctx context.Context) error { func (s *Store) EnsureUsersSchema(ctx context.Context) error {
stmts := []string{ stmts := []string{
`CREATE TABLE IF NOT EXISTS ssh_users ( `CREATE TABLE IF NOT EXISTS ssh_users (
@@ -1355,6 +1365,39 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
return err return err
} }
} }
return s.migrateSSHPasswords(ctx)
}
func (s *Store) migrateSSHPasswords(ctx context.Context) error {
rows, err := s.db.QueryContext(ctx, `SELECT username, password FROM ssh_users WHERE password <> '' 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 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 { &totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
return nil, err return nil, err
} }
password, err = openSSHPassword(password)
if err != nil {
return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err)
}
cfg := UserConfig{ cfg := UserConfig{
Username: username, 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. // UpsertUser creates or updates a row in ssh_users.
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error { 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 ( INSERT INTO ssh_users (
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down, 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 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, totp_digits = EXCLUDED.totp_digits,
allow_static_password = EXCLUDED.allow_static_password`, allow_static_password = EXCLUDED.allow_static_password`,
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only. // 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) u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
return err return err
} }
@@ -1634,7 +1685,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
go func() { go func() {
log.Printf("Admin HTTP (panel + API) listening on %s", addr) 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) log.Printf("admin http error: %v", err)
} }
}() }()
@@ -1756,6 +1816,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
return return
} }
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller { 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.OwnerUsername = sess.Username
} }
p.ServerID = "" p.ServerID = ""
@@ -1769,6 +1850,20 @@ func handleCreateUser(store *Store) http.HandlerFunc {
return 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: // Decide what password to use:
// - if payload has non-empty password -> use it // - if payload has non-empty password -> use it
// - else try to read existing password from DB // - else try to read existing password from DB
@@ -1802,7 +1897,6 @@ func handleCreateUser(store *Store) http.HandlerFunc {
} }
// Determine owner and enforce reseller quota // Determine owner and enforce reseller quota
sess := sessionFromCtx(ctx)
ownerUsername := "" ownerUsername := ""
if sess != nil && sess.Role == RoleReseller { if sess != nil && sess.Role == RoleReseller {
ownerUsername = sess.Username ownerUsername = sess.Username
@@ -1813,7 +1907,12 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB) ).Scan(&existsInDB)
if !existsInDB { if !existsInDB {
owner, ok := adminUsers.get(sess.Username) 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) http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return return
} }
@@ -2218,7 +2317,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
return 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", log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
u.Cfg.Username, target, req.OriginAddr, req.OriginPort) 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)") 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") quietFlag := flag.Bool("quiet", false, "override config and disable logs")
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)") 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() 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) resolvedConfigPath := resolveMainConfigPath(*configPath)
cfg, userMap, err := loadConfig(resolvedConfigPath) cfg, userMap, err := loadConfig(resolvedConfigPath)
+204 -30
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -15,6 +16,25 @@ import (
"time" "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 { type ManagedServer struct {
ID int ID int
Name string Name string
@@ -66,7 +86,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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) { 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 { 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 return nil, err
} }
plainKey, err := openManagedCredential(ms.AdminKey)
if err != nil {
return nil, err
}
ms.AdminKey = plainKey
out = append(out, ms) out = append(out, ms)
} }
return out, rows.Err() return out, rows.Err()
@@ -100,12 +161,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
plainKey, err := openManagedCredential(ms.AdminKey)
if err != nil {
return nil, err
}
ms.AdminKey = plainKey
return ms, nil return ms, nil
} }
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) { func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
name := strings.TrimSpace(p.Name) name := strings.TrimSpace(p.Name)
baseURL := normalizeManagedServerBaseURL(p.BaseURL) baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
adminUsername := strings.TrimSpace(p.AdminUsername) adminUsername := strings.TrimSpace(p.AdminUsername)
if adminUsername == "" { if adminUsername == "" {
adminUsername = "admin" adminUsername = "admin"
@@ -113,8 +179,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
if name == "" { if name == "" {
return nil, fmt.Errorf("server name required") return nil, fmt.Errorf("server name required")
} }
if baseURL == "" { if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
return nil, fmt.Errorf("base url required") 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" { if p.ID != "" && p.ID != "local" {
id, err := strconv.Atoi(p.ID) 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() 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) WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
} else { } else {
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
if sealErr != nil {
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
}
_, err = s.db.ExecContext(ctx, ` _, err = s.db.ExecContext(ctx, `
UPDATE managed_servers 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() 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 { if err != nil {
return nil, err return nil, err
@@ -140,8 +219,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
if strings.TrimSpace(p.AdminKey) == "" { if strings.TrimSpace(p.AdminKey) == "" {
return nil, fmt.Errorf("admin key/password required") 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 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) 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) VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (base_url) DO UPDATE SET 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, enable_xray=EXCLUDED.enable_xray,
is_active=EXCLUDED.is_active, is_active=EXCLUDED.is_active,
updated_at=NOW() 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 { if err != nil {
return nil, err return nil, err
} }
@@ -193,21 +276,39 @@ func localManagedServerDTO() ManagedServerDTO {
} }
func normalizeManagedServerBaseURL(raw string) string { func normalizeManagedServerBaseURL(raw string) string {
normalized, _ := validateManagedServerBaseURL(raw)
return normalized
}
func validateManagedServerBaseURL(raw string) (string, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if 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 raw = "http://" + raw
} }
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" { 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.RawQuery = ""
u.Fragment = "" u.Fragment = ""
return strings.TrimRight(u.String(), "/") return strings.TrimRight(u.String(), "/"), nil
} }
func requestedServerID(r *http.Request) string { func requestedServerID(r *http.Request) string {
@@ -250,7 +351,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second} client := managedServerHTTPClient(15 * time.Second)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err 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("Content-Type", contentType)
req.Header.Set("X-Session-Token", token) req.Header.Set("X-Session-Token", token)
client := &http.Client{Timeout: 30 * time.Second} client := managedServerHTTPClient(30 * time.Second)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return 0, nil, "", err 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 { func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
if owner == "" || username == "" { if username == "" {
return false return "", false, nil
} }
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json") status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
if err != nil || status < 200 || status >= 300 { 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{} var rows []map[string]interface{}
if err := json.Unmarshal(data, &rows); err != nil { if err := json.Unmarshal(data, &rows); err != nil {
return false return "", false, err
} }
for _, row := range rows { for _, row := range rows {
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner { if fmt.Sprint(row["username"]) == username {
return true 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 { func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
if owner == "" || uuid == "" { actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
return false 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") status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
if err != nil || status < 200 || status >= 300 { 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{} var inbounds []map[string]interface{}
if err := json.Unmarshal(data, &inbounds); err != nil { if err := json.Unmarshal(data, &inbounds); err != nil {
return false return "", false, err
} }
for _, ib := range inbounds { for _, ib := range inbounds {
clients, _ := ib["clients"].([]interface{}) clients, _ := ib["clients"].([]interface{})
for _, c := range clients { for _, c := range clients {
m, _ := c.(map[string]interface{}) m, _ := c.(map[string]interface{})
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner { if fmt.Sprint(m["id"]) == uuid {
return true 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
} }
+6 -2
View File
@@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "config path not set", http.StatusInternalServerError) http.Error(w, "config path not set", http.StatusInternalServerError)
return 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 { if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest) http.Error(w, "failed to read body", http.StatusBadRequest)
return return
} }
if len(body) > 512*1024 {
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
return
}
var newCfg Config var newCfg Config
if err := json.Unmarshal(body, &newCfg); err != nil { if err := json.Unmarshal(body, &newCfg); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) 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) http.Error(w, "marshal error", http.StatusInternalServerError)
return 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) http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
return return
} }
+97 -24
View File
@@ -4,21 +4,57 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"math/big" "math/big"
"net"
"net/http" "net/http"
"net/mail"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings"
"time" "time"
) )
const tlsCertsDir = "/opt/sshpanel/certs" 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 // handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the
// given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths. // given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths.
func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) { func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
@@ -29,12 +65,18 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Domain string `json:"domain"` 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 == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
http.Error(w, "domain required", http.StatusBadRequest) http.Error(w, "domain required", http.StatusBadRequest)
return 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 { if err := os.MkdirAll(certDir, 0o700); err != nil {
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError) http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
return return
@@ -47,40 +89,45 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
return 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{ tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), SerialNumber: serialNumber,
Subject: pkix.Name{CommonName: req.Domain}, Subject: pkix.Name{CommonName: domain},
NotBefore: time.Now().Add(-time.Minute), NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 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) der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil { if err != nil {
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
return 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) privDER, err := x509.MarshalECPrivateKey(priv)
if err != nil { if err != nil {
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError) http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
return return
} }
kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err != nil { 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) http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
return return
} }
_ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
kf.Close()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
@@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
Domain string `json:"domain"` Domain string `json:"domain"`
Email string `json:"email"` 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 == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" {
http.Error(w, "domain and email required", http.StatusBadRequest) http.Error(w, "domain and email required", http.StatusBadRequest)
return 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", 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() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
return return
} }
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem" certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem"
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem" keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem"
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
@@ -136,13 +195,27 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
Cert string `json:"cert"` Cert string `json:"cert"`
Key string `json:"key"` 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 == "" { 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) http.Error(w, "name, cert, and key required", http.StatusBadRequest)
return return
} }
name := filepath.Base(req.Name) name, err := normalizeTLSStoreName(req.Name)
if name == "." || name == "/" || name == "" { if err != nil {
http.Error(w, "invalid name", http.StatusBadRequest) 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 return
} }
certDir := filepath.Join(tlsCertsDir, name) certDir := filepath.Join(tlsCertsDir, name)
@@ -152,11 +225,11 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
} }
certFile := filepath.Join(certDir, "cert.pem") certFile := filepath.Join(certDir, "cert.pem")
keyFile := filepath.Join(certDir, "key.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) http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
return 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) http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
return return
} }
+25 -1
View File
@@ -2458,6 +2458,25 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
return return
} }
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller { 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.OwnerUsername = sess.Username
} }
req.ServerID = "" req.ServerID = ""
@@ -2491,7 +2510,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reseller account suspended or expired", http.StatusForbidden) http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
return 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) http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return return
} }
+25 -13
View File
@@ -1068,18 +1068,30 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
t.Fatalf("write fast mux frame: %v", err) t.Fatalf("write fast mux frame: %v", err)
} }
meta, err := readNativeMuxMetadata(conn) for {
if err != nil { meta, err := readNativeMuxMetadata(conn)
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err) 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) var got []byte
} if meta.option&nativeMuxOptionData != 0 {
got, err := readNativeMuxDataBlock(conn) got, err = readNativeMuxDataBlock(conn)
if err != nil { if err != nil {
t.Fatalf("read fast session payload: %v", err) t.Fatalf("read mux response payload: %v", err)
} }
if !bytes.Equal(got, want) { }
t.Fatalf("fast session echo mismatch: got %q want %q", got, want) // 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
} }
} }