237 lines
15 KiB
HTML
237 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Настройки - LPRA</title>
|
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
|
<script src="{{asset "pin.js"}}"></script>
|
|
<style>
|
|
/* An install command, with a button to copy it at its right. */
|
|
.copybox { position:relative; }
|
|
.copybox pre { padding-right:48px; }
|
|
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
|
|
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
|
|
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
|
|
.codeform { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; margin-top:10px; font-size:13px; }
|
|
.codeform label { margin:0; }
|
|
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
|
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="topbar">
|
|
<div class="brand">LPRA · Удалённый доступ</div>
|
|
<nav>
|
|
<a href="/">Машины</a>
|
|
<a href="/settings">Настройки</a>
|
|
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
|
</nav>
|
|
</header>
|
|
<div class="wrap">
|
|
<h1>Настройки</h1>
|
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
|
|
|
<div class="card">
|
|
<h2>Установка на новую машину</h2>
|
|
<p class="muted">Выполните на Linux-клиенте под root. Скрипт сам скачает бинарник,
|
|
поставит сервис и зарегистрирует машину.</p>
|
|
|
|
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
|
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
|
|
|
<form method="post" action="/settings/install/code" class="codeform"
|
|
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label for="codelen">Символов в коде</label>
|
|
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
|
|
<button type="submit" class="btn ghost small ico"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>Новый код</button>
|
|
<span class="muted">латиница и цифры, от 3 до 24; новый код отменяет старую ссылку</span>
|
|
</form>
|
|
|
|
<details style="margin-top:10px">
|
|
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
|
<label>curl</label>
|
|
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
|
<label>wget</label>
|
|
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
|
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
|
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
|
</details>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Адрес хоста</h2>
|
|
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
|
<form method="post" action="/settings/host" class="row">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
|
<button type="submit">Сохранить</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Прямой туннель (tcp/udp проброс)</h2>
|
|
{{if not .TunnelDefault}}
|
|
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
|
|
{{else}}
|
|
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
|
|
с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
|
|
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
|
|
<form method="post" action="/settings/tunnel" class="row">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="text" name="tunnel_host" value="{{.TunnelHost}}" placeholder="хост (как у панели)" style="max-width:260px">
|
|
<input type="number" name="tunnel_port" min="1" max="65535" value="{{if .TunnelCustom}}{{.TunnelPort}}{{end}}" placeholder="{{.TunnelDefault}}" style="max-width:120px">
|
|
<button type="submit">Сохранить</button>
|
|
</form>
|
|
<p class="muted" style="font-size:13px">Сейчас сервер слушает порт <b>{{.TunnelPort}}</b> (tcp и udp). При смене порта сервер
|
|
сразу начинает слушать новый, агенты переподключаются в течение ~30 секунд, правило ufw переносится.
|
|
Агенты старше 0.0.3 и агенты с заданными <code>TUNNEL_ADDR</code>/<code>TUNNEL_PORT</code> адрес с панели не берут.</p>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Enroll-секрет</h2>
|
|
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
|
<div class="row">
|
|
<code>{{.Secret}}</code>
|
|
<form method="post" action="/settings/secret/rotate" style="display:inline"
|
|
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button class="btn ghost small" type="submit">Сменить</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Администраторы</h2>
|
|
<table>
|
|
<tbody>
|
|
{{range .Admins}}
|
|
<tr>
|
|
<td>{{.Username}}</td>
|
|
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}{{if index $.PassOff .ID}} · пароль выключен{{end}}</td>
|
|
<td style="text-align:right" class="nowrap">
|
|
{{if index $.KeyCounts .ID}}
|
|
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
|
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
|
|
</form>
|
|
{{end}}
|
|
<form method="post" action="/settings/admins/delete" style="display:inline"
|
|
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<button class="btn danger small" type="submit">Удалить</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<div class="row">
|
|
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
|
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required>
|
|
<button type="submit">Добавить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Firewall (ufw)</h2>
|
|
{{if not .UfwInstalled}}
|
|
<p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
|
|
{{else if not .UfwRunnable}}
|
|
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
|
|
Запустите сервер под root или настройте sudo для ufw.</p>
|
|
{{else}}
|
|
<p class="muted">
|
|
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
|
|
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
|
|
</p>
|
|
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp - также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
|
|
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
|
|
<form method="post" action="/settings/ufw/toggle" style="display:inline">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
|
<button class="btn {{if .UfwEnabled}}ghost{{end}} small" type="submit">
|
|
{{if .UfwEnabled}}Выключить интеграцию{{else}}Включить интеграцию{{end}}
|
|
</button>
|
|
</form>
|
|
|
|
{{if .UfwRules}}
|
|
<h2 style="margin-top:18px; font-size:15px">Правила, созданные панелью</h2>
|
|
<table>
|
|
<tbody>
|
|
{{range .UfwRules}}
|
|
<tr>
|
|
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
|
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
|
|
<td style="text-align:right">
|
|
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
|
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<button class="btn danger small" type="submit">Отменить</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
|
{{end}}
|
|
|
|
{{if and .UfwActive .UfwEnabled}}
|
|
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
|
|
<form method="post" action="/settings/ufw/add" class="row">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
|
|
<select name="proto" style="max-width:100px">
|
|
<option value="tcp">tcp</option>
|
|
<option value="udp">udp</option>
|
|
</select>
|
|
<button type="submit">Открыть</button>
|
|
</form>
|
|
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
|
|
{{end}}
|
|
{{end}}
|
|
</div>
|
|
|
|
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
|
</div>
|
|
<script>
|
|
// Copy an install command: the clipboard API on https, the old way otherwise.
|
|
(function () {
|
|
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
function copy(text) {
|
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
var ok = document.execCommand('copy');
|
|
ta.remove();
|
|
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
|
}
|
|
document.querySelectorAll('.copybox .copy').forEach(function (b) {
|
|
var icon = b.innerHTML;
|
|
b.onclick = function () {
|
|
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
|
|
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
|
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
|
}, function (e) { alert('Не скопировалось: ' + e.message); });
|
|
};
|
|
});
|
|
})();
|
|
</script>
|
|
<script src="{{asset "account.js"}}"></script>
|
|
</body>
|
|
</html>
|