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.
326 lines
8.1 KiB
Go
326 lines
8.1 KiB
Go
package server
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
|
)
|
|
|
|
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
|
|
// It carries three kinds of work to an agent through its single control poll:
|
|
// terminal sessions, port-forward streams, and one-shot tasks (file manager).
|
|
// Nothing here is persisted.
|
|
type Hub struct {
|
|
mu sync.Mutex
|
|
|
|
// jobs holds control messages queued for an agent but not yet delivered via
|
|
// its control poll.
|
|
jobs map[string][]proto.ControlMessage
|
|
// ctrlWaiters wakes a blocked control poll the moment a job is enqueued.
|
|
ctrlWaiters map[string]chan struct{}
|
|
|
|
sessions map[string]*Session
|
|
tasks map[string]*taskEntry
|
|
|
|
// Presence: control polls held open right now per agent, and when each
|
|
// agent's last one ended. started is when this hub came up.
|
|
polls map[string]int
|
|
lastPoll map[string]time.Time
|
|
started time.Time
|
|
}
|
|
|
|
// taskEntry is a pending one-shot task awaiting the agent's result.
|
|
type taskEntry struct {
|
|
env proto.TaskEnvelope
|
|
result chan proto.TaskResult
|
|
}
|
|
|
|
// Session is one live byte stream between the panel and an agent (a PTY terminal
|
|
// or a raw TCP forward — the hub treats both identically).
|
|
type Session struct {
|
|
ID string
|
|
AgentID string
|
|
|
|
mu sync.Mutex
|
|
inBuf []proto.InFrame
|
|
inSignal chan struct{} // size-1; pulsed when inBuf gains data
|
|
|
|
out chan proto.OutMessage // agent -> panel
|
|
|
|
closed chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func NewHub() *Hub {
|
|
return &Hub{
|
|
jobs: map[string][]proto.ControlMessage{},
|
|
ctrlWaiters: map[string]chan struct{}{},
|
|
sessions: map[string]*Session{},
|
|
tasks: map[string]*taskEntry{},
|
|
polls: map[string]int{},
|
|
lastPoll: map[string]time.Time{},
|
|
started: time.Now(),
|
|
}
|
|
}
|
|
|
|
// enqueueJob appends a control message for an agent and wakes its poll.
|
|
func (h *Hub) enqueueJob(agentID string, msg proto.ControlMessage) {
|
|
h.mu.Lock()
|
|
h.jobs[agentID] = append(h.jobs[agentID], msg)
|
|
if w := h.ctrlWaiters[agentID]; w != nil {
|
|
select {
|
|
case w <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func (h *Hub) newSession(agentID string) *Session {
|
|
s := &Session{
|
|
ID: randToken()[:16],
|
|
AgentID: agentID,
|
|
inSignal: make(chan struct{}, 1),
|
|
out: make(chan proto.OutMessage, 1024),
|
|
closed: make(chan struct{}),
|
|
}
|
|
h.mu.Lock()
|
|
h.sessions[s.ID] = s
|
|
h.mu.Unlock()
|
|
return s
|
|
}
|
|
|
|
// CreateSession registers a terminal session and queues it for the agent. user
|
|
// is the OS user to run the shell as ("" = the agent's own user).
|
|
func (h *Hub) CreateSession(agentID, user string) *Session {
|
|
s := h.newSession(agentID)
|
|
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user})
|
|
return s
|
|
}
|
|
|
|
// CreateForward registers a port-forward stream (agent dials 127.0.0.1:port).
|
|
func (h *Hub) CreateForward(agentID string, port int) *Session {
|
|
s := h.newSession(agentID)
|
|
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenForward, SessionID: s.ID, Port: port})
|
|
return s
|
|
}
|
|
|
|
// pollGrace is how long an agent stays online between two control polls: it
|
|
// re-polls at once, so this only has to cover a slow round trip or one retry.
|
|
const pollGrace = 10 * time.Second
|
|
|
|
// Online reports whether the agent is connected: it holds a control poll open
|
|
// or has just finished one. ok is false while that cannot be told yet (right
|
|
// after a start, before agents had time to come back).
|
|
func (h *Hub) Online(agentID string) (online, ok bool) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.polls[agentID] > 0 {
|
|
return true, true
|
|
}
|
|
if t, seen := h.lastPoll[agentID]; seen {
|
|
return time.Since(t) < pollGrace, true
|
|
}
|
|
return false, time.Since(h.started) > pollHold+pollGrace
|
|
}
|
|
|
|
// Forget drops what the hub keeps about a deleted agent.
|
|
func (h *Hub) Forget(agentID string) {
|
|
h.mu.Lock()
|
|
delete(h.jobs, agentID)
|
|
delete(h.ctrlWaiters, agentID)
|
|
delete(h.lastPoll, agentID)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// WaitControl blocks until a job is queued for agentID, the timeout elapses
|
|
// or the agent hangs up (done). The agent counts as online meanwhile.
|
|
func (h *Hub) WaitControl(done <-chan struct{}, agentID string, timeout time.Duration) proto.ControlMessage {
|
|
h.mu.Lock()
|
|
h.polls[agentID]++
|
|
h.mu.Unlock()
|
|
defer func() {
|
|
h.mu.Lock()
|
|
if h.polls[agentID]--; h.polls[agentID] <= 0 {
|
|
delete(h.polls, agentID)
|
|
}
|
|
// A poll cut off by the agent is not a sign of life.
|
|
select {
|
|
case <-done:
|
|
default:
|
|
h.lastPoll[agentID] = time.Now()
|
|
}
|
|
h.mu.Unlock()
|
|
}()
|
|
t := time.NewTimer(timeout)
|
|
defer t.Stop()
|
|
deadline := t.C
|
|
for {
|
|
h.mu.Lock()
|
|
if q := h.jobs[agentID]; len(q) > 0 {
|
|
msg := q[0]
|
|
h.jobs[agentID] = q[1:]
|
|
h.mu.Unlock()
|
|
return msg
|
|
}
|
|
w := h.ctrlWaiters[agentID]
|
|
if w == nil {
|
|
w = make(chan struct{}, 1)
|
|
h.ctrlWaiters[agentID] = w
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
select {
|
|
case <-w:
|
|
case <-deadline:
|
|
return proto.ControlMessage{Type: proto.CtrlIdle}
|
|
case <-done:
|
|
// The job, if one comes, stays queued for the next poll.
|
|
return proto.ControlMessage{Type: proto.CtrlIdle}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Hub) Session(id string) *Session {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.sessions[id]
|
|
}
|
|
|
|
func (h *Hub) CloseSession(id string) {
|
|
h.mu.Lock()
|
|
s := h.sessions[id]
|
|
delete(h.sessions, id)
|
|
h.mu.Unlock()
|
|
if s != nil {
|
|
s.close()
|
|
}
|
|
}
|
|
|
|
// --- one-shot tasks ---
|
|
|
|
// CreateTask queues a task and returns its id plus a channel for the result.
|
|
func (h *Hub) CreateTask(agentID, typ string, args []byte) (string, chan proto.TaskResult) {
|
|
id := randToken()[:16]
|
|
entry := &taskEntry{
|
|
env: proto.TaskEnvelope{Type: typ, Args: args},
|
|
result: make(chan proto.TaskResult, 1),
|
|
}
|
|
h.mu.Lock()
|
|
h.tasks[id] = entry
|
|
h.mu.Unlock()
|
|
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlTask, TaskID: id})
|
|
return id, entry.result
|
|
}
|
|
|
|
// GetTask returns the envelope the agent should execute.
|
|
func (h *Hub) GetTask(id string) (proto.TaskEnvelope, bool) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
e := h.tasks[id]
|
|
if e == nil {
|
|
return proto.TaskEnvelope{}, false
|
|
}
|
|
return e.env, true
|
|
}
|
|
|
|
// CompleteTask delivers a result to the waiting panel handler.
|
|
func (h *Hub) CompleteTask(id string, res proto.TaskResult) {
|
|
h.mu.Lock()
|
|
e := h.tasks[id]
|
|
h.mu.Unlock()
|
|
if e != nil {
|
|
select {
|
|
case e.result <- res:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// DiscardTask drops a task from the registry.
|
|
func (h *Hub) DiscardTask(id string) {
|
|
h.mu.Lock()
|
|
delete(h.tasks, id)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
func (s *Session) close() { s.closeOnce.Do(func() { close(s.closed) }) }
|
|
|
|
// Done returns a channel closed when the session ends.
|
|
func (s *Session) Done() <-chan struct{} { return s.closed }
|
|
|
|
// PushIn appends a downstream frame (panel -> agent) and wakes the agent poll.
|
|
func (s *Session) PushIn(f proto.InFrame) {
|
|
s.mu.Lock()
|
|
s.inBuf = append(s.inBuf, f)
|
|
s.mu.Unlock()
|
|
select {
|
|
case s.inSignal <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// PollIn drains queued downstream frames, blocking up to timeout for the first.
|
|
func (s *Session) PollIn(timeout time.Duration) ([]proto.InFrame, bool) {
|
|
deadline := time.After(timeout)
|
|
for {
|
|
s.mu.Lock()
|
|
if len(s.inBuf) > 0 {
|
|
b := s.inBuf
|
|
s.inBuf = nil
|
|
s.mu.Unlock()
|
|
return b, true
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
select {
|
|
case <-s.inSignal:
|
|
case <-deadline:
|
|
return nil, true
|
|
case <-s.closed:
|
|
return nil, false
|
|
}
|
|
}
|
|
}
|
|
|
|
// PushOut delivers agent output to the panel side. Returns false if closed.
|
|
func (s *Session) PushOut(m proto.OutMessage) bool {
|
|
select {
|
|
case s.out <- m:
|
|
return true
|
|
case <-s.closed:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Session) Out() <-chan proto.OutMessage { return s.out }
|
|
|
|
// PollOut drains queued upstream messages, blocking up to timeout for the first.
|
|
func (s *Session) PollOut(timeout time.Duration) ([]proto.OutMessage, bool) {
|
|
var batch []proto.OutMessage
|
|
select {
|
|
case m := <-s.out:
|
|
batch = append(batch, m)
|
|
case <-time.After(timeout):
|
|
return nil, true
|
|
case <-s.closed:
|
|
// Hand over output queued before the close (e.g. the reason a shell
|
|
// failed to start) instead of dropping it.
|
|
select {
|
|
case m := <-s.out:
|
|
batch = append(batch, m)
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case m := <-s.out:
|
|
batch = append(batch, m)
|
|
default:
|
|
return batch, true
|
|
}
|
|
}
|
|
}
|