2151 lines
104 KiB
HTML
2151 lines
104 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<title>SSH Panel</title>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<style>
|
|
:root{
|
|
--bg:#0f172a;--bg2:#111827;--card:#020617;--accent:#3b82f6;
|
|
--accent-soft:rgba(59,130,246,.15);--border:#1f2937;--text:#e5e7eb;
|
|
--muted:#9ca3af;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;
|
|
}
|
|
*{box-sizing:border-box;margin:0;padding:0;}
|
|
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:radial-gradient(circle at top,#1e293b,#020617 50%);color:var(--text);min-height:100vh;}
|
|
.app{padding:10px;}
|
|
@media(min-width:768px){.app{padding:20px;}}
|
|
|
|
/* ── Shell ── */
|
|
.shell{max-width:1100px;margin:0 auto;background:linear-gradient(145deg,rgba(15,23,42,.97),rgba(15,23,42,.99));border-radius:16px;border:1px solid rgba(148,163,184,.1);box-shadow:0 18px 60px rgba(15,23,42,.9);padding:14px;}
|
|
@media(min-width:768px){.shell{padding:20px;}}
|
|
|
|
/* ── Header ── */
|
|
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--border);}
|
|
.logo{font-size:1.1rem;font-weight:700;letter-spacing:.03em;}
|
|
.logo span{color:var(--accent);}
|
|
nav{display:flex;gap:4px;flex-wrap:wrap;}
|
|
.tab-btn{background:transparent;border:1px solid transparent;border-radius:999px;padding:5px 12px;font-size:.75rem;color:var(--muted);cursor:pointer;}
|
|
.tab-btn:hover{border-color:var(--border);color:var(--text);}
|
|
.tab-btn.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);}
|
|
.hright{display:flex;align-items:center;gap:8px;font-size:.75rem;color:var(--muted);}
|
|
.hright strong{color:var(--text);}
|
|
|
|
/* ── Cards / Grid ── */
|
|
.grid2{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:12px;}
|
|
@media(max-width:860px){.grid2{grid-template-columns:1fr;}}
|
|
.card{background:radial-gradient(circle at top left,rgba(59,130,246,.07),var(--card));border-radius:14px;border:1px solid rgba(31,41,55,.95);padding:12px;position:relative;}
|
|
.card+.card{margin-top:12px;}
|
|
.card-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;gap:6px;flex-wrap:wrap;}
|
|
.card-title{font-size:.85rem;font-weight:600;display:flex;align-items:center;gap:6px;}
|
|
.chip{font-size:.62rem;padding:2px 7px;border-radius:999px;background:rgba(15,23,42,.85);border:1px solid rgba(55,65,81,.9);color:var(--muted);}
|
|
.chip.green{border-color:rgba(34,197,94,.5);color:var(--success);}
|
|
.chip.red{border-color:rgba(239,68,68,.5);color:var(--danger);}
|
|
.chip.warn{border-color:rgba(245,158,11,.5);color:var(--warn);}
|
|
|
|
/* ── Buttons ── */
|
|
.btn{border:none;border-radius:999px;padding:5px 11px;font-size:.72rem;font-weight:500;background:var(--accent);color:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:opacity .15s;}
|
|
.btn:hover{opacity:.85;}
|
|
.btn:disabled{opacity:.45;cursor:default;}
|
|
.btn-ghost{background:transparent;border:1px solid rgba(55,65,81,.9);color:var(--muted);}
|
|
.btn-ghost:hover{border-color:var(--accent);color:var(--accent);}
|
|
.btn-danger{background:rgba(239,68,68,.08);color:var(--danger);border:1px solid rgba(248,113,113,.4);}
|
|
.btn-warn{background:rgba(245,158,11,.08);color:var(--warn);border:1px solid rgba(245,158,11,.4);}
|
|
.btn-sm{padding:3px 9px;font-size:.68rem;}
|
|
|
|
/* ── Tables ── */
|
|
.tbl-wrap{overflow-x:auto;margin:4px 0;}
|
|
table{width:100%;border-collapse:collapse;font-size:.73rem;}
|
|
thead{background:rgba(15,23,42,.9);}
|
|
th,td{padding:6px 7px;text-align:left;border-bottom:1px solid rgba(31,41,55,.95);white-space:nowrap;}
|
|
th{font-weight:500;color:var(--muted);font-size:.68rem;}
|
|
tbody tr:hover{background:rgba(15,23,42,.85);}
|
|
|
|
/* ── Metrics ── */
|
|
.metrics{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 10px;}
|
|
.metric{flex:1 1 80px;min-width:80px;padding:8px;border-radius:10px;background:rgba(15,23,42,.9);border:1px solid rgba(31,41,55,.9);}
|
|
.m-label{font-size:.68rem;color:var(--muted);}
|
|
.m-val{font-size:.9rem;font-weight:600;margin-top:2px;}
|
|
.bar{height:5px;border-radius:999px;background:rgba(31,41,55,.9);overflow:hidden;margin-top:5px;}
|
|
.bar-inner{height:100%;background:linear-gradient(90deg,#22c55e,#eab308,#ef4444);transition:width .25s;}
|
|
|
|
/* ── Forms ── */
|
|
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:8px;}
|
|
@media(max-width:600px){.form-grid{grid-template-columns:1fr;}}
|
|
.field{display:flex;flex-direction:column;gap:3px;}
|
|
.field label{font-size:.68rem;color:var(--muted);}
|
|
.field input,.field select,.field textarea{border-radius:8px;border:1px solid rgba(55,65,81,.9);padding:6px 10px;background:rgba(15,23,42,.9);color:var(--text);font-size:.73rem;outline:none;font-family:inherit;}
|
|
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px rgba(59,130,246,.4);}
|
|
.field-row{display:flex;gap:6px;align-items:center;}
|
|
.field-row input{flex:1 1 0;}
|
|
.hint{font-size:.68rem;color:var(--muted);}
|
|
.form-actions{margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
|
|
.statusbar{margin-top:6px;font-size:.68rem;color:var(--muted);display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;}
|
|
|
|
/* ── Tabs ── */
|
|
.tab-pane{display:none;}
|
|
.tab-pane.active{display:block;}
|
|
|
|
/* ── Collapsible ── */
|
|
.collapsible.collapsed{display:none;}
|
|
|
|
/* ── Login overlay ── */
|
|
.overlay{position:fixed;inset:0;background:radial-gradient(circle at top,rgba(15,23,42,.98),rgba(15,23,42,.995));display:flex;align-items:center;justify-content:center;z-index:30;}
|
|
.overlay-inner{width:100%;max-width:340px;margin:0 14px;padding:22px;border-radius:16px;background:linear-gradient(145deg,rgba(15,23,42,.98),rgba(2,6,23,1));border:1px solid rgba(55,65,81,.9);box-shadow:0 20px 60px rgba(15,23,42,1);}
|
|
.ov-title{font-size:1.05rem;font-weight:700;margin-bottom:4px;}
|
|
.ov-sub{font-size:.8rem;color:var(--muted);margin-bottom:14px;}
|
|
.ov-field{width:100%;margin:6px 0;border-radius:8px;border:1px solid rgba(55,65,81,.9);background:rgba(15,23,42,.9);color:var(--text);padding:8px 12px;font-size:.82rem;outline:none;}
|
|
.ov-field:focus{border-color:var(--accent);}
|
|
.hidden{display:none!important;}
|
|
.badge-on{color:var(--success);font-size:.68rem;}
|
|
.badge-off{color:var(--muted);font-size:.68rem;}
|
|
|
|
/* ── Xray log / config editor ── */
|
|
.code-area{width:100%;background:rgba(15,23,42,.9);color:#e5e7eb;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-family:monospace;font-size:.7rem;resize:vertical;outline:none;}
|
|
pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-size:.68rem;overflow-y:auto;max-height:180px;white-space:pre-wrap;word-break:break-all;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="shell">
|
|
|
|
<!-- Login overlay -->
|
|
<div class="overlay" id="loginOverlay">
|
|
<div class="overlay-inner">
|
|
<div class="ov-title">SSH Panel</div>
|
|
<div class="ov-sub">Sign in with your admin or reseller credentials.</div>
|
|
<input id="loginUser" class="ov-field" placeholder="Username" autocomplete="username"/>
|
|
<input id="loginPass" class="ov-field" type="password" placeholder="Password" autocomplete="current-password"/>
|
|
<button class="btn" id="loginBtn" style="width:100%;margin-top:6px;justify-content:center;">Sign in</button>
|
|
<div id="loginErr" style="margin-top:6px;font-size:.72rem;color:var(--danger);"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main app -->
|
|
<div id="mainApp" class="hidden">
|
|
|
|
<!-- Header -->
|
|
<header>
|
|
<div class="logo">SSH <span>Panel</span></div>
|
|
<nav id="mainNav">
|
|
<button class="tab-btn active" data-tab="ssh">SSH Users</button>
|
|
<button class="tab-btn superadmin-only hidden" data-tab="xray">Xray</button>
|
|
<button class="tab-btn superadmin-only hidden" data-tab="resellers">Resellers</button>
|
|
<button class="tab-btn superadmin-only hidden" data-tab="stats">Stats</button>
|
|
<button class="tab-btn superadmin-only hidden" data-tab="server">Server Config</button>
|
|
</nav>
|
|
<div class="hright">
|
|
<span id="roleChip"></span>
|
|
<strong id="meUsername"></strong>
|
|
<button class="btn btn-ghost btn-sm" id="logoutBtn">Logout</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ═══════════ SSH Users Tab ═══════════ -->
|
|
<div class="tab-pane active" id="tab-ssh">
|
|
|
|
<!-- Reseller info card (visible to resellers only) -->
|
|
<div id="resellerInfoCard" class="card hidden" style="margin-bottom:12px;">
|
|
<div class="card-hdr"><div class="card-title">My Account</div></div>
|
|
<div class="metrics">
|
|
<div class="metric">
|
|
<div class="m-label">Users (used / max)</div>
|
|
<div class="m-val" id="rUsedMax">-- / --</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="m-label">Expires</div>
|
|
<div class="m-val" id="rExpiry">--</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="m-label">Status</div>
|
|
<div class="m-val" id="rStatus">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid2">
|
|
<!-- Users list -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Users <span class="chip" id="userCountChip">0</span></div>
|
|
<div style="display:flex;gap:5px;">
|
|
<button class="btn btn-ghost btn-sm" id="newUserBtn">+ New</button>
|
|
<button class="btn btn-ghost btn-sm" id="reloadUsersBtn">Reload</button>
|
|
</div>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr>
|
|
<th>User</th><th>Status</th><th>Auth</th>
|
|
<th>Conn</th><th>Max</th><th>Up</th><th>Dn</th><th>Expires</th>
|
|
<th id="ownerColHead" class="superadmin-only hidden">Owner</th>
|
|
<th>Actions</th>
|
|
</tr></thead>
|
|
<tbody id="usersBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="statusbar"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
|
|
</div>
|
|
|
|
<!-- Create / edit user form -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Create / update user</div>
|
|
<button class="btn btn-ghost btn-sm" id="toggleFormBtn">Show form</button>
|
|
</div>
|
|
<div id="userFormWrap" class="collapsible collapsed">
|
|
<form id="userForm">
|
|
<div class="form-grid">
|
|
<div class="field"><label>Username</label><input id="fUsername" required autocomplete="off"/></div>
|
|
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="fPassword" type="password" autocomplete="new-password"/></div>
|
|
<div class="field"><label>TOTP Secret</label>
|
|
<div class="field-row">
|
|
<input id="fTotpSecret" placeholder="Leave blank to disable"/>
|
|
<button class="btn btn-ghost btn-sm" type="button" id="genTotpBtn">Gen</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" id="clearTotpBtn">X</button>
|
|
</div>
|
|
</div>
|
|
<div class="field"><label>TOTP Period (s)</label><input id="fTotpPeriod" type="number" min="15" step="15" placeholder="60"/></div>
|
|
<div class="field"><label>TOTP Window</label><input id="fTotpWindow" type="number" min="0" placeholder="1"/></div>
|
|
<div class="field"><label>TOTP Digits</label><input id="fTotpDigits" type="number" min="6" max="8" placeholder="6"/></div>
|
|
<div class="field"><label>Allow static password too</label><input id="fAllowStatic" type="checkbox" style="width:16px;height:16px;margin-top:10px;"/></div>
|
|
<div class="field"><label>Max connections</label><input id="fMaxConn" type="number" min="0" placeholder="0 = unlimited"/></div>
|
|
<div class="field"><label>Expires at</label><input id="fExpires" type="datetime-local"/></div>
|
|
<div class="field"><label>Max Upload (Mb/s)</label><input id="fUp" type="number" min="0" placeholder="0 = default"/></div>
|
|
<div class="field"><label>Max Download (Mb/s)</label><input id="fDown" type="number" min="0" placeholder="0 = default"/></div>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn" type="submit" id="saveUserBtn">Save user</button>
|
|
<button class="btn btn-ghost" type="button" id="cancelUserBtn">Cancel</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div><!-- /tab-ssh -->
|
|
|
|
<!-- ═══════════ Xray Tab (superadmin only) ═══════════ -->
|
|
<div class="tab-pane" id="tab-xray">
|
|
<!-- Edit Client Panel (hidden by default) -->
|
|
<div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Edit Client <span id="editClientUUID" style="font-family:monospace;font-size:.65rem;color:var(--muted);"></span></div>
|
|
<button class="btn btn-ghost btn-sm" onclick="closeEditXrayClient()">Cancel</button>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="field"><label>Display Name</label><input id="editXrayName" autocomplete="off"/></div>
|
|
<div class="field"><label>Email / Label</label><input id="editXrayEmail" autocomplete="off"/></div>
|
|
<div class="field"><label>Expiry Date</label><input type="datetime-local" id="editXrayExpiry" style="color-scheme:dark;"/></div>
|
|
<div class="field"><label>Max Connections <span class="hint">(0 = unlimited)</span></label><input type="number" min="0" id="editXrayMaxConns"/></div>
|
|
</div>
|
|
<div class="form-actions" style="margin-top:8px;">
|
|
<button class="btn btn-sm" onclick="saveEditXrayClient()">Save Changes</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="closeEditXrayClient()">Cancel</button>
|
|
</div>
|
|
<div id="editXrayClientStatus" class="hint" style="margin-top:4px;"></div>
|
|
</div>
|
|
<!-- Status -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
|
|
<div style="display:flex;gap:5px;flex-wrap:wrap;">
|
|
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
|
|
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
|
|
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
|
|
<button class="btn btn-ghost btn-sm" id="xRefreshBtn">Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div class="metrics">
|
|
<div class="metric"><div class="m-label">Status</div><div class="m-val" id="xRunning">--</div></div>
|
|
<div class="metric"><div class="m-label">PID</div><div class="m-val" id="xPID">--</div></div>
|
|
<div class="metric"><div class="m-label">Uptime</div><div class="m-val" id="xUptime">--</div></div>
|
|
</div>
|
|
<div class="statusbar"><span id="xStatus">Ready.</span></div>
|
|
</div>
|
|
|
|
<!-- Inbounds & clients -->
|
|
<div class="card" style="margin-top:12px;">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Inbounds & Clients</div>
|
|
<button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button>
|
|
</div>
|
|
<div id="inboundsContainer">
|
|
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Config editor -->
|
|
<div class="card" style="margin-top:12px;">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Xray Config</div>
|
|
<div style="display:flex;gap:4px;">
|
|
<button class="btn btn-sm" id="xrayWizardTabBtn" onclick="setXrayCfgMode('wizard')">Visual</button>
|
|
<button class="btn btn-ghost btn-sm" id="xrayJsonTabBtn" onclick="setXrayCfgMode('json')">JSON</button>
|
|
</div>
|
|
</div>
|
|
<!-- Wizard pane -->
|
|
<div id="xrayWizardPane">
|
|
<div class="form-grid" style="margin-bottom:8px;">
|
|
<div class="field">
|
|
<label>Log Level</label>
|
|
<select id="wzLogLevel">
|
|
<option value="none">none</option>
|
|
<option value="error">error</option>
|
|
<option value="warning" selected>warning</option>
|
|
<option value="info">info</option>
|
|
<option value="debug">debug</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
|
<span style="font-size:.8rem;font-weight:600;">Inbounds</span>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Add</button>
|
|
</div>
|
|
<div id="wzInboundsList" style="margin-bottom:8px;"></div>
|
|
<!-- Add inbound form -->
|
|
<div id="wzAddInboundForm" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;">
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label>Protocol</label>
|
|
<select id="wzProtocol" onchange="onWzProtoChange(this.value)">
|
|
<option value="vless">VLESS</option>
|
|
<option value="trojan">Trojan</option>
|
|
<option value="shadowsocks">Shadowsocks</option>
|
|
<option value="socks">SOCKS5 (local)</option>
|
|
</select>
|
|
</div>
|
|
<div class="field"><label>Port</label><input type="number" id="wzPort" min="1" max="65535" placeholder="10086"/></div>
|
|
<div class="field"><label>Listen IP</label><input type="text" id="wzListenIP" placeholder="0.0.0.0"/></div>
|
|
<div class="field"><label>Tag</label><input type="text" id="wzTag" placeholder="vless-in"/></div>
|
|
<!-- VLESS fields -->
|
|
<div id="wzVlessFields" style="grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field">
|
|
<label>Network</label>
|
|
<select id="wzNetwork" onchange="onWzNetworkChange(this.value)">
|
|
<option value="tcp">TCP</option>
|
|
<option value="ws">WebSocket</option>
|
|
<option value="xhttp">XHTTP (SplitHTTP)</option>
|
|
<option value="httpupgrade">HTTPUpgrade</option>
|
|
<option value="h2">HTTP/2</option>
|
|
<option value="grpc">gRPC</option>
|
|
</select>
|
|
</div>
|
|
<!-- TCP: no extra fields -->
|
|
<!-- WebSocket -->
|
|
<div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div>
|
|
<!-- XHTTP -->
|
|
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp"/></div>
|
|
<div class="field" id="wzXHTTPHostField" style="display:none;"><label>Host <span class="hint">(SNI)</span></label><input type="text" id="wzXHTTPHost" placeholder="example.com"/></div>
|
|
<div class="field" id="wzXHTTPModeField" style="display:none;">
|
|
<label>Mode</label>
|
|
<select id="wzXHTTPMode">
|
|
<option value="auto">auto</option>
|
|
<option value="packet-up">packet-up</option>
|
|
<option value="stream-up">stream-up</option>
|
|
<option value="stream-down">stream-down</option>
|
|
<option value="stream-one">stream-one</option>
|
|
</select>
|
|
</div>
|
|
<!-- HTTPUpgrade -->
|
|
<div class="field" id="wzHUPathField" style="display:none;"><label>Path</label><input type="text" id="wzHUPath" placeholder="/upgrade"/></div>
|
|
<div class="field" id="wzHUHostField" style="display:none;"><label>Host <span class="hint">(SNI)</span></label><input type="text" id="wzHUHost" placeholder="example.com"/></div>
|
|
<!-- H2 -->
|
|
<div class="field" id="wzH2PathField" style="display:none;"><label>Path</label><input type="text" id="wzH2Path" placeholder="/h2"/></div>
|
|
<div class="field" id="wzH2HostField" style="display:none;"><label>Host</label><input type="text" id="wzH2Host" placeholder="example.com"/></div>
|
|
<!-- gRPC -->
|
|
<div class="field" id="wzGRPCServiceField" style="display:none;"><label>Service Name</label><input type="text" id="wzGRPCService" placeholder="grpc-service"/></div>
|
|
<div class="field" id="wzGRPCMultiField" style="display:none;">
|
|
<label style="display:flex;align-items:center;gap:5px;margin-top:20px;">
|
|
<input type="checkbox" id="wzGRPCMulti"/> Multi-mode
|
|
</label>
|
|
</div>
|
|
<!-- TLS (all transports) -->
|
|
<div class="field" style="grid-column:1/-1;">
|
|
<label>TLS</label>
|
|
<select id="wzTLS" onchange="onWzTLSChange(this.value)">
|
|
<option value="none">None</option>
|
|
<option value="tls">TLS</option>
|
|
<option value="reality">Reality</option>
|
|
</select>
|
|
</div>
|
|
<!-- TLS cert/key picker — shown when wzTLS=tls -->
|
|
<div id="wzTLSCertBlock" style="display:none;grid-column:1/-1;padding:8px;background:var(--card-bg);border:1px solid var(--border);border-radius:6px;">
|
|
<input type="hidden" id="wzTLSCert"/>
|
|
<input type="hidden" id="wzTLSKey"/>
|
|
<div style="margin-bottom:6px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
|
<span style="font-size:.73rem;color:var(--muted);">Certificate source:</span>
|
|
<button class="btn btn-sm" id="wzCertSrcFileBtn" type="button" onclick="setWzCertSrc('file')">File Path</button>
|
|
<button class="btn btn-ghost btn-sm" id="wzCertSrcPasteBtn" type="button" onclick="setWzCertSrc('paste')">Paste PEM</button>
|
|
<button class="btn btn-ghost btn-sm" id="wzCertSrcGenBtn" type="button" onclick="setWzCertSrc('gen')">Self-Signed</button>
|
|
</div>
|
|
<!-- File Path sub-panel -->
|
|
<div id="wzCertSrcFile" style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field" style="margin:0;"><label style="font-size:.72rem;">Cert File Path</label><input type="text" id="wzTLSCertPath" placeholder="/opt/sshpanel/certs/…/cert.pem" oninput="document.getElementById('wzTLSCert').value=this.value"/></div>
|
|
<div class="field" style="margin:0;"><label style="font-size:.72rem;">Key File Path</label><input type="text" id="wzTLSKeyPath" placeholder="/opt/sshpanel/certs/…/key.pem" oninput="document.getElementById('wzTLSKey').value=this.value"/></div>
|
|
</div>
|
|
<!-- Paste PEM sub-panel -->
|
|
<div id="wzCertSrcPaste" style="display:none;">
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field" style="margin:0;"><label style="font-size:.72rem;">Certificate PEM</label><textarea id="wzPastedCert" rows="5" placeholder="-----BEGIN CERTIFICATE----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
<div class="field" style="margin:0;"><label style="font-size:.72rem;">Private Key PEM</label><textarea id="wzPastedKey" rows="5" placeholder="-----BEGIN EC PRIVATE KEY----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;align-items:flex-end;margin-top:6px;flex-wrap:wrap;">
|
|
<div class="field" style="flex:1;min-width:120px;margin:0;"><label style="font-size:.72rem;">Name <span class="hint">(storage folder)</span></label><input type="text" id="wzPastedName" placeholder="my-cert"/></div>
|
|
<button class="btn btn-sm" type="button" onclick="wzSavePastedCert()">Save PEM</button>
|
|
<span id="wzPasteCertStatus" class="hint"></span>
|
|
</div>
|
|
</div>
|
|
<!-- Self-Signed sub-panel -->
|
|
<div id="wzCertSrcGen" style="display:none;">
|
|
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;">
|
|
<div class="field" style="flex:1;min-width:140px;margin:0;"><label style="font-size:.72rem;">Domain <span class="hint">(CN)</span></label><input type="text" id="wzGenDomain" placeholder="example.com"/></div>
|
|
<button class="btn btn-sm" type="button" onclick="wzGenerateCert()">Generate</button>
|
|
<span id="wzGenCertStatus" class="hint"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Reality extra fields -->
|
|
<div class="field" id="wzRealityDestField" style="display:none;"><label>Dest <span class="hint">(e.g. example.com:443)</span></label><input type="text" id="wzRealityDest" placeholder="example.com:443"/></div>
|
|
<div class="field" id="wzRealitySNIField" style="display:none;"><label>Server Name</label><input type="text" id="wzRealitySNI" placeholder="example.com"/></div>
|
|
<div class="field" id="wzRealityPrivField" style="display:none;"><label>Private Key</label><input type="text" id="wzRealityPriv" placeholder="reality private key"/></div>
|
|
<div class="field" id="wzRealityShortIDField" style="display:none;"><label>Short ID</label><input type="text" id="wzRealityShortID" placeholder="short id (hex)"/></div>
|
|
</div>
|
|
<!-- Trojan fields -->
|
|
<div id="wzTrojanFields" class="hidden" style="grid-column:1/-1;">
|
|
<div class="field"><label>Password</label><input type="text" id="wzTrojanPass" placeholder="trojan-password"/></div>
|
|
</div>
|
|
<!-- Shadowsocks fields -->
|
|
<div id="wzSSFields" class="hidden" style="grid-column:1/-1;display:none;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field"><label>Password</label><input type="text" id="wzSSPass" placeholder="password"/></div>
|
|
<div class="field"><label>Method</label>
|
|
<select id="wzSSMethod">
|
|
<option value="chacha20-ietf-poly1305">chacha20-ietf-poly1305</option>
|
|
<option value="aes-256-gcm">aes-256-gcm</option>
|
|
<option value="aes-128-gcm">aes-128-gcm</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions" style="margin-top:8px;">
|
|
<button class="btn btn-sm" type="button" onclick="wzSaveInbound()">Add Inbound</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('wzAddInboundForm').classList.add('hidden')">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">
|
|
<button class="btn btn-sm" type="button" onclick="applyWizardConfig()">Save Config</button>
|
|
<span class="hint" id="wzStatus" style="margin-left:8px;"></span>
|
|
</div>
|
|
</div>
|
|
<!-- JSON pane (hidden by default) -->
|
|
<div id="xrayCfgPaneJson" class="hidden">
|
|
<textarea id="xCfgEditor" class="code-area" rows="14" placeholder="Xray JSON config…"></textarea>
|
|
<div class="statusbar"><span id="xCfgStatus">Ready.</span></div>
|
|
<div class="form-actions" style="margin-top:8px;">
|
|
<button class="btn btn-ghost btn-sm" id="xLoadCfgBtn">Load JSON</button>
|
|
<button class="btn btn-sm" id="xSaveCfgBtn">Save & Restart</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs -->
|
|
<div class="card" style="margin-top:12px;">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Logs <span class="chip">last 200 lines</span></div>
|
|
<button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button>
|
|
</div>
|
|
<pre class="log-box" id="xLogsBox"></pre>
|
|
</div>
|
|
</div><!-- /tab-xray -->
|
|
|
|
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
|
<div class="tab-pane" id="tab-resellers">
|
|
<div class="grid2">
|
|
<!-- Resellers list -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Resellers <span class="chip" id="resellerCountChip">0</span></div>
|
|
<div style="display:flex;gap:5px;">
|
|
<button class="btn btn-ghost btn-sm" id="newResellerBtn">+ New</button>
|
|
<button class="btn btn-ghost btn-sm" id="reloadResellersBtn">Reload</button>
|
|
</div>
|
|
</div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr>
|
|
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th>
|
|
</tr></thead>
|
|
<tbody id="resellersBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="statusbar"><span id="resellerStatus">Ready.</span></div>
|
|
</div>
|
|
|
|
<!-- Create / edit reseller form -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title" id="resellerFormTitle">Create Reseller</div>
|
|
<button class="btn btn-ghost btn-sm" id="cancelResellerBtn">Cancel</button>
|
|
</div>
|
|
<form id="resellerForm">
|
|
<div class="form-grid">
|
|
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
|
|
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
|
|
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
|
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
|
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div><!-- /tab-resellers -->
|
|
|
|
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
|
|
<div class="tab-pane" id="tab-stats">
|
|
<div class="grid2">
|
|
<div class="card">
|
|
<div class="card-hdr"><div class="card-title">Server Load</div><span class="hint" id="statsUpdated">--</span></div>
|
|
<div class="metrics">
|
|
<div class="metric">
|
|
<div class="m-label">CPU</div>
|
|
<div class="m-val"><span id="cpuVal">--%</span></div>
|
|
<div class="bar"><div class="bar-inner" id="cpuBar" style="width:0%"></div></div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="m-label">RAM</div>
|
|
<div class="m-val"><span id="memVal">--%</span> <span class="hint" id="memDetail"></span></div>
|
|
<div class="bar"><div class="bar-inner" id="memBar" style="width:0%"></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-hdr"><div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span></div></div>
|
|
<div class="tbl-wrap">
|
|
<table>
|
|
<thead><tr><th>Interface</th><th>Rx Mbps</th><th>Tx Mbps</th><th>Rx Total</th><th>Tx Total</th></tr></thead>
|
|
<tbody id="ifaceBody"></tbody>
|
|
</table>
|
|
</div>
|
|
<div class="statusbar"><span id="ifaceSummary"></span></div>
|
|
</div>
|
|
</div>
|
|
</div><!-- /tab-stats -->
|
|
|
|
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
|
|
<div class="tab-pane" id="tab-server">
|
|
|
|
<div class="grid2">
|
|
<!-- ── Left column ── -->
|
|
<div>
|
|
|
|
<!-- Network -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Network</div>
|
|
<span class="chip green">live</span>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label>Main Listen (SSH / HTTP)</label>
|
|
<input type="text" id="cfgListen" placeholder="0.0.0.0:80"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>Local SSH Listen</label>
|
|
<input type="text" id="cfgLocalSSH" placeholder="127.0.0.1:2222 (blank = off)"/>
|
|
</div>
|
|
</div>
|
|
<div class="field" style="margin-top:6px">
|
|
<label>Extra Listen Addresses <span class="hint">(one per line, e.g. 0.0.0.0:8080)</span></label>
|
|
<textarea id="cfgExtraListen" rows="3" style="resize:vertical"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SSH & general -->
|
|
<div class="card" style="margin-top:12px">
|
|
<div class="card-hdr">
|
|
<div class="card-title">SSH & General</div>
|
|
</div>
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label>Default Upload Limit (Mbps)</label>
|
|
<input type="number" id="cfgLimitUp" min="0" placeholder="0 = unlimited"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>Default Download Limit (Mbps)</label>
|
|
<input type="number" id="cfgLimitDown" min="0" placeholder="0 = unlimited"/>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:16px;margin-top:8px;flex-wrap:wrap;">
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;">
|
|
<input type="checkbox" id="cfgQuiet"/> Quiet Logs
|
|
</label>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;">
|
|
<input type="checkbox" id="cfgUserCount"/> User Count Display
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Banner -->
|
|
<div class="card" style="margin-top:12px">
|
|
<div class="card-hdr">
|
|
<div class="card-title">SSH Banner</div>
|
|
<span class="chip green">live</span>
|
|
</div>
|
|
<div class="field">
|
|
<label>Banner Text <span class="hint">(shown to connecting SSH clients)</span></label>
|
|
<textarea id="cfgBanner" rows="4" style="resize:vertical"></textarea>
|
|
</div>
|
|
<div class="hint" style="margin-top:6px;">Banner file: /opt/sshpanel/banner.txt</div>
|
|
</div>
|
|
|
|
</div><!-- /left -->
|
|
|
|
<!-- ── Right column ── -->
|
|
<div>
|
|
|
|
<!-- DNSTT -->
|
|
<div class="card">
|
|
<div class="card-hdr">
|
|
<div class="card-title">DNSTT Tunnel</div>
|
|
<span class="chip green">live</span>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-left:auto;">
|
|
<input type="checkbox" id="cfgDnsttEnabled" onchange="toggleDnsttFields(this.checked)"/> Enabled
|
|
</label>
|
|
</div>
|
|
<div id="dnsttFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
|
<div class="field">
|
|
<label>Domain</label>
|
|
<input type="text" id="cfgDnsttDomain" placeholder="t.example.com"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>UDP Listen</label>
|
|
<input type="text" id="cfgDnsttUDP" placeholder="[::]:5300"/>
|
|
</div>
|
|
<div class="field" style="grid-column:1/-1">
|
|
<label>Private Key <span class="hint">auto-saved to /opt/sshpanel/dnstt.key</span></label>
|
|
<div class="field-row">
|
|
<input type="text" id="cfgDnsttKey" readonly placeholder="Generate a key first…"/>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="generateDnsttKey()">Generate</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="loadDnsttPubkey()">Public Key</button>
|
|
</div>
|
|
<div id="dnsttPubkeyWrap" class="hidden" style="margin-top:6px;padding:8px;border-radius:8px;background:rgba(15,23,42,.9);border:1px solid rgba(55,65,81,.9);">
|
|
<div style="font-size:.68rem;color:var(--muted);margin-bottom:4px;">Public Key — share with dnstt clients</div>
|
|
<div class="field-row">
|
|
<input type="text" id="dnsttPubkeyVal" readonly style="font-family:monospace;font-size:.65rem;"/>
|
|
<button class="btn btn-ghost btn-sm" type="button" id="dnsttCopyBtn" onclick="navigator.clipboard.writeText(document.getElementById('dnsttPubkeyVal').value);document.getElementById('dnsttCopyBtn').textContent='Copied!';setTimeout(()=>document.getElementById('dnsttCopyBtn').textContent='Copy',1500)">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div id="dnsttKeyStatus" class="hint" style="margin-top:4px;"></div>
|
|
</div>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
|
<input type="checkbox" id="cfgDnsttNoStats"/> Disable Stats Log
|
|
</label>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
|
<input type="checkbox" id="cfgDnsttNoConsole"/> Disable Console Log
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- UDPGW -->
|
|
<div class="card" style="margin-top:12px">
|
|
<div class="card-hdr">
|
|
<div class="card-title">UDP Gateway</div>
|
|
<span class="chip green">live</span>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-left:auto;">
|
|
<input type="checkbox" id="cfgUdpgwEnabled" onchange="toggleUdpgwFields(this.checked)"/> Enabled
|
|
</label>
|
|
</div>
|
|
<div id="udpgwFields" class="form-grid" style="opacity:.4;pointer-events:none;">
|
|
<div class="field">
|
|
<label>Listen</label>
|
|
<input type="text" id="cfgUdpgwListen" placeholder="0.0.0.0:7400"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>Max Client Conns</label>
|
|
<input type="number" id="cfgUdpgwMaxConns" min="0" placeholder="10"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>Idle Timeout</label>
|
|
<input type="text" id="cfgUdpgwIdle" placeholder="2m"/>
|
|
</div>
|
|
<div class="field">
|
|
<label>Map TTL</label>
|
|
<input type="text" id="cfgUdpgwMapTTL" placeholder="90s"/>
|
|
</div>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;grid-column:1/-1">
|
|
<input type="checkbox" id="cfgUdpgwDebug"/> Debug Logging
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TLS Forwarders -->
|
|
<div class="card" style="margin-top:12px">
|
|
<div class="card-hdr">
|
|
<div class="card-title">TLS Forwarders <span class="chip" id="tlsCountChip">0</span></div>
|
|
<span class="chip green">live</span>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleAddTLSForm()">+ Add</button>
|
|
</div>
|
|
<div id="tlsForwardersList" style="margin-bottom:4px;"></div>
|
|
<div id="addTLSPanel" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-top:6px;">
|
|
<div class="form-grid">
|
|
<div class="field" style="grid-column:1/-1">
|
|
<label>Listen Address</label>
|
|
<input type="text" id="tlsListenAddr" placeholder="0.0.0.0:443"/>
|
|
</div>
|
|
<div class="field" style="grid-column:1/-1">
|
|
<label>Certificate</label>
|
|
<select id="tlsCertType" onchange="onTLSTypeChange(this.value)">
|
|
<option value="selfsigned">Generate Self-Signed</option>
|
|
<option value="letsencrypt">Let's Encrypt (certbot)</option>
|
|
<option value="paste">Paste PEM text</option>
|
|
<option value="custom">Custom file paths</option>
|
|
</select>
|
|
</div>
|
|
<div id="tlsSSFields" style="grid-column:1/-1">
|
|
<div class="field"><label>Domain Name <span class="hint">(CN for certificate)</span></label><input type="text" id="tlsSSLDomain" placeholder="example.com"/></div>
|
|
</div>
|
|
<div id="tlsLEFields" class="hidden" style="grid-column:1/-1;display:none;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field"><label>Domain</label><input type="text" id="tlsLEDomain" placeholder="example.com"/></div>
|
|
<div class="field"><label>Email</label><input type="text" id="tlsLEEmail" placeholder="admin@example.com"/></div>
|
|
</div>
|
|
<div id="tlsCustomFields" class="hidden" style="grid-column:1/-1;display:none;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field"><label>Cert File</label><input type="text" id="tlsCustomCert" placeholder="/path/to/fullchain.pem"/></div>
|
|
<div class="field"><label>Key File</label><input type="text" id="tlsCustomKey" placeholder="/path/to/privkey.pem"/></div>
|
|
</div>
|
|
<div id="tlsPasteFields" class="hidden" style="grid-column:1/-1;display:none;">
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
|
<div class="field"><label>Certificate PEM</label><textarea id="tlsPasteCert" rows="5" placeholder="-----BEGIN CERTIFICATE----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
<div class="field"><label>Private Key PEM</label><textarea id="tlsPasteKey" rows="5" placeholder="-----BEGIN EC PRIVATE KEY----- …" style="font-family:monospace;font-size:.7rem;width:100%;box-sizing:border-box;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:inherit;padding:4px;"></textarea></div>
|
|
</div>
|
|
<div class="field" style="margin-top:6px;"><label>Name <span class="hint">(storage folder, e.g. my-cert)</span></label><input type="text" id="tlsPasteName" placeholder="my-cert"/></div>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions" style="margin-top:8px;">
|
|
<button class="btn btn-sm" type="button" onclick="addTLSForwarder()">Add Forwarder</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" onclick="toggleAddTLSForm()">Cancel</button>
|
|
</div>
|
|
<div id="tlsAddStatus" class="hint" style="margin-top:4px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Xray -->
|
|
<div class="card" style="margin-top:12px">
|
|
<div class="card-hdr">
|
|
<div class="card-title">Xray Core</div>
|
|
<span class="chip green">live</span>
|
|
</div>
|
|
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;margin-top:4px;">
|
|
<input type="checkbox" id="cfgXrayEnabled"/> Enabled
|
|
</label>
|
|
<div class="hint" style="margin-top:6px;color:var(--muted);">Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json</div>
|
|
</div>
|
|
|
|
</div><!-- /right -->
|
|
</div><!-- /grid2 -->
|
|
|
|
<!-- Save bar -->
|
|
<div class="form-actions" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
|
|
<button class="btn" onclick="saveServerConfig()">Save Config</button>
|
|
<button class="btn btn-ghost" onclick="loadServerConfig()">Reload</button>
|
|
<span id="srvCfgStatus" class="hint">All changes apply live. Only <strong>host_key_file</strong> requires a restart.</span>
|
|
</div>
|
|
|
|
</div><!-- /tab-server -->
|
|
|
|
</div><!-- /mainApp -->
|
|
</div><!-- /shell -->
|
|
</div><!-- /app -->
|
|
|
|
<script>
|
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
|
|
let currentRole = "";
|
|
let currentUser = "";
|
|
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
|
let formCollapsed = true;
|
|
let tlsForwardersState = [];
|
|
let editingXrayClientId = null;
|
|
let wzInbounds = [];
|
|
|
|
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
|
const loginOverlay = document.getElementById("loginOverlay");
|
|
const loginUser = document.getElementById("loginUser");
|
|
const loginPass = document.getElementById("loginPass");
|
|
const loginBtn = document.getElementById("loginBtn");
|
|
const loginErr = document.getElementById("loginErr");
|
|
const mainApp = document.getElementById("mainApp");
|
|
const meUsername = document.getElementById("meUsername");
|
|
const roleChip = document.getElementById("roleChip");
|
|
const logoutBtn = document.getElementById("logoutBtn");
|
|
|
|
// Users
|
|
const usersBody = document.getElementById("usersBody");
|
|
const userCountChip = document.getElementById("userCountChip");
|
|
const userStatus = document.getElementById("userStatus");
|
|
const lastReload = document.getElementById("lastReload");
|
|
const ownerColHead = document.getElementById("ownerColHead");
|
|
const resellerInfoCard = document.getElementById("resellerInfoCard");
|
|
const rUsedMax = document.getElementById("rUsedMax");
|
|
const rExpiry = document.getElementById("rExpiry");
|
|
const rStatus = document.getElementById("rStatus");
|
|
|
|
// User form
|
|
const userForm = document.getElementById("userForm");
|
|
const userFormWrap = document.getElementById("userFormWrap");
|
|
const toggleFormBtn = document.getElementById("toggleFormBtn");
|
|
const cancelUserBtn = document.getElementById("cancelUserBtn");
|
|
const newUserBtn = document.getElementById("newUserBtn");
|
|
const saveUserBtn = document.getElementById("saveUserBtn");
|
|
const fUsername = document.getElementById("fUsername");
|
|
const fPassword = document.getElementById("fPassword");
|
|
const fTotpSecret = document.getElementById("fTotpSecret");
|
|
const fTotpPeriod = document.getElementById("fTotpPeriod");
|
|
const fTotpWindow = document.getElementById("fTotpWindow");
|
|
const fTotpDigits = document.getElementById("fTotpDigits");
|
|
const fAllowStatic = document.getElementById("fAllowStatic");
|
|
const fMaxConn = document.getElementById("fMaxConn");
|
|
const fExpires = document.getElementById("fExpires");
|
|
const fUp = document.getElementById("fUp");
|
|
const fDown = document.getElementById("fDown");
|
|
|
|
// Xray
|
|
const xrayChip = document.getElementById("xrayChip");
|
|
const xRunning = document.getElementById("xRunning");
|
|
const xPID = document.getElementById("xPID");
|
|
const xUptime = document.getElementById("xUptime");
|
|
const xStatus = document.getElementById("xStatus");
|
|
const xCfgEditor = document.getElementById("xCfgEditor");
|
|
const xCfgStatus = document.getElementById("xCfgStatus");
|
|
const xLogsBox = document.getElementById("xLogsBox");
|
|
const inboundsContainer = document.getElementById("inboundsContainer");
|
|
|
|
// Resellers
|
|
const resellersBody = document.getElementById("resellersBody");
|
|
const resellerCountChip = document.getElementById("resellerCountChip");
|
|
const resellerStatus = document.getElementById("resellerStatus");
|
|
const resellerFormTitle = document.getElementById("resellerFormTitle");
|
|
const resellerForm = document.getElementById("resellerForm");
|
|
const rUsername = document.getElementById("rUsername");
|
|
const rPassword = document.getElementById("rPassword");
|
|
const rMaxUsers = document.getElementById("rMaxUsers");
|
|
const rExpires = document.getElementById("rExpires");
|
|
const rActive = document.getElementById("rActive");
|
|
|
|
// Stats
|
|
const cpuVal = document.getElementById("cpuVal");
|
|
const cpuBar = document.getElementById("cpuBar");
|
|
const memVal = document.getElementById("memVal");
|
|
const memBar = document.getElementById("memBar");
|
|
const memDetail = document.getElementById("memDetail");
|
|
const ifaceBody = document.getElementById("ifaceBody");
|
|
const ifaceSummary = document.getElementById("ifaceSummary");
|
|
const statsUpdated = document.getElementById("statsUpdated");
|
|
|
|
// ─── API helper ───────────────────────────────────────────────────────────────
|
|
async function api(path, opts = {}) {
|
|
const o = Object.assign({ headers: {} }, opts);
|
|
o.headers = Object.assign({}, o.headers, {
|
|
"Content-Type": "application/json",
|
|
"X-Session-Token": sessionToken,
|
|
});
|
|
const res = await fetch(path, o);
|
|
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
|
return res;
|
|
}
|
|
|
|
// ─── Formatters ──────────────────────────────────────────────────────────────
|
|
const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%";
|
|
const fmtMbps = n => (n == null || isNaN(n)) ? "--" : n.toFixed(2);
|
|
function fmtBytes(n) {
|
|
if (!Number.isFinite(n)) return "--";
|
|
if (n<1024) return n+" B";
|
|
const k=n/1024; if(k<1024) return k.toFixed(1)+" KiB";
|
|
const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
|
|
return (m/1024).toFixed(1)+" GiB";
|
|
}
|
|
function fmtDate(iso) {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString()+" "+d.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
|
|
}
|
|
function isoFromLocal(v) {
|
|
if (!v) return "";
|
|
const d = new Date(v);
|
|
return isNaN(d.getTime()) ? "" : d.toISOString();
|
|
}
|
|
function localFromISO(iso) {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "";
|
|
const pad = n => String(n).padStart(2,"0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
function genBase32(len=20) {
|
|
const alpha="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
const bytes=new Uint8Array(len);
|
|
crypto.getRandomValues(bytes);
|
|
let bits=0,val=0,out="";
|
|
for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){out+=alpha[(val>>>(bits-5))&31];bits-=5;}}
|
|
if(bits>0) out+=alpha[(val<<(5-bits))&31];
|
|
return out;
|
|
}
|
|
function genUUID() {
|
|
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
|
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
|
|
}
|
|
|
|
// ─── Tab switching ────────────────────────────────────────────────────────────
|
|
document.querySelectorAll(".tab-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
|
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
document.getElementById("tab-" + btn.dataset.tab).classList.add("active");
|
|
});
|
|
});
|
|
|
|
// ─── Login / Logout ───────────────────────────────────────────────────────────
|
|
loginBtn.addEventListener("click", doLogin);
|
|
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");
|
|
clearTimers();
|
|
mainApp.classList.add("hidden");
|
|
loginOverlay.classList.remove("hidden");
|
|
loginErr.textContent = "";
|
|
loginUser.value = loginPass.value = "";
|
|
});
|
|
|
|
async function doLogin() {
|
|
loginErr.textContent = "";
|
|
loginBtn.disabled = true;
|
|
try {
|
|
const res = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: {"Content-Type":"application/json"},
|
|
body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value }),
|
|
});
|
|
if (!res.ok) {
|
|
loginErr.textContent = res.status === 401 ? "Invalid credentials." :
|
|
res.status === 403 ? "Account suspended or expired." :
|
|
"Login failed.";
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
sessionToken = data.token;
|
|
currentRole = data.role;
|
|
currentUser = data.username;
|
|
localStorage.setItem("SESSION_TOKEN", sessionToken);
|
|
loginOverlay.classList.add("hidden");
|
|
mainApp.classList.remove("hidden");
|
|
initAfterLogin();
|
|
} catch (e) {
|
|
loginErr.textContent = "Network error.";
|
|
} finally {
|
|
loginBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ─── Init after login ─────────────────────────────────────────────────────────
|
|
function clearTimers() {
|
|
[statsTimer, usersTimer, xrayTimer].forEach(t => t && clearInterval(t));
|
|
statsTimer = usersTimer = xrayTimer = null;
|
|
}
|
|
|
|
function initAfterLogin() {
|
|
meUsername.textContent = currentUser;
|
|
roleChip.innerHTML = currentRole === "superadmin"
|
|
? `<span class="chip green">superadmin</span>`
|
|
: `<span class="chip warn">reseller</span>`;
|
|
|
|
// Show/hide superadmin-only elements
|
|
document.querySelectorAll(".superadmin-only").forEach(el => {
|
|
el.classList.toggle("hidden", currentRole !== "superadmin");
|
|
});
|
|
|
|
// Reset to SSH tab
|
|
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
|
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
|
|
document.querySelector("[data-tab='ssh']").classList.add("active");
|
|
document.getElementById("tab-ssh").classList.add("active");
|
|
|
|
if (currentRole === "superadmin") {
|
|
loadStats();
|
|
statsTimer = setInterval(loadStats, 2000);
|
|
xrayTimer = setInterval(loadXrayStatus, 5000);
|
|
} else {
|
|
resellerInfoCard.classList.remove("hidden");
|
|
loadMe();
|
|
}
|
|
|
|
loadUsers();
|
|
usersTimer = setInterval(() => loadUsersSilent(), 3000);
|
|
}
|
|
|
|
// ─── Me (reseller info) ───────────────────────────────────────────────────────
|
|
async function loadMe() {
|
|
try {
|
|
const res = await api("/api/auth/me");
|
|
const d = await res.json();
|
|
rUsedMax.textContent = (d.used_users ?? "--") + " / " + (d.max_users || "∞");
|
|
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit";
|
|
rStatus.textContent = d.is_active ? "Active" : "Suspended";
|
|
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
|
|
} catch {}
|
|
}
|
|
|
|
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
|
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
|
newUserBtn.addEventListener("click", () => {
|
|
setFormCollapsed(false);
|
|
userForm.reset();
|
|
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
|
userStatus.textContent = "New user.";
|
|
fUsername.focus();
|
|
});
|
|
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
|
|
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
|
|
document.getElementById("genTotpBtn").addEventListener("click", () => {
|
|
fTotpSecret.value = genBase32();
|
|
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
|
|
if (!fTotpWindow.value) fTotpWindow.value = 1;
|
|
if (!fTotpDigits.value) fTotpDigits.value = 6;
|
|
userStatus.textContent = "TOTP secret generated.";
|
|
});
|
|
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
|
|
|
|
function setFormCollapsed(v) {
|
|
formCollapsed = v;
|
|
userFormWrap.classList.toggle("collapsed", v);
|
|
toggleFormBtn.textContent = v ? "Show form" : "Hide form";
|
|
}
|
|
|
|
async function loadUsers() {
|
|
userStatus.textContent = "Loading…";
|
|
try {
|
|
const res = await api("/api/users");
|
|
const data = await res.json();
|
|
renderUsers(data || []);
|
|
userStatus.textContent = "Loaded.";
|
|
lastReload.textContent = "Last reload: " + new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = "Error loading users."; }
|
|
}
|
|
}
|
|
async function loadUsersSilent() {
|
|
try {
|
|
const res = await api("/api/users");
|
|
const data = await res.json();
|
|
renderUsers(data || []);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
}
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
const isSA = currentRole === "superadmin";
|
|
userCountChip.textContent = users.length;
|
|
if (isSA) ownerColHead.classList.remove("hidden");
|
|
usersBody.innerHTML = "";
|
|
let online = 0;
|
|
users.forEach(u => {
|
|
const on = (u.active_conns || 0) > 0;
|
|
if (on) online++;
|
|
const tr = document.createElement("tr");
|
|
const cells = [
|
|
u.username,
|
|
on ? '<span class="badge-on">online</span>' : '<span class="badge-off">idle</span>',
|
|
u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password",
|
|
u.active_conns ?? 0,
|
|
u.max_connections || 0,
|
|
u.limit_mbps_up || 0,
|
|
u.limit_mbps_down || 0,
|
|
u.expires_at ? fmtDate(u.expires_at) : "—",
|
|
];
|
|
if (isSA) cells.push(u.owner_username || "—");
|
|
cells.forEach((c, i) => {
|
|
const td = document.createElement("td");
|
|
if (i === 1) td.innerHTML = c; else td.textContent = c;
|
|
tr.appendChild(td);
|
|
});
|
|
const tdA = document.createElement("td");
|
|
const editBtn = Object.assign(document.createElement("button"), {
|
|
className:"btn btn-ghost btn-sm", textContent:"Edit",
|
|
onclick: () => fillUserForm(u),
|
|
});
|
|
const delBtn = Object.assign(document.createElement("button"), {
|
|
className:"btn btn-danger btn-sm", textContent:"Del",
|
|
style: "margin-left:4px;",
|
|
onclick: () => deleteUser(u.username),
|
|
});
|
|
tdA.append(editBtn, delBtn);
|
|
tr.appendChild(tdA);
|
|
usersBody.appendChild(tr);
|
|
});
|
|
userCountChip.textContent = `${users.length} (${online} online)`;
|
|
}
|
|
|
|
function fillUserForm(u) {
|
|
setFormCollapsed(false);
|
|
fUsername.value = u.username || "";
|
|
fPassword.value = "";
|
|
fTotpSecret.value = u.totp_secret || "";
|
|
fTotpPeriod.value = u.totp_period || 60;
|
|
fTotpWindow.value = u.totp_window ?? 1;
|
|
fTotpDigits.value = u.totp_digits || 6;
|
|
fAllowStatic.checked = !!u.allow_static_password;
|
|
fMaxConn.value = u.max_connections || "";
|
|
fUp.value = u.limit_mbps_up || "";
|
|
fDown.value = u.limit_mbps_down || "";
|
|
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
|
userStatus.textContent = `Editing ${u.username}`;
|
|
}
|
|
|
|
userForm.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
saveUserBtn.disabled = true;
|
|
userStatus.textContent = "Saving…";
|
|
const payload = {
|
|
username: fUsername.value.trim(),
|
|
password: fPassword.value || undefined,
|
|
totp_secret: fTotpSecret.value.trim(),
|
|
totp_period: parseInt(fTotpPeriod.value||"60",10),
|
|
totp_window: parseInt(fTotpWindow.value||"1",10),
|
|
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
|
allow_static_password: !!fAllowStatic.checked,
|
|
max_connections: parseInt(fMaxConn.value||"0",10),
|
|
expires_at: isoFromLocal(fExpires.value),
|
|
limit_mbps_up: parseInt(fUp.value||"0",10),
|
|
limit_mbps_down: parseInt(fDown.value||"0",10),
|
|
};
|
|
try {
|
|
const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
userStatus.textContent = "Saved.";
|
|
fPassword.value = "";
|
|
loadUsers();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else userStatus.textContent = "Error: " + e.message;
|
|
} finally {
|
|
saveUserBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function deleteUser(username) {
|
|
if (!confirm(`Delete user "${username}"?`)) return;
|
|
userStatus.textContent = `Deleting ${username}…`;
|
|
try {
|
|
const res = await api(`/api/users/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
|
if (!res.ok && res.status !== 204) throw new Error("delete failed");
|
|
userStatus.textContent = "Deleted.";
|
|
loadUsers();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else userStatus.textContent = "Error deleting.";
|
|
}
|
|
}
|
|
|
|
// ─── Xray ─────────────────────────────────────────────────────────────────────
|
|
document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("start"));
|
|
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
|
|
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
|
|
document.getElementById("xRefreshBtn").addEventListener("click", loadXrayStatus);
|
|
document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
|
|
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
|
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
|
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
|
|
|
|
document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
|
|
loadXrayStatus(); loadInbounds(); loadWizardFromConfig();
|
|
});
|
|
|
|
async function loadXrayStatus() {
|
|
try {
|
|
const res = await api("/api/xray/status");
|
|
const s = await res.json();
|
|
const run = !!s.running;
|
|
xrayChip.textContent = run ? "running" : (s.enabled ? "stopped" : "disabled");
|
|
xrayChip.className = "chip " + (run ? "green" : "red");
|
|
xRunning.textContent = run ? "Running" : "Stopped";
|
|
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
|
|
xPID.textContent = s.pid || "--";
|
|
xUptime.textContent = s.uptime || "--";
|
|
if (s.error) xStatus.textContent = "Error: " + s.error;
|
|
} catch (e) { if (e.message==="auth") doAuthError(); }
|
|
}
|
|
|
|
async function xrayCtrl(action) {
|
|
xStatus.textContent = action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…";
|
|
try {
|
|
const res = await api(`/api/xray/${action}`, { method:"POST" });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
xStatus.textContent = "Xray "+action+" OK.";
|
|
setTimeout(loadXrayStatus, 700);
|
|
setTimeout(loadInbounds, 1200);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else xStatus.textContent = "Error: "+e.message;
|
|
}
|
|
}
|
|
|
|
async function loadInbounds() {
|
|
inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">Loading…</div>';
|
|
try {
|
|
const res = await api("/api/xray/inbounds");
|
|
const inbounds = await res.json();
|
|
renderInbounds(inbounds || []);
|
|
} catch (e) {
|
|
inboundsContainer.textContent = "Error loading inbounds.";
|
|
if (e.message==="auth") doAuthError();
|
|
}
|
|
}
|
|
|
|
function renderInbounds(inbounds) {
|
|
if (!inbounds.length) {
|
|
inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">No VLESS/VMess/Trojan inbounds found.</div>';
|
|
return;
|
|
}
|
|
inboundsContainer.innerHTML = "";
|
|
inbounds.forEach(ib => {
|
|
const section = document.createElement("div");
|
|
section.style = "margin-bottom:14px;";
|
|
|
|
const hdr = document.createElement("div");
|
|
hdr.className = "card-hdr";
|
|
hdr.style = "margin-bottom:6px;";
|
|
hdr.innerHTML = `
|
|
<div class="card-title" style="font-size:.8rem;">
|
|
<span class="chip">${ib.protocol}</span>
|
|
${ib.tag || "untagged"}
|
|
<span class="hint">:${ib.port ?? "?"}</span>
|
|
</div>
|
|
<button class="btn btn-sm" onclick="openAddClient('${ib.tag}')">+ Add Client</button>`;
|
|
section.appendChild(hdr);
|
|
|
|
// Add client mini-form (hidden by default)
|
|
const addForm = document.createElement("div");
|
|
addForm.id = `add-form-${ib.tag}`;
|
|
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 = `
|
|
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div class="field"><label>Email / label</label><input id="newEmail-${ib.tag}" placeholder="user@example" style="border-radius:6px;"/></div>
|
|
<div class="field"><label>Display Name</label><input id="newName-${ib.tag}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div>
|
|
<div class="field"><label>Expiry Date</label><input type="datetime-local" id="newExpiry-${ib.tag}" style="border-radius:6px;color-scheme:dark;"/></div>
|
|
<div class="field"><label>Max Connections <span class="hint">(0 = unlimited)</span></label><input type="number" min="0" id="newMaxConns-${ib.tag}" 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}')">Add</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('add-form-${ib.tag}').classList.add('hidden')">Cancel</button>
|
|
</div>`;
|
|
section.appendChild(addForm);
|
|
|
|
// Clients table
|
|
const tblWrap = document.createElement("div");
|
|
tblWrap.className = "tbl-wrap";
|
|
const clients = ib.clients || [];
|
|
if (!clients.length) {
|
|
tblWrap.innerHTML = '<div class="hint" style="padding:4px 0;">No clients.</div>';
|
|
} else {
|
|
const tbl = document.createElement("table");
|
|
tbl.innerHTML = `<thead><tr><th>Name</th><th>UUID</th><th>Email</th><th>Expiry</th><th>Status</th><th>Max</th><th>Actions</th></tr></thead>`;
|
|
const tbody = document.createElement("tbody");
|
|
clients.forEach(c => {
|
|
const exp = c.expires_at ? new Date(c.expires_at) : null;
|
|
const expStr = exp ? exp.toLocaleDateString() : "Unlimited";
|
|
const isExpired = !!c.expired;
|
|
const daysLeft = c.expiration_days;
|
|
let statusHtml;
|
|
if (isExpired) {
|
|
statusHtml = `<span style="color:var(--danger);font-size:.68rem;">Expired</span>`;
|
|
} else if (daysLeft === -1 || !exp) {
|
|
statusHtml = `<span style="color:var(--success);font-size:.68rem;">Active</span>`;
|
|
} else {
|
|
statusHtml = `<span style="color:var(--success);font-size:.68rem;">Active (${daysLeft}d)</span>`;
|
|
}
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${c.name || "—"}</td>
|
|
<td style="font-family:monospace;font-size:.65rem;">${c.id}</td>
|
|
<td>${c.email || "—"}</td>
|
|
<td style="font-size:.7rem;">${expStr}</td>
|
|
<td>${statusHtml}</td>
|
|
<td style="font-size:.7rem;">${c.max_conns || "∞"}</td>`;
|
|
const actTd = document.createElement("td");
|
|
actTd.style.whiteSpace = "nowrap";
|
|
const copyBtn = document.createElement("button");
|
|
copyBtn.className = "btn btn-ghost btn-sm";
|
|
copyBtn.textContent = "Copy";
|
|
copyBtn.onclick = () => navigator.clipboard.writeText(c.id);
|
|
const editBtn = document.createElement("button");
|
|
editBtn.className = "btn btn-warn btn-sm";
|
|
editBtn.style.marginLeft = "4px";
|
|
editBtn.textContent = "Edit";
|
|
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "btn btn-danger btn-sm";
|
|
delBtn.style.marginLeft = "4px";
|
|
delBtn.textContent = "Del";
|
|
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
|
actTd.append(copyBtn, editBtn, delBtn);
|
|
tr.appendChild(actTd);
|
|
tbody.appendChild(tr);
|
|
});
|
|
tbl.appendChild(tbody);
|
|
tblWrap.appendChild(tbl);
|
|
}
|
|
section.appendChild(tblWrap);
|
|
|
|
const divider = document.createElement("hr");
|
|
divider.style = "border:none;border-top:1px solid var(--border);margin-top:10px;";
|
|
section.appendChild(divider);
|
|
|
|
inboundsContainer.appendChild(section);
|
|
});
|
|
}
|
|
|
|
function openAddClient(tag) {
|
|
const form = document.getElementById(`add-form-${tag}`);
|
|
if (form) { form.classList.remove("hidden"); }
|
|
const uuidField = document.getElementById(`newUUID-${tag}`);
|
|
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}`);
|
|
const uuid = (uuidEl?.value || "").trim();
|
|
const email = (emailEl?.value || "").trim();
|
|
const name = (nameEl?.value || "").trim();
|
|
const expiresAt = isoFromLocal(expiryEl?.value || "");
|
|
const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
|
|
if (!uuid) { xStatus.textContent = "UUID required."; return; }
|
|
try {
|
|
const res = await api("/api/xray/clients/add", {
|
|
method: "POST",
|
|
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns }),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
xStatus.textContent = `Client ${uuid.slice(0,8)}… added. Restarting Xray…`;
|
|
setTimeout(loadInbounds, 1500);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else xStatus.textContent = "Error: "+e.message;
|
|
}
|
|
}
|
|
|
|
async function removeClient(tag, uuid) {
|
|
if (!confirm(`Remove client ${uuid.slice(0,8)}… from ${tag}?`)) return;
|
|
try {
|
|
const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" });
|
|
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
|
xStatus.textContent = "Client removed. Restarting Xray…";
|
|
setTimeout(loadInbounds, 1500);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else xStatus.textContent = "Error: "+e.message;
|
|
}
|
|
}
|
|
|
|
async function loadXrayCfg() {
|
|
try {
|
|
const res = await api("/api/xray/config");
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const text = await res.text();
|
|
try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
|
|
catch { xCfgEditor.value = text; }
|
|
xCfgStatus.textContent = "Config loaded.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else xCfgStatus.textContent = "Error: "+e.message;
|
|
}
|
|
}
|
|
|
|
async function saveXrayCfg() {
|
|
const text = xCfgEditor.value.trim();
|
|
try { JSON.parse(text); } catch(e) { xCfgStatus.textContent = "Invalid JSON: "+e.message; return; }
|
|
xCfgStatus.textContent = "Saving…";
|
|
try {
|
|
const res = await api("/api/xray/config", { method:"POST", body: text });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
xCfgStatus.textContent = "Saved. Restarting Xray…";
|
|
await xrayCtrl("restart");
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else xCfgStatus.textContent = "Error: "+e.message;
|
|
}
|
|
}
|
|
|
|
async function loadXrayLogs() {
|
|
try {
|
|
const res = await api("/api/xray/logs");
|
|
const data = await res.json();
|
|
xLogsBox.textContent = (data.lines||[]).join("\n");
|
|
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
|
} catch (e) { if (e.message==="auth") doAuthError(); }
|
|
}
|
|
|
|
// ─── Resellers ────────────────────────────────────────────────────────────────
|
|
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
|
document.getElementById("newResellerBtn").addEventListener("click", () => {
|
|
resellerFormTitle.textContent = "Create Reseller";
|
|
resellerForm.reset();
|
|
rActive.checked = true;
|
|
resellerStatus.textContent = "New reseller.";
|
|
});
|
|
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
|
|
resellerForm.reset();
|
|
rActive.checked = true;
|
|
resellerFormTitle.textContent = "Create Reseller";
|
|
});
|
|
|
|
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
|
|
|
async function loadResellers() {
|
|
resellerStatus.textContent = "Loading…";
|
|
try {
|
|
const res = await api("/api/resellers");
|
|
const data = await res.json();
|
|
renderResellers(data || []);
|
|
resellerStatus.textContent = "Loaded.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else resellerStatus.textContent = "Error loading.";
|
|
}
|
|
}
|
|
|
|
function renderResellers(list) {
|
|
resellerCountChip.textContent = list.length;
|
|
resellersBody.innerHTML = "";
|
|
list.forEach(r => {
|
|
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${r.username}</td>
|
|
<td>${r.used_users} / ${r.max_users || "∞"}</td>
|
|
<td>${r.expires_at ? 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;
|
|
const editBtn = Object.assign(document.createElement("button"),{
|
|
className:"btn btn-ghost btn-sm", textContent:"Edit",
|
|
onclick: () => fillResellerForm(r),
|
|
});
|
|
const delBtn = Object.assign(document.createElement("button"),{
|
|
className:"btn btn-danger btn-sm", textContent:"Del",
|
|
style: "margin-left:4px;",
|
|
onclick: () => deleteReseller(r.username),
|
|
});
|
|
tdA.append(editBtn, delBtn);
|
|
resellersBody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function fillResellerForm(r) {
|
|
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
|
rUsername.value = r.username;
|
|
rPassword.value = "";
|
|
rMaxUsers.value = r.max_users || 0;
|
|
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
|
rActive.checked = r.is_active;
|
|
resellerStatus.textContent = `Editing ${r.username}.`;
|
|
}
|
|
|
|
resellerForm.addEventListener("submit", async e => {
|
|
e.preventDefault();
|
|
const btn = document.getElementById("saveResellerBtn");
|
|
btn.disabled = true;
|
|
resellerStatus.textContent = "Saving…";
|
|
const payload = {
|
|
username: rUsername.value.trim(),
|
|
password: rPassword.value || undefined,
|
|
max_users: parseInt(rMaxUsers.value||"0",10),
|
|
expires_at: isoFromLocal(rExpires.value),
|
|
is_active: rActive.checked,
|
|
};
|
|
try {
|
|
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
resellerStatus.textContent = "Saved.";
|
|
resellerForm.reset(); rActive.checked = true;
|
|
resellerFormTitle.textContent = "Create Reseller";
|
|
loadResellers();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else resellerStatus.textContent = "Error: "+e.message;
|
|
} finally { btn.disabled = false; }
|
|
});
|
|
|
|
async function deleteReseller(username) {
|
|
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
|
|
resellerStatus.textContent = `Deleting ${username}…`;
|
|
try {
|
|
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
|
if (!res.ok && res.status !== 204) throw new Error("failed");
|
|
resellerStatus.textContent = "Deleted.";
|
|
loadResellers();
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else resellerStatus.textContent = "Error deleting.";
|
|
}
|
|
}
|
|
|
|
// ─── Stats ────────────────────────────────────────────────────────────────────
|
|
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const res = await api("/api/stats");
|
|
const s = await res.json();
|
|
const cpu = s?.cpu_percent ?? 0;
|
|
cpuVal.textContent = fmtPct(cpu);
|
|
cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
|
const mp = s?.mem_percent ?? null;
|
|
memVal.textContent = mp == null ? "--%" : fmtPct(mp);
|
|
memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%";
|
|
const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes;
|
|
memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : "";
|
|
const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
|
|
ifaceBody.innerHTML = "";
|
|
let totRx = 0, totTx = 0;
|
|
ifaces.forEach(it => {
|
|
totRx += it.rx_bytes||0; totTx += it.tx_bytes||0;
|
|
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>`;
|
|
ifaceBody.appendChild(tr);
|
|
});
|
|
ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
|
statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
|
|
} catch (e) { if (e.message==="auth") doAuthError(); }
|
|
}
|
|
|
|
// ─── Server Config ────────────────────────────────────────────────────────────
|
|
document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
|
|
|
|
function toggleDnsttFields(on) {
|
|
const el = document.getElementById("dnsttFields");
|
|
el.style.opacity = on ? "1" : ".4";
|
|
el.style.pointerEvents = on ? "" : "none";
|
|
}
|
|
function toggleUdpgwFields(on) {
|
|
const el = document.getElementById("udpgwFields");
|
|
el.style.opacity = on ? "1" : ".4";
|
|
el.style.pointerEvents = on ? "" : "none";
|
|
}
|
|
|
|
async function loadServerConfig() {
|
|
const st = document.getElementById("srvCfgStatus");
|
|
st.textContent = "Loading…";
|
|
try {
|
|
const res = await api("/api/server/config");
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const c = await res.json();
|
|
|
|
// Network
|
|
document.getElementById("cfgListen").value = c.listen || "";
|
|
document.getElementById("cfgLocalSSH").value = c.local_ssh_listen || "";
|
|
document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
|
|
|
|
// SSH / general
|
|
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
|
|
document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0;
|
|
document.getElementById("cfgQuiet").checked = !!c.quiet;
|
|
document.getElementById("cfgUserCount").checked = !!c.user_count;
|
|
|
|
// Banner
|
|
document.getElementById("cfgBanner").value = c.banner || "";
|
|
|
|
// DNSTT
|
|
const hasDnstt = !!c.dnstt;
|
|
document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
|
|
toggleDnsttFields(hasDnstt);
|
|
const d = c.dnstt || {};
|
|
document.getElementById("cfgDnsttDomain").value = d.domain || "";
|
|
document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
|
|
document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
|
|
document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
|
|
document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
|
|
|
|
// UDPGW
|
|
const hasUdpgw = !!c.udpgw;
|
|
document.getElementById("cfgUdpgwEnabled").checked = hasUdpgw;
|
|
toggleUdpgwFields(hasUdpgw);
|
|
const u = c.udpgw || {};
|
|
document.getElementById("cfgUdpgwListen").value = u.listen || "";
|
|
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
|
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
|
|
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
|
|
document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
|
|
|
|
// TLS forwarders
|
|
tlsForwardersState = c.tls_forwarders || [];
|
|
renderTLSForwarders();
|
|
|
|
// Xray
|
|
const x = c.xray || {};
|
|
document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
|
|
|
|
st.textContent = "Config loaded.";
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
async function saveServerConfig() {
|
|
const st = document.getElementById("srvCfgStatus");
|
|
st.textContent = "Saving…";
|
|
|
|
const tlsArr = tlsForwardersState;
|
|
|
|
const extraLines = document.getElementById("cfgExtraListen").value
|
|
.split("\n").map(s => s.trim()).filter(Boolean);
|
|
|
|
const cfg = {
|
|
listen: document.getElementById("cfgListen").value.trim(),
|
|
extra_listen: extraLines,
|
|
local_ssh_listen: document.getElementById("cfgLocalSSH").value.trim(),
|
|
host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
|
|
admin_dir: "/opt/sshpanel/admin",
|
|
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
|
|
default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10),
|
|
quiet: document.getElementById("cfgQuiet").checked,
|
|
user_count: document.getElementById("cfgUserCount").checked,
|
|
banner: document.getElementById("cfgBanner").value,
|
|
banner_file: "/opt/sshpanel/banner.txt",
|
|
dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
|
|
domain: document.getElementById("cfgDnsttDomain").value.trim(),
|
|
udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
|
|
privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
|
|
disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
|
|
disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
|
|
} : null,
|
|
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
|
|
listen: document.getElementById("cfgUdpgwListen").value.trim(),
|
|
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
|
|
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
|
|
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
|
|
debug: document.getElementById("cfgUdpgwDebug").checked,
|
|
} : null,
|
|
tls_forwarders: tlsArr,
|
|
xray: {
|
|
enabled: document.getElementById("cfgXrayEnabled").checked,
|
|
bin_path: "/opt/sshpanel/xray",
|
|
config_file: "/opt/sshpanel/xray_config.json",
|
|
},
|
|
};
|
|
|
|
try {
|
|
const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
st.textContent = "Saved. Banner applied live — other changes need a server restart.";
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── TLS Forwarders ────────────────────────────────────────────────────────────
|
|
function renderTLSForwarders() {
|
|
const list = document.getElementById("tlsForwardersList");
|
|
const chip = document.getElementById("tlsCountChip");
|
|
if (!list) return;
|
|
chip.textContent = tlsForwardersState.length;
|
|
if (!tlsForwardersState.length) {
|
|
list.innerHTML = '<div class="hint" style="padding:4px 0;">No TLS forwarders configured.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = "";
|
|
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>`;
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "btn btn-danger btn-sm";
|
|
delBtn.textContent = "Remove";
|
|
delBtn.onclick = () => { tlsForwardersState.splice(i,1); renderTLSForwarders(); };
|
|
row.appendChild(delBtn);
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function toggleAddTLSForm() {
|
|
const panel = document.getElementById("addTLSPanel");
|
|
panel.classList.toggle("hidden");
|
|
if (!panel.classList.contains("hidden")) {
|
|
document.getElementById("tlsAddStatus").textContent = "";
|
|
document.getElementById("tlsListenAddr").value = "";
|
|
document.getElementById("tlsSSLDomain").value = "";
|
|
document.getElementById("tlsCertType").value = "selfsigned";
|
|
onTLSTypeChange("selfsigned");
|
|
}
|
|
}
|
|
|
|
function onTLSTypeChange(val) {
|
|
document.getElementById("tlsSSFields").style.display = val === "selfsigned" ? "" : "none";
|
|
document.getElementById("tlsLEFields").style.display = val === "letsencrypt" ? "grid" : "none";
|
|
document.getElementById("tlsPasteFields").style.display = val === "paste" ? "" : "none";
|
|
document.getElementById("tlsCustomFields").style.display = val === "custom" ? "grid" : "none";
|
|
}
|
|
|
|
async function addTLSForwarder() {
|
|
const st = document.getElementById("tlsAddStatus");
|
|
const listen = document.getElementById("tlsListenAddr").value.trim();
|
|
const certType = document.getElementById("tlsCertType").value;
|
|
if (!listen) { st.textContent = "Listen address required."; return; }
|
|
let certFile = "", keyFile = "";
|
|
st.textContent = "Processing…";
|
|
if (certType === "selfsigned") {
|
|
const domain = document.getElementById("tlsSSLDomain").value.trim();
|
|
if (!domain) { st.textContent = "Domain required."; return; }
|
|
try {
|
|
const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
certFile = data.cert_file; keyFile = data.key_file;
|
|
st.textContent = "Self-signed cert generated.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Cert error: " + e.message;
|
|
return;
|
|
}
|
|
} else if (certType === "letsencrypt") {
|
|
const domain = document.getElementById("tlsLEDomain").value.trim();
|
|
const email = document.getElementById("tlsLEEmail").value.trim();
|
|
if (!domain || !email) { st.textContent = "Domain and email required."; return; }
|
|
st.textContent = "Running certbot… (may take ~30s)";
|
|
try {
|
|
const res = await api("/api/tls/letsencrypt", { method:"POST", body: JSON.stringify({ domain, email }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
certFile = data.cert_file; keyFile = data.key_file;
|
|
st.textContent = "Let's Encrypt cert issued.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "certbot error: " + e.message;
|
|
return;
|
|
}
|
|
} else if (certType === "paste") {
|
|
const name = document.getElementById("tlsPasteName").value.trim();
|
|
const cert = document.getElementById("tlsPasteCert").value.trim();
|
|
const key = document.getElementById("tlsPasteKey").value.trim();
|
|
if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; }
|
|
try {
|
|
const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
certFile = data.cert_file; keyFile = data.key_file;
|
|
st.textContent = "PEM saved.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Upload error: " + e.message;
|
|
return;
|
|
}
|
|
} else {
|
|
certFile = document.getElementById("tlsCustomCert").value.trim();
|
|
keyFile = document.getElementById("tlsCustomKey").value.trim();
|
|
if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; }
|
|
}
|
|
tlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile });
|
|
renderTLSForwarders();
|
|
document.getElementById("addTLSPanel").classList.add("hidden");
|
|
st.textContent = "Added. Save config to apply.";
|
|
}
|
|
|
|
// ─── Xray wizard cert source picker ──────────────────────────────────────────
|
|
function setWzCertSrc(mode) {
|
|
["file","paste","gen"].forEach(m => {
|
|
const cap = m.charAt(0).toUpperCase() + m.slice(1);
|
|
document.getElementById("wzCertSrc"+cap).style.display = m === mode ? "" : "none";
|
|
const btn = document.getElementById("wzCertSrc"+cap+"Btn");
|
|
if (btn) btn.className = (m === mode ? "btn btn-sm" : "btn btn-ghost btn-sm");
|
|
});
|
|
document.getElementById("wzPasteCertStatus").textContent = "";
|
|
document.getElementById("wzGenCertStatus").textContent = "";
|
|
}
|
|
|
|
async function wzSavePastedCert() {
|
|
const st = document.getElementById("wzPasteCertStatus");
|
|
const name = document.getElementById("wzPastedName").value.trim();
|
|
const cert = document.getElementById("wzPastedCert").value.trim();
|
|
const key = document.getElementById("wzPastedKey").value.trim();
|
|
if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; }
|
|
st.textContent = "Saving…";
|
|
try {
|
|
const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
document.getElementById("wzTLSCert").value = data.cert_file;
|
|
document.getElementById("wzTLSKey").value = data.key_file;
|
|
st.textContent = "Saved ✓ paths set.";
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
async function wzGenerateCert() {
|
|
const st = document.getElementById("wzGenCertStatus");
|
|
const domain = document.getElementById("wzGenDomain").value.trim();
|
|
if (!domain) { st.textContent = "Domain required."; return; }
|
|
st.textContent = "Generating…";
|
|
try {
|
|
const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
document.getElementById("wzTLSCert").value = data.cert_file;
|
|
document.getElementById("wzTLSKey").value = data.key_file;
|
|
st.textContent = "Generated ✓ paths set.";
|
|
} catch (e) {
|
|
if (e.message === "auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── DNSTT Key Management ─────────────────────────────────────────────────────
|
|
async function generateDnsttKey() {
|
|
const st = document.getElementById("dnsttKeyStatus");
|
|
st.textContent = "Generating key…";
|
|
try {
|
|
const res = await api("/api/dnstt/genkey", { method: "POST" });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
document.getElementById("cfgDnsttKey").value = data.privkey_file;
|
|
document.getElementById("dnsttPubkeyVal").value = data.pubkey;
|
|
document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
|
|
st.textContent = "Key generated. Save config to apply.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
async function loadDnsttPubkey() {
|
|
const st = document.getElementById("dnsttKeyStatus");
|
|
st.textContent = "Loading public key…";
|
|
try {
|
|
const res = await api("/api/dnstt/pubkey");
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
document.getElementById("dnsttPubkeyVal").value = data.pubkey;
|
|
document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
|
|
st.textContent = "";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── Xray Client Edit ─────────────────────────────────────────────────────────
|
|
function openEditXrayClient(tag, client) {
|
|
editingXrayClientId = client.id;
|
|
document.getElementById("editClientUUID").textContent = client.id;
|
|
document.getElementById("editXrayName").value = client.name || "";
|
|
document.getElementById("editXrayEmail").value = client.email || "";
|
|
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
|
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
|
document.getElementById("editXrayClientStatus").textContent = "";
|
|
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
|
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
|
}
|
|
|
|
function closeEditXrayClient() {
|
|
editingXrayClientId = null;
|
|
document.getElementById("editXrayClientPanel").classList.add("hidden");
|
|
}
|
|
|
|
async function saveEditXrayClient() {
|
|
if (!editingXrayClientId) return;
|
|
const st = document.getElementById("editXrayClientStatus");
|
|
st.textContent = "Saving…";
|
|
const payload = {
|
|
uuid: editingXrayClientId,
|
|
name: document.getElementById("editXrayName").value.trim(),
|
|
email: document.getElementById("editXrayEmail").value.trim(),
|
|
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
|
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
|
};
|
|
try {
|
|
const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
st.textContent = "Saved.";
|
|
setTimeout(() => { closeEditXrayClient(); loadInbounds(); }, 700);
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── Xray Config Wizard ────────────────────────────────────────────────────────
|
|
function setXrayCfgMode(mode) {
|
|
const wizPane = document.getElementById("xrayWizardPane");
|
|
const jsonPane = document.getElementById("xrayCfgPaneJson");
|
|
const wizBtn = document.getElementById("xrayWizardTabBtn");
|
|
const jsonBtn = document.getElementById("xrayJsonTabBtn");
|
|
if (mode === "wizard") {
|
|
wizPane.classList.remove("hidden");
|
|
jsonPane.classList.add("hidden");
|
|
wizBtn.classList.remove("btn-ghost");
|
|
jsonBtn.classList.add("btn-ghost");
|
|
loadWizardFromConfig();
|
|
} else {
|
|
wizPane.classList.add("hidden");
|
|
jsonPane.classList.remove("hidden");
|
|
jsonBtn.classList.remove("btn-ghost");
|
|
wizBtn.classList.add("btn-ghost");
|
|
loadXrayCfg();
|
|
}
|
|
}
|
|
|
|
function loadWizardFromConfig() {
|
|
api("/api/xray/config").then(async res => {
|
|
if (!res.ok) return;
|
|
try {
|
|
const cfg = JSON.parse(await res.text());
|
|
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
|
wzInbounds = cfg.inbounds || [];
|
|
renderWzInbounds();
|
|
} catch {}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderWzInbounds() {
|
|
const list = document.getElementById("wzInboundsList");
|
|
if (!list) return;
|
|
if (!wzInbounds.length) {
|
|
list.innerHTML = '<div class="hint" style="padding:4px 0;">No inbounds. Click + Add to create one.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = "";
|
|
wzInbounds.forEach((ib, 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;";
|
|
const portStr = ib.port !== undefined ? `:${ib.port}` : "";
|
|
const ss = ib.streamSettings || {};
|
|
const net = ss.network || "";
|
|
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>`;
|
|
const clients = ib.settings?.clients;
|
|
if (Array.isArray(clients) && clients.length) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "chip green";
|
|
badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
|
|
row.appendChild(badge);
|
|
}
|
|
const delBtn = document.createElement("button");
|
|
delBtn.className = "btn btn-danger btn-sm";
|
|
delBtn.textContent = "Remove";
|
|
delBtn.onclick = () => { wzInbounds.splice(i,1); renderWzInbounds(); };
|
|
row.appendChild(delBtn);
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function wzToggleAddInbound() {
|
|
const form = document.getElementById("wzAddInboundForm");
|
|
form.classList.toggle("hidden");
|
|
if (!form.classList.contains("hidden")) {
|
|
onWzProtoChange(document.getElementById("wzProtocol").value);
|
|
onWzNetworkChange(document.getElementById("wzNetwork").value);
|
|
onWzTLSChange(document.getElementById("wzTLS").value);
|
|
}
|
|
}
|
|
|
|
function onWzProtoChange(val) {
|
|
document.getElementById("wzVlessFields").style.display = val === "vless" ? "grid" : "none";
|
|
document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
|
|
document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
|
|
const portMap = { vless:10086, trojan:8443, shadowsocks:8388, socks:10808 };
|
|
const tagMap = { vless:"vless-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
|
|
const portEl = document.getElementById("wzPort");
|
|
const tagEl = document.getElementById("wzTag");
|
|
const lisEl = document.getElementById("wzListenIP");
|
|
if (!portEl.value) portEl.value = portMap[val] || "";
|
|
if (!tagEl.value) tagEl.value = tagMap[val] || val+"-in";
|
|
if (!lisEl.value) lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0";
|
|
}
|
|
|
|
function onWzNetworkChange(val) {
|
|
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
|
// WebSocket
|
|
show("wzWSPathField", val === "ws");
|
|
// XHTTP
|
|
show("wzXHTTPPathField", val === "xhttp");
|
|
show("wzXHTTPHostField", val === "xhttp");
|
|
show("wzXHTTPModeField", val === "xhttp");
|
|
// HTTPUpgrade
|
|
show("wzHUPathField", val === "httpupgrade");
|
|
show("wzHUHostField", val === "httpupgrade");
|
|
// H2
|
|
show("wzH2PathField", val === "h2");
|
|
show("wzH2HostField", val === "h2");
|
|
// gRPC
|
|
show("wzGRPCServiceField", val === "grpc");
|
|
show("wzGRPCMultiField", val === "grpc");
|
|
// Auto-select TLS defaults
|
|
const tlsSel = document.getElementById("wzTLS");
|
|
if ((val === "h2" || val === "grpc") && tlsSel.value === "none") {
|
|
tlsSel.value = "tls"; onWzTLSChange("tls");
|
|
}
|
|
}
|
|
|
|
function onWzTLSChange(val) {
|
|
const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
|
|
show("wzTLSCertBlock", val === "tls");
|
|
show("wzRealityDestField", val === "reality");
|
|
show("wzRealitySNIField", val === "reality");
|
|
show("wzRealityPrivField", val === "reality");
|
|
show("wzRealityShortIDField",val === "reality");
|
|
}
|
|
|
|
function wzSaveInbound() {
|
|
const proto = document.getElementById("wzProtocol").value;
|
|
const port = parseInt(document.getElementById("wzPort").value || "0", 10);
|
|
const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
|
|
const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
|
|
if (!port) { alert("Port required."); return; }
|
|
const ib = { tag, port, listen, protocol: proto, settings: {} };
|
|
if (proto === "vless") {
|
|
ib.settings = { clients: [], decryption: "none" };
|
|
const net = document.getElementById("wzNetwork").value;
|
|
const tlsVal = document.getElementById("wzTLS").value;
|
|
ib.streamSettings = { network: net };
|
|
// Transport-specific settings
|
|
switch (net) {
|
|
case "ws":
|
|
ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
|
|
break;
|
|
case "xhttp":
|
|
ib.streamSettings.xhttpSettings = {
|
|
path: document.getElementById("wzXHTTPPath").value.trim() || "/",
|
|
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
|
mode: document.getElementById("wzXHTTPMode").value,
|
|
};
|
|
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
|
break;
|
|
case "httpupgrade":
|
|
ib.streamSettings.httpupgradeSettings = {
|
|
path: document.getElementById("wzHUPath").value.trim() || "/",
|
|
host: document.getElementById("wzHUHost").value.trim() || undefined,
|
|
};
|
|
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
|
|
break;
|
|
case "h2":
|
|
ib.streamSettings.httpSettings = {
|
|
path: document.getElementById("wzH2Path").value.trim() || "/",
|
|
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
|
};
|
|
break;
|
|
case "grpc":
|
|
ib.streamSettings.grpcSettings = {
|
|
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
|
multiMode: document.getElementById("wzGRPCMulti").checked,
|
|
};
|
|
break;
|
|
}
|
|
// TLS / Reality
|
|
if (tlsVal === "tls") {
|
|
ib.streamSettings.security = "tls";
|
|
ib.streamSettings.tlsSettings = {
|
|
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
|
};
|
|
} else if (tlsVal === "reality") {
|
|
ib.streamSettings.security = "reality";
|
|
ib.streamSettings.realitySettings = {
|
|
dest: document.getElementById("wzRealityDest").value.trim(),
|
|
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
|
|
privateKey: document.getElementById("wzRealityPriv").value.trim(),
|
|
shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
|
|
};
|
|
}
|
|
} else if (proto === "trojan") {
|
|
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
|
ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} };
|
|
} else if (proto === "shadowsocks") {
|
|
ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" };
|
|
} else if (proto === "socks") {
|
|
ib.settings = { auth: "noauth", udp: true };
|
|
ib.streamSettings = { network: "tcp" };
|
|
}
|
|
wzInbounds.push(ib);
|
|
renderWzInbounds();
|
|
document.getElementById("wzAddInboundForm").classList.add("hidden");
|
|
document.getElementById("wzPort").value = "";
|
|
document.getElementById("wzTag").value = "";
|
|
document.getElementById("wzListenIP").value = "";
|
|
}
|
|
|
|
async function applyWizardConfig() {
|
|
const st = document.getElementById("wzStatus");
|
|
st.textContent = "Saving…";
|
|
const cfg = {
|
|
log: { loglevel: document.getElementById("wzLogLevel").value },
|
|
inbounds: wzInbounds,
|
|
outbounds: [
|
|
{ tag:"direct", protocol:"freedom", settings:{} },
|
|
{ tag:"blocked", protocol:"blackhole", settings:{} }
|
|
]
|
|
};
|
|
try {
|
|
const res = await api("/api/xray/config", { method:"POST", body: JSON.stringify(cfg, null, 2) });
|
|
if (!res.ok) throw new Error(await res.text());
|
|
st.textContent = "Saved. Restarting Xray…";
|
|
await xrayCtrl("restart");
|
|
st.textContent = "Config saved and Xray restarted.";
|
|
} catch (e) {
|
|
if (e.message==="auth") doAuthError();
|
|
else st.textContent = "Error: " + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── Auth error ───────────────────────────────────────────────────────────────
|
|
function doAuthError() {
|
|
sessionToken = "";
|
|
localStorage.removeItem("SESSION_TOKEN");
|
|
clearTimers();
|
|
mainApp.classList.add("hidden");
|
|
loginOverlay.classList.remove("hidden");
|
|
loginErr.textContent = "Session expired — please sign in again.";
|
|
}
|
|
|
|
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
window.addEventListener("load", () => {
|
|
if (sessionToken) {
|
|
// Try to validate the stored token
|
|
api("/api/auth/me").then(async res => {
|
|
if (!res.ok) { doAuthError(); return; }
|
|
const d = await res.json();
|
|
currentRole = d.role;
|
|
currentUser = d.username;
|
|
loginOverlay.classList.add("hidden");
|
|
mainApp.classList.remove("hidden");
|
|
initAfterLogin();
|
|
}).catch(() => doAuthError());
|
|
} else {
|
|
loginOverlay.classList.remove("hidden");
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|