From 94bf29249428b72771756db8334f038623fcdb94 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 22:18:27 +0300 Subject: [PATCH] feat: saved SSH hosts per user, the way in sealed with AES-GCM under a server key kept beside the database --- internal/proto/proto.go | 3 + internal/server/db.go | 10 +- internal/server/handlers_panel_term.go | 4 + internal/server/server.go | 3 + internal/server/ship.go | 4 + internal/server/sshfs.go | 4 + internal/server/sshhosts.go | 215 ++++++++++++++++++++++++ internal/server/sshhosts_test.go | 54 ++++++ internal/server/web/static/app.css | 4 + internal/server/web/static/workspace.js | 53 +++++- 10 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 internal/server/sshhosts.go create mode 100644 internal/server/sshhosts_test.go diff --git a/internal/proto/proto.go b/internal/proto/proto.go index fce4ab5..5bc736e 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -33,6 +33,9 @@ type SSHTarget struct { Password string `json:"password,omitempty"` Key string `json:"key,omitempty"` Passphrase string `json:"passphrase,omitempty"` + // Saved: a host the user saved on the server; it fills the rest there and + // never reaches an agent. + Saved int64 `json:"saved,omitempty"` } // EnrollRequest is sent once by a freshly installed agent. diff --git a/internal/server/db.go b/internal/server/db.go index 73809c3..6942b40 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -11,7 +11,8 @@ import ( // DB wraps the SQLite connection and all queries used by the server. type DB struct { - sql *sql.DB + sql *sql.DB + path string // the file; the key of saved secrets sits beside it } // OpenDB opens (creating if needed) the SQLite database at path and runs @@ -30,7 +31,7 @@ func OpenDB(path string) (*DB, error) { if err := sdb.Ping(); err != nil { return nil, err } - db := &DB{sql: sdb} + db := &DB{sql: sdb, path: path} if err := db.migrate(); err != nil { return nil, err } @@ -122,6 +123,11 @@ CREATE TABLE IF NOT EXISTS forward_rules ( `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`, `ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`, `ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS ssh_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, user TEXT NOT NULL, + auth TEXT NOT NULL, secret TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS admin_agents ( admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, agent_id TEXT NOT NULL, diff --git a/internal/server/handlers_panel_term.go b/internal/server/handlers_panel_term.go index 7380042..9006acc 100644 --- a/internal/server/handlers_panel_term.go +++ b/internal/server/handlers_panel_term.go @@ -41,6 +41,10 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { return } } + if err := s.fillSaved(s.currentAdmin(r).ID, body.SSH); err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } if t := body.SSH; t != nil { t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) if t.Port == 0 { diff --git a/internal/server/server.go b/internal/server/server.go index ae39ed6..58a3de2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -251,6 +251,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState)) + mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts)) + mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd)) + mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete)) mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle)) mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat)) mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) diff --git a/internal/server/ship.go b/internal/server/ship.go index eaa1e31..9a57156 100644 --- a/internal/server/ship.go +++ b/internal/server/ship.go @@ -139,6 +139,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) { return } where := "" + if err := s.fillSaved(s.currentAdmin(r).ID, q.SSH); err != nil { + writeJSON(w, proto.TaskResult{Error: err.Error()}) + return + } if q.SSH != nil { t := q.SSH t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) diff --git a/internal/server/sshfs.go b/internal/server/sshfs.go index ecb8f62..2139bac 100644 --- a/internal/server/sshfs.go +++ b/internal/server/sshfs.go @@ -24,6 +24,10 @@ func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) { writeJSON(w, proto.TaskResult{Error: "не указан хост"}) return } + if err := s.fillSaved(s.currentAdmin(r).ID, in.SSH); err != nil { + writeJSON(w, proto.TaskResult{Error: err.Error()}) + return + } t := in.SSH t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) if t.Port == 0 { diff --git a/internal/server/sshhosts.go b/internal/server/sshhosts.go new file mode 100644 index 0000000..6230315 --- /dev/null +++ b/internal/server/sshhosts.go @@ -0,0 +1,215 @@ +package server + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "strconv" + "strings" + "sync" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// Saved SSH hosts: each user keeps their own list of hosts with the way in - +// password or key - so the SSH dialog connects with one choice. The secret is +// sealed with AES-256-GCM under the server's key, which lives apart from the +// database (LPRA_SECRET_KEY, or a file beside the database made on first +// use), and bound to its owner: a row read out of the database, or moved to +// another user, opens to nobody. + +type savedHost struct { + ID int64 `json:"id"` + Label string `json:"label"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Auth string `json:"auth"` // password, key or agent +} + +type sshSecret struct { + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + Passphrase string `json:"passphrase,omitempty"` +} + +var ( + secretKeyOnce sync.Once + secretKey []byte + secretKeyErr error +) + +// sealKey is the server's key for saved secrets. +func (s *Server) sealKey() ([]byte, error) { + secretKeyOnce.Do(func() { + if v := strings.TrimSpace(os.Getenv("LPRA_SECRET_KEY")); v != "" { + k, err := hex.DecodeString(v) + if err != nil || len(k) != 32 { + k, err = base64.StdEncoding.DecodeString(v) + } + if err != nil || len(k) != 32 { + secretKeyErr = errors.New("LPRA_SECRET_KEY: нужны 32 байта (hex или base64)") + return + } + secretKey = k + return + } + path := s.db.path + ".key" + if b, err := os.ReadFile(path); err == nil { + k, err := hex.DecodeString(strings.TrimSpace(string(b))) + if err != nil || len(k) != 32 { + secretKeyErr = errors.New(path + ": испорчен ключ") + return + } + secretKey = k + return + } + k := make([]byte, 32) + _, _ = rand.Read(k) + if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil { + secretKeyErr = err + return + } + secretKey = k + }) + return secretKey, secretKeyErr +} + +func (s *Server) seal(owner int64, plain []byte) (string, error) { + k, err := s.sealKey() + if err != nil { + return "", err + } + blk, _ := aes.NewCipher(k) + g, _ := cipher.NewGCM(blk) + nonce := make([]byte, g.NonceSize()) + _, _ = rand.Read(nonce) + out := g.Seal(nonce, nonce, plain, []byte("ssh:"+strconv.FormatInt(owner, 10))) + return base64.StdEncoding.EncodeToString(out), nil +} + +func (s *Server) open(owner int64, sealed string) ([]byte, error) { + k, err := s.sealKey() + if err != nil { + return nil, err + } + b, err := base64.StdEncoding.DecodeString(sealed) + blk, _ := aes.NewCipher(k) + g, _ := cipher.NewGCM(blk) + if err != nil || len(b) < g.NonceSize() { + return nil, errors.New("испорчена запись") + } + return g.Open(nil, b[:g.NonceSize()], b[g.NonceSize():], []byte("ssh:"+strconv.FormatInt(owner, 10))) +} + +func (s *Server) saveSSHHost(owner int64, h savedHost, sec sshSecret) (int64, error) { + plain, _ := json.Marshal(sec) + sealed, err := s.seal(owner, plain) + if err != nil { + return 0, err + } + res, err := s.db.sql.Exec(`INSERT INTO ssh_hosts (admin_id, label, host, port, user, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`, + owner, h.Label, h.Host, h.Port, h.User, h.Auth, sealed) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Server) listSSHHosts(owner int64) []savedHost { + rows, err := s.db.sql.Query(`SELECT id, label, host, port, user, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner) + list := []savedHost{} + if err != nil { + return list + } + defer rows.Close() + for rows.Next() { + var h savedHost + if rows.Scan(&h.ID, &h.Label, &h.Host, &h.Port, &h.User, &h.Auth) == nil { + list = append(list, h) + } + } + return list +} + +// fillSaved puts the saved host t.Saved of owner - address and way in - into +// t, which then goes on as if typed in. +func (s *Server) fillSaved(owner int64, t *proto.SSHTarget) error { + if t == nil || t.Saved == 0 { + return nil + } + var h savedHost + var sealed string + err := s.db.sql.QueryRow(`SELECT host, port, user, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner). + Scan(&h.Host, &h.Port, &h.User, &h.Auth, &sealed) + if err != nil { + return errors.New("сохранённый хост не найден") + } + plain, err := s.open(owner, sealed) + if err != nil { + return errors.New("сохранённый хост не расшифровался") + } + var sec sshSecret + _ = json.Unmarshal(plain, &sec) + *t = proto.SSHTarget{Host: h.Host, Port: h.Port, User: h.User, Password: sec.Password, Key: sec.Key, Passphrase: sec.Passphrase} + return nil +} + +// GET /api/panel/ssh/hosts - the user's saved hosts, without secrets. +func (s *Server) handleSSHHosts(w http.ResponseWriter, r *http.Request) { + writeJSON(w, s.listSSHHosts(s.currentAdmin(r).ID)) +} + +// POST /api/panel/ssh/hosts - save a host: {label, host, port, user, auth, +// password | key, passphrase}. +func (s *Server) handleSSHHostAdd(w http.ResponseWriter, r *http.Request) { + var in struct { + savedHost + sshSecret + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + h := in.savedHost + h.Host, h.User, h.Label = strings.TrimSpace(h.Host), strings.TrimSpace(h.User), strings.TrimSpace(h.Label) + if h.Port == 0 { + h.Port = 22 + } + if h.Host == "" || h.User == "" || h.Port < 1 || h.Port > 65535 || strings.ContainsAny(h.Host+h.User, " \t\r\n/") { + writeJSON(w, map[string]any{"ok": false, "error": "нужны хост, порт и пользователь"}) + return + } + if h.Label == "" { + h.Label = h.User + "@" + h.Host + } + sec := in.sshSecret + switch h.Auth { + case "key": + sec.Password = "" + case "agent": + sec = sshSecret{} + default: + h.Auth = "password" + sec.Key, sec.Passphrase = "", "" + } + id, err := s.saveSSHHost(s.currentAdmin(r).ID, h, sec) + if err != nil { + writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, map[string]any{"ok": true, "id": id}) +} + +// POST /api/panel/ssh/hosts/{hid}/delete +func (s *Server) handleSSHHostDelete(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("hid"), 10, 64) + _, _ = s.db.sql.Exec(`DELETE FROM ssh_hosts WHERE id = ? AND admin_id = ?`, id, s.currentAdmin(r).ID) + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/internal/server/sshhosts_test.go b/internal/server/sshhosts_test.go new file mode 100644 index 0000000..76fb1e6 --- /dev/null +++ b/internal/server/sshhosts_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// A saved host's secret is stored encrypted, and only its owner gets it back. +func TestSavedSSHHosts(t *testing.T) { + s, _ := newTestServer(t) + a, _ := s.db.AdminByUsername("adm") + id, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "s3cret-pass"}) + if err != nil { + t.Fatal(err) + } + var raw string + _ = s.db.sql.QueryRow(`SELECT secret FROM ssh_hosts WHERE id = ?`, id).Scan(&raw) + if raw == "" || strings.Contains(raw, "s3cret-pass") { + t.Fatalf("secret stored as %q", raw) + } + got := &proto.SSHTarget{Saved: id} + if err := s.fillSaved(a.ID, got); err != nil || got.Password != "s3cret-pass" || got.Host != "h" || got.User != "root" || got.Saved != 0 { + t.Fatalf("own: %+v %v", got, err) + } + if err := s.fillSaved(a.ID+1, &proto.SSHTarget{Saved: id}); err == nil { + t.Fatal("another user got the host") + } + if list := s.listSSHHosts(a.ID); len(list) != 1 || list[0].Label != "web" { + t.Fatalf("list: %+v", list) + } + if len(s.listSSHHosts(a.ID + 1)) != 0 { + t.Fatal("another user sees the host") + } +} + +// The routes: a saved host shows without its secret. +func TestSavedSSHHostsRoute(t *testing.T) { + s, c := newTestServer(t) + req := httptest.NewRequest(http.MethodPost, "/api/panel/ssh/hosts", strings.NewReader(`{"label":"db","host":"h2","user":"u","auth":"password","password":"pw-xyz"}`)) + req.AddCookie(c) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if !strings.Contains(w.Body.String(), `"ok":true`) { + t.Fatalf("save: %s", w.Body.String()) + } + w = get(t, s, c, "/api/panel/ssh/hosts") + if !strings.Contains(w.Body.String(), `"h2"`) || strings.Contains(w.Body.String(), "pw-xyz") { + t.Fatalf("list: %s", w.Body.String()) + } +} diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index c8b6121..cb70f08 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -852,3 +852,7 @@ dialog.uidlg { max-width:420px; width:92vw; } .uidlg form { padding:18px; display:grid; gap:14px; } .uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; } .uidlg .ui-input[hidden] { display:none; } +.sshdlg .chk { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); } +.sshdlg .chk input { width:auto; } +.sshdlg .saved-row[hidden] { display:none; } +.sshdlg .saved-row .frow { align-items:center; } diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 2502376..b053e10 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -550,6 +550,8 @@ '' + '
' + '' + + '
Сохранённый хост
' + + '
' + '
' + '
' + '' + @@ -562,6 +564,7 @@ '
' + '' + '' + + '' + '

' + '
' + ''; @@ -592,13 +595,40 @@ } d.addEventListener('cancel', ev => { ev.preventDefault(); close(); }); d.addEventListener('click', ev => { if (ev.target === d) close(); }); - let agents = []; - f.onsubmit = ev => { + let agents = [], saved = []; + // A saved host: its address shows, the way in stays on the server. + const useSaved = () => { + const h = saved.find(x => String(x.id) === f.saved.value); + d.querySelector('[data-delsaved]').hidden = !h; + d.querySelectorAll('.seg, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; }); + if (!h) { setAuth(auth); return; } + f.host.value = h.host; f.port.value = h.port; f.user.value = h.user; + }; + f.saved.onchange = useSaved; + const loadSaved = () => fetch('/api/panel/ssh/hosts', { cache: 'no-store' }).then(r => r.json()).then(list => { + saved = list || []; + const keep = f.saved.value; + f.saved.innerHTML = '' + saved.map(h => '').join(''); + f.saved.value = saved.some(h => String(h.id) === keep) ? keep : ''; + d.querySelector('.saved-row').hidden = !saved.length; + useSaved(); + }).catch(() => {}); + d.querySelector('[data-delsaved]').onclick = async () => { + const h = saved.find(x => String(x.id) === f.saved.value); + if (!h || !await UI.confirm('Удалить «' + h.label + '» из сохранённых?', { ok: 'Удалить', danger: true })) return; + await fetch('/api/panel/ssh/hosts/' + h.id + '/delete', { method: 'POST' }); + f.saved.value = ''; + loadSaved(); + }; + f.onsubmit = async ev => { ev.preventDefault(); const a = agents.find(x => x.id === f.agent.value); if (!a) { msg.textContent = 'выберите машину'; return; } const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() }; - if (auth === 'password') { + if (f.saved.value) { + t.saved = Number(f.saved.value); + } else if (auth === 'password') { if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; } t.password = f.password.value; } else if (auth === 'key') { @@ -606,7 +636,15 @@ t.key = f.key.value; if (f.passphrase.value) t.passphrase = f.passphrase.value; } - try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ } + if (!t.saved && f.save.checked) { + // Saved first, then used by its id like any saved host. + const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' })); + if (!r.ok) { msg.textContent = r.error || 'не сохранилось'; return; } + f.save.checked = false; + loadSaved(); + } + try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth, saved: t.saved || 0 })); } catch (e) { /* not kept */ } f.password.value = ''; f.passphrase.value = ''; f.key.value = ''; d.querySelector('.keyname').textContent = ''; if (sshMode === 'files') { @@ -649,7 +687,12 @@ if (here && !agents.some(a => a.id === here.id)) agents.unshift(here); fill(); }).catch(() => {}); - if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth }; + if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth, saved: last.saved }; + f.saved.value = ''; + loadSaved().then(() => { + // The host used last, if it was a saved one and still is. + if (last.saved && saved.some(h => h.id === last.saved && h.host === last.host)) { f.saved.value = String(last.saved); useSaved(); } + }); if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; } setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password'); d.showModal();