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:
redtea committed 2026-10-15 22:18:27 +03:00
1 parent 1f930e5aea
commit 94bf292494
10 files changed
+346 -6

No files matched your search

+3
View File
@@ -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.
+7 -1
View File
@@ -12,6 +12,7 @@ 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,
+4
View File
@@ -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 {
+3
View File
@@ -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))
+4
View File
@@ -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)
+4
View File
@@ -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 {
+215
View File
@@ -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})
}
+54
View File
@@ -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())
}
}
+4
View File
@@ -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; }
+48 -5
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();