feat: file manager on SSH hosts through a machine - open from the plus menu

This commit is contained in:
redtea committed 2026-10-15 21:32:18 +03:00
1 parent 2859e69abe
commit 7e4daeda4c
8 files changed
+202 -11

No files matched your search

+26
View File
@@ -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)
}
}
+26 -7
View File
@@ -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
+4
View File
@@ -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))
+105
View File
@@ -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"))
}
+3 -2
View File
@@ -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; }
+24
View File
@@ -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: 'ошибка сети'}));
})();
+4 -1
View File
@@ -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 = '<p class="dv-note">загрузка…</p>';
@@ -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';
+10 -1
View File
@@ -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.