From 6dd1120a41871c3b586763dfe81385854816e0ce Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 20:46:35 +0300 Subject: [PATCH] feat: the password is turned off by hand in the account, with a passkey in place; password change; tabs sized by their titles --- internal/server/db.go | 43 ++++++++++-- internal/server/handlers_panel.go | 8 ++- internal/server/passkeys.go | 72 ++++++++++++++++++++- internal/server/server.go | 2 + internal/server/web/static/account.js | 52 +++++++++++++-- internal/server/web/static/app.css | 16 +++++ internal/server/web/static/terminal.js | 30 ++++++++- internal/server/web/templates/settings.html | 6 +- internal/server/web/templates/terminal.html | 8 +-- 9 files changed, 212 insertions(+), 25 deletions(-) diff --git a/internal/server/db.go b/internal/server/db.go index 578206f..5741034 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`, } { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { return err @@ -246,8 +247,29 @@ func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) { return out, rows.Err() } -// CountAdminKeys is how many passkeys an admin has: with any, the password -// no longer signs them in. +// PasswordOff tells an admin who turned the password off: then only a +// passkey signs them in. It holds only while they have one. +func (db *DB) PasswordOff(adminID int64) bool { + var off int + _ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off) + return off == 1 && db.CountAdminKeys(adminID) > 0 +} + +func (db *DB) SetAdminPassword(adminID int64, passHash string) error { + _, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID) + return err +} + +func (db *DB) SetPasswordOff(adminID int64, off bool) error { + v := 0 + if off { + v = 1 + } + _, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID) + return err +} + +// CountAdminKeys is how many passkeys an admin has. func (db *DB) CountAdminKeys(adminID int64) int { var n int _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n) @@ -271,14 +293,23 @@ func (db *DB) UseAdminKey(id int64, signCount uint32) error { return err } +// DeleteAdminKey removes a passkey; with the last one gone, the password +// signs the admin in again. func (db *DB) DeleteAdminKey(adminID, id int64) error { - _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id) - return err + if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil { + return err + } + if db.CountAdminKeys(adminID) == 0 { + return db.SetPasswordOff(adminID, false) + } + return nil } func (db *DB) DeleteAdminKeys(adminID int64) error { - _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID) - return err + if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil { + return err + } + return db.SetPasswordOff(adminID, false) } // --- admin sessions --- diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index 2b25934..fd93b85 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -90,8 +90,8 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username}) return } - // With a passkey on the account, the password alone does not do. - if s.db.CountAdminKeys(admin.ID) > 0 { + // An admin who turned the password off signs in with a passkey only. + if s.db.PasswordOff(admin.ID) { s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username}) return } @@ -378,9 +378,10 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { admins, _ := s.db.ListAdmins() - keyCounts := map[int64]int{} + keyCounts, passOff := map[int64]int{}, map[int64]bool{} for _, a := range admins { keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) + passOff[a.ID] = s.db.PasswordOff(a.ID) } host := s.db.HostAddress() secret := s.db.EnrollSecret() @@ -393,6 +394,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "Secret": secret, "Admins": admins, "KeyCounts": keyCounts, + "PassOff": passOff, "CSRF": csrfToken(r), "InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go index 5486f86..f313435 100644 --- a/internal/server/passkeys.go +++ b/internal/server/passkeys.go @@ -26,8 +26,8 @@ import ( ) // Passkeys (WebAuthn): an admin signs in with a key held by the device - a -// fingerprint, the phone's lock, a hardware key - instead of a password. Once -// an admin has a key, the password no longer signs them in. +// fingerprint, the phone's lock, a hardware key - instead of a password. With +// a key in place, the admin can turn the password off in their account. // // Attestation is not checked ("none"): the panel trusts any authenticator, // it only needs the public key and, at sign-in, a signature made with it. @@ -353,7 +353,73 @@ 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}) + writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID)}) +} + +// POST /api/panel/account/password/change - a new password, given the +// current one. +func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Current string `json:"current"` + New string `json:"new"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + ip := clientIP(r) + if s.limiter.blocked(ip) { + writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."}) + return + } + if !checkPassword(a.PassHash, in.Current) { + s.limiter.fin(ip, false) + writeJSON(w, map[string]any{"ok": false, "error": "текущий пароль не тот"}) + return + } + if len(in.New) < 8 { + writeJSON(w, map[string]any{"ok": false, "error": "новый пароль - не короче 8 символов"}) + return + } + hash, err := hashPassword(in.New) + if err == nil { + err = s.db.SetAdminPassword(a.ID, hash) + } + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + s.limiter.fin(ip, true) + s.db.Audit(a.ID, "", "password_change") + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /api/panel/account/password {"off": bool} - turn signing in with the +// password off or on again. Off only with a passkey to sign in with. +func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + var in struct { + Off bool `json:"off"` + } + 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 in.Off && s.db.CountAdminKeys(a.ID) == 0 { + writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"}) + return + } + if err := s.db.SetPasswordOff(a.ID, in.Off); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + action := "password_on" + if in.Off { + action = "password_off" + } + s.db.Audit(a.ID, "", action) + writeJSON(w, map[string]any{"ok": true}) } // POST /api/panel/account/keys/begin - options to make a new passkey with. diff --git a/internal/server/server.go b/internal/server/server.go index 03ab904..8a48478 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -237,6 +237,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) 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/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)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index 358dc1b..9bbe772 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -1,6 +1,6 @@ // The account button in the top bar: a dialog with who is signed in, their -// passkeys (add one, remove one) and signing out. Once there is a passkey, -// the password no longer signs this account in. +// passkeys (add one, remove one), the password switch and signing out. The +// password can be turned off once there is a passkey to sign in with. (function () { 'use strict'; var btn = document.getElementById('acct'); @@ -34,11 +34,20 @@ '
' + '
' + '

