216 lines
6.1 KiB
Go
216 lines
6.1 KiB
Go
// Package hub accepts agent connections, authenticates them, and turns their
|
|
// messages into stored inventory and live UI signals.
|
|
package hub
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/coder/websocket/wsjson"
|
|
|
|
"github.com/shcizo/package-updater/internal/agenttoken"
|
|
"github.com/shcizo/package-updater/internal/master/events"
|
|
"github.com/shcizo/package-updater/internal/master/model"
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
// readLimit caps one incoming message. The library default of 32 KiB is too
|
|
// small for a full snapshot from a busy machine.
|
|
const readLimit = 1 << 20
|
|
|
|
// Store is the storage the hub writes to. *store.Store satisfies it.
|
|
type Store interface {
|
|
AgentByTokenHash(ctx context.Context, hash string) (model.Agent, error)
|
|
RecordHello(ctx context.Context, agentID int64, h protocol.Hello, at time.Time) error
|
|
ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error
|
|
UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error
|
|
RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error
|
|
MarkSeen(ctx context.Context, agentID int64, at time.Time) error
|
|
}
|
|
|
|
// Publisher receives "something changed" signals. *events.Broker satisfies it.
|
|
type Publisher interface {
|
|
Publish(t events.Topic)
|
|
}
|
|
|
|
// Hub is the master's endpoint for agents.
|
|
type Hub struct {
|
|
store Store
|
|
pub Publisher
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
conns map[int64]*websocket.Conn
|
|
}
|
|
|
|
// New returns a Hub. Mount it at GET /agent on the TLS listener.
|
|
func New(store Store, pub Publisher, log *slog.Logger) *Hub {
|
|
return &Hub{store: store, pub: pub, log: log, now: time.Now, conns: make(map[int64]*websocket.Conn)}
|
|
}
|
|
|
|
// ServeHTTP authenticates the agent's token and runs its connection. The token
|
|
// is only ever hashed; it is never logged or stored.
|
|
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !ok || token == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
agent, err := h.store.AgentByTokenHash(r.Context(), agenttoken.Hash(token))
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
h.log.Error("agent_lookup_failed", "error", err)
|
|
}
|
|
if err != nil || agent.RevokedAt != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
c, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
c.SetReadLimit(readLimit)
|
|
h.register(agent.ID, c)
|
|
defer h.unregister(agent.ID, c)
|
|
|
|
s := &session{hub: h, agent: agent}
|
|
err = s.run(r.Context(), c)
|
|
h.log.Info("agent_disconnected", "agent", agent.Name, "reason", err)
|
|
}
|
|
|
|
// Online reports whether the agent has a live connection.
|
|
func (h *Hub) Online(agentID int64) bool {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
_, ok := h.conns[agentID]
|
|
return ok
|
|
}
|
|
|
|
// Disconnect closes the agent's live connection, if any. Call it after
|
|
// revoking the agent so the revocation takes effect immediately.
|
|
func (h *Hub) Disconnect(agentID int64) {
|
|
h.mu.Lock()
|
|
c := h.conns[agentID]
|
|
h.mu.Unlock()
|
|
if c != nil {
|
|
c.CloseNow()
|
|
}
|
|
}
|
|
|
|
func (h *Hub) register(id int64, c *websocket.Conn) {
|
|
h.mu.Lock()
|
|
old := h.conns[id]
|
|
h.conns[id] = c
|
|
h.mu.Unlock()
|
|
if old != nil {
|
|
old.CloseNow()
|
|
}
|
|
}
|
|
|
|
func (h *Hub) unregister(id int64, c *websocket.Conn) {
|
|
h.mu.Lock()
|
|
current := h.conns[id] == c
|
|
if current {
|
|
delete(h.conns, id)
|
|
}
|
|
h.mu.Unlock()
|
|
if !current {
|
|
return // replaced by a newer connection; that one owns the state
|
|
}
|
|
if err := h.store.MarkSeen(context.Background(), id, h.now()); err != nil {
|
|
h.log.Error("mark_seen_failed", "agent_id", id, "error", err)
|
|
}
|
|
h.pub.Publish(events.Agents)
|
|
}
|
|
|
|
// session is one agent connection's state.
|
|
type session struct {
|
|
hub *Hub
|
|
agent model.Agent
|
|
hello *protocol.Hello
|
|
}
|
|
|
|
// swarmManager reports whether this agent may report Swarm services. The same
|
|
// rule gates snapshots, live upserts and live removes.
|
|
func (s *session) swarmManager() bool {
|
|
return s.hello.SwarmManager && s.hello.SwarmClusterID != ""
|
|
}
|
|
|
|
func (s *session) run(ctx context.Context, c *websocket.Conn) error {
|
|
for {
|
|
var env protocol.Envelope
|
|
if err := wsjson.Read(ctx, c, &env); err != nil {
|
|
return err
|
|
}
|
|
if s.hello == nil && env.Type != protocol.TypeHello {
|
|
_ = c.Close(websocket.StatusPolicyViolation, "hello required first")
|
|
return fmt.Errorf("%q before hello", env.Type)
|
|
}
|
|
if err := s.handle(ctx, env); err != nil {
|
|
_ = c.Close(websocket.StatusPolicyViolation, "bad message")
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) handle(ctx context.Context, env protocol.Envelope) error {
|
|
h := s.hub
|
|
switch env.Type {
|
|
case protocol.TypeHello:
|
|
v, err := protocol.Decode[protocol.Hello](env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := h.store.RecordHello(ctx, s.agent.ID, v, h.now()); err != nil {
|
|
return err
|
|
}
|
|
s.hello = &v
|
|
h.log.Info("agent_connected", "agent", s.agent.Name, "hostname", v.Hostname, "swarm_manager", v.SwarmManager)
|
|
h.pub.Publish(events.Agents)
|
|
case protocol.TypeSnapshot:
|
|
v, err := protocol.Decode[protocol.Snapshot](env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := h.store.ApplySnapshot(ctx, s.agent.ID, *s.hello, v.Services, h.now()); err != nil {
|
|
return err
|
|
}
|
|
h.pub.Publish(events.Services)
|
|
case protocol.TypeUpsert:
|
|
v, err := protocol.Decode[protocol.Upsert](env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v.Service.Kind == protocol.KindSwarm && !s.swarmManager() {
|
|
return nil
|
|
}
|
|
if err := h.store.UpsertService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Service, h.now()); err != nil {
|
|
return err
|
|
}
|
|
h.pub.Publish(events.Services)
|
|
case protocol.TypeRemove:
|
|
v, err := protocol.Decode[protocol.Remove](env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.HasPrefix(v.Key, string(protocol.KindSwarm)+"|") && !s.swarmManager() {
|
|
return nil
|
|
}
|
|
if err := h.store.RemoveService(ctx, s.agent.ID, s.hello.SwarmClusterID, v.Key, h.now()); err != nil {
|
|
return err
|
|
}
|
|
h.pub.Publish(events.Services)
|
|
default:
|
|
return fmt.Errorf("unknown message type %q", env.Type)
|
|
}
|
|
return nil
|
|
}
|