feat: file manager on SSH hosts through a machine - open from the plus menu
This commit is contained in:
1 parent
2859e69abe
commit
7e4daeda4c
8 files changed
+202
-11
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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: 'ошибка сети'}));
|
||||
})();
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user