Files
lpra/internal/server/handlers_panel_fs.go
T
redtea 86cb0226ab feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)
Status page per machine: CPU load, RAM and disk of the client, sampled
by the agent only while the page is open and visible (one reading
serves every open tab), plus a button that has the agent measure its
link to the host: ping, down and up.

Online/offline now comes from the control polls the hub holds open, so
a dropped agent shows offline at once instead of 45 s later, and the
dashboard keeps the status column live.

A forward rule can be created "without ufw": the panel opens nothing in
ufw for it and so revokes nothing when it is deleted. The checkbox is
shown only while the ufw integration is on.

Dashboard row actions are icons; a status action is added.
2026-10-13 18:56:15 +03:00

117 lines
3.2 KiB
Go

package server
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"path"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// fsOps maps the URL op segment to a proto task type.
var fsOps = map[string]string{
"list": proto.TaskFSList,
"read": proto.TaskFSRead,
"write": proto.TaskFSWrite,
"mkdir": proto.TaskFSMkdir,
"delete": proto.TaskFSDelete,
"deltree": proto.TaskFSDelTree,
"unzip": proto.TaskFSUnzip,
"zip": proto.TaskFSZip,
"move": proto.TaskFSMove,
}
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
const fsTaskTimeout = 60 * time.Second
func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
}
// POST /api/panel/agents/{id}/fs/{op} — run a file-manager operation on the
// agent and return its result. The request body is the op's JSON args and is
// forwarded verbatim to the agent.
func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
op := r.PathValue("op")
taskType, ok := fsOps[op]
if !ok {
http.Error(w, "unknown op", http.StatusBadRequest)
return
}
a, err := s.db.GetAgent(agentID)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !s.online(a) {
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
return
}
args, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
defer s.hub.DiscardTask(taskID)
select {
case res := <-resultCh:
writeJSON(w, res)
case <-time.After(fsTaskTimeout):
writeJSON(w, proto.TaskResult{Error: "таймаут ожидания агента"})
}
}
// GET /api/panel/agents/{id}/fs/download?path=... — stream a file as a download.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
p := r.URL.Query().Get("path")
a, err := s.db.GetAgent(agentID)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !s.online(a) {
http.Error(w, "машина офлайн", http.StatusServiceUnavailable)
return
}
args, _ := json.Marshal(proto.FSPathArgs{Path: p})
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSDownload, args)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
http.Error(w, res.Error, http.StatusBadGateway)
return
}
var rr proto.FSReadResult
if err := json.Unmarshal(res.Data, &rr); err != nil {
http.Error(w, "bad result", http.StatusBadGateway)
return
}
data, _ := base64.StdEncoding.DecodeString(rr.Content)
name := path.Base(p)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
if rr.Truncated {
w.Header().Set("X-Truncated", "1")
}
_, _ = w.Write(data)
case <-time.After(fsTaskTimeout):
http.Error(w, "таймаут ожидания агента", http.StatusGatewayTimeout)
}
}