feat: saved SSH hosts per user, the way in sealed with AES-GCM under a server key kept beside the database
This commit is contained in:
1 parent
1f930e5aea
commit
94bf292494
10 files changed
+347
-7
No files matched your search
@@ -33,6 +33,9 @@ type SSHTarget struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
Passphrase string `json:"passphrase,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.
|
// EnrollRequest is sent once by a freshly installed agent.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import (
|
|||||||
|
|
||||||
// DB wraps the SQLite connection and all queries used by the server.
|
// DB wraps the SQLite connection and all queries used by the server.
|
||||||
type DB struct {
|
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
|
// 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 {
|
if err := sdb.Ping(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
db := &DB{sql: sdb}
|
db := &DB{sql: sdb, path: path}
|
||||||
if err := db.migrate(); err != nil {
|
if err := db.migrate(); err != nil {
|
||||||
return nil, err
|
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 pin_hash TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
||||||
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
`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 (
|
`CREATE TABLE IF NOT EXISTS admin_agents (
|
||||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
agent_id TEXT NOT NULL,
|
agent_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if t := body.SSH; t != nil {
|
||||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
if t.Port == 0 {
|
if t.Port == 0 {
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
|
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/account", s.requireAdmin(s.handleAccount))
|
||||||
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
|
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/toggle", s.requireAdmin(s.handleAIToggle))
|
||||||
mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
|
mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
|
||||||
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
where := ""
|
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 {
|
if q.SSH != nil {
|
||||||
t := q.SSH
|
t := q.SSH
|
||||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, proto.TaskResult{Error: "не указан хост"})
|
writeJSON(w, proto.TaskResult{Error: "не указан хост"})
|
||||||
return
|
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 := in.SSH
|
||||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||||
if t.Port == 0 {
|
if t.Port == 0 {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -852,3 +852,7 @@ dialog.uidlg { max-width:420px; width:92vw; }
|
|||||||
.uidlg form { padding:18px; display:grid; gap:14px; }
|
.uidlg form { padding:18px; display:grid; gap:14px; }
|
||||||
.uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; }
|
.uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; }
|
||||||
.uidlg .ui-input[hidden] { display:none; }
|
.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; }
|
||||||
@@ -550,6 +550,8 @@
|
|||||||
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
|
||||||
'<div class="dlg-b">' +
|
'<div class="dlg-b">' +
|
||||||
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
|
||||||
|
'<div class="f saved-row"><span>Сохранённый хост</span><div class="frow"><select name="saved" class="grow"><option value="">новый хост</option></select>' +
|
||||||
|
'<button type="button" class="btn ghost small icon close x" data-delsaved title="Удалить из сохранённых" aria-label="Удалить из сохранённых" hidden>' + X + '</button></div></div>' +
|
||||||
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
|
||||||
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
|
||||||
@@ -562,6 +564,7 @@
|
|||||||
'<input type="file" hidden></div>' +
|
'<input type="file" hidden></div>' +
|
||||||
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
|
||||||
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
|
||||||
|
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
|
||||||
'<p class="sshmsg"></p>' +
|
'<p class="sshmsg"></p>' +
|
||||||
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||||
'</div></form>';
|
'</div></form>';
|
||||||
@@ -592,13 +595,40 @@
|
|||||||
}
|
}
|
||||||
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
|
||||||
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
d.addEventListener('click', ev => { if (ev.target === d) close(); });
|
||||||
let agents = [];
|
let agents = [], saved = [];
|
||||||
f.onsubmit = ev => {
|
// 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 = '<option value="">новый хост</option>' + saved.map(h => '<option value="' + h.id + '">' +
|
||||||
|
String(h.label).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) + '</option>').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();
|
ev.preventDefault();
|
||||||
const a = agents.find(x => x.id === f.agent.value);
|
const a = agents.find(x => x.id === f.agent.value);
|
||||||
if (!a) { msg.textContent = 'выберите машину'; return; }
|
if (!a) { msg.textContent = 'выберите машину'; return; }
|
||||||
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
|
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; }
|
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||||
t.password = f.password.value;
|
t.password = f.password.value;
|
||||||
} else if (auth === 'key') {
|
} else if (auth === 'key') {
|
||||||
@@ -606,7 +636,15 @@
|
|||||||
t.key = f.key.value;
|
t.key = f.key.value;
|
||||||
if (f.passphrase.value) t.passphrase = f.passphrase.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 = '';
|
f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
|
||||||
d.querySelector('.keyname').textContent = '';
|
d.querySelector('.keyname').textContent = '';
|
||||||
if (sshMode === 'files') {
|
if (sshMode === 'files') {
|
||||||
@@ -649,7 +687,12 @@
|
|||||||
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||||
fill();
|
fill();
|
||||||
}).catch(() => {});
|
}).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 || ''; }
|
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');
|
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||||||
d.showModal();
|
d.showModal();
|
||||||
|
|||||||
Reference in new issue
Block a user