security fix
This commit is contained in:
@@ -268,7 +268,7 @@ Ou gerar uma senha nova automaticamente:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
O script atualiza o usuário `admin` no PostgreSQL com hash bcrypt, ativa ele como `superadmin`, remove qualquer `ADMIN_PASSWORD` legado em texto puro de `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
|
||||
### Atualização automática pelo Git
|
||||
|
||||
@@ -779,7 +779,7 @@ Or generate a new password automatically:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
The script updates the `admin` user in PostgreSQL with a bcrypt hash, enables it as `superadmin`, removes any legacy plaintext `ADMIN_PASSWORD` from `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
|
||||
### Automatic Git update
|
||||
|
||||
|
||||
@@ -649,3 +649,53 @@ select:disabled {
|
||||
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
|
||||
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
|
||||
|
||||
/* --- Bot sales workspace --- */
|
||||
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
|
||||
.bot-hero{
|
||||
position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%),
|
||||
radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;}
|
||||
.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;}
|
||||
.bot-hero-copy{max-width:620px;}
|
||||
.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;}
|
||||
.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;}
|
||||
.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;}
|
||||
.bot-hero-copy p{margin-top:8px;}
|
||||
.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;}
|
||||
.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;}
|
||||
.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;}
|
||||
.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);}
|
||||
.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);}
|
||||
.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;}
|
||||
.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);}
|
||||
.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);}
|
||||
|
||||
.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);}
|
||||
.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;}
|
||||
.bot-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);}
|
||||
.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;}
|
||||
.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;}
|
||||
.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;}
|
||||
|
||||
.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;}
|
||||
.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);}
|
||||
.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);}
|
||||
.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;}
|
||||
.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;}
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
|
||||
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
|
||||
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
|
||||
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
|
||||
let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || "";
|
||||
if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
@@ -564,4 +566,3 @@ function patchRenderedInbounds(inbounds) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await api("/api/auth/logout", { method: "POST" }); } catch {}
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
@@ -83,7 +83,7 @@ async function doLogin() {
|
||||
sessionToken = data.token;
|
||||
currentRole = data.role;
|
||||
currentUser = data.username;
|
||||
localStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
loginOverlay.classList.add("hidden");
|
||||
mainApp.classList.remove("hidden");
|
||||
initAfterLogin();
|
||||
@@ -288,4 +288,3 @@ function refreshDashboard() {
|
||||
if (currentRole === "superadmin") loadStats();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
}
|
||||
|
||||
|
||||
+30
-22
@@ -161,7 +161,8 @@ function renderInbounds(inbounds, options = {}) {
|
||||
}
|
||||
inboundsContainer.innerHTML = "";
|
||||
lastInboundsStructure = nextStructure;
|
||||
inbounds.forEach(ib => {
|
||||
inbounds.forEach((ib, inboundIndex) => {
|
||||
const formKey = String(inboundIndex);
|
||||
const section = document.createElement("div");
|
||||
section.dataset.inboundTag = String(ib.tag || "");
|
||||
section.dataset.inboundProtocol = String(ib.protocol || "");
|
||||
@@ -179,13 +180,18 @@ function renderInbounds(inbounds, options = {}) {
|
||||
${escapeHTML(ib.tag || "untagged")}
|
||||
<span class="hint">:${escapeHTML(ib.port ?? "?")}</span>
|
||||
<span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" onclick="openAddClient('${ib.tag}')">${t("+ Add Client")}</button>`;
|
||||
</div>`;
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "btn btn-sm";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = t("+ Add Client");
|
||||
openButton.addEventListener("click", () => openAddClient(ib.tag, formKey));
|
||||
hdr.appendChild(openButton);
|
||||
section.appendChild(hdr);
|
||||
|
||||
// Add client mini-form (hidden by default)
|
||||
const addForm = document.createElement("div");
|
||||
addForm.id = `add-form-${ib.tag}`;
|
||||
addForm.id = `add-form-${formKey}`;
|
||||
addForm.className = "hidden";
|
||||
addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
|
||||
addForm.innerHTML = `
|
||||
@@ -193,19 +199,22 @@ function renderInbounds(inbounds, options = {}) {
|
||||
<div class="field">
|
||||
<label>UUID</label>
|
||||
<div class="field-row">
|
||||
<input id="newUUID-${ib.tag}" 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>
|
||||
<input id="newUUID-${formKey}" placeholder="auto-generate" style="border-radius:6px;"/>
|
||||
<button class="btn btn-ghost btn-sm" id="genUUID-${formKey}" type="button">Gen</button>
|
||||
</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("Display Name")}</label><input id="newName-${ib.tag}" 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("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("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-${formKey}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></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-${formKey}" placeholder="0" style="border-radius:6px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:6px;">
|
||||
<button class="btn btn-sm" onclick="addClient('${ib.tag}')">${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-sm" id="addClient-${formKey}" type="button">${t("Add")}</button>
|
||||
<button class="btn btn-ghost btn-sm" id="cancelAddClient-${formKey}" type="button">${t("Cancel")}</button>
|
||||
</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);
|
||||
|
||||
// Clients table
|
||||
@@ -262,19 +271,19 @@ function renderInbounds(inbounds, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openAddClient(tag) {
|
||||
const form = document.getElementById(`add-form-${tag}`);
|
||||
function openAddClient(tag, formKey = tag) {
|
||||
const form = document.getElementById(`add-form-${formKey}`);
|
||||
if (form) { form.classList.remove("hidden"); }
|
||||
const uuidField = document.getElementById(`newUUID-${tag}`);
|
||||
const uuidField = document.getElementById(`newUUID-${formKey}`);
|
||||
if (uuidField && !uuidField.value) uuidField.value = genUUID();
|
||||
}
|
||||
|
||||
async function addClient(tag) {
|
||||
const uuidEl = document.getElementById(`newUUID-${tag}`);
|
||||
const emailEl = document.getElementById(`newEmail-${tag}`);
|
||||
const nameEl = document.getElementById(`newName-${tag}`);
|
||||
const expiryEl = document.getElementById(`newExpiry-${tag}`);
|
||||
const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
|
||||
async function addClient(tag, formKey = tag) {
|
||||
const uuidEl = document.getElementById(`newUUID-${formKey}`);
|
||||
const emailEl = document.getElementById(`newEmail-${formKey}`);
|
||||
const nameEl = document.getElementById(`newName-${formKey}`);
|
||||
const expiryEl = document.getElementById(`newExpiry-${formKey}`);
|
||||
const maxConnsEl = document.getElementById(`newMaxConns-${formKey}`);
|
||||
const uuid = (uuidEl?.value || "").trim();
|
||||
const email = (emailEl?.value || "").trim();
|
||||
const name = (nameEl?.value || "").trim();
|
||||
@@ -349,4 +358,3 @@ async function loadXrayLogs() {
|
||||
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ function renderResellers(list) {
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${r.username}</td>
|
||||
<td>${escapeHTML(r.username)}</td>
|
||||
<td>
|
||||
<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="table-meter"><span style="width:${pct}%"></span></div>
|
||||
</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></td>`;
|
||||
const tdA = tr.lastElementChild;
|
||||
@@ -110,4 +110,3 @@ async function deleteReseller(username) {
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ async function loadDnsttHealth() {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ async function loadStats() {
|
||||
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
|
||||
if (!ifaceBody) return;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<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);
|
||||
});
|
||||
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
||||
@@ -175,13 +175,13 @@ function renderVnstatRows(body, rows, emptyLabel) {
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${emptyLabel}</td>`;
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${escapeHTML(emptyLabel)}</td>`;
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(r => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -273,4 +273,3 @@ async function clearPanelLog() {
|
||||
else st.textContent = "Error cleaning panel log: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,8 +263,8 @@ function renderTLSForwarders() {
|
||||
tlsForwardersState.forEach((fw, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${fw.listen}</span>
|
||||
<span class="hint">${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}</span>`;
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
||||
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
@@ -446,4 +446,3 @@ async function loadDnsttPubkey() {
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,9 +113,9 @@ function renderWzInbounds() {
|
||||
const sec = ss.security || "";
|
||||
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
|
||||
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
|
||||
row.innerHTML = `<span class="chip">${ib.protocol}</span>
|
||||
<span style="font-family:monospace;">${ib.tag||"untagged"}${portStr}</span>
|
||||
<span class="hint" style="flex:1;">${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}</span>`;
|
||||
row.innerHTML = `<span class="chip">${escapeHTML(ib.protocol || "")}</span>
|
||||
<span style="font-family:monospace;">${escapeHTML((ib.tag||"untagged")+portStr)}</span>
|
||||
<span class="hint" style="flex:1;">${escapeHTML((ib.listen||"0.0.0.0")+(net?" · "+net:"")+modeLabel+secLabel)}</span>`;
|
||||
const clients = ib.settings?.clients;
|
||||
if (Array.isArray(clients) && clients.length) {
|
||||
const badge = document.createElement("span");
|
||||
@@ -392,4 +392,3 @@ async function applyWizardConfig() {
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ─── Auth error ───────────────────────────────────────────────────────────────
|
||||
function doAuthError() {
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
|
||||
+547
-272
@@ -1,340 +1,615 @@
|
||||
// ─── Bot / Vendas (Telegram + Mercado Pago) ─────────────────────────────────
|
||||
// Superadmin-only tab. Uses the shared api() helper from 01-core.js.
|
||||
// Bot / Vendas — safe DOM rendering and sectioned management workspace.
|
||||
|
||||
function botEsc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
function botStatus(id, msg, ok) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.style.color = ok === false ? "var(--danger,#e5484d)" : ""; }
|
||||
}
|
||||
function brl(cents) {
|
||||
return "R$ " + (Number(cents || 0) / 100).toFixed(2).replace(".", ",");
|
||||
}
|
||||
const botState = {
|
||||
config: null,
|
||||
plans: [],
|
||||
packages: [],
|
||||
users: [],
|
||||
transactions: [],
|
||||
section: sessionStorage.getItem("BOT_SECTION") || "config",
|
||||
};
|
||||
|
||||
async function loadBotTab() {
|
||||
loadBotInbounds();
|
||||
const sel = document.getElementById("botSection");
|
||||
botShowSection(sel ? sel.value : "config");
|
||||
}
|
||||
|
||||
// Show one section at a time and lazy-load its data.
|
||||
function botShowSection(name) {
|
||||
document.querySelectorAll("#tab-bot .bot-section").forEach(s => { s.style.display = "none"; });
|
||||
const el = document.getElementById("botSec-" + name);
|
||||
if (el) el.style.display = "";
|
||||
switch (name) {
|
||||
case "config": loadBotConfig(); break;
|
||||
case "plans": loadBotPlans(); break;
|
||||
case "packages": loadBotPkgs(); break;
|
||||
case "messages": loadBotSettings(); break;
|
||||
case "users": loadBotUsers(); break;
|
||||
case "transactions": loadBotTxns(); break;
|
||||
function botStatus(id, message, ok) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
if (id === "botConfigStatus") {
|
||||
element.classList.toggle("is-ok", ok === true);
|
||||
element.classList.toggle("is-error", ok === false);
|
||||
} else {
|
||||
element.style.color = ok === false ? "var(--danger)" : "";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config ───
|
||||
function botBRL(cents) {
|
||||
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100);
|
||||
}
|
||||
|
||||
function botNode(tag, options = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
if (options.className) element.className = options.className;
|
||||
if (options.text != null) element.textContent = String(options.text);
|
||||
if (options.title) element.title = options.title;
|
||||
if (options.type) element.type = options.type;
|
||||
for (const child of children) if (child) element.appendChild(child);
|
||||
return element;
|
||||
}
|
||||
|
||||
function botCell(content, className = "") {
|
||||
const cell = document.createElement("td");
|
||||
if (className) cell.className = className;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = String(content == null || content === "" ? "—" : content);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function botPrimaryCell(title, detail) {
|
||||
const wrapper = botNode("div", { className: "bot-primary-cell" });
|
||||
wrapper.appendChild(botNode("strong", { text: title || "—" }));
|
||||
if (detail) wrapper.appendChild(botNode("small", { text: detail }));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function botBadge(label, tone) {
|
||||
return botNode("span", { className: "bot-status " + tone, text: label });
|
||||
}
|
||||
|
||||
function botButton(label, handler, className = "btn btn-ghost btn-sm") {
|
||||
const button = botNode("button", { className, text: label, type: "button" });
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function botActions(buttons) {
|
||||
return botNode("div", { className: "bot-row-actions" }, buttons);
|
||||
}
|
||||
|
||||
function botEmptyRow(body, columns, message) {
|
||||
const row = botNode("tr", { className: "bot-empty-row" });
|
||||
const cell = botCell(message);
|
||||
cell.colSpan = columns;
|
||||
row.appendChild(cell);
|
||||
body.replaceChildren(row);
|
||||
}
|
||||
|
||||
async function botRequest(path, options = {}) {
|
||||
const response = await api(path, options);
|
||||
if (!response.ok) {
|
||||
const message = (await response.text()).trim();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function botHandleError(error, statusID, fallback) {
|
||||
if (error.message === "auth") {
|
||||
doAuthError();
|
||||
return;
|
||||
}
|
||||
botStatus(statusID, error.message || fallback, false);
|
||||
}
|
||||
|
||||
function botSetSection(section) {
|
||||
const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]);
|
||||
if (!allowedSections.has(section)) section = "config";
|
||||
botState.section = section;
|
||||
sessionStorage.setItem("BOT_SECTION", section);
|
||||
document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section));
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section));
|
||||
const select = document.getElementById("botSection");
|
||||
if (select) select.value = section;
|
||||
}
|
||||
|
||||
function botUpdateMetrics() {
|
||||
const config = botState.config;
|
||||
const stateMetric = document.getElementById("botMetricState");
|
||||
if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível";
|
||||
const plansMetric = document.getElementById("botMetricPlans");
|
||||
if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length);
|
||||
const usersMetric = document.getElementById("botMetricUsers");
|
||||
if (usersMetric) usersMetric.textContent = String(botState.users.length);
|
||||
const pendingMetric = document.getElementById("botMetricPending");
|
||||
if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length);
|
||||
}
|
||||
|
||||
async function loadBotTab() {
|
||||
botSetSection(botState.section);
|
||||
botStatus("botConfigStatus", "Atualizando dados…");
|
||||
await Promise.allSettled([
|
||||
loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(),
|
||||
loadBotUsers(), loadBotTxns(), loadBotSettings(),
|
||||
]);
|
||||
botUpdateMetrics();
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async function loadBotConfig() {
|
||||
try {
|
||||
const res = await api("/api/bot/config");
|
||||
const c = await res.json();
|
||||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v ?? ""; };
|
||||
const chk = (id, v) => { const e = document.getElementById(id); if (e) e.checked = !!v; };
|
||||
chk("botEnabled", c.enabled);
|
||||
set("botMPConfirmMode", c.mp_confirm_mode);
|
||||
set("botMPPollInterval", c.mp_poll_interval);
|
||||
set("botPixExp", c.pix_expiration_minutes);
|
||||
chk("botTrialEnabled", c.trial_enabled);
|
||||
set("botTrialHours", c.trial_hours);
|
||||
set("botTrialMaxConns", c.trial_max_connections);
|
||||
set("botTrialKind", c.trial_kind);
|
||||
set("botTrialInbound", c.trial_inbound_tag);
|
||||
set("botAdminIDs", (c.admin_telegram_ids || []).join(","));
|
||||
set("botPublicHost", c.public_host);
|
||||
set("botXrayPublicHost", c.xray_public_host);
|
||||
const hint = (id, ok) => { const e = document.getElementById(id); if (e) e.textContent = ok ? "✓ configurado" : "não definido"; };
|
||||
hint("botHasTgToken", c.has_telegram_token);
|
||||
hint("botHasMpToken", c.has_mp_access_token);
|
||||
hint("botHasMpSecret", c.has_mp_webhook_secret);
|
||||
const config = await botRequest("/api/bot/config");
|
||||
botState.config = config;
|
||||
const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
|
||||
setChecked("botEnabled", config.enabled);
|
||||
setValue("botMPConfirmMode", config.mp_confirm_mode);
|
||||
setValue("botMPPollInterval", config.mp_poll_interval);
|
||||
setValue("botPixExp", config.pix_expiration_minutes);
|
||||
setChecked("botTrialEnabled", config.trial_enabled);
|
||||
setValue("botTrialHours", config.trial_hours);
|
||||
setValue("botTrialMaxConns", config.trial_max_connections);
|
||||
setValue("botTrialKind", config.trial_kind);
|
||||
setValue("botTrialInbound", config.trial_inbound_tag);
|
||||
setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
|
||||
setValue("botPublicHost", config.public_host);
|
||||
setValue("botXrayPublicHost", config.xray_public_host);
|
||||
botSetSecretState("botHasTgToken", config.has_telegram_token);
|
||||
botSetSecretState("botHasMpToken", config.has_mp_access_token);
|
||||
botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
|
||||
botToggleMPWebhookBox();
|
||||
botStatus("botConfigStatus", "Carregado.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao carregar.", false); }
|
||||
botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
|
||||
botUpdateMetrics();
|
||||
return config;
|
||||
} catch (error) {
|
||||
botState.config = null;
|
||||
botHandleError(error, "botConfigStatus", "Erro ao carregar configuração.");
|
||||
botUpdateMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
function botSetSecretState(id, configured) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = configured ? "● protegido" : "○ não configurado";
|
||||
element.classList.toggle("is-set", !!configured);
|
||||
element.classList.toggle("is-missing", !configured);
|
||||
}
|
||||
|
||||
function botToggleMPWebhookBox() {
|
||||
const mode = document.getElementById("botMPConfirmMode")?.value;
|
||||
const box = document.getElementById("botMPWebhookBox");
|
||||
if (box) box.style.display = mode === "webhook" ? "" : "none";
|
||||
document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
|
||||
const url = document.getElementById("botMPWebhookURL");
|
||||
if (url) url.textContent = location.origin + "/api/mp/webhook";
|
||||
}
|
||||
|
||||
async function saveBotConfig() {
|
||||
const val = id => (document.getElementById(id)?.value || "").trim();
|
||||
const num = id => parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const chk = id => !!document.getElementById(id)?.checked;
|
||||
const ids = val("botAdminIDs").split(",").map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
|
||||
const body = {
|
||||
enabled: chk("botEnabled"),
|
||||
telegram_token: val("botTelegramToken"),
|
||||
mp_access_token: val("botMPToken"),
|
||||
mp_confirm_mode: val("botMPConfirmMode"),
|
||||
mp_webhook_secret: val("botMPWebhookSecret"),
|
||||
mp_poll_interval: val("botMPPollInterval"),
|
||||
pix_expiration_minutes: num("botPixExp"),
|
||||
trial_enabled: chk("botTrialEnabled"),
|
||||
trial_hours: num("botTrialHours"),
|
||||
trial_max_connections: num("botTrialMaxConns"),
|
||||
trial_kind: val("botTrialKind"),
|
||||
trial_inbound_tag: val("botTrialInbound"),
|
||||
admin_telegram_ids: ids,
|
||||
public_host: val("botPublicHost"),
|
||||
xray_public_host: val("botXrayPublicHost"),
|
||||
const value = id => (document.getElementById(id)?.value || "").trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const checked = id => !!document.getElementById(id)?.checked;
|
||||
const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
|
||||
const payload = {
|
||||
enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
|
||||
mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
|
||||
mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
|
||||
pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
|
||||
trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
|
||||
trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
|
||||
admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
|
||||
};
|
||||
botStatus("botConfigStatus", "Salvando e reiniciando…");
|
||||
try {
|
||||
await api("/api/bot/config", { method: "POST", body: JSON.stringify(body) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const e = document.getElementById(id); if (e) e.value = ""; });
|
||||
botStatus("botConfigStatus", "Configuração salva e bot reiniciado.");
|
||||
loadBotConfig();
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro ao salvar.", false); }
|
||||
await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
|
||||
await loadBotConfig();
|
||||
botStatus("botConfigStatus", "Configuração salva", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
|
||||
}
|
||||
}
|
||||
|
||||
async function testBot() {
|
||||
botStatus("botConfigStatus", "Testando...");
|
||||
const body = {
|
||||
botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
|
||||
const payload = {
|
||||
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
|
||||
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/bot/test", { method: "POST", body: JSON.stringify(body) });
|
||||
const r = await res.json();
|
||||
const tg = r.telegram_ok ? `Telegram OK (${r.telegram_bot || ""})` : `Telegram: ${r.telegram_error || "falha"}`;
|
||||
const mp = r.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${r.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", tg + " · " + mp, r.telegram_ok && r.mp_ok);
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botConfigStatus", "Erro no teste.", false); }
|
||||
const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
|
||||
const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
|
||||
const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao testar integrações.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botCopyWebhook() {
|
||||
const value = document.getElementById("botMPWebhookURL")?.textContent || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const button = document.getElementById("botCopyWebhookBtn");
|
||||
if (button) {
|
||||
button.textContent = "Copiado";
|
||||
setTimeout(() => { button.textContent = "Copiar"; }, 1400);
|
||||
}
|
||||
} catch {
|
||||
botStatus("botConfigStatus", "Não foi possível copiar a URL.", false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inbounds datalist ───
|
||||
async function loadBotInbounds() {
|
||||
try {
|
||||
const res = await api("/api/xray/inbounds");
|
||||
const list = await res.json();
|
||||
const dl = document.getElementById("botInboundList");
|
||||
if (dl) dl.innerHTML = (list || []).map(ib => `<option value="${botEsc(ib.tag)}">${botEsc(ib.protocol)}</option>`).join("");
|
||||
} catch (e) { /* xray may be off; ignore */ }
|
||||
const inbounds = await botRequest("/api/xray/inbounds");
|
||||
const datalist = document.getElementById("botInboundList");
|
||||
if (!datalist) return;
|
||||
datalist.replaceChildren(...(inbounds || []).map(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(inbound.tag || "");
|
||||
option.textContent = String(inbound.protocol || "");
|
||||
return option;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.message === "auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Plans ───
|
||||
// Plans
|
||||
async function loadBotPlans() {
|
||||
try {
|
||||
const res = await api("/api/bot/plans");
|
||||
const plans = await res.json() || [];
|
||||
document.getElementById("botPlanCount").textContent = plans.length;
|
||||
document.getElementById("botPlansBody").innerHTML = plans.map(p => `
|
||||
<tr>
|
||||
<td>${botEsc(p.Name)}</td><td>${botEsc(p.Kind)}</td><td>${p.Days}</td>
|
||||
<td>${brl(p.PriceCents)}</td><td>${p.CreditCost}</td>
|
||||
<td>${p.IsActive ? "✅" : "—"}</td>
|
||||
<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); }
|
||||
const plans = await botRequest("/api/bot/plans");
|
||||
botState.plans = plans || [];
|
||||
renderBotPlans(botState.plans);
|
||||
botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return plans;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Credit packages ───
|
||||
function renderBotPlans(plans) {
|
||||
const body = document.getElementById("botPlansBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPlanCount").textContent = String(plans.length);
|
||||
if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado.");
|
||||
const rows = plans.map(plan => {
|
||||
const row = document.createElement("tr");
|
||||
const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH";
|
||||
const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`);
|
||||
row.append(
|
||||
botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery),
|
||||
botCell(`${plan.Days} dias`), botCell(price),
|
||||
botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPlan(plan)),
|
||||
botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function botEditPlan(plan) {
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days);
|
||||
set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown);
|
||||
set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2));
|
||||
set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder);
|
||||
document.getElementById("planActive").checked = !!plan.IsActive;
|
||||
document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`;
|
||||
document.getElementById("planName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPlanForm() {
|
||||
document.getElementById("botPlanForm")?.reset();
|
||||
document.getElementById("planId").value = "";
|
||||
document.getElementById("planActive").checked = true;
|
||||
document.getElementById("botPlanFormTitle").textContent = "Novo plano";
|
||||
}
|
||||
|
||||
async function botSavePlan(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"),
|
||||
MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"),
|
||||
XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"),
|
||||
ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"),
|
||||
};
|
||||
botStatus("botPlansStatus", "Salvando plano…");
|
||||
try {
|
||||
await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPlanForm();
|
||||
await loadBotPlans();
|
||||
botStatus("botPlansStatus", "Plano salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao salvar plano.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePlan(id) {
|
||||
if (!confirm("Excluir este plano? Esta ação não pode ser desfeita.")) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPlans();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao excluir plano.");
|
||||
}
|
||||
}
|
||||
|
||||
// Credit packages
|
||||
async function loadBotPkgs() {
|
||||
try {
|
||||
const res = await api("/api/bot/credit-packages");
|
||||
const pkgs = await res.json() || [];
|
||||
document.getElementById("botPkgCount").textContent = pkgs.length;
|
||||
document.getElementById("botPkgsBody").innerHTML = pkgs.map(p => `
|
||||
<tr>
|
||||
<td>${botEsc(p.Name)}</td><td>${p.Credits}</td><td>${brl(p.PriceCents)}</td>
|
||||
<td>${p.IsActive ? "✅" : "—"}</td>
|
||||
<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); }
|
||||
const packages = await botRequest("/api/bot/credit-packages");
|
||||
botState.packages = packages || [];
|
||||
renderBotPackages(botState.packages);
|
||||
botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
|
||||
return packages;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bot users ───
|
||||
function renderBotPackages(packages) {
|
||||
const body = document.getElementById("botPkgsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPkgCount").textContent = String(packages.length);
|
||||
if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado.");
|
||||
body.replaceChildren(...packages.map(item => {
|
||||
const row = document.createElement("tr");
|
||||
row.append(
|
||||
botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)),
|
||||
botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPkg(item)),
|
||||
botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botEditPkg(item) {
|
||||
const set = (id, value) => { document.getElementById(id).value = value ?? ""; };
|
||||
set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits);
|
||||
set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder);
|
||||
document.getElementById("pkgActive").checked = !!item.IsActive;
|
||||
document.getElementById("pkgName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPkgForm() {
|
||||
document.getElementById("botPkgForm")?.reset();
|
||||
document.getElementById("pkgId").value = "";
|
||||
document.getElementById("pkgActive").checked = true;
|
||||
}
|
||||
|
||||
async function botSavePkg(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100),
|
||||
SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
|
||||
};
|
||||
botStatus("botPkgStatus", "Salvando pacote…");
|
||||
try {
|
||||
await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPkgForm();
|
||||
await loadBotPkgs();
|
||||
botStatus("botPkgStatus", "Pacote salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao salvar pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePkg(id) {
|
||||
if (!confirm("Excluir este pacote de créditos?")) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPkgs();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao excluir pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
// Users
|
||||
async function loadBotUsers() {
|
||||
try {
|
||||
const res = await api("/api/bot/users");
|
||||
const users = await res.json() || [];
|
||||
document.getElementById("botUserCount").textContent = users.length;
|
||||
document.getElementById("botUsersBody").innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.TelegramID}</td><td>${botEsc(u.FirstName)}</td><td>${botEsc(u.Username)}</td>
|
||||
<td>${botEsc(u.Role)}</td><td>${botEsc(u.LinkedAdminUsername)}</td><td>${u.CreditBalance}</td>
|
||||
<td>
|
||||
<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); }
|
||||
const users = await botRequest("/api/bot/users");
|
||||
botState.users = users || [];
|
||||
renderBotUsers(botState.users);
|
||||
botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return users;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transactions ───
|
||||
function renderBotUsers(users) {
|
||||
const body = document.getElementById("botUsersBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botUserCount").textContent = String(users.length);
|
||||
if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda.");
|
||||
body.replaceChildren(...users.map(user => {
|
||||
const row = document.createElement("tr");
|
||||
const displayName = user.FirstName || user.Username || "Sem nome";
|
||||
const username = user.Username ? `@${user.Username}` : "Sem username";
|
||||
const isBlocked = user.Role === "blocked";
|
||||
row.append(
|
||||
botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID),
|
||||
botCell(botBadge(user.Role || "customer", user.Role || "customer")),
|
||||
botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`),
|
||||
botCell(botActions([
|
||||
botButton("Função", () => botOpenUserAction(user, "role")),
|
||||
botButton("Saldo", () => botOpenUserAction(user, "credits")),
|
||||
botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botOpenUserAction(user, mode) {
|
||||
document.getElementById("botActionTelegramID").value = String(user.TelegramID);
|
||||
document.getElementById("botActionMode").value = mode;
|
||||
document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos";
|
||||
document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`;
|
||||
document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role");
|
||||
document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits");
|
||||
document.getElementById("botActionRole").value = user.Role || "customer";
|
||||
document.getElementById("botActionLinked").value = user.LinkedAdminUsername || "";
|
||||
document.getElementById("botActionCredits").value = "";
|
||||
botToggleLinkedAdminField();
|
||||
document.getElementById("botUserActionModal").classList.remove("hidden");
|
||||
document.body.classList.add("bot-modal-open");
|
||||
setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0);
|
||||
}
|
||||
|
||||
function botCloseUserAction() {
|
||||
document.getElementById("botUserActionModal")?.classList.add("hidden");
|
||||
document.body.classList.remove("bot-modal-open");
|
||||
}
|
||||
|
||||
function botToggleLinkedAdminField() {
|
||||
const show = document.getElementById("botActionRole")?.value === "reseller";
|
||||
document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
async function botSaveUserAction(event) {
|
||||
event.preventDefault();
|
||||
const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10);
|
||||
const mode = document.getElementById("botActionMode").value;
|
||||
const payload = mode === "role" ? {
|
||||
telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value,
|
||||
linked_admin_username: document.getElementById("botActionLinked").value.trim(),
|
||||
} : {
|
||||
telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0,
|
||||
};
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
botCloseUserAction();
|
||||
await loadBotUsers();
|
||||
botStatus("botUsersStatus", "Cliente atualizado.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botToggleBlock(user) {
|
||||
const isBlocked = user.Role === "blocked";
|
||||
if (!confirm(isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?")) return;
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
|
||||
await loadBotUsers();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio.");
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions
|
||||
async function loadBotTxns() {
|
||||
const filter = document.getElementById("botTxnFilter")?.value || "";
|
||||
try {
|
||||
const res = await api("/api/bot/transactions?limit=200&status=" + encodeURIComponent(filter));
|
||||
const txns = await res.json() || [];
|
||||
document.getElementById("botTxnCount").textContent = txns.length;
|
||||
document.getElementById("botTxnsBody").innerHTML = txns.map(t => `
|
||||
<tr>
|
||||
<td>${t.ID}</td><td>${t.TelegramID}</td><td>${botEsc(t.Type)}</td><td>${brl(t.AmountCents)}</td>
|
||||
<td>${botEsc(t.Status)}</td><td>${botEsc(t.TargetUsername)}</td>
|
||||
<td>${botEsc((t.CreatedAt || "").slice(0, 16).replace("T", " "))}</td>
|
||||
<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); }
|
||||
const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
|
||||
botState.transactions = transactions || [];
|
||||
renderBotTransactions(botState.transactions);
|
||||
botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings (messages) ───
|
||||
function botTransactionType(type) {
|
||||
return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—";
|
||||
}
|
||||
|
||||
function renderBotTransactions(transactions) {
|
||||
const body = document.getElementById("botTxnsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botTxnCount").textContent = String(transactions.length);
|
||||
if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro.");
|
||||
body.replaceChildren(...transactions.map(transaction => {
|
||||
const row = document.createElement("tr");
|
||||
const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—";
|
||||
const buttons = [];
|
||||
if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID)));
|
||||
if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm"));
|
||||
row.append(
|
||||
botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")),
|
||||
botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)),
|
||||
botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"),
|
||||
botCell(createdAt), botCell(botActions(buttons)),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function botReprocess(id) {
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) });
|
||||
botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true);
|
||||
setTimeout(loadBotTxns, 1400);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botRefund(id) {
|
||||
if (!confirm(`Isso apenas marca o pagamento #${id} como estornado no painel. Não envia um estorno financeiro ao Mercado Pago. Continuar?`)) return;
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
|
||||
await loadBotTxns();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
async function loadBotSettings() {
|
||||
try {
|
||||
const res = await api("/api/bot/settings");
|
||||
const s = await res.json() || {};
|
||||
const set = (id, v) => { const e = document.getElementById(id); if (e) e.value = v || ""; };
|
||||
set("setWelcome", s.welcome_text); set("setContact", s.contact_text);
|
||||
set("setAppText", s.app_text); set("setAppUrl", s.app_url);
|
||||
botStatus("botSettingsStatus", "Ready.");
|
||||
} catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao carregar.", false); }
|
||||
}
|
||||
async function saveBotSettings() {
|
||||
const val = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: val("setWelcome"), contact_text: val("setContact"), app_text: val("setAppText"), app_url: val("setAppUrl") };
|
||||
try { await api("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) }); botStatus("botSettingsStatus", "Mensagens salvas."); }
|
||||
catch (e) { if (e.message !== "auth") botStatus("botSettingsStatus", "Erro ao salvar.", false); }
|
||||
const settings = await botRequest("/api/bot/settings");
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
|
||||
set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
|
||||
set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
|
||||
botStatus("botSettingsStatus", "Mensagens carregadas.", true);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wiring ───
|
||||
async function saveBotSettings() {
|
||||
const value = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() };
|
||||
botStatus("botSettingsStatus", "Salvando mensagens…");
|
||||
try {
|
||||
await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||
botStatus("botSettingsStatus", "Mensagens salvas.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection)));
|
||||
document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value));
|
||||
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
|
||||
document.getElementById("botTestBtn")?.addEventListener("click", testBot);
|
||||
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
|
||||
document.getElementById("botSection")?.addEventListener("change", e => botShowSection(e.target.value));
|
||||
document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook);
|
||||
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
|
||||
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
|
||||
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
|
||||
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
|
||||
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
|
||||
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
|
||||
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
|
||||
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
|
||||
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings);
|
||||
document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction);
|
||||
document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField);
|
||||
document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); });
|
||||
|
||||
botSetSection(botState.section);
|
||||
|
||||
+134
-232
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713bot3"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713secure1"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -951,234 +951,136 @@
|
||||
|
||||
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-bot">
|
||||
|
||||
<!-- Section switcher -->
|
||||
<div class="card">
|
||||
<div class="card-hdr" style="align-items:center;">
|
||||
<div class="card-title">🤖 Bot / Vendas</div>
|
||||
<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>
|
||||
<section class="bot-hero">
|
||||
<div class="bot-hero-copy">
|
||||
<span class="bot-eyebrow">Central de automação</span>
|
||||
<h2>Bot de vendas</h2>
|
||||
<p>Telegram, PIX, planos e clientes em um único fluxo.</p>
|
||||
</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>
|
||||
|
||||
<!-- ═══ Section: Config ═══ -->
|
||||
<div class="bot-section" id="botSec-config">
|
||||
<!-- Config: sticky action bar -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">⚙️ Configuração do Bot</div>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
|
||||
<span id="botConfigStatus" class="hint">Pronto.</span>
|
||||
<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>
|
||||
<section class="bot-section active" id="botSec-config" data-bot-panel="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>
|
||||
<div class="bot-config-grid">
|
||||
<article class="card bot-integration-card">
|
||||
<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="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 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>
|
||||
</article>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- Telegram -->
|
||||
<div class="card">
|
||||
<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"/>
|
||||
<article class="card bot-integration-card">
|
||||
<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="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="form-grid">
|
||||
<div class="field"><label>Nome</label><input id="planName" required/></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 de validade</label><input id="planDays" type="number" min="1" value="30"/></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 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>Intervalo</label><input id="botMPPollInterval" value="20s" placeholder="20s"/></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>
|
||||
<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>
|
||||
</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 -->
|
||||
|
||||
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
|
||||
@@ -1494,17 +1396,17 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution 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/02-shell.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713bot3"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713secure1"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713secure1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,22 +4,32 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
|
||||
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
@@ -50,9 +60,11 @@ type sessionStoreT struct {
|
||||
|
||||
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)}
|
||||
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession {
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) (*AdminSession, error) {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
sess := &AdminSession{
|
||||
Token: tok,
|
||||
@@ -64,7 +76,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession
|
||||
s.mu.Lock()
|
||||
s.m[tok] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Get(token string) *AdminSession {
|
||||
@@ -86,6 +98,16 @@ func (s *sessionStoreT) Delete(token string) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) DeleteUser(userID int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token, sess := range s.m {
|
||||
if sess.UserID == userID {
|
||||
delete(s.m, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -170,6 +192,15 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || !u.IsActive || u.Role != s.Role ||
|
||||
(u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
@@ -194,11 +225,112 @@ func saSession(next http.Handler) http.Handler {
|
||||
|
||||
// ---------- Password hashing ----------
|
||||
|
||||
func hashAdminPassword(pw string) string {
|
||||
func legacyAdminPasswordHash(pw string) string {
|
||||
h := sha256.Sum256([]byte(pw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func hashAdminPassword(pw string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), adminBcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// verifyAdminPassword accepts bcrypt and the legacy unsalted SHA-256 format.
|
||||
// Legacy hashes are upgraded immediately after a successful login.
|
||||
func verifyAdminPassword(storedHash, password string) (valid bool, needsUpgrade bool) {
|
||||
if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") {
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) != nil {
|
||||
return false, false
|
||||
}
|
||||
cost, err := bcrypt.Cost([]byte(storedHash))
|
||||
return true, err != nil || cost < adminBcryptCost
|
||||
}
|
||||
if len(storedHash) != sha256.Size*2 {
|
||||
return false, false
|
||||
}
|
||||
expected := legacyAdminPasswordHash(password)
|
||||
return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expected)) == 1, true
|
||||
}
|
||||
|
||||
func validateAdminPassword(password string) error {
|
||||
if len(password) < 10 {
|
||||
return fmt.Errorf("password must contain at least 10 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAdminUsername(username string) error {
|
||||
if !adminUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Login throttling ----------
|
||||
|
||||
type loginAttempt struct {
|
||||
Failures int
|
||||
FirstSeen time.Time
|
||||
BlockedTo time.Time
|
||||
}
|
||||
|
||||
type loginThrottleT struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
var loginThrottle = &loginThrottleT{attempts: make(map[string]loginAttempt)}
|
||||
|
||||
func loginAttemptKey(r *http.Request, username string) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
return host + "\x00" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) retryAfter(key string, now time.Time) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.attempts[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !a.BlockedTo.IsZero() && now.Before(a.BlockedTo) {
|
||||
return time.Until(a.BlockedTo)
|
||||
}
|
||||
if now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) fail(key string, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.attempts[key]
|
||||
if a.FirstSeen.IsZero() || now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
a = loginAttempt{FirstSeen: now}
|
||||
}
|
||||
a.Failures++
|
||||
if a.Failures >= 5 {
|
||||
a.BlockedTo = now.Add(15 * time.Minute)
|
||||
}
|
||||
l.attempts[key] = a
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) success(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- DB methods on Store ----------
|
||||
|
||||
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
@@ -286,6 +418,11 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminPasswordHash(ctx context.Context, id int, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE admin_users SET password_hash=$2 WHERE id=$1`, id, passwordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAdminUser(ctx context.Context, username string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM admin_users WHERE username=$1`, username)
|
||||
return err
|
||||
@@ -352,11 +489,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
b := make([]byte, 10)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate bootstrap password: %w", err)
|
||||
}
|
||||
pw := hex.EncodeToString(b)
|
||||
passwordHash, err := hashAdminPassword(pw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &AdminUser{
|
||||
Username: "admin",
|
||||
PasswordHash: hashAdminPassword(pw),
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleSuperAdmin,
|
||||
MaxUsers: 0,
|
||||
IsActive: true,
|
||||
@@ -474,18 +617,33 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := loginAttemptKey(r, req.Username)
|
||||
now := time.Now()
|
||||
if retry := loginThrottle.retryAfter(key, now); retry > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds()))))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
@@ -493,7 +651,16 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u == nil || u.PasswordHash != hashAdminPassword(req.Password) {
|
||||
valid := false
|
||||
needsUpgrade := false
|
||||
if u != nil {
|
||||
valid, needsUpgrade = verifyAdminPassword(u.PasswordHash, req.Password)
|
||||
} else {
|
||||
// Keep roughly the same CPU cost for unknown users to reduce account probing.
|
||||
_, _ = hashAdminPassword(req.Password)
|
||||
}
|
||||
if !valid {
|
||||
loginThrottle.fail(key, now)
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -506,7 +673,23 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if needsUpgrade {
|
||||
if upgradedHash, hashErr := hashAdminPassword(req.Password); hashErr == nil {
|
||||
if updateErr := store.UpdateAdminPasswordHash(r.Context(), u.ID, upgradedHash); updateErr != nil {
|
||||
log.Printf("upgrade admin password hash for %s: %v", u.Username, updateErr)
|
||||
} else {
|
||||
u.PasswordHash = upgradedHash
|
||||
adminUsers.set(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
loginThrottle.success(key)
|
||||
sess, err := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if err != nil {
|
||||
log.Printf("create admin session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": sess.Token,
|
||||
@@ -614,8 +797,13 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAdminUsername(p.Username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.MaxUsers < 0 || p.MaxUsers > 1000000 {
|
||||
http.Error(w, "max_users must be between 0 and 1000000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -638,7 +826,16 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if p.Password != "" {
|
||||
u.PasswordHash = hashAdminPassword(p.Password)
|
||||
if err := validateAdminPassword(p.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
passwordHash, err := hashAdminPassword(p.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u.PasswordHash = passwordHash
|
||||
}
|
||||
u.MaxUsers = p.MaxUsers
|
||||
u.IsActive = p.IsActive
|
||||
@@ -658,6 +855,9 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
adminUsers.set(u)
|
||||
if p.Password != "" && existing != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
|
||||
if u.Role == RoleReseller {
|
||||
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
@@ -676,12 +876,17 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
u, _ := store.GetAdminUserByUsername(ctx, username)
|
||||
if u != nil && u.Role == RoleSuperAdmin {
|
||||
http.Error(w, "superadmin accounts cannot be deleted from the reseller endpoint", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteAdminUser(ctx, username); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -689,6 +894,9 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
disconnectOwnerUsers(username)
|
||||
removeOwnerXrayClients(ctx, store, username)
|
||||
adminUsers.delete(username)
|
||||
if u != nil {
|
||||
sessions.DeleteUser(u.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
+172
-2
@@ -4,11 +4,73 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var botSettingKeys = map[string]int{
|
||||
"welcome_text": 4096,
|
||||
"contact_text": 4096,
|
||||
"app_text": 4096,
|
||||
"app_url": 2048,
|
||||
}
|
||||
|
||||
func botHasControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool {
|
||||
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'
|
||||
}) >= 0
|
||||
}
|
||||
|
||||
func botHasAnyControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, unicode.IsControl) >= 0
|
||||
}
|
||||
|
||||
func validateBotPlan(p *BotPlan) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Kind = strings.ToLower(strings.TrimSpace(p.Kind))
|
||||
p.XrayProtocol = strings.ToLower(strings.TrimSpace(p.XrayProtocol))
|
||||
p.XrayInboundTag = strings.TrimSpace(p.XrayInboundTag)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("plan name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Kind != "ssh" && p.Kind != "xray" {
|
||||
return fmt.Errorf("plan kind must be ssh or xray")
|
||||
}
|
||||
if p.Days < 1 || p.Days > 3650 || p.MaxConnections < 0 || p.MaxConnections > 10000 {
|
||||
return fmt.Errorf("invalid plan duration or connection limit")
|
||||
}
|
||||
if p.LimitMbpsUp < 0 || p.LimitMbpsUp > 1000000 || p.LimitMbpsDown < 0 || p.LimitMbpsDown > 1000000 {
|
||||
return fmt.Errorf("invalid bandwidth limit")
|
||||
}
|
||||
if p.PriceCents < 0 || p.PriceCents > 1000000000 || p.CreditCost < 0 || p.CreditCost > 1000000000 {
|
||||
return fmt.Errorf("invalid plan price or credit cost")
|
||||
}
|
||||
if p.Kind == "xray" && p.XrayProtocol != "" && p.XrayProtocol != "vless" && p.XrayProtocol != "vmess" && p.XrayProtocol != "trojan" {
|
||||
return fmt.Errorf("invalid Xray protocol")
|
||||
}
|
||||
if len(p.XrayInboundTag) > 128 || len(p.ServerID) > 128 {
|
||||
return fmt.Errorf("inbound tag or server id is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotPackage(p *BotCreditPackage) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("package name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Credits < 1 || p.Credits > 1000000000 || p.PriceCents < 0 || p.PriceCents > 1000000000 {
|
||||
return fmt.Errorf("invalid package credits or price")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func botWriteJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
@@ -80,10 +142,69 @@ func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
})
|
||||
case http.MethodPost:
|
||||
var dto botConfigDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.MPConfirmMode = strings.ToLower(strings.TrimSpace(dto.MPConfirmMode))
|
||||
if dto.MPConfirmMode != "polling" && dto.MPConfirmMode != "webhook" {
|
||||
http.Error(w, "confirmation mode must be polling or webhook", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollInterval, err := time.ParseDuration(strings.TrimSpace(dto.MPPollInterval))
|
||||
if err != nil || pollInterval < 5*time.Second || pollInterval > 5*time.Minute {
|
||||
http.Error(w, "poll interval must be between 5s and 5m", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dto.PixExpirationMinutes < 5 || dto.PixExpirationMinutes > 1440 || dto.TrialHours < 1 || dto.TrialHours > 720 || dto.TrialMaxConnections < 1 || dto.TrialMaxConnections > 1000 {
|
||||
http.Error(w, "invalid PIX expiration or trial limits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.TrialKind = strings.ToLower(strings.TrimSpace(dto.TrialKind))
|
||||
if dto.TrialKind != "ssh" && dto.TrialKind != "xray" {
|
||||
http.Error(w, "trial kind must be ssh or xray", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(dto.AdminTelegramIDs) > 100 {
|
||||
http.Error(w, "too many admin Telegram IDs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, id := range dto.AdminTelegramIDs {
|
||||
if id <= 0 {
|
||||
http.Error(w, "admin Telegram IDs must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, value := range []string{dto.TelegramToken, dto.MPAccessToken, dto.MPWebhookSecret, dto.PublicHost, dto.XrayPublicHost, dto.TrialInboundTag} {
|
||||
if len(value) > 2048 || botHasAnyControlCharacters(value) {
|
||||
http.Error(w, "configuration contains an invalid value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
existing, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load existing config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
effectiveTelegramToken := strings.TrimSpace(dto.TelegramToken)
|
||||
if effectiveTelegramToken == "" {
|
||||
effectiveTelegramToken = existing.TelegramToken
|
||||
}
|
||||
if dto.Enabled && effectiveTelegramToken == "" {
|
||||
http.Error(w, "Telegram token is required before enabling the bot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
effectiveWebhookSecret := strings.TrimSpace(dto.MPWebhookSecret)
|
||||
if effectiveWebhookSecret == "" {
|
||||
effectiveWebhookSecret = existing.MPWebhookSecret
|
||||
}
|
||||
if dto.MPConfirmMode == "webhook" && len(effectiveWebhookSecret) < 16 {
|
||||
http.Error(w, "a webhook secret of at least 16 characters is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := &BotConfig{
|
||||
Enabled: dto.Enabled,
|
||||
TelegramToken: strings.TrimSpace(dto.TelegramToken),
|
||||
@@ -139,6 +260,10 @@ func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
if p.Kind == "" {
|
||||
p.Kind = "ssh"
|
||||
}
|
||||
if err := validateBotPlan(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -183,6 +308,10 @@ func handleBotCreditPackages(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBotPackage(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertCreditPackage(ctx, &p); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -238,6 +367,21 @@ func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
if req.Role == "" {
|
||||
req.Role = "customer"
|
||||
}
|
||||
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if req.Role != "customer" && req.Role != "reseller" && req.Role != "blocked" {
|
||||
http.Error(w, "role must be customer, reseller, or blocked", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.LinkedAdminUsername = strings.TrimSpace(req.LinkedAdminUsername)
|
||||
if req.Role == "reseller" {
|
||||
linked, err := store.GetAdminUserByUsername(ctx, req.LinkedAdminUsername)
|
||||
if err != nil || linked == nil || linked.Role != RoleReseller {
|
||||
http.Error(w, "linked reseller account not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.LinkedAdminUsername = ""
|
||||
}
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -253,6 +397,10 @@ func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
case "adjust_credits":
|
||||
if req.Credits == 0 || req.Credits < -1000000000 || req.Credits > 1000000000 {
|
||||
http.Error(w, "invalid credit adjustment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil {
|
||||
http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -278,8 +426,18 @@ func handleBotTransactions(store *Store) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status := r.URL.Query().Get("status")
|
||||
status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "pending" && status != "approved" && status != "expired" && status != "refunded" && status != "error" {
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
txns, err := store.ListTransactions(ctx, status, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
@@ -342,6 +500,18 @@ func handleBotSettings(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
for k, v := range kv {
|
||||
maxLen, ok := botSettingKeys[k]
|
||||
if !ok || len(v) > maxLen || botHasControlCharacters(v) {
|
||||
http.Error(w, "invalid bot setting", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if k == "app_url" && strings.TrimSpace(v) != "" {
|
||||
u, err := url.ParseRequestURI(strings.TrimSpace(v))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
http.Error(w, "app_url must be an http or https URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.SetSetting(ctx, k, v); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+35
-4
@@ -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).
|
||||
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if secret == "" {
|
||||
return true // validation disabled
|
||||
return false
|
||||
}
|
||||
var ts, v1 string
|
||||
for _, part := range strings.Split(xSignature, ",") {
|
||||
@@ -147,7 +147,18 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if ts == "" || v1 == "" {
|
||||
return false
|
||||
}
|
||||
manifest := fmt.Sprintf("id:%s;request-id:%s;ts:%s;", strings.ToLower(dataID), xRequestID, ts)
|
||||
if timestamp, err := strconv.ParseInt(ts, 10, 64); err != nil || timestamp <= 0 {
|
||||
return false
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if dataID != "" {
|
||||
parts = append(parts, "id:"+strings.ToLower(dataID))
|
||||
}
|
||||
if xRequestID != "" {
|
||||
parts = append(parts, "request-id:"+xRequestID)
|
||||
}
|
||||
parts = append(parts, "ts:"+ts)
|
||||
manifest := strings.Join(parts, ";") + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(manifest))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
@@ -157,6 +168,10 @@ func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
// handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
|
||||
// It never trusts the body: it re-fetches the payment and fulfills idempotently.
|
||||
func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := currentBot()
|
||||
if b == nil {
|
||||
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
|
||||
@@ -173,6 +188,7 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if dataID == "" {
|
||||
dataID = r.URL.Query().Get("id")
|
||||
}
|
||||
signatureDataID := dataID
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action"`
|
||||
@@ -180,7 +196,12 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid webhook body", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if dataID == "" {
|
||||
@@ -191,8 +212,18 @@ func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if len(dataID) > 32 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, char := range dataID {
|
||||
if char < '0' || char > '9' {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), dataID, b.cfg.MPWebhookSecret) {
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), signatureDataID, b.cfg.MPWebhookSecret) {
|
||||
log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
+12
-24
@@ -104,18 +104,15 @@ get_env_value() {
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
update_env_password() {
|
||||
local new_password="$1"
|
||||
remove_legacy_env_password() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v line="ADMIN_PASSWORD=${new_password}" '
|
||||
BEGIN { done = 0 }
|
||||
/^ADMIN_PASSWORD=/ { print line; done = 1; next }
|
||||
awk '
|
||||
/^ADMIN_PASSWORD=/ { next }
|
||||
{ print }
|
||||
END { if (!done) print line }
|
||||
' "$ENV_FILE" > "$tmp"
|
||||
cat "$tmp" > "$ENV_FILE"
|
||||
rm -f "$tmp"
|
||||
install -m 600 "$tmp" "$ENV_FILE"
|
||||
rm -f -- "$tmp"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -135,15 +132,8 @@ generate_password() {
|
||||
|
||||
hash_password() {
|
||||
local pw="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | openssl dgst -sha256 -r | awk '{print $1}'
|
||||
else
|
||||
error "No SHA-256 tool found. Install coreutils or openssl."
|
||||
fi
|
||||
[[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel"
|
||||
printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null
|
||||
}
|
||||
|
||||
PG_DSN="$(get_env_value PG_DSN)"
|
||||
@@ -168,12 +158,12 @@ elif [[ -z "$NEW_PASSWORD" ]]; then
|
||||
fi
|
||||
|
||||
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
|
||||
if [[ ${#NEW_PASSWORD} -lt 8 ]]; then
|
||||
error "Password must have at least 8 characters."
|
||||
if [[ ${#NEW_PASSWORD} -lt 10 ]]; then
|
||||
error "Password must have at least 10 characters."
|
||||
fi
|
||||
|
||||
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")"
|
||||
[[ ${#PASSWORD_HASH} -eq 64 ]] || error "Failed to generate valid SHA-256 password hash."
|
||||
[[ "$PASSWORD_HASH" == \$2* ]] || error "Failed to generate a valid bcrypt password hash."
|
||||
|
||||
info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
|
||||
psql "$PG_DSN" -v ON_ERROR_STOP=1 \
|
||||
@@ -201,10 +191,8 @@ ON CONFLICT (username) DO UPDATE SET
|
||||
SQL
|
||||
|
||||
if [[ "$ADMIN_USER" == "admin" ]]; then
|
||||
update_env_password "$NEW_PASSWORD"
|
||||
info "Updated ADMIN_PASSWORD inside $ENV_FILE"
|
||||
else
|
||||
warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'."
|
||||
remove_legacy_env_password
|
||||
info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE"
|
||||
fi
|
||||
|
||||
if ! $NO_RESTART; then
|
||||
|
||||
@@ -1532,7 +1532,6 @@ func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncoded
|
||||
atomic.AddUint64(&dnsttStats.RespSent, 1)
|
||||
atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeMaxEncodedPayload computes the maximum amount of downstream TXT RR
|
||||
|
||||
+3
-3
@@ -493,7 +493,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true)
|
||||
if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then
|
||||
ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}')
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null)
|
||||
[[ "$ADMIN_PASSWORD_HASH" == \$2* ]] || error "Failed to generate admin bcrypt password hash"
|
||||
su -c "psql -d ${DB_NAME}" postgres <<SQL
|
||||
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE)
|
||||
@@ -509,7 +510,6 @@ SQL
|
||||
cat > "$INSTALL_DIR/.env" <<EOF
|
||||
PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable
|
||||
ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ADMIN_HTTP_ADDR=0.0.0.0:9090
|
||||
EOF
|
||||
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 token : ${YELLOW}${ADMIN_TOKEN}${NC}"
|
||||
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 " tail -f ${INSTALL_DIR}/logs/panel.log"
|
||||
echo ""
|
||||
|
||||
@@ -1328,6 +1328,16 @@ func NewStore(dsn string) (*Store, error) {
|
||||
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 {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS ssh_users (
|
||||
@@ -1355,6 +1365,39 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1391,6 +1434,10 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err = openSSHPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err)
|
||||
}
|
||||
|
||||
cfg := UserConfig{
|
||||
Username: username,
|
||||
@@ -1426,7 +1473,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
|
||||
// UpsertUser creates or updates a row in ssh_users.
|
||||
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
storedPassword, err := sealSSHPassword(u.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt SSH password: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO ssh_users (
|
||||
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
|
||||
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
|
||||
@@ -1444,7 +1495,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
totp_digits = EXCLUDED.totp_digits,
|
||||
allow_static_password = EXCLUDED.allow_static_password`,
|
||||
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
|
||||
u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
|
||||
return err
|
||||
}
|
||||
@@ -1634,7 +1685,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
|
||||
go func() {
|
||||
log.Printf("Admin HTTP (panel + API) listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: securePanelHandler(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Printf("admin http error: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -1756,6 +1816,27 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
currentOwner, exists, ownerErr := remoteSSHUserOwner(ctx, ms, p.Username)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if exists && currentOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
p.ServerID = ""
|
||||
@@ -1769,6 +1850,20 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
var existingOwner string
|
||||
err := store.db.QueryRowContext(ctx, `SELECT owner_username FROM ssh_users WHERE username=$1`, p.Username).Scan(&existingOwner)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == nil && existingOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
// - if payload has non-empty password -> use it
|
||||
// - else try to read existing password from DB
|
||||
@@ -1802,7 +1897,6 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Determine owner and enforce reseller quota
|
||||
sess := sessionFromCtx(ctx)
|
||||
ownerUsername := ""
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
@@ -1813,7 +1907,12 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
).Scan(&existsInDB)
|
||||
if !existsInDB {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(ctx, store, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -2218,7 +2317,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
return
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s:%d", req.Host, req.Port)
|
||||
target := net.JoinHostPort(req.Host, strconv.FormatUint(uint64(req.Port), 10))
|
||||
log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
|
||||
u.Cfg.Username, target, req.OriginAddr, req.OriginPort)
|
||||
|
||||
@@ -2841,7 +2940,23 @@ func main() {
|
||||
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
|
||||
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
|
||||
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
|
||||
hashAdminPasswordStdin := flag.Bool("hash-admin-password-stdin", false, "read an admin password from stdin and print a bcrypt hash")
|
||||
flag.Parse()
|
||||
if *hashAdminPasswordStdin {
|
||||
password, readErr := io.ReadAll(io.LimitReader(os.Stdin, 1025))
|
||||
if readErr != nil {
|
||||
log.Fatalf("read admin password: %v", readErr)
|
||||
}
|
||||
if err := validateAdminPassword(string(password)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
hash, hashErr := hashAdminPassword(string(password))
|
||||
if hashErr != nil {
|
||||
log.Fatal(hashErr)
|
||||
}
|
||||
fmt.Println(hash)
|
||||
return
|
||||
}
|
||||
|
||||
resolvedConfigPath := resolveMainConfigPath(*configPath)
|
||||
cfg, userMap, err := loadConfig(resolvedConfigPath)
|
||||
|
||||
+204
-30
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -15,6 +16,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const managedCredentialPrefix = "enc:v1:"
|
||||
|
||||
func sealManagedCredential(plain string) (string, error) {
|
||||
return sealCredential(managedCredentialPrefix, plain)
|
||||
}
|
||||
|
||||
func openManagedCredential(stored string) (string, error) {
|
||||
return openCredential(managedCredentialPrefix, stored)
|
||||
}
|
||||
|
||||
func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -66,7 +86,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateManagedServerCredentials(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateManagedServerCredentials(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyCredential struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
var legacy []legacyCredential
|
||||
for rows.Next() {
|
||||
var item legacyCredential
|
||||
if err := rows.Scan(&item.id, &item.key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealManagedCredential(item.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
@@ -83,6 +139,11 @@ func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -100,12 +161,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
|
||||
baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
@@ -113,8 +179,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid server name")
|
||||
}
|
||||
if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin username")
|
||||
}
|
||||
if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin credential")
|
||||
}
|
||||
if baseURLErr != nil {
|
||||
return nil, baseURLErr
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
@@ -127,10 +202,14 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
|
||||
if sealErr != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,8 +219,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
sealedKey, err := sealManagedCredential(p.AdminKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", err)
|
||||
}
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
@@ -152,7 +235,7 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,21 +276,39 @@ func localManagedServerDTO() ManagedServerDTO {
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
normalized, _ := validateManagedServerBaseURL(raw)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateManagedServerBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("base url required")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("base url must use http or https")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("base url must not contain credentials")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("base url must not contain a path")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
|
||||
return "", fmt.Errorf("base url uses a forbidden address")
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
@@ -250,7 +351,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := managedServerHTTPClient(15 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -286,7 +387,7 @@ func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path str
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := managedServerHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
@@ -560,46 +661,119 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
if owner == "" || username == "" {
|
||||
return false
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
if username == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return false
|
||||
return "", false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
if owner == "" || uuid == "" {
|
||||
return false
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
if uuid == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return false
|
||||
return "", false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
return strings.TrimSpace(fmt.Sprint(m["owner_username"])), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
if store == nil || owner == "" {
|
||||
return 0, nil
|
||||
}
|
||||
total := countOwnedQuota(ctx, store, owner)
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
if !ms.IsActive {
|
||||
continue
|
||||
}
|
||||
if ms.EnableSSH {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ms.EnableXray {
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024+1))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 512*1024 {
|
||||
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -110,7 +114,7 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "marshal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(globalCfgPath, out, 0o644); err != nil {
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
+97
-24
@@ -4,21 +4,57 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsCertsDir = "/opt/sshpanel/certs"
|
||||
|
||||
var (
|
||||
tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
tlsStoreNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
func normalizeTLSDomain(raw string, allowWildcard bool) (domain string, dirName string, err error) {
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(raw), ".")
|
||||
if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/\\\x00\r\n") {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
return domain, strings.ReplaceAll(domain, ":", "_"), nil
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") && !allowWildcard {
|
||||
return "", "", fmt.Errorf("wildcard domains are not supported by this operation")
|
||||
}
|
||||
if !tlsDNSNamePattern.MatchString(domain) {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
dirName = strings.ReplaceAll(domain, "*", "_wildcard_")
|
||||
return domain, dirName, nil
|
||||
}
|
||||
|
||||
func normalizeTLSStoreName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if !tlsStoreNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("name must use only letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the
|
||||
// given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths.
|
||||
func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,12 +65,18 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain, dirName, err := normalizeTLSDomain(req.Domain, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, req.Domain)
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -47,40 +89,45 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "serial generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: req.Domain},
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{req.Domain},
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
if net.ParseIP(domain) != nil {
|
||||
tmpl.DNSNames = nil
|
||||
tmpl.IPAddresses = []net.IP{net.ParseIP(domain)}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cf, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
cf.Close()
|
||||
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
kf.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" {
|
||||
http.Error(w, "domain and email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain, _, err := normalizeTLSDomain(req.Domain, false)
|
||||
if err != nil || net.ParseIP(domain) != nil {
|
||||
http.Error(w, "a valid DNS domain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
parsedEmail, err := mail.ParseAddress(email)
|
||||
if err != nil || parsedEmail.Address != email || len(email) > 254 {
|
||||
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", req.Email, "-d", req.Domain)
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem"
|
||||
certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -136,13 +195,27 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Cert == "" || req.Key == "" {
|
||||
http.Error(w, "name, cert, and key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(req.Name)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
http.Error(w, "invalid name", http.StatusBadRequest)
|
||||
name, err := normalizeTLSStoreName(req.Name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Cert) > 1024*1024 || len(req.Key) > 1024*1024 {
|
||||
http.Error(w, "certificate or key is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(req.Cert), []byte(req.Key))
|
||||
if err != nil || len(pair.Certificate) == 0 {
|
||||
http.Error(w, "certificate and private key are invalid or do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
http.Error(w, "invalid leaf certificate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
@@ -152,11 +225,11 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := os.WriteFile(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
+25
-1
@@ -2458,6 +2458,25 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if ok && owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
req.OwnerUsername = sess.Username
|
||||
}
|
||||
req.ServerID = ""
|
||||
@@ -2491,7 +2510,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers {
|
||||
used, quotaErr := countOwnedQuotaAcrossManagedServers(r.Context(), statsStore, sess.Username)
|
||||
if quotaErr != nil {
|
||||
http.Error(w, "could not verify reseller quota", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if owner.MaxUsers > 0 && used >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
+25
-13
@@ -1068,18 +1068,30 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
|
||||
t.Fatalf("write fast mux frame: %v", err)
|
||||
}
|
||||
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
if meta.sessionID != 2 || meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
got, err := readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
for {
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
var got []byte
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
got, err = readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read mux response payload: %v", err)
|
||||
}
|
||||
}
|
||||
// Some networks reject TEST-NET immediately instead of letting the dial
|
||||
// time out. Ignore that session-1 error frame and keep waiting for the
|
||||
// independent fast session.
|
||||
if meta.sessionID != 2 {
|
||||
continue
|
||||
}
|
||||
if meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user