Ключи доступа

' + - '

Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.

' + + '

Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.

' + ' ' + '
' + '
' + '

' + + ' ' + + '
Сменить пароль' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + '
' + '
'; document.body.appendChild(dlg); @@ -54,8 +63,14 @@ function load() { 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; $('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase(); - $('.acct-mode').textContent = d.keys.length ? 'вход только по ключу доступа' : 'вход по паролю'; + $('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю'; + const sw = $('.acct-pw .sw'); + sw.checked = !d.password_off; + sw.disabled = !d.keys.length; + $('.acct-pwnote').textContent = !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ' + : d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом'; var ul = $('.acct-keys'); ul.innerHTML = d.keys.length ? '' : '
  • Ключей пока нет.
  • '; d.keys.forEach(function (k) { @@ -65,7 +80,7 @@ ''; li.querySelector('button').onclick = function () { var last = d.keys.length === 1; - if (!confirm('Удалить ключ «' + k.name + '»?' + (last ? ' Это последний ключ: вход по паролю снова станет возможен.' : ''))) return; + if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return; fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); }) .then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } }); }; @@ -102,6 +117,33 @@ msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true); }).then(function () { add.disabled = false; }); }; + $('.acct-pwform').onsubmit = function (ev) { + ev.preventDefault(); + const f = ev.target; + if (f.new.value !== f.again.value) { msg('новые пароли не совпадают', true); return; } + f.querySelector('button').disabled = true; + fetch('/api/panel/account/password/change', {method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({current: f.current.value, new: f.new.value})}) + .then(function (r) { return r.json(); }) + .then(function (r) { + if (!r.ok) { msg(r.error, true); return; } + f.reset(); + dlg.querySelector('.acct-chpw').open = false; + msg('пароль сменён'); + }) + .catch(function () { msg('ошибка сети', true); }) + .then(function () { f.querySelector('button').disabled = false; }); + }; + $('.acct-pw .sw').onchange = function (ev) { + const off = !ev.target.checked; + if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { + ev.target.checked = true; + return; + } + fetch('/api/panel/account/password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({off: off})}) + .then(function (r) { return r.json(); }) + .then(function (r) { if (!r.ok) msg(r.error, true); else msg(off ? 'вход по паролю выключен' : 'вход по паролю включён'); load(); }); + }; btn.onclick = function () { msg(''); load().then(function () { dlg.showModal(); dlg.focus(); }); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index c6086c9..0bb295a 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -134,6 +134,22 @@ header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padd .acct-add button { flex: none; } .acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); } .acct-msg.bad { color: var(--danger); } +.acct-pw { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 0; padding-top: 12px; + border-top: 1px solid var(--border); color: var(--text); font-size: 14px; cursor: pointer; } +.acct-pw b { display: block; font-weight: 500; } +.acct-pw small { display: block; font-size: 12px; margin-top: 2px; } +/* A switch: a checkbox drawn as a sliding knob. */ +input.sw { -webkit-appearance: none; appearance: none; flex: none; width: 40px; height: 22px; margin: 0; border-radius: 11px; + background: var(--border); position: relative; cursor: pointer; transition: background .2s; } +input.sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; + background: #fff; transition: transform .2s; } +input.sw:checked { background: var(--accent); } +input.sw:checked::after { transform: translateX(18px); } +input.sw:disabled { opacity: .45; cursor: not-allowed; } +.acct-chpw { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } +.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-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/terminal.js b/internal/server/web/static/terminal.js index de00d94..ab72a47 100644 --- a/internal/server/web/static/terminal.js +++ b/internal/server/web/static/terminal.js @@ -173,8 +173,9 @@ const at = index == null ? tabs.length : index; tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark); tabs.splice(at, 0, t); - requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter'))); renderTab(t); + fitTabs(); + requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter'))); return t; } function tabTitle(t) { @@ -182,8 +183,35 @@ const a = t.panes[0].agent; return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name; } + // Tab widths: as wide as the title wants, from a comfortable minimum up to + // a maximum; when they do not fit, all give way evenly down to a floor, + // titles cut with "…", and below the floor the strip scrolls instead. + const ruler = document.createElement('canvas').getContext('2d'); + const W = touch ? { min: 76, base: 108, max: 170, font: 12 } : { min: 96, base: 150, max: 280, font: 13 }; + const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders + function fitTabs() { + ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily; + const live = tabs; + const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME))); + const strip = $('tabs'); + const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1); + let cap = W.max; + if (want.reduce((a, b) => a + b, 0) > room) { + // The widest give way first, to one width for all at the most. + let lo = W.min, hi = W.max; + for (let k = 0; k < 20; k++) { + const mid = (lo + hi) / 2; + if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid; + } + cap = Math.max(W.min, Math.floor(lo)); + } + live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; }); + } + window.addEventListener('resize', () => fitTabs()); + function renderTab(t) { t.el.querySelector('.tab-title').textContent = tabTitle(t); + requestAnimationFrame(fitTabs); t.el.title = t.panes.map(s => s.agent.user + '@' + s.agent.host).join(', '); // The tab's dot: red if any shell failed, amber while one connects. const st = t.panes.map(s => s.state); diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 3b10f4f..57986d7 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -96,14 +96,14 @@ {{range .Admins}} {{.Username}} - {{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}} + {{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}{{if index $.PassOff .ID}} · пароль выключен{{end}} {{if index $.KeyCounts .ID}}
    + onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')"> - +
    {{end}}