Files
lpra/internal/server/handlers_agent.go
T
redtea 3dde07f403 feat: terminal as a chosen OS user, create users from the panel (0.0.2)
- agent reports the OS user it runs as (enroll + poll header)
- per-machine terminal user on the edit page; defaults to the agent's own user (root on Linux)
- create a new user from the panel: name, password, optional sudo group
- show the reason in the terminal when a shell fails to start
- bump version to 0.0.2
2026-10-13 01:28:57 +03:00

135 lines
4.1 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)
}
writeJSON(w, s.hub.WaitControl(a.ID, pollHold))
}
// 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)
}