Add node mode with SSH auto-install and Remnawave-style manual deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:03:33 +03:00
co-authored by Cursor
parent 93bbbf8ce8
commit 7b77d84f68
20 changed files with 1470 additions and 8 deletions
+89
View File
@@ -276,6 +276,93 @@ input:focus {
}
.badge.on { background: rgba(61,214,198,0.15); color: var(--ok); }
.badge.off { background: rgba(122,135,156,0.18); color: var(--off); }
.badge.warn { background: rgba(255, 196, 87, 0.16); color: #ffc457; }
.badge.err { background: rgba(255,107,122,0.15); color: #ff8a96; }
.alert.ok {
background: rgba(61,214,198,0.12);
border: 1px solid rgba(61,214,198,0.35);
color: #9af0e6;
}
.row-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.stats-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.25rem;
max-width: 760px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem 1rem;
margin-bottom: 0.85rem;
}
textarea, select {
width: 100%;
border: 1px solid var(--line);
background: rgba(0,0,0,0.25);
color: var(--text);
border-radius: 10px;
padding: 0.75rem 0.9rem;
font: inherit;
}
textarea { resize: vertical; font-family: var(--mono); font-size: 0.85rem; }
.hint { color: var(--muted); font-size: 0.85rem; margin: -0.35rem 0 1rem; }
.mode-pick {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.85rem 1rem;
margin: 0 0 1.25rem;
}
.mode-pick legend { padding: 0 0.35rem; color: var(--muted); }
.radio {
display: flex;
gap: 0.65rem;
align-items: flex-start;
margin: 0.55rem 0;
color: var(--text);
}
.radio input { width: auto; margin-top: 0.25rem; }
.code-block {
background: rgba(0,0,0,0.35);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem;
overflow: auto;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
}
.code-block.log { max-height: 320px; }
.steps { color: var(--muted); padding-left: 1.2rem; }
.steps li { margin: 0.35rem 0; }
.empty-state {
border: 1px dashed var(--line);
border-radius: var(--radius);
padding: 2rem;
text-align: center;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
.panel-section { margin-top: 1.75rem; }
.panel-section h2 { margin: 0 0 0.75rem; font-size: 1.1rem; }
.table-wrap {
background: var(--surface);
@@ -326,6 +413,8 @@ input:focus {
.sidebar nav { grid-auto-flow: column; grid-auto-columns: max-content; overflow-x: auto; }
.sidebar-foot { display: flex; align-items: center; justify-content: space-between; }
.stats { grid-template-columns: 1fr; }
.stats-5 { grid-template-columns: 1fr 1fr; }
.form-grid, .meta-grid { grid-template-columns: 1fr; }
.admin-main { padding: 1.25rem; }
.home-top, .home-hero { padding-left: 1.25rem; padding-right: 1.25rem; }
}
+37 -2
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
@@ -25,10 +26,18 @@
<main class="admin-main">
<header class="admin-header">
<h1>Обзор</h1>
<p class="muted">Состояние панели и активные протоколы</p>
<p class="muted">Панель, ноды и протоколы</p>
</header>
<section class="stats">
<section class="stats stats-5">
<div class="stat">
<span class="stat-label">Ноды</span>
<span class="stat-value">{{.Stats.NodesTotal}}</span>
</div>
<div class="stat">
<span class="stat-label">Online</span>
<span class="stat-value accent">{{.Stats.NodesOnline}}</span>
</div>
<div class="stat">
<span class="stat-label">Протоколов</span>
<span class="stat-value">{{.Stats.ProtocolsTotal}}</span>
@@ -43,6 +52,32 @@
</div>
</section>
<section class="panel-section">
<div class="section-head">
<h2>Ноды</h2>
<a href="/admin/nodes" class="btn btn-ghost btn-sm">Все ноды</a>
</div>
{{if .Nodes}}
<div class="proto-grid">
{{range .Nodes}}
<article class="proto-card">
<div class="proto-top">
<h3><a href="/admin/nodes/{{.ID}}">{{.Name}}</a></h3>
<span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span>
</div>
<p>{{.Host}}:{{.NodePort}}</p>
<div class="proto-meta">
<code>{{.InstallMode}}</code>
<span>{{if .AgentVersion}}v{{.AgentVersion}}{{else}}—{{end}}</span>
</div>
</article>
{{end}}
</div>
{{else}}
<p class="muted">Ноды не добавлены. <a href="/admin/nodes/new">Добавить</a></p>
{{end}}
</section>
<section class="panel-section">
<div class="section-head">
<h2>Протоколы</h2>
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header">
<h1>Новая нода</h1>
<p class="muted">Авторежим: укажите SSH — мастер поставит Docker и агент. Ручной: получите docker-compose как в Remnawave.</p>
</header>
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<form class="form-card" method="POST" action="/admin/nodes/new" autocomplete="off">
<div class="form-grid">
<label>Имя
<input name="name" required value="{{.Form.Name}}" placeholder="eu-1">
</label>
<label>Хост / IP
<input name="host" required value="{{.Form.Host}}" placeholder="203.0.113.10">
</label>
<label>SSH порт
<input name="ssh_port" type="number" value="{{.Form.SSHPort}}">
</label>
<label>SSH пользователь
<input name="ssh_user" value="{{.Form.SSHUser}}" placeholder="root">
</label>
<label>Тип SSH
<select name="ssh_auth_type">
<option value="password" {{if eq .Form.SSHAuthType "password"}}selected{{end}}>Пароль</option>
<option value="key" {{if eq .Form.SSHAuthType "key"}}selected{{end}}>Приватный ключ</option>
</select>
</label>
<label>Node API порт
<input name="node_port" type="number" value="{{.Form.NodePort}}">
</label>
</div>
<label>SSH пароль или приватный ключ
<textarea name="ssh_secret" rows="4" placeholder="пароль или содержимое id_rsa"></textarea>
</label>
<p class="hint">Хранится зашифрованным (AES-GCM от APP_SECRET). Для ручного режима можно оставить пустым.</p>
<fieldset class="mode-pick">
<legend>Режим установки</legend>
<label class="radio">
<input type="radio" name="install_mode" value="auto" {{if ne .Form.InstallMode "manual"}}checked{{end}}>
<span><strong>Авто по SSH</strong> — панель сама ставит Docker + агент</span>
</label>
<label class="radio">
<input type="radio" name="install_mode" value="manual" {{if eq .Form.InstallMode "manual"}}checked{{end}}>
<span><strong>Вручную</strong> — скопировать docker-compose (как Remnawave Node)</span>
</label>
</fieldset>
<div class="cta-row">
<button class="btn btn-primary" type="submit">Создать</button>
<a class="btn btn-ghost" href="/admin/nodes">Отмена</a>
</div>
</form>
</main>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
{{if eq (printf "%s" .Node.Status) "installing"}}
<meta http-equiv="refresh" content="4">
{{end}}
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header row-head">
<div>
<h1>{{.Node.Name}}</h1>
<p class="muted"><code>{{.Node.Host}}</code> · API :{{.Node.NodePort}} · {{.Node.InstallMode}}</p>
</div>
<span class="badge {{statusClass (printf "%s" .Node.Status)}}">{{.Node.StatusLabel}}</span>
</header>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
{{if .Node.LastError}}<div class="alert">{{.Node.LastError}}</div>{{end}}
<div class="cta-row" style="margin-bottom:1.25rem">
<form method="POST" action="/admin/nodes/{{.Node.ID}}/install">
<button class="btn btn-primary" type="submit">Установить / переустановить по SSH</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/check">
<button class="btn btn-ghost" type="submit">Проверить online</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/delete" onsubmit="return confirm('Удалить ноду из панели?');">
<button class="btn btn-ghost" type="submit">Удалить</button>
</form>
<a class="btn btn-ghost" href="/admin/nodes">К списку</a>
</div>
<section class="panel-section">
<h2>Важно</h2>
<p class="muted">Откройте порт <strong>{{.Node.NodePort}}</strong> на ноде <em>только</em> для IP панели (как NODE_PORT в Remnawave).</p>
</section>
<section class="panel-section">
<h2>docker-compose.yml</h2>
<pre class="code-block">{{.Compose}}</pre>
</section>
<section class="panel-section">
<h2>Ручная установка ( Remnawave-style )</h2>
<ol class="steps">
<li>На сервере ноды: <code>sudo curl -fsSL https://get.docker.com | sh</code></li>
<li><code>mkdir -p /opt/vpn-panel-node && cd /opt/vpn-panel-node</code></li>
<li>Скопируйте бинарник <code>vpn-node</code> (linux amd64) из образа панели <code>/app/bin/vpn-node</code></li>
<li>Вставьте compose ниже / выше и выполните <code>docker compose up -d</code></li>
</ol>
<pre class="code-block">{{.ManualScript}}</pre>
</section>
{{if .Node.InstallLog}}
<section class="panel-section">
<h2>Лог установки</h2>
<pre class="code-block log">{{.Node.InstallLog}}</pre>
</section>
{{end}}
<section class="panel-section meta-grid">
<div><span class="stat-label">Агент</span><div>{{if .Node.AgentVersion}}{{.Node.AgentVersion}}{{else}}—{{end}}</div></div>
<div><span class="stat-label">Last seen</span><div>{{if .Node.LastSeenAt}}{{.Node.LastSeenAt}}{{else}}—{{end}}</div></div>
<div><span class="stat-label">Создана</span><div>{{.Node.CreatedAt}}</div></div>
</section>
</main>
</body>
</html>
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VPN Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body class="page-admin">
<aside class="sidebar">
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">
<span class="user-chip">{{.UserName}}</span>
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
</div>
</aside>
<main class="admin-main">
<header class="admin-header row-head">
<div>
<h1>Ноды</h1>
<p class="muted">Удалённые серверы: автоустановка по SSH или ручной docker compose</p>
</div>
<a class="btn btn-primary" href="/admin/nodes/new">+ Добавить ноду</a>
</header>
{{if .Flash}}<div class="alert ok">Готово: {{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">Ошибка: {{.Error}}</div>{{end}}
{{if .Nodes}}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Имя</th>
<th>Хост</th>
<th>API порт</th>
<th>Режим</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Nodes}}
<tr>
<td><strong><a href="/admin/nodes/{{.ID}}">{{.Name}}</a></strong></td>
<td><code>{{.Host}}</code></td>
<td>{{.NodePort}}</td>
<td>{{.InstallMode}}</td>
<td><span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span></td>
<td class="actions"><a class="btn btn-sm btn-ghost" href="/admin/nodes/{{.ID}}">Открыть</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>Нод пока нет. Добавьте сервер — панель сама поставит Docker и агент (или выдаст compose как Remnawave).</p>
<a class="btn btn-primary" href="/admin/nodes/new">Добавить первую ноду</a>
</div>
{{end}}
</main>
</body>
</html>
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
</nav>
<div class="sidebar-foot">