towerops-agent/agent.go
Graham McIntire aef770ea83
test: achieve 92%+ coverage with comprehensive integration and edge case tests
Cover WebSocket session lifecycle (result channels, SNMP batch flushing,
heartbeats, restart), MikroTik TLS TOFU verification mismatch, worker pool
exhaustion, plaintext WebSocket rejection, HTTP/TCP/DNS check execution,
IPv4 fallback, and customer network edge cases.

Make heartbeat intervals and toWebSocketURL exit injectable for testing.
2026-03-04 16:06:29 -06:00

499 lines
13 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/towerops-app/towerops-agent/pb"
"google.golang.org/protobuf/proto"
)
var osExit = os.Exit
var doSelfUpdate = selfUpdate
var errRestartRequested = fmt.Errorf("restart requested")
var joinTimeout = 10 * time.Second
var heartbeatInterval = 60 * time.Second
var channelHeartbeatInterval = 25 * time.Second
const maxJobPayloadBytes = 4 << 20 // 4 MB — well above any legitimate job list
// channelMsg is the WebSocket channel message format (JSON wrapper around binary protobuf).
type channelMsg struct {
Topic string `json:"topic"`
Event string `json:"event"`
Payload json.RawMessage `json:"payload"`
Ref *string `json:"ref"`
}
// runAgent connects to the server and runs the event loop with reconnect.
func runAgent(ctx context.Context, wsURL, token string) {
baseURL := strings.TrimRight(wsURL, "/")
retryDelay := time.Second
maxRetry := 60 * time.Second
for {
select {
case <-ctx.Done():
return
default:
}
err := runSession(ctx, baseURL, token)
if ctx.Err() != nil {
return
}
if errors.Is(err, errRestartRequested) {
osExit(0)
return
}
if err != nil {
slog.Error("agent disconnected", "error", err)
}
slog.Info("reconnecting", "delay", retryDelay)
select {
case <-ctx.Done():
return
case <-time.After(retryDelay):
}
retryDelay = nextBackoff(retryDelay, maxRetry)
}
}
// runSession runs a single WebSocket session. Returns when disconnected or ctx cancelled.
func runSession(ctx context.Context, baseURL, token string) error {
endpoint := baseURL + "/socket/agent/websocket"
slog.Info("connecting", "url", sanitizeURL(endpoint))
ws, err := WSDial(endpoint)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer func() { _ = ws.Close() }()
agentID := fmt.Sprintf("agent-%d", time.Now().Unix())
topic := "agent:" + agentID
slog.Info("connected", "agent_id", agentID)
// Channel for serializing WebSocket writes
writeCh := make(chan []byte, 10000)
// Worker pools — bounded concurrency for each job type
pools := &jobPools{
snmp: newWorkerPool(100),
mikrotik: newWorkerPool(20),
ping: newWorkerPool(50),
checks: newWorkerPool(50),
}
// Result channels
snmpResultCh := make(chan *pb.SnmpResult, 10000)
mikrotikResultCh := make(chan *pb.MikrotikResult, 5000)
credTestResultCh := make(chan *pb.CredentialTestResult, 5000)
monitoringCheckCh := make(chan *pb.MonitoringCheck, 10000)
checkResultCh := make(chan *pb.CheckResult, 10000)
// Ref counter for outbound messages
var refCounter atomic.Uint64
refCounter.Store(1)
nextRef := func() string {
return strconv.FormatUint(refCounter.Add(1), 10)
}
sendMsg := func(event string, payload json.RawMessage) {
msg := channelMsg{
Topic: topic,
Event: event,
Payload: payload,
}
data, err := json.Marshal(msg)
if err != nil {
slog.Error("marshal message", "error", err)
return
}
select {
case writeCh <- data:
default:
slog.Warn("write channel full, dropping message", "event", event)
}
}
bufPool := &sync.Pool{
New: func() any {
b := make([]byte, 0, 4096)
return &b
},
}
sendBinaryResult := func(event string, msg proto.Message) {
bp := bufPool.Get().(*[]byte)
buf := (*bp)[:0]
bin, err := proto.MarshalOptions{}.MarshalAppend(buf, msg)
if err != nil {
slog.Error("marshal protobuf", "error", err)
return
}
encoded := base64.StdEncoding.EncodeToString(bin)
full := (*bp)[:cap(*bp)]
zeroBytes(full)
*bp = full[:0]
bufPool.Put(bp)
payload, _ := json.Marshal(map[string]string{"binary": encoded})
sendMsg(event, payload)
}
// Reader goroutine — must start before join so we can receive the reply
msgCh := make(chan []byte, 100)
errCh := make(chan error, 1)
go func() {
for {
data, _, err := ws.ReadMessage()
if err != nil {
errCh <- err
return
}
msgCh <- data
}
}()
// Join channel
joinPayload, _ := json.Marshal(map[string]string{"token": token})
joinMsg := channelMsg{
Topic: topic,
Event: "phx_join",
Payload: joinPayload,
Ref: strPtr("1"),
}
joinData, _ := json.Marshal(joinMsg)
if err := ws.WriteText(joinData); err != nil {
return fmt.Errorf("send join: %w", err)
}
slog.Debug("sent channel join request")
// Wait for join reply before entering main loop
select {
case data := <-msgCh:
var reply channelMsg
if err := json.Unmarshal(data, &reply); err != nil {
return fmt.Errorf("join reply unmarshal: %w", err)
}
if reply.Event == "phx_reply" {
var status struct {
Status string `json:"status"`
Response any `json:"response"`
}
if err := json.Unmarshal(reply.Payload, &status); err == nil && status.Status != "ok" {
return fmt.Errorf("join rejected: %s", status.Status)
}
}
slog.Info("channel joined")
case err := <-errCh:
return fmt.Errorf("read during join: %w", err)
case <-time.After(joinTimeout):
return fmt.Errorf("join timeout")
}
// Writer goroutine - serializes all writes to the WebSocket
var writerWg sync.WaitGroup
writeErrCh := make(chan error, 1)
writerWg.Add(1)
go func() {
defer writerWg.Done()
for data := range writeCh {
if err := ws.WriteText(data); err != nil {
slog.Error("websocket write", "error", err)
select {
case writeErrCh <- err:
default:
}
return
}
}
}()
heartbeatTicker := time.NewTicker(heartbeatInterval)
defer heartbeatTicker.Stop()
channelHeartbeatTicker := time.NewTicker(channelHeartbeatInterval)
defer channelHeartbeatTicker.Stop()
flushTicker := time.NewTicker(100 * time.Millisecond)
defer flushTicker.Stop()
startTime := time.Now()
defer func() {
pools.snmp.stop()
pools.mikrotik.stop()
pools.ping.stop()
pools.checks.stop()
close(writeCh)
writerWg.Wait()
}()
var snmpBatch []*pb.SnmpResult
flushSnmpBatch := func() {
if len(snmpBatch) == 0 {
return
}
for _, r := range snmpBatch {
sendBinaryResult("result", r)
}
slog.Info("flushed snmp results", "count", len(snmpBatch))
snmpBatch = snmpBatch[:0]
}
for {
select {
case <-ctx.Done():
slog.Info("shutdown signal, closing connection")
flushSnmpBatch()
return nil
case err := <-errCh:
flushSnmpBatch()
return fmt.Errorf("read: %w", err)
case err := <-writeErrCh:
flushSnmpBatch()
return fmt.Errorf("write: %w", err)
case data := <-msgCh:
var msg channelMsg
if err := json.Unmarshal(data, &msg); err != nil {
slog.Warn("invalid message", "error", err)
continue
}
if handleMessage(ctx, msg, pools, snmpResultCh, mikrotikResultCh, credTestResultCh, monitoringCheckCh, checkResultCh) {
flushSnmpBatch()
return errRestartRequested
}
case result := <-snmpResultCh:
snmpBatch = append(snmpBatch, result)
if len(snmpBatch) >= 50 {
flushSnmpBatch()
}
case result := <-mikrotikResultCh:
sendBinaryResult("mikrotik_result", result)
slog.Info("sent mikrotik result", "device", result.DeviceId, "job", result.JobId)
case result := <-credTestResultCh:
sendBinaryResult("credential_test_result", result)
slog.Info("sent credential test result", "test_id", result.TestId, "success", result.Success)
case result := <-monitoringCheckCh:
sendBinaryResult("monitoring_check", result)
slog.Info("sent monitoring check", "device", result.DeviceId, "status", result.Status)
case result := <-checkResultCh:
sendBinaryResult("check_result", result)
slog.Info("sent check result", "check", result.CheckId, "status", result.Status)
case <-flushTicker.C:
flushSnmpBatch()
case <-heartbeatTicker.C:
hb := &pb.AgentHeartbeat{
Version: version,
UptimeSeconds: uint64(time.Since(startTime).Seconds()),
Arch: runtime.GOARCH,
}
sendBinaryResult("heartbeat", hb)
slog.Debug("sent heartbeat")
case <-channelHeartbeatTicker.C:
ref := nextRef()
msg := channelMsg{
Topic: "phoenix",
Event: "heartbeat",
Payload: json.RawMessage(`{}`),
Ref: &ref,
}
data, _ := json.Marshal(msg)
select {
case writeCh <- data:
default:
}
slog.Debug("sent channel heartbeat", "ref", ref)
}
}
}
// handleMessage dispatches incoming channel messages.
// Returns true if the session should end (e.g. restart requested).
func handleMessage(
ctx context.Context,
msg channelMsg,
pools *jobPools,
snmpResultCh chan<- *pb.SnmpResult,
mikrotikResultCh chan<- *pb.MikrotikResult,
credTestResultCh chan<- *pb.CredentialTestResult,
monitoringCheckCh chan<- *pb.MonitoringCheck,
checkResultCh chan<- *pb.CheckResult,
) bool {
switch msg.Event {
case "phx_reply":
slog.Debug("channel reply", "topic", msg.Topic)
case "jobs", "discovery_job", "backup_job":
var payload struct {
Binary string `json:"binary"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
slog.Error("decode job payload", "error", err)
return false
}
if len(payload.Binary) > maxJobPayloadBytes {
slog.Error("job payload too large", "size", len(payload.Binary), "max", maxJobPayloadBytes)
return false
}
bin, err := base64.StdEncoding.DecodeString(payload.Binary)
if err != nil {
slog.Error("decode base64", "error", err)
return false
}
var jobList pb.AgentJobList
if err := proto.Unmarshal(bin, &jobList); err != nil {
slog.Error("unmarshal job list", "error", err)
return false
}
slog.Info("received jobs", "count", len(jobList.Jobs))
for _, job := range jobList.Jobs {
dispatchJob(ctx, job, pools, snmpResultCh, mikrotikResultCh, credTestResultCh, monitoringCheckCh, checkResultCh)
}
case "check_jobs":
var payload struct {
Binary string `json:"binary"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
slog.Error("decode check_jobs payload", "error", err)
return false
}
bin, err := base64.StdEncoding.DecodeString(payload.Binary)
if err != nil {
slog.Error("decode check_jobs base64", "error", err)
return false
}
var checkList pb.CheckList
if err := proto.Unmarshal(bin, &checkList); err != nil {
slog.Error("unmarshal check list", "error", err)
return false
}
slog.Info("received checks", "count", len(checkList.Checks))
for _, check := range checkList.Checks {
executeCheck(ctx, check, pools, checkResultCh)
}
case "restart":
slog.Info("restart requested by server")
return true
case "update":
var payload struct {
URL string `json:"url"`
Checksum string `json:"checksum"`
}
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.URL == "" || payload.Checksum == "" {
slog.Error("invalid update payload")
return false
}
slog.Info("update requested", "url", payload.URL)
if err := doSelfUpdate(payload.URL, payload.Checksum); err != nil {
slog.Error("self-update failed", "error", err)
}
default:
slog.Debug("ignoring event", "event", msg.Event)
}
return false
}
// jobPools holds the worker pools for each job type.
type jobPools struct {
snmp *workerPool
mikrotik *workerPool
ping *workerPool
checks *workerPool
}
// dispatchJob routes a job to the appropriate worker pool.
func dispatchJob(
ctx context.Context,
job *pb.AgentJob,
pools *jobPools,
snmpResultCh chan<- *pb.SnmpResult,
mikrotikResultCh chan<- *pb.MikrotikResult,
credTestResultCh chan<- *pb.CredentialTestResult,
monitoringCheckCh chan<- *pb.MonitoringCheck,
checkResultCh chan<- *pb.CheckResult,
) {
slog.Info("starting job", "job_id", job.JobId, "type", job.JobType)
var ok bool
switch job.JobType {
case pb.JobType_MIKROTIK:
ok = pools.mikrotik.submit(ctx, func() { executeMikrotikJob(ctx, job, mikrotikResultCh) })
case pb.JobType_TEST_CREDENTIALS:
ok = pools.snmp.submit(ctx, func() { executeCredentialTest(ctx, job, credTestResultCh) })
case pb.JobType_PING:
ok = pools.ping.submit(ctx, func() { executePingJob(ctx, job, monitoringCheckCh) })
default:
ok = pools.snmp.submit(ctx, func() { executeSnmpJob(ctx, job, snmpResultCh) })
}
if !ok {
slog.Warn("job dropped, pool full", "job_id", job.JobId)
}
}
// nextBackoff doubles the current delay (capped at max) and adds up to 25% jitter.
func nextBackoff(current, maxDelay time.Duration) time.Duration {
next := current * 2
if next > maxDelay {
next = maxDelay
}
jitter := time.Duration(rand.Int64N(int64(next / 4)))
return next + jitter
}
// zeroBytes overwrites a byte slice with zeros.
// SECURITY: Go strings are immutable and cannot be zeroed in place. This utility
// is for zeroing byte slices (e.g., password buffers) to limit credential lifetime
// in memory. Credentials stored as Go strings (ssh.go, snmp.go, mikrotik.go)
// cannot benefit from this until the protocol layer supports []byte credentials.
func zeroBytes(b []byte) {
for i := range b {
b[i] = 0
}
}
func strPtr(s string) *string { return &s }
// executeCheck dispatches a check to the worker pool.
func executeCheck(ctx context.Context, check *pb.Check, pools *jobPools, checkResultCh chan<- *pb.CheckResult) {
ok := pools.checks.submit(ctx, func() {
result := ExecuteCheck(ctx, check)
select {
case checkResultCh <- result:
case <-ctx.Done():
}
})
if !ok {
slog.Warn("check rejected (pool full)", "check_id", check.Id, "type", check.CheckType)
}
}