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.
194 lines
5.3 KiB
Go
194 lines
5.3 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
|
)
|
|
|
|
const (
|
|
// statTaskTimeout bounds how long a status request waits for the agent.
|
|
statTaskTimeout = 8 * time.Second
|
|
// statCacheFor is how long one sample answers every open status page, so
|
|
// several tabs cost the agent a single reading.
|
|
statCacheFor = 2 * time.Second
|
|
// speedTaskTimeout covers the agent's ping + down + up windows with slack.
|
|
speedTaskTimeout = 30 * time.Second
|
|
// speedDownCap bounds one download stream; speedDownFor ends it if the
|
|
// agent does not hang up first.
|
|
speedDownCap = 256 << 20
|
|
speedDownFor = 6 * time.Second
|
|
speedUpCap = 64 << 20
|
|
)
|
|
|
|
// statCache holds the last sample per agent and serializes its refresh.
|
|
type statCache struct {
|
|
mu sync.Mutex
|
|
m map[string]*statEntry
|
|
}
|
|
|
|
type statEntry struct {
|
|
mu sync.Mutex
|
|
at time.Time
|
|
res proto.TaskResult
|
|
}
|
|
|
|
func (c *statCache) entry(agentID string) *statEntry {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.m == nil {
|
|
c.m = map[string]*statEntry{}
|
|
}
|
|
e := c.m[agentID]
|
|
if e == nil {
|
|
e = &statEntry{}
|
|
c.m[agentID] = e
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (c *statCache) drop(agentID string) {
|
|
c.mu.Lock()
|
|
delete(c.m, agentID)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
|
a, err := s.db.GetAgent(r.PathValue("id"))
|
|
if err != nil || a == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
s.render(w, "status.html", map[string]any{"Agent": a})
|
|
}
|
|
|
|
// runTask runs a one-shot task on the agent and waits for its result.
|
|
func (s *Server) runTask(agentID, typ string, timeout time.Duration) (proto.TaskResult, error) {
|
|
taskID, ch := s.hub.CreateTask(agentID, typ, nil)
|
|
defer s.hub.DiscardTask(taskID)
|
|
t := time.NewTimer(timeout)
|
|
defer t.Stop()
|
|
select {
|
|
case res := <-ch:
|
|
return res, nil
|
|
case <-t.C:
|
|
return proto.TaskResult{}, errors.New("таймаут ожидания клиента")
|
|
}
|
|
}
|
|
|
|
// onlineAgent answers the request itself and returns false when the agent is
|
|
// unknown or offline.
|
|
func (s *Server) onlineAgent(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
id := r.PathValue("id")
|
|
a, err := s.db.GetAgent(id)
|
|
if err != nil || a == nil {
|
|
http.NotFound(w, r)
|
|
return "", false
|
|
}
|
|
if !s.online(a) {
|
|
writeJSON(w, proto.TaskResult{Error: "машина офлайн"})
|
|
return "", false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// oldAgentError replaces the "unknown task" answer of an agent that predates
|
|
// the task.
|
|
func oldAgentError(res proto.TaskResult) proto.TaskResult {
|
|
if !res.OK && strings.Contains(res.Error, "неизвестная операция") {
|
|
res.Error = "клиент не поддерживает эту функцию — обновите его (нужен 0.0.5+)"
|
|
}
|
|
return res
|
|
}
|
|
|
|
// GET /api/panel/agents/{id}/stat — CPU, RAM and disk of the machine right now.
|
|
func (s *Server) handleAgentStat(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.onlineAgent(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
e := s.stats.entry(id)
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
if time.Since(e.at) > statCacheFor {
|
|
res, err := s.runTask(id, proto.TaskSysStat, statTaskTimeout)
|
|
if err != nil {
|
|
// Cached too, so pages queued behind this one don't each wait it out.
|
|
res = proto.TaskResult{Error: err.Error()}
|
|
}
|
|
e.res, e.at = oldAgentError(res), time.Now()
|
|
}
|
|
writeJSON(w, e.res)
|
|
}
|
|
|
|
// POST /api/panel/agents/{id}/speed — have the agent measure its link to us.
|
|
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.onlineAgent(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
res, err := s.runTask(id, proto.TaskSysSpeed, speedTaskTimeout)
|
|
if err != nil {
|
|
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, oldAgentError(res))
|
|
}
|
|
|
|
// speedBlock is one chunk of incompressible bytes, made once and sent over and
|
|
// over, so a speed test costs the server no CPU beyond the copy.
|
|
var speedBlock = sync.OnceValue(func() []byte {
|
|
b := make([]byte, 64<<10)
|
|
rand.Read(b)
|
|
return b
|
|
})
|
|
|
|
// GET /api/agent/speed/down?bytes=N — stream N bytes (default: as much as fits
|
|
// in the time window) for the agent to time. bytes=0 is the ping probe.
|
|
func (s *Server) handleSpeedDown(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
left := speedDownCap
|
|
if v := r.URL.Query().Get("bytes"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < left {
|
|
left = n
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
// Keep a proxy in front from buffering or compressing the stream.
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
block := speedBlock()
|
|
deadline := time.Now().Add(speedDownFor)
|
|
for left > 0 && time.Now().Before(deadline) && r.Context().Err() == nil {
|
|
chunk := block
|
|
if len(chunk) > left {
|
|
chunk = chunk[:left]
|
|
}
|
|
n, err := w.Write(chunk)
|
|
if err != nil {
|
|
return
|
|
}
|
|
left -= n
|
|
}
|
|
}
|
|
|
|
// POST /api/agent/speed/up — swallow the agent's upload.
|
|
func (s *Server) handleSpeedUp(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = io.Copy(io.Discard, http.MaxBytesReader(w, r.Body, speedUpCap))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|