From 7e4daeda4cd93a16b0efce0e18a7fb56565e1970 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 21:32:18 +0300 Subject: [PATCH] feat: file manager on SSH hosts through a machine - open from the plus menu --- internal/server/frag_test.go | 26 ++++++ internal/server/fsstream.go | 33 ++++++-- internal/server/server.go | 4 + internal/server/sshfs.go | 105 ++++++++++++++++++++++++ internal/server/web/static/app.css | 5 +- internal/server/web/static/filepane.js | 24 ++++++ internal/server/web/static/fileviews.js | 5 +- internal/server/web/static/workspace.js | 11 ++- 8 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 internal/server/sshfs.go diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go index 28073f4..92e9f33 100644 --- a/internal/server/frag_test.go +++ b/internal/server/frag_test.go @@ -63,3 +63,29 @@ func TestShellRoutes(t *testing.T) { } } } + +func TestSSHFSOpen(t *testing.T) { + s, admin := newTestServer(t) + // Long gone: the machine is offline. + if _, err := s.db.sql.Exec(`UPDATE agents SET last_seen = 0`); err != nil { + t.Fatal(err) + } + post := func(c *http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/panel/agents/x1/sshfs/open", strings.NewReader(`{"ssh":{"host":"h","user":"u","password":"p"}}`)) + req.AddCookie(c) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + return w + } + // The machine exists but is offline: the route answers in JSON. + if w := post(admin); w.Code != 200 || !strings.Contains(w.Body.String(), "офлайн") { + t.Fatalf("admin: %d %s", w.Code, w.Body.String()) + } + // A user without the machine does not reach it. + hash, _ := hashPassword("userpass123") + id, _ := s.db.CreateUser("bob", hash, RoleUser) + _ = s.db.CreateSession("sid-bob", id, sessionTTL) + if w := post(&http.Cookie{Name: sessionCookie, Value: "sid-bob"}); w.Code != http.StatusNotFound { + t.Fatalf("a user without the machine got %d", w.Code) + } +} diff --git a/internal/server/fsstream.go b/internal/server/fsstream.go index 5ef7b52..e6ab534 100644 --- a/internal/server/fsstream.go +++ b/internal/server/fsstream.go @@ -315,6 +315,13 @@ func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) { // file. The answer names the transfer and the piece size; the browser then // PUTs the pieces and asks for the result. func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) { + s.uploadStart(w, r, proto.TaskFSPut, "") +} + +// uploadStart starts a file upload to the agent's task (fs.put on the +// machine, sshfs.put onto an SSH host through it, conn naming the +// connection). +func (s *Server) uploadStart(w http.ResponseWriter, r *http.Request, task, conn string) { agentID, p := r.PathValue("id"), r.URL.Query().Get("path") if !s.fsAgent(w, agentID, true) { return @@ -329,19 +336,21 @@ func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) { return } x := s.xfers.open(agentID, size) - go s.runUpload(x, p) + go s.runUpload(x, p, task, conn) data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk}) writeJSON(w, proto.TaskResult{OK: true, Data: data}) } // runUpload has the agent write the file and keeps the result for the // browser to collect. -func (s *Server) runUpload(x *xfer, p string) { +func (s *Server) runUpload(x *xfer, p, task, conn string) { ctx, cancel := context.WithTimeout(context.Background(), xferTimeout) defer cancel() - args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk} - res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout) - if err == nil && unknownOp(res) { + args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk, Conn: conn} + res, err := s.waitTask(ctx, x.agentID, task, args, xferTimeout) + if err == nil && unknownOp(res) && task != proto.TaskFSPut { + res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"} + } else if err == nil && unknownOp(res) { res = s.legacyUpload(ctx, x, p) } if err != nil { @@ -502,6 +511,12 @@ func fileHeaders(w http.ResponseWriter, p string, inline bool) { // one response fed by the pieces the agent sends. inline=1 serves pictures, // video and sound for the page to show. func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { + s.download(w, r, proto.TaskFSSend, "") +} + +// download sends a file from the agent's task (fs.send, or sshfs.send for +// an SSH host through it) as one response. +func (s *Server) download(w http.ResponseWriter, r *http.Request, task, conn string) { agentID, p := r.PathValue("id"), r.URL.Query().Get("path") inline := r.URL.Query().Get("inline") != "" if !s.fsAgent(w, agentID, false) { @@ -511,8 +526,8 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { defer s.xfers.drop(x.id) defer x.fail("браузер ушёл") // stops the agent if the response ends early - raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk}) - taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw) + raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk, Conn: conn}) + taskID, ch := s.hub.CreateTask(agentID, task, raw) defer s.hub.DiscardTask(taskID) // The agent's result: an error ends the transfer wherever it stands. results := make(chan proto.TaskResult, 1) @@ -530,6 +545,10 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { select { case <-x.sized: case res := <-results: + if unknownOp(res) && task != proto.TaskFSSend { + http.Error(w, "агент не умеет файлы по SSH - обновите его", http.StatusBadGateway) + return + } if unknownOp(res) { s.legacyDownload(w, r, agentID, p, inline) return diff --git a/internal/server/server.go b/internal/server/server.go index 2bf4ccd..b67b5a5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -279,6 +279,10 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob)) mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp)) + mux.Handle("POST /api/panel/agents/{id}/sshfs/open", s.requireAdmin(s.handleSSHFSOpen)) + mux.Handle("GET /api/panel/agents/{id}/sshfs/{conn}/download", s.requireAdmin(s.handleSSHFSDownload)) + mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/upload", s.requireAdmin(s.handleSSHFSUpload)) + mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/{op}", s.requireAdmin(s.handleSSHFSOp)) // Machine status (admin). mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence)) diff --git a/internal/server/sshfs.go b/internal/server/sshfs.go new file mode 100644 index 0000000..3c0bebc --- /dev/null +++ b/internal/server/sshfs.go @@ -0,0 +1,105 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// Files on an SSH host through a machine, for the panel's file panes: the +// machine's agent keeps the SSH connection (open gives its id) and runs the +// operations there. Downloads and uploads go in the same pieces as a +// machine's own files. + +// POST /api/panel/agents/{id}/sshfs/open - {"ssh": {...}} → {conn, home}. +func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + var in struct { + SSH *proto.SSHTarget `json:"ssh"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil || in.SSH == nil { + writeJSON(w, proto.TaskResult{Error: "не указан хост"}) + return + } + t := in.SSH + t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) + if t.Port == 0 { + t.Port = 22 + } + if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 { + writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"}) + return + } + if !s.fsAgent(w, agentID, true) { + return + } + res, err := s.waitTask(r.Context(), agentID, proto.TaskSSHFSOpen, proto.SSHFSArgs{SSH: t}, fsTaskTimeout) + if err != nil { + res = proto.TaskResult{Error: err.Error()} + } + if unknownOp(res) { + res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"} + } + if res.OK { + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, agentID, "sshfs.open:"+t.User+"@"+t.Host) + } + } + writeJSON(w, res) +} + +// sshfsChanges are the operations that change files, for the audit log. +var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true} + +// POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host; +// the body is in the shape of the machine's own fs.{op}. "close" drops the +// connection. +func (s *Server) handleSSHFSOp(w http.ResponseWriter, r *http.Request) { + agentID, conn, op := r.PathValue("id"), r.PathValue("conn"), r.PathValue("op") + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !s.fsAgent(w, agentID, true) { + return + } + task, args := proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: conn, Op: op, Args: json.RawMessage(body)} + if op == "close" { + task, args = proto.TaskSSHFSClose, proto.SSHFSArgs{Conn: conn} + } + if len(args.Args) == 0 { + args.Args = json.RawMessage("{}") + } + if sshfsChanges[op] { + var d struct{ Path, Src, Dst string } + _ = json.Unmarshal(body, &d) + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, agentID, "sshfs."+op+":"+strings.TrimSpace(d.Path+" "+d.Src+" "+d.Dst)) + } + } + res, err := s.waitTask(r.Context(), agentID, task, args, fsTaskTimeout) + if err != nil { + res = proto.TaskResult{Error: err.Error()} + } + if unknownOp(res) && op != "close" { + res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"} + } + writeJSON(w, res) +} + +// GET /api/panel/agents/{id}/sshfs/{conn}/download?path=...[&inline=1] +func (s *Server) handleSSHFSDownload(w http.ResponseWriter, r *http.Request) { + s.download(w, r, proto.TaskSSHFSSend, r.PathValue("conn")) +} + +// POST /api/panel/agents/{id}/sshfs/{conn}/upload?path=...&size=N +func (s *Server) handleSSHFSUpload(w http.ResponseWriter, r *http.Request) { + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, r.PathValue("id"), "sshfs.upload:"+r.URL.Query().Get("path")) + } + s.uploadStart(w, r, proto.TaskSSHFSPut, r.PathValue("conn")) +} diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 8e19d0f..2383d65 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -757,5 +757,6 @@ p.mv-a { margin:0; font-size:13px; } /* The pane: its bar and list scroll inside it. */ .fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; } .tabview:not(.split) .fpane-body { padding:12px 16px 16px; } -/* A lone file pane: its first bar keeps clear of the corner "+". */ -.tabview:not(.split) .fpane .fm-bar:first-child, body.split1 .fpane .fm-bar:first-child { margin-right:40px; } +/* The file pane under the corner "+" keeps its first bar clear of it, + while the "+" is there (one tab; with the tab strip it is hidden). */ +body:not(.multi) .fpane.under-plus .fm-bar:first-child { margin-right:40px; } diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index 41e240b..da3cf2e 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -55,6 +55,21 @@ close() {} } + // An SSH host's files, through the machine that holds the connection. + class SSHSource { + constructor(agent, conn, home) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = {zip: false, sqlite: false}; } + base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; } + url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); } + op(name, body) { + return fetch(this.base() + name, { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin' + }).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'})); + } + home() { return this.homeDir; } + // The pane closing lets the machine drop the connection at once. + close() { fetch(this.base() + 'close', {method: 'POST', keepalive: true, credentials: 'same-origin'}).catch(() => {}); } + } + const UP_CAP = 512 * 1024 * 1024; const SORT_DEF = {by: 'name', dir: 1}; const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); @@ -366,4 +381,13 @@ window.FilePane = FilePane; window.MachineSource = MachineSource; Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent}); + Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home)}); + // sshFiles connects agent to the SSH host t, then opens its files in a + // pane; the password or key goes to the machine once and is not kept here. + Workspace.sshFiles = (agent, t) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin' + }).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => { + if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, ssh: {host: t.host, port: t.port, user: t.user}}); + return r; + }).catch(() => ({ok: false, error: 'ошибка сети'})); })(); diff --git a/internal/server/web/static/fileviews.js b/internal/server/web/static/fileviews.js index ecbce28..6c49ea4 100644 --- a/internal/server/web/static/fileviews.js +++ b/internal/server/web/static/fileviews.js @@ -269,7 +269,7 @@ if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT); return; } - if (/^(db|sqlite3?|db3)$/.test(ext)) { + if (/^(db|sqlite3?|db3)$/.test(ext) && cur.src.caps.sqlite) { // The agent opens the database and edits it; one that predates that // gets the file read here, for viewing only. box.innerHTML = '

загрузка…

'; @@ -858,6 +858,9 @@ $('mv-src').textContent = M.src + (e.is_dir ? '/' : ''); setOp('move'); if (!M.job) { busy(false); setRemote(false); } + // From an SSH host the file goes elsewhere by dragging it to a pane; + // this dialog sends a machine's files only. + $('mv-net').hidden = cur.kind === 'sshfiles'; $('mv-dir').value = cur.cwd === '/' ? '/' : cur.cwd + '/'; $('mv-name').value = e.name; $('mv-conflict').value = 'error'; diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 3e8fdf3..fb3229f 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -198,10 +198,15 @@ if (!W || !H) return; const L = rects(t.layout, t.c, t.r, W, H); t.view.classList.toggle('instant', !animate); + // The pane in the top right corner sits under the corner "+": it keeps + // its first bar clear of it. + let corner = 0; + L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; }); t.panes.forEach((s, i) => { const p = L.panes[i]; s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px'; s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px'; + s.el.classList.toggle('under-plus', i === corner); }); while (t.gutters.length < L.gutters.length) { const g = document.createElement('div'); @@ -639,7 +644,11 @@ window.visualViewport.addEventListener('scroll', refit); } window.addEventListener('pagehide', () => { - tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); })); + tabs.forEach(t => t.panes.forEach(s => { + if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); + // A file pane on an SSH host lets the machine drop its connection. + if (s.src && s.src.close) s.src.close(); + })); }); // The view coming into sight: fit the panes, show the status line.