diff --git a/internal/server/db.go b/internal/server/db.go
index b8326b7..b5c58a0 100644
--- a/internal/server/db.go
+++ b/internal/server/db.go
@@ -119,6 +119,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
+ `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
} {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -256,6 +257,18 @@ func (db *DB) PasswordOff(adminID int64) bool {
return off == 1 && db.CountAdminKeys(adminID) > 0
}
+// AdminPin is the hash of the admin's PIN, "" when there is none.
+func (db *DB) AdminPin(adminID int64) string {
+ var h string
+ _ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h)
+ return h
+}
+
+func (db *DB) SetAdminPin(adminID int64, pinHash string) error {
+ _, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID)
+ return err
+}
+
func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
return err
diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go
index f313435..2df064e 100644
--- a/internal/server/passkeys.go
+++ b/internal/server/passkeys.go
@@ -353,7 +353,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
for _, k := range keys {
list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed})
}
- writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID)})
+ writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != ""})
}
// POST /api/panel/account/password/change - a new password, given the
diff --git a/internal/server/pin.go b/internal/server/pin.go
new file mode 100644
index 0000000..a6b5e89
--- /dev/null
+++ b/internal/server/pin.go
@@ -0,0 +1,108 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "regexp"
+ "sync"
+)
+
+// The PIN: four digits an admin can set, asked for by the page in every new
+// browser tab (a reload of the same tab does not ask again). The server
+// checks it; five wrong ones in a row end the session.
+
+var pinRe = regexp.MustCompile(`^[0-9]{4}$`)
+
+// pinMisses counts wrong PINs per session.
+type pinMisses struct {
+ mu sync.Mutex
+ m map[string]int
+}
+
+func (p *pinMisses) miss(sid string) int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.m == nil {
+ p.m = map[string]int{}
+ }
+ p.m[sid]++
+ return p.m[sid]
+}
+
+func (p *pinMisses) clear(sid string) {
+ p.mu.Lock()
+ delete(p.m, sid)
+ p.mu.Unlock()
+}
+
+// GET /api/panel/pin - whether the admin has a PIN to ask for.
+func (s *Server) handlePinState(w http.ResponseWriter, r *http.Request) {
+ a := s.currentAdmin(r)
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, map[string]any{"pin": s.db.AdminPin(a.ID) != ""})
+}
+
+// POST /api/panel/pin/check {"pin"} - the PIN typed in a new tab.
+func (s *Server) handlePinCheck(w http.ResponseWriter, r *http.Request) {
+ a := s.currentAdmin(r)
+ var in struct {
+ Pin string `json:"pin"`
+ }
+ _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in)
+ h := s.db.AdminPin(a.ID)
+ c, _ := r.Cookie(sessionCookie)
+ if h == "" || (pinRe.MatchString(in.Pin) && checkPassword(h, in.Pin)) {
+ s.pinMisses.clear(c.Value)
+ writeJSON(w, map[string]any{"ok": true})
+ return
+ }
+ if n := s.pinMisses.miss(c.Value); n >= 5 {
+ s.pinMisses.clear(c.Value)
+ s.clearSession(w, r)
+ s.db.Audit(a.ID, "", "pin_lockout")
+ writeJSON(w, map[string]any{"ok": false, "out": true, "error": "слишком много неверных попыток"})
+ return
+ } else {
+ writeJSON(w, map[string]any{"ok": false, "left": 5 - n, "error": "неверный PIN"})
+ }
+}
+
+// POST /api/panel/account/pin {"pin", "current"} - set or change the PIN;
+// with pin empty, remove it. A PIN in place is needed to change or drop it.
+func (s *Server) handlePinSet(w http.ResponseWriter, r *http.Request) {
+ a := s.currentAdmin(r)
+ var in struct {
+ Pin string `json:"pin"`
+ Current string `json:"current"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
+ return
+ }
+ if h := s.db.AdminPin(a.ID); h != "" && !checkPassword(h, in.Current) {
+ writeJSON(w, map[string]any{"ok": false, "error": "текущий PIN не тот"})
+ return
+ }
+ hash := ""
+ if in.Pin != "" {
+ if !pinRe.MatchString(in.Pin) {
+ writeJSON(w, map[string]any{"ok": false, "error": "PIN - ровно 4 цифры"})
+ return
+ }
+ var err error
+ if hash, err = hashPassword(in.Pin); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ }
+ if err := s.db.SetAdminPin(a.ID, hash); err != nil {
+ writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ action := "pin_set"
+ if hash == "" {
+ action = "pin_remove"
+ }
+ s.db.Audit(a.ID, "", action)
+ writeJSON(w, map[string]any{"ok": true})
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 7c49c4e..287d110 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -61,6 +61,7 @@ type Server struct {
xfers xfers // files on their way between a browser and an agent
challenges challenges // passkey challenges handed out
wsAgents wsAgents // agents on their WebSocket now
+ pinMisses pinMisses // wrong PINs per session
assets map[string]string // static file -> hash of its content
}
@@ -241,6 +242,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
+ mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
+ mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
+ mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js
index 9bbe772..6995a56 100644
--- a/internal/server/web/static/account.js
+++ b/internal/server/web/static/account.js
@@ -48,6 +48,13 @@
' ' +
' ' +
' ' +
+ ' PIN-код
' +
+ ' Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.
' +
'
' +
'';
document.body.appendChild(dlg);
@@ -64,6 +71,11 @@
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
$('.acct-name').textContent = d.username;
dlg.querySelector('.acct-pwform [name=username]').value = d.username;
+ const pf = dlg.querySelector('.acct-pinform');
+ dlg.querySelector('.acct-pinstate').textContent = d.pin ? '· установлен' : '· не задан';
+ pf.current.hidden = !d.pin;
+ pf.querySelector('.acct-pindel').hidden = !d.pin;
+ pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить';
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw');
@@ -134,6 +146,35 @@
.catch(function () { msg('ошибка сети', true); })
.then(function () { f.querySelector('button').disabled = false; });
};
+ function pinMsg(text, bad) {
+ $('.acct-pinmsg').textContent = text || '';
+ $('.acct-pinmsg').className = 'acct-pinmsg' + (bad ? ' bad' : '');
+ }
+ function savePin(pin) {
+ const f = $('.acct-pinform');
+ return fetch('/api/panel/account/pin', {method: 'POST', headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({pin: pin, current: f.current.value})})
+ .then(function (r) { return r.json(); })
+ .then(function (r) {
+ if (!r.ok) { pinMsg(r.error, true); return; }
+ f.reset();
+ // This tab stays unlocked: the PIN is for tabs opened from now on.
+ try { sessionStorage.setItem('lpra.pin.ok', '1'); } catch (e) { /* asked next load */ }
+ pinMsg(pin ? 'PIN сохранён' : 'PIN убран');
+ load();
+ })
+ .catch(function () { pinMsg('ошибка сети', true); });
+ }
+ $('.acct-pinform').onsubmit = function (ev) {
+ ev.preventDefault();
+ const f = ev.target;
+ if (!/^[0-9]{4}$/.test(f.pin.value)) { pinMsg('PIN - ровно 4 цифры', true); return; }
+ if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; }
+ savePin(f.pin.value);
+ };
+ $('.acct-pindel').onclick = function () {
+ if (confirm('Убрать PIN-код?')) savePin('');
+ };
$('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
@@ -146,6 +187,7 @@
};
btn.onclick = function () {
msg('');
+ pinMsg('');
load().then(function () { dlg.showModal(); dlg.focus(); });
};
})();
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css
index 0bb295a..1e38921 100644
--- a/internal/server/web/static/app.css
+++ b/internal/server/web/static/app.css
@@ -107,6 +107,39 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); }
+/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */
+html.pin-wait body > :not(.pinlock) { visibility: hidden; }
+.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
+ background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
+ opacity: 0; transition: opacity .3s ease; }
+.pinlock.on { opacity: 1; }
+.pinlock.done { opacity: 0; pointer-events: none; }
+.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center;
+ transform: translateY(8px) scale(.98); transition: transform .3s ease; }
+.pinlock.on .pin-card { transform: none; }
+.pinlock.done .pin-card { transform: scale(1.03); }
+.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
+ background: #12202f; border: 1px solid var(--border); color: var(--accent); }
+.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; }
+.pin-dots { display: flex; gap: 16px; }
+.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; }
+.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); }
+.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); }
+.pin-card.bad .pin-dots { animation: pin-shake .38s ease; }
+@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
+.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); }
+.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; }
+.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center;
+ background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400;
+ -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; }
+.pin-pad button:hover { background: #1b222c; }
+.pin-pad button:active { background: #12202f; transform: scale(.94); }
+.pin-pad button[data-k="back"] { background: transparent; border-color: transparent; color: var(--muted); }
+.pin-out { margin-top: 18px; }
+.pin-out button { background: transparent; color: var(--muted); padding: 6px 12px; font-size: 14px; }
+.pin-out button:hover { background: transparent; color: var(--text); }
+@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } }
+
/* Dialogs shared by the pages. */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
@@ -150,6 +183,14 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
.acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); }
.acct-pwform { display: grid; gap: 8px; margin-top: 10px; }
.acct-pwform button { justify-self: end; }
+.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
+.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); }
+.acct-pin small { color: var(--muted); font-size: 12px; }
+.acct-pinform { display: grid; gap: 8px; margin-top: 10px; }
+.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
+.acct-pinform .row { justify-content: flex-end; }
+.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
+.acct-pinmsg.bad { color: var(--danger); }
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
/* Sign-in page. */
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
diff --git a/internal/server/web/static/pin.js b/internal/server/web/static/pin.js
new file mode 100644
index 0000000..cc922d9
--- /dev/null
+++ b/internal/server/web/static/pin.js
@@ -0,0 +1,100 @@
+// The PIN lock: in a new browser tab the panel asks for the admin's PIN
+// before showing anything. The tab remembers it was unlocked (sessionStorage
+// lives as long as the tab, through reloads, and a new tab starts empty), so
+// a reload does not ask again. Loaded in , before the page shows.
+(function () {
+ 'use strict';
+ var KEY = 'lpra.pin.ok';
+ var ok = false;
+ try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
+ if (ok) return;
+ var root = document.documentElement;
+ root.classList.add('pin-wait');
+ function release() { root.classList.remove('pin-wait'); }
+ function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
+
+ fetch('/api/panel/pin', {cache: 'no-store'})
+ .then(function (r) { return r.ok ? r.json() : {pin: false}; })
+ .then(function (d) {
+ if (!d.pin) { remember(); release(); return; }
+ if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
+ })
+ .catch(release);
+
+ function lock() {
+ var LOCK = '';
+ var BACK = '';
+ var box = document.createElement('div');
+ box.className = 'pinlock';
+ box.setAttribute('role', 'dialog');
+ box.setAttribute('aria-modal', 'true');
+ box.setAttribute('aria-label', 'Ввод PIN-кода');
+ var keys = '';
+ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].forEach(function (k) {
+ if (!k) { keys += ''; return; }
+ keys += '';
+ });
+ box.innerHTML =
+ '' +
+ '
' + LOCK + '
' +
+ '
Введите PIN-код
' +
+ '
' +
+ '
' +
+ '
' + keys + '
' +
+ '
' +
+ '
';
+ document.body.appendChild(box);
+ requestAnimationFrame(function () { box.classList.add('on'); });
+ var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
+ var code = '', busy = false;
+ function show() {
+ for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
+ }
+ function press(k) {
+ if (busy) return;
+ if (k === 'back') { code = code.slice(0, -1); show(); return; }
+ if (code.length >= 4) return;
+ code += k;
+ msg.textContent = '';
+ card.classList.remove('bad');
+ show();
+ if (code.length === 4) check();
+ }
+ function check() {
+ busy = true;
+ fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
+ .then(function (r) { return r.json(); })
+ .then(function (r) {
+ if (r.ok) {
+ remember();
+ box.classList.add('done');
+ release();
+ setTimeout(function () { box.remove(); }, 320);
+ document.removeEventListener('keydown', onKey, true);
+ return;
+ }
+ if (r.out) { location.href = '/login'; return; }
+ card.classList.remove('bad');
+ void card.offsetWidth; // restart the shake
+ card.classList.add('bad');
+ msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
+ setTimeout(function () { code = ''; show(); busy = false; }, 380);
+ })
+ .catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
+ }
+ box.querySelector('.pin-pad').addEventListener('click', function (ev) {
+ var b = ev.target.closest('button');
+ if (b) press(b.dataset.k);
+ });
+ // Typed digits, ahead of anything on the page (the terminal included).
+ function onKey(ev) {
+ if (/^[0-9]$/.test(ev.key)) press(ev.key);
+ else if (ev.key === 'Backspace') press('back');
+ else if (ev.key !== 'Tab') return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ }
+ document.addEventListener('keydown', onKey, true);
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
+ }
+})();
diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html
index 121ddec..347f115 100644
--- a/internal/server/web/templates/dashboard.html
+++ b/internal/server/web/templates/dashboard.html
@@ -5,6 +5,7 @@
Машины - LPRA
+
diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html
index ac25e00..292099c 100644
--- a/internal/server/web/templates/edit.html
+++ b/internal/server/web/templates/edit.html
@@ -5,6 +5,7 @@
Изменить машину - LPRA
+
diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html
index 2dcb9d1..57d1a23 100644
--- a/internal/server/web/templates/files.html
+++ b/internal/server/web/templates/files.html
@@ -5,6 +5,7 @@
Файлы · {{.Agent.Name}} - LPRA
+