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.
159 lines
4.9 KiB
Go
159 lines
4.9 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
|
)
|
|
|
|
// Long-poll hold window. Must stay below nginx proxy_read_timeout (120s).
|
|
const pollHold = 25 * time.Second
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// POST /api/agent/enroll — exchange the shared enroll secret for a per-agent id+token.
|
|
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
|
|
secret := r.Header.Get("X-Enroll-Secret")
|
|
if !constantTimeEqual(secret, s.db.EnrollSecret()) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
var req proto.EnrollRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
id, token, err := s.enroll(req.Name, req.Hostname, req.OS, req.Arch, req.Version, req.User)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, proto.EnrollResponse{AgentID: id, Token: token})
|
|
}
|
|
|
|
// GET /api/agent/poll — control channel. Held until a job is queued or timeout.
|
|
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|
a := s.authenticateAgent(r)
|
|
if a == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// The agent sends its version and OS user occasionally; record them when
|
|
// present. Agents older than 0.0.2 send no user, so keep what we have.
|
|
v := r.Header.Get("X-Agent-Version")
|
|
u := firstNonEmpty(r.Header.Get("X-Agent-User"), a.RunUser)
|
|
if v != "" && (v != a.Version || u != a.RunUser) {
|
|
_ = s.db.TouchAgentInfo(a.ID, v, u)
|
|
} else {
|
|
_ = s.db.TouchAgent(a.ID)
|
|
}
|
|
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
|
// Set after the hold so a port changed meanwhile reaches the agent now.
|
|
s.setTunnelHeader(w)
|
|
writeJSON(w, msg)
|
|
}
|
|
|
|
// tunnelAddrHeader carries the direct-tunnel address ("host:port", host empty
|
|
// = the panel's host) to agents, on every poll and on /api/agent/config.
|
|
const tunnelAddrHeader = "X-Tunnel-Addr"
|
|
|
|
func (s *Server) setTunnelHeader(w http.ResponseWriter) {
|
|
if addr := s.tunnelAdvert(); addr != "" {
|
|
w.Header().Set(tunnelAddrHeader, addr)
|
|
}
|
|
}
|
|
|
|
// GET /api/agent/config — settings an agent picks up when it connects, without
|
|
// waiting out a poll hold.
|
|
func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
s.setTunnelHeader(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/agent/task/{id} — the agent fetches the task to run.
|
|
func (s *Server) handleTaskFetch(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
env, ok := s.hub.GetTask(r.PathValue("id"))
|
|
if !ok {
|
|
http.Error(w, "no task", http.StatusGone)
|
|
return
|
|
}
|
|
writeJSON(w, env)
|
|
}
|
|
|
|
// POST /api/agent/task/{id}/result — the agent returns the task result.
|
|
func (s *Server) handleTaskResult(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var res proto.TaskResult
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<20)).Decode(&res); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.hub.CompleteTask(r.PathValue("id"), res)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/agent/term/{sid}/in — downstream (panel -> agent) long-poll.
|
|
func (s *Server) handleTermIn(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
sess := s.hub.Session(r.PathValue("sid"))
|
|
if sess == nil {
|
|
http.Error(w, "no session", http.StatusGone)
|
|
return
|
|
}
|
|
frames, alive := sess.PollIn(pollHold)
|
|
if !alive {
|
|
http.Error(w, "closed", http.StatusGone)
|
|
return
|
|
}
|
|
writeJSON(w, proto.InBatch{Frames: frames})
|
|
}
|
|
|
|
// POST /api/agent/term/{sid}/out — upstream (agent PTY -> panel).
|
|
func (s *Server) handleTermOut(w http.ResponseWriter, r *http.Request) {
|
|
if s.authenticateAgent(r) == nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
sess := s.hub.Session(r.PathValue("sid"))
|
|
if sess == nil {
|
|
http.Error(w, "no session", http.StatusGone)
|
|
return
|
|
}
|
|
var msg proto.OutMessage
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&msg); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !sess.PushOut(msg) {
|
|
http.Error(w, "closed", http.StatusGone)
|
|
return
|
|
}
|
|
if msg.Exit {
|
|
// End the stream now, but keep the session reachable for a moment so
|
|
// the panel can still collect the last output (e.g. why the shell
|
|
// failed to start).
|
|
sess.close()
|
|
time.AfterFunc(pollHold, func() { s.hub.CloseSession(sess.ID) })
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|