381 lines
11 KiB
Go
381 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"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
|
|
// speedRelayUpFor is how long the agent's upload is passed on to the
|
|
// browser: the agent's own window with slack.
|
|
speedRelayUpFor = 4 * time.Second
|
|
)
|
|
|
|
// 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, args []byte, timeout time.Duration) (proto.TaskResult, error) {
|
|
taskID, ch := s.hub.CreateTask(agentID, typ, args)
|
|
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, nil, 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.
|
|
// With relay=1 its streams run through the relay opened for it, to and from
|
|
// the browser.
|
|
func (s *Server) handleAgentSpeed(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.onlineAgent(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
relay := r.URL.Query().Get("relay") != ""
|
|
var args []byte
|
|
if relay {
|
|
rl := s.relays.get(id)
|
|
if rl == nil {
|
|
writeJSON(w, proto.TaskResult{Error: "замер через браузер не открыт"})
|
|
return
|
|
}
|
|
defer s.relays.drop(id, rl)
|
|
args, _ = json.Marshal(proto.SysSpeedArgs{Relay: true})
|
|
}
|
|
res, err := s.runTask(id, proto.TaskSysSpeed, args, speedTaskTimeout)
|
|
if err != nil {
|
|
writeJSON(w, proto.TaskResult{Error: err.Error()})
|
|
return
|
|
}
|
|
res = oldAgentError(res)
|
|
if relay && res.OK {
|
|
// An agent that predates the relay ignores the argument and measures
|
|
// its own link.
|
|
var d proto.SysSpeedResult
|
|
if json.Unmarshal(res.Data, &d) != nil || !d.Relay {
|
|
res = proto.TaskResult{Error: "клиент не умеет замер через браузер - обновите его (нужен 0.0.6+)"}
|
|
}
|
|
}
|
|
writeJSON(w, 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
|
|
})
|
|
|
|
func speedStreamHeaders(w http.ResponseWriter) {
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
speedStreamHeaders(w)
|
|
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)
|
|
}
|
|
|
|
// speedRelay joins the agent's speed test streams to the browser's, so the
|
|
// bytes travel browser - panel - client. Both pipes are unbuffered: a slow
|
|
// reader holds its writer back, and the rate is that of the whole path.
|
|
type speedRelay struct {
|
|
downR *io.PipeReader // browser -> agent
|
|
downW *io.PipeWriter
|
|
upR *io.PipeReader // agent -> browser
|
|
upW *io.PipeWriter
|
|
}
|
|
|
|
func (rl *speedRelay) close() {
|
|
rl.downR.CloseWithError(io.ErrClosedPipe)
|
|
rl.downW.CloseWithError(io.ErrClosedPipe)
|
|
rl.upR.CloseWithError(io.ErrClosedPipe)
|
|
rl.upW.CloseWithError(io.ErrClosedPipe)
|
|
}
|
|
|
|
// speedRelays holds the relay of each agent under test, one per agent.
|
|
type speedRelays struct {
|
|
mu sync.Mutex
|
|
m map[string]*speedRelay
|
|
}
|
|
|
|
// open starts a fresh relay for the agent, ending the one it had.
|
|
func (c *speedRelays) open(agentID string) {
|
|
rl := &speedRelay{}
|
|
rl.downR, rl.downW = io.Pipe()
|
|
rl.upR, rl.upW = io.Pipe()
|
|
c.mu.Lock()
|
|
if c.m == nil {
|
|
c.m = map[string]*speedRelay{}
|
|
}
|
|
old := c.m[agentID]
|
|
c.m[agentID] = rl
|
|
c.mu.Unlock()
|
|
if old != nil {
|
|
old.close()
|
|
}
|
|
}
|
|
|
|
func (c *speedRelays) get(agentID string) *speedRelay {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.m[agentID]
|
|
}
|
|
|
|
// drop ends the agent's relay; with rl given, only if it is still that one.
|
|
func (c *speedRelays) drop(agentID string, rl *speedRelay) {
|
|
c.mu.Lock()
|
|
cur := c.m[agentID]
|
|
if cur != nil && (rl == nil || rl == cur) {
|
|
delete(c.m, agentID)
|
|
} else {
|
|
cur = nil
|
|
}
|
|
c.mu.Unlock()
|
|
if cur != nil {
|
|
cur.close()
|
|
}
|
|
}
|
|
|
|
// GET /api/panel/ping - an empty answer for the browser to time.
|
|
func (s *Server) handlePanelPing(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// POST /api/panel/agents/{id}/speed/relay - open the relay of a speed test
|
|
// that runs through the browser. The test itself ends it.
|
|
func (s *Server) handleSpeedRelayOpen(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := s.onlineAgent(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.relays.open(id)
|
|
writeJSON(w, proto.TaskResult{OK: true})
|
|
}
|
|
|
|
// POST /api/panel/agents/{id}/speed/down - a piece of the browser's upload,
|
|
// passed on to the agent. 204 asks for the next piece, 200 says the agent has
|
|
// stopped reading.
|
|
func (s *Server) handleSpeedRelayFeed(w http.ResponseWriter, r *http.Request) {
|
|
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
|
rl := s.relays.get(r.PathValue("id"))
|
|
if rl != nil {
|
|
if _, err := io.Copy(rl.downW, body); err == nil {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
// Take the rest of the piece before answering: an answer sent over an
|
|
// unread body makes Go drop the connection, which a proxy in front turns
|
|
// into a 502.
|
|
_, _ = io.Copy(io.Discard, body)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = io.WriteString(w, "done")
|
|
}
|
|
|
|
// GET /api/panel/agents/{id}/speed/up - the agent's upload, for the browser to
|
|
// time. Ends when the agent is done sending.
|
|
func (s *Server) handleSpeedRelayDrain(w http.ResponseWriter, r *http.Request) {
|
|
rl := s.relays.get(r.PathValue("id"))
|
|
if rl == nil {
|
|
http.Error(w, "gone", http.StatusGone)
|
|
return
|
|
}
|
|
speedStreamHeaders(w)
|
|
_, _ = io.Copy(w, rl.upR)
|
|
// The browser may have left: don't let the agent keep sending to nobody.
|
|
rl.upR.CloseWithError(io.ErrClosedPipe)
|
|
}
|
|
|
|
// GET /api/agent/speed/relay/down - what the browser uploads, for the agent to
|
|
// time.
|
|
func (s *Server) handleSpeedRelayDown(w http.ResponseWriter, r *http.Request) {
|
|
a := s.authenticateAgent(r)
|
|
if a == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
rl := s.relays.get(a.ID)
|
|
if rl == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
speedStreamHeaders(w)
|
|
// The agent waits on the headers, the browser may not have sent a byte yet.
|
|
_ = http.NewResponseController(w).Flush()
|
|
_, _ = io.Copy(w, rl.downR)
|
|
// The agent hung up at the end of its window: stop the browser's upload.
|
|
rl.downR.CloseWithError(io.ErrClosedPipe)
|
|
}
|
|
|
|
// POST /api/agent/speed/relay/up - the agent's upload, passed on to the browser.
|
|
func (s *Server) handleSpeedRelayUp(w http.ResponseWriter, r *http.Request) {
|
|
a := s.authenticateAgent(r)
|
|
if a == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
rl := s.relays.get(a.ID)
|
|
if rl == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body := http.MaxBytesReader(w, r.Body, speedUpCap)
|
|
// A proxy in front may hand over the whole upload at once: pass on only
|
|
// what the browser takes in the window, or the agent would wait for the
|
|
// rest to trickle through a slower link.
|
|
t := time.AfterFunc(speedRelayUpFor, func() { rl.upW.Close() })
|
|
_, _ = io.Copy(rl.upW, body)
|
|
t.Stop()
|
|
rl.upW.Close()
|
|
_, _ = io.Copy(io.Discard, body)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|