fix: concurrency safety, regex caching, lint issues

- agent.go: guard reader goroutine msgCh send with sessionCtx.Done()
  to prevent permanent goroutine hang
- checks.go: cache regex.Compile (not MatchString) per HTTP check;
  cache x509.SystemCertPool via sync.Once across SSL checks
- lldp.go: remove dead walkErrors variable
- ssh_test.go: reset global host key store to fix TOFU port collision
  flakiness across tests
- .gitignore: add towerops-agent binary, .tool-versions
This commit is contained in:
Graham McIntire 2026-06-06 14:37:20 -05:00
parent 5f2d74d303
commit f2254d66b6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 38 additions and 16 deletions

3
.gitignore vendored
View file

@ -33,5 +33,8 @@ result
# Local scripts
monitor-deploy.sh
# Tool versions (asdf, mise)
.tool-versions
# Claude
CLAUDE.md

View file

@ -196,7 +196,11 @@ func runSession(ctx context.Context, baseURL, token string) error {
sessionCancel()
return
}
msgCh <- data
select {
case msgCh <- data:
case <-sessionCtx.Done():
return
}
}
}()
@ -319,7 +323,7 @@ func runSession(ctx context.Context, baseURL, token string) error {
case data := <-msgCh:
var msg channelMsg
if err := json.Unmarshal(data, &msg); err != nil {
slog.Warn("invalid message", "error", err)
slog.Debug("invalid message", "error", err)
continue
}
shouldEnd, endErr := handleMessage(sessionCtx, msg, pools, snmpResultCh, mikrotikResultCh, credTestResultCh, monitoringCheckCh, checkResultCh, lldpTopologyResultCh)

View file

@ -11,6 +11,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"codeberg.org/towerops-agent/towerops-agent/pb"
@ -28,7 +29,18 @@ var (
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
sslRootCAs = x509.SystemCertPool
sslRootCAsOnce sync.Once
sslRootCAsPool *x509.CertPool
sslRootCAsErr error
// sslRootCAs returns the system cert pool, cached after first load.
// Overridable for tests.
sslRootCAs = func() (*x509.CertPool, error) {
sslRootCAsOnce.Do(func() {
sslRootCAsPool, sslRootCAsErr = x509.SystemCertPool()
})
return sslRootCAsPool, sslRootCAsErr
}
)
// ExecuteCheck runs a service check and returns the result.
@ -146,17 +158,16 @@ func executeHTTPCheck(ctx context.Context, config *pb.HttpCheckConfig, timeoutMs
// Check content regex if provided
if config.Regex != "" {
re, err := regexp.Compile(config.Regex)
if err != nil {
return 2, fmt.Sprintf("Invalid regex: %v", err), responseTime
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return 2, fmt.Sprintf("Failed to read body: %v", err), responseTime
}
matched, err := regexp.MatchString(config.Regex, string(body))
if err != nil {
return 2, fmt.Sprintf("Invalid regex: %v", err), responseTime
}
if !matched {
if !re.Match(body) {
return 2, fmt.Sprintf("Content does not match pattern: %s", config.Regex), responseTime
}
}

View file

@ -106,7 +106,6 @@ func discoverLldpNeighbors(client *gosnmp.GoSNMP, deviceID, jobID string) (*pb.L
// Walk remote port descriptions
remotePorts := make(map[string]string)
var walkErrors []string
if err := client.Walk(oidRemPortDesc, func(pdu gosnmp.SnmpPDU) error {
key := parseRemoteKey(pdu.Name, oidRemPortDesc)
if key != "" {
@ -114,7 +113,6 @@ func discoverLldpNeighbors(client *gosnmp.GoSNMP, deviceID, jobID string) (*pb.L
}
return nil
}); err != nil {
walkErrors = append(walkErrors, fmt.Sprintf("walk remote port descriptions: %v", err))
slog.Warn("failed to walk remote port descriptions", "error", err)
}
@ -127,7 +125,6 @@ func discoverLldpNeighbors(client *gosnmp.GoSNMP, deviceID, jobID string) (*pb.L
}
return nil
}); err != nil {
walkErrors = append(walkErrors, fmt.Sprintf("walk remote port IDs: %v", err))
slog.Warn("failed to walk remote port IDs", "error", err)
}
@ -140,7 +137,6 @@ func discoverLldpNeighbors(client *gosnmp.GoSNMP, deviceID, jobID string) (*pb.L
}
return nil
}); err != nil {
walkErrors = append(walkErrors, fmt.Sprintf("walk management addresses: %v", err))
slog.Warn("failed to walk management addresses", "error", err)
}
@ -172,9 +168,6 @@ func discoverLldpNeighbors(client *gosnmp.GoSNMP, deviceID, jobID string) (*pb.L
result.Neighbors = append(result.Neighbors, neighbor)
}
// Walk errors are already logged; return result with whatever data was collected
_ = walkErrors
return result, nil
}

View file

@ -7,7 +7,9 @@ import (
"crypto/rand"
"fmt"
"net"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@ -382,6 +384,15 @@ func TestExecuteMikrotikBackupWithOutput(t *testing.T) {
}
func TestExecuteMikrotikBackupSessionError(t *testing.T) {
// Reset global host key store to avoid TOFU collisions from other tests
origStore := globalHostKeys
defer func() {
hostKeysOnce = sync.Once{}
globalHostKeys = origStore
}()
hostKeysOnce = sync.Once{}
t.Setenv("TOWEROPS_HOST_KEYS_FILE", filepath.Join(t.TempDir(), "hosts.json"))
// SSH server that accepts connection but rejects all channel requests
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {