266 lines
19 KiB
Go
266 lines
19 KiB
Go
package main
|
|
|
|
const ui = `<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>sdm-panel</title>
|
|
<style>
|
|
:root{--bg:#0d1117;--side:#0a0e13;--card:#151b23;--line:#1f2833;--txt:#dfe6ee;--mut:#8b98a5;--acc:#4f8ef7;--ok:#3fb97f;--err:#ff6b6b;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--txt);font-size:14px}
|
|
.hidden{display:none!important}
|
|
button{background:#1d2735;color:var(--txt);border:1px solid #2a3546;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px;transition:background .15s}
|
|
button:hover{background:#273449}
|
|
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
|
|
button.primary:hover{background:#3d7ee6}
|
|
button.ghost{background:transparent;border-color:transparent;color:var(--mut);padding:6px 10px;font-size:12px}
|
|
button.ghost:hover{background:#1a222c;color:var(--txt)}
|
|
button.danger{background:#2a1820;border-color:#4a2330;color:#ff9d9d}
|
|
button.icon{font-size:14px;padding:6px 9px}
|
|
input[type=text],input[type=password],input[type=number],textarea{width:100%;background:#0b1016;border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:9px 11px;font-family:inherit;font-size:13px}
|
|
input:focus,textarea:focus{outline:none;border-color:var(--acc)}
|
|
label{display:block;color:var(--mut);font-size:12px;margin:0 0 5px}
|
|
/* login */
|
|
#login{min-height:100vh;display:flex;align-items:center;justify-content:center}
|
|
.login-card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:36px;width:340px}
|
|
.login-card h1{margin:0 0 4px;font-size:22px}
|
|
.login-card .sub{color:var(--mut);margin:0 0 24px;font-size:13px}
|
|
/* layout */
|
|
#app{display:flex;min-height:100vh}
|
|
aside{width:220px;background:var(--side);border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
|
|
aside .brand{display:flex;align-items:center;gap:10px;padding:4px 10px 18px;font-weight:600;font-size:16px}
|
|
aside .brand .dot{width:10px;height:10px;border-radius:50%;background:var(--acc);box-shadow:0 0 10px var(--acc)}
|
|
aside nav{display:flex;flex-direction:column;gap:2px;flex:1}
|
|
aside nav button{text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:var(--mut);font-size:13px}
|
|
aside nav button:hover{background:#151b23;color:var(--txt)}
|
|
aside nav button.on{background:var(--card);color:var(--txt);border-color:var(--line)}
|
|
aside nav button .glyph{margin-right:8px;opacity:.7}
|
|
aside .logout{background:transparent;border:none;color:var(--mut);text-align:left;padding:10px 12px;cursor:pointer;font-size:13px}
|
|
aside .logout:hover{color:var(--err)}
|
|
main{flex:1;padding:28px 32px;max-width:1040px}
|
|
main h2{margin:0 0 4px;font-size:20px}
|
|
main .crumb{color:var(--mut);font-size:12px;margin:0 0 20px}
|
|
/* cards */
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:14px;margin-bottom:20px}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}
|
|
.stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.6px}
|
|
.stat .v{font-size:15px;margin-top:6px;word-break:break-all;line-height:1.4}
|
|
.badge{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:20px;font-size:12px;font-weight:500}
|
|
.badge::before{content:"";width:8px;height:8px;border-radius:50%}
|
|
.badge.ok{background:rgba(63,185,127,.12);color:var(--ok)}.badge.ok::before{background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
|
.badge.bad{background:rgba(255,107,107,.12);color:var(--err)}.badge.bad::before{background:var(--err)}
|
|
.section{margin:22px 0 8px;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:1px}
|
|
/* clients */
|
|
.cli{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 16px;margin-bottom:10px}
|
|
.cli .nm{font-weight:500;min-width:120px}
|
|
.cli .uid{font-family:var(--mono);font-size:12px;color:var(--mut);flex:1;word-break:break-all}
|
|
.cli .acts{display:flex;gap:6px;flex-shrink:0}
|
|
/* forms */
|
|
.row{display:flex;gap:12px;flex-wrap:wrap}
|
|
.row > div{flex:1;min-width:150px}
|
|
textarea{min-height:110px;font-family:var(--mono);font-size:12px;line-height:1.5}
|
|
.mut{color:var(--mut);font-size:12px}
|
|
pre{background:#0b1016;border:1px solid var(--line);border-radius:12px;padding:14px;overflow:auto;max-height:440px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;line-height:1.5}
|
|
.actions{display:flex;gap:10px;align-items:center;margin-top:16px}
|
|
#toast{position:fixed;top:18px;right:18px;background:#1d2735;border:1px solid var(--line);border-radius:10px;padding:12px 18px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:50;max-width:380px;font-size:13px}
|
|
#toast.show{opacity:1}
|
|
#toast.ok{border-color:var(--ok)}#toast.err{border-color:var(--err)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="login"><div class="login-card">
|
|
<h1>sdm-panel</h1>
|
|
<p class="sub">stealth tunnel · управление сервером</p>
|
|
<label>Пароль</label>
|
|
<input type="password" id="pw" onkeydown="if(event.key==='Enter')login()" autofocus>
|
|
<br><br>
|
|
<button class="primary" style="width:100%" onclick="login()">Войти</button>
|
|
</div></div>
|
|
|
|
<div id="app" class="hidden">
|
|
<aside>
|
|
<div class="brand"><span class="dot"></span>sdm-panel</div>
|
|
<nav>
|
|
<button class="on" data-t="status" onclick="tab('status')"><span class="glyph">▤</span>Статус</button>
|
|
<button data-t="clients" onclick="tab('clients')"><span class="glyph">👥</span>Клиенты</button>
|
|
<button data-t="tunnel" onclick="tab('tunnel')"><span class="glyph">⚙</span>Туннель</button>
|
|
<button data-t="panel" onclick="tab('panel')"><span class="glyph">🛠</span>Панель</button>
|
|
<button data-t="logs" onclick="tab('logs')"><span class="glyph">≡</span>Логи</button>
|
|
</nav>
|
|
<button class="logout" onclick="logout()">⏻ Выйти</button>
|
|
</aside>
|
|
|
|
<main>
|
|
<div id="t-status">
|
|
<h2>Статус</h2><p class="crumb">Общее состояние сервера и туннеля</p>
|
|
<div class="grid" id="statusGrid"></div>
|
|
<div class="card actions">
|
|
<button class="primary" onclick="restart()">Перезапустить xray</button>
|
|
<button onclick="loadStatus()">Обновить</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-clients" class="hidden">
|
|
<h2>Клиенты</h2><p class="crumb">Устройства, которым разрешено подключаться</p>
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="row">
|
|
<div style="flex:3"><label>Имя клиента</label><input type="text" id="newName" placeholder="например: iphone, ноутбук, роутер"></div>
|
|
<div style="flex:1;display:flex;align-items:flex-end"><button class="primary" style="width:100%" onclick="addClient()">Добавить</button></div>
|
|
</div>
|
|
</div>
|
|
<div id="clientsList"></div>
|
|
</div>
|
|
|
|
<div id="t-tunnel" class="hidden">
|
|
<h2>Туннель</h2><p class="crumb">Зеркало и параметры транспорта</p>
|
|
|
|
<div class="card">
|
|
<div class="section">Зеркало (сайт, который видит зонд/браузер)</div>
|
|
<div class="row">
|
|
<div style="flex:2"><label>target — выбери или впиши свой</label>
|
|
<input type="text" id="target" list="targetList" placeholder="https://www.iana.org">
|
|
<datalist id="targetList">
|
|
<option value="https://www.iana.org">IANA (нейтральный, рекомендуется)</option>
|
|
<option value="https://www.apple.com">Apple</option>
|
|
<option value="https://www.openbsd.org">OpenBSD</option>
|
|
<option value="https://www.debian.org">Debian</option>
|
|
<option value="https://www.gnu.org">GNU</option>
|
|
<option value="https://www.mozilla.org">Mozilla</option>
|
|
<option value="https://www.kernel.org">kernel.org</option>
|
|
<option value="https://www.cloudflare.com">Cloudflare</option>
|
|
<option value="">— без зеркала (встроенная страница) —</option>
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<details class="card" style="padding:14px 16px">
|
|
<summary style="cursor:pointer;font-weight:500">Экспертные настройки</summary>
|
|
<div class="section">Пути туннеля (по одному на строку; клиенты и сервер должны совпадать)</div>
|
|
<textarea id="paths"></textarea>
|
|
<div class="section">Транспорт</div>
|
|
<div class="row">
|
|
<div><label>Порты (через запятую)</label><input type="text" id="ports"></div>
|
|
<div><label>padMax</label><input type="number" id="padMax"></div>
|
|
<div><label>padProb (%)</label><input type="number" id="padProb"></div>
|
|
<div><label>paceMs</label><input type="number" id="paceMs"></div>
|
|
<div><label>paceJitterMs</label><input type="number" id="paceJitterMs"></div>
|
|
<div><label>bps (0 = выкл)</label><input type="number" id="bps"></div>
|
|
<div><label>retries</label><input type="number" id="retries"></div>
|
|
<div><label>padProfile</label><input type="text" id="padProfile"></div>
|
|
</div>
|
|
<div class="section">Decoy (фоновый трафик)</div>
|
|
<div class="row">
|
|
<div><label>decoy (true/false)</label><input type="text" id="decoy"></div>
|
|
<div><label>decoyEverySecs</label><input type="number" id="decoyEverySecs"></div>
|
|
<div><label>decoyTarget</label><input type="text" id="decoyTarget"></div>
|
|
</div>
|
|
</details>
|
|
|
|
<div class="card actions">
|
|
<button class="primary" onclick="saveStealth()">Сохранить и перезапустить</button>
|
|
<button onclick="rotStealthKey()">Сменить stealth-ключ</button>
|
|
<span class="mut">После сохранения xray перезапустится; клиентам понадобятся обновлённые конфиги.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-panel" class="hidden">
|
|
<h2>Панель</h2><p class="crumb">Настройки самой панели</p>
|
|
<div class="card">
|
|
<div class="row">
|
|
<div><label>serverName (домен для клиентских конфигов)</label><input type="text" id="pServer"></div>
|
|
<div><label>bind</label><input type="text" id="pBind"></div>
|
|
</div>
|
|
<div style="margin-top:12px"><label>config path</label><input type="text" id="pConfig"></div>
|
|
<div style="margin-top:12px"><label>Новый пароль (пусто = не менять)</label><input type="password" id="pPass"></div>
|
|
<div class="actions">
|
|
<button class="primary" onclick="savePanel()">Сохранить</button>
|
|
<span class="mut">Смена bind/пароля вступает в силу после перезапуска панели: systemctl restart sdm-panel</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="t-logs" class="hidden">
|
|
<h2>Логи</h2><p class="crumb">Последние записи xray</p>
|
|
<div class="card">
|
|
<button onclick="loadLogs()">Обновить</button>
|
|
<pre id="logs" style="margin-top:12px"></pre>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
let TOK = localStorage.getItem('tok') || '';
|
|
function api(p, opt){ opt=opt||{};
|
|
opt.headers = Object.assign({'Authorization':'Bearer '+TOK}, opt.headers||{});
|
|
return fetch(p,opt).then(async r=>{const j=await r.json().catch(()=>({}));
|
|
if(r.status===401){logout();throw new Error('unauthorized')}
|
|
if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
|
|
return j;});
|
|
}
|
|
function toast(msg,ok){const t=document.getElementById('toast');t.textContent=msg;t.className='show '+(ok?'ok':'err');setTimeout(()=>t.className='',2600)}
|
|
function esc(s){return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
function login(){fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.getElementById('pw').value})})
|
|
.then(r=>r.json()).then(j=>{if(!j.token)throw new Error('неверный пароль');TOK=j.token;localStorage.setItem('tok',j.token);showApp()}).catch(e=>toast(e.message,false))}
|
|
function logout(){localStorage.removeItem('tok');TOK='';location.reload()}
|
|
function showApp(){document.getElementById('login').classList.add('hidden');document.getElementById('app').classList.remove('hidden');tab('status')}
|
|
function tab(n){document.querySelectorAll('#app main > div').forEach(t=>t.classList.toggle('hidden',t.id!=='t-'+n));document.querySelectorAll('aside nav button').forEach(b=>b.classList.toggle('on',b.dataset.t===n));if(n==='status')loadStatus();if(n==='clients')loadClients();if(n==='tunnel')loadStealth();if(n==='panel')loadPanel();if(n==='logs')loadLogs()}
|
|
|
|
async function loadStatus(){try{const s=await api('api/status');
|
|
const badge=s.service==='active'?'<span class="badge ok">active</span>':'<span class="badge bad">'+esc(s.service)+'</span>';
|
|
const g=document.getElementById('statusGrid');g.innerHTML='';
|
|
[['Сервис',badge],['Порты',(s.ports||[]).join(', ')||'-'],['Пути',(s.paths||[]).join(', ')||'-'],['Зеркало',esc(s.target||'-')],['Клиентов',s.clientCount||0]].forEach(([k,v])=>{
|
|
const d=document.createElement('div');d.className='card stat';d.innerHTML='<div class="k">'+k+'</div><div class="v">'+v+'</div>';g.appendChild(d)})}catch(e){toast(e.message,false)}}
|
|
|
|
async function loadClients(){try{const j=await api('api/clients');const L=document.getElementById('clientsList');L.innerHTML='';
|
|
if(!j.clients.length){L.innerHTML='<div class="card mut">Клиентов пока нет — добавьте первого.</div>';return}
|
|
j.clients.forEach(cl=>{const d=document.createElement('div');d.className='cli';
|
|
d.innerHTML='<div class="nm">'+esc(cl.name||'—')+'</div><div class="uid">'+esc(cl.id||'')+'</div>'+
|
|
'<div class="acts">'+
|
|
'<button class="ghost" title="Скопировать vless:// ссылку" onclick="copyLink(\''+esc(cl.id)+'\')">⧉ ссылка</button>'+
|
|
'<button class="ghost" title="Скопировать конфиг" onclick="copyConfig(\''+esc(cl.id)+'\')">⧉ конфиг</button>'+
|
|
'<button class="ghost" title="Скопировать UUID" onclick="copyText(\''+esc(cl.id)+'\',\'UUID скопирован\')">⧉ uuid</button>'+
|
|
'<button class="ghost" title="Новый UUID" onclick="rotClient(\''+esc(cl.id)+'\')">⟳</button>'+
|
|
'<button class="ghost" title="Удалить" onclick="delClient(\''+esc(cl.id)+'\')">✕</button>'+
|
|
'</div>';
|
|
L.appendChild(d)})}catch(e){toast(e.message,false)}}
|
|
async function addClient(){try{const n=document.getElementById('newName').value.trim();const j=await api('api/clients',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n||'client'})});toast('клиент добавлен',true);document.getElementById('newName').value='';loadClients()}catch(e){toast(e.message,false)}}
|
|
async function delClient(id){if(!confirm('Удалить клиента?'))return;try{await api('api/clients/'+id,{method:'DELETE'});toast('клиент удалён',true);loadClients()}catch(e){toast(e.message,false)}}
|
|
async function rotClient(id){try{const j=await api('api/clients/'+id+'/rotate',{method:'POST'});toast('новый UUID выдан',true);loadClients()}catch(e){toast(e.message,false)}}
|
|
async function copyConfig(id){try{const r=await fetch('api/clients/'+id+'/config',{headers:{'Authorization':'Bearer '+TOK}});const txt=await r.text();await navigator.clipboard.writeText(txt);toast('конфиг client-stealth.json скопирован',true)}catch(e){toast('скопировать не удалось: '+e.message,false)}}
|
|
async function copyLink(id){try{const r=await fetch('api/clients/'+id+'/link',{headers:{'Authorization':'Bearer '+TOK}});const j=await r.json();await navigator.clipboard.writeText(j.link);toast('vless:// ссылка скопирована',true)}catch(e){toast('ошибка: '+e.message,false)}}
|
|
function copyText(t,msg){navigator.clipboard.writeText(t).then(()=>toast(msg||'скопировано',true)).catch(e=>toast(e.message,false))}
|
|
|
|
async function loadStealth(){try{const s=await api('api/stealth');
|
|
document.getElementById('paths').value=(s.paths||[]).join('\n');
|
|
const set=(id,v)=>document.getElementById(id).value=(v===undefined?'':v);
|
|
set('ports',(s.ports||[]).join(', '));set('padMax',s.padMax);set('padProb',s.padProb);set('paceMs',s.paceMs);set('paceJitterMs',s.paceJitterMs);set('bps',s.bps);set('retries',s.retries);set('padProfile',s.padProfile);set('target',s.target);set('decoy',s.decoy);set('decoyEverySecs',s.decoyEverySecs);set('decoyTarget',s.decoyTarget)}catch(e){toast(e.message,false)}}
|
|
async function saveStealth(){try{const num=v=>{const n=Number(v);return isNaN(n)?0:n};
|
|
const body={paths:document.getElementById('paths').value.split('\n').map(s=>s.trim()).filter(Boolean),
|
|
ports:document.getElementById('ports').value.split(',').map(s=>Number(s.trim())).filter(Boolean),
|
|
padMax:num(document.getElementById('padMax').value),padProb:num(document.getElementById('padProb').value),
|
|
paceMs:num(document.getElementById('paceMs').value),paceJitterMs:num(document.getElementById('paceJitterMs').value),
|
|
bps:num(document.getElementById('bps').value),retries:num(document.getElementById('retries').value),
|
|
padProfile:document.getElementById('padProfile').value,target:document.getElementById('target').value,
|
|
decoy:document.getElementById('decoy').value==='true',decoyEverySecs:num(document.getElementById('decoyEverySecs').value),
|
|
decoyTarget:document.getElementById('decoyTarget').value};
|
|
const j=await api('api/stealth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('сохранено, xray: '+(j.restart||'ok'),true)}catch(e){toast(e.message,false)}}
|
|
async function rotStealthKey(){try{const j=await api('api/stealth/key',{method:'POST'});toast('новый stealth-ключ: '+j.key,true)}catch(e){toast(e.message,false)}}
|
|
|
|
async function loadPanel(){try{const p=await api('api/panel');document.getElementById('pServer').value=p.serverName||'';document.getElementById('pBind').value=p.bind;document.getElementById('pConfig').value=p.config}catch(e){toast(e.message,false)}}
|
|
async function savePanel(){try{await api('api/panel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({serverName:document.getElementById('pServer').value,bind:document.getElementById('pBind').value,config:document.getElementById('pConfig').value,password:document.getElementById('pPass').value})});toast('сохранено',true);document.getElementById('pPass').value=''}catch(e){toast(e.message,false)}}
|
|
|
|
async function restart(){try{const j=await api('api/restart',{method:'POST'});toast('xray: '+(j.restart||'ok'),true);loadStatus()}catch(e){toast(e.message,false)}}
|
|
async function loadLogs(){try{const j=await api('api/logs?n=150');document.getElementById('logs').textContent=j.logs||''}catch(e){toast(e.message,false)}}
|
|
|
|
if(TOK){showApp()}else{document.getElementById('pw').focus()}
|
|
</script>
|
|
</body></html>
|
|
`
|