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.
132 lines
3.5 KiB
Go
132 lines
3.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
var insecureFlag bool
|
|
|
|
func main() {
|
|
apiURL := flag.String("api-url", os.Getenv("TOWEROPS_API_URL"), "API URL (e.g., wss://towerops.net)")
|
|
token := flag.String("token", os.Getenv("TOWEROPS_AGENT_TOKEN"), "Agent authentication token")
|
|
tokenFile := flag.String("token-file", "", "Path to file containing agent token (preferred over --token)")
|
|
logLevel := flag.String("log-level", envOrDefault("LOG_LEVEL", "info"), "Log level (debug, info, warn, error)")
|
|
flag.BoolVar(&insecureFlag, "insecure", false, "Allow plaintext ws:// connections (insecure)")
|
|
flag.Parse()
|
|
|
|
// Read token from file if --token-file is provided
|
|
if *tokenFile != "" {
|
|
data, err := os.ReadFile(*tokenFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: cannot read token file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
t := strings.TrimSpace(string(data))
|
|
token = &t
|
|
}
|
|
|
|
// Warn if --token was used via CLI (visible in /proc/cmdline)
|
|
if *tokenFile == "" && isFlagSet("token") {
|
|
fmt.Fprintln(os.Stderr, "WARNING: --token flag exposes the token in the process table. Use TOWEROPS_AGENT_TOKEN env var or --token-file instead.")
|
|
}
|
|
|
|
// Setup structured logging
|
|
var level slog.Level
|
|
switch strings.ToLower(*logLevel) {
|
|
case "debug":
|
|
level = slog.LevelDebug
|
|
case "warn", "warning":
|
|
level = slog.LevelWarn
|
|
case "error":
|
|
level = slog.LevelError
|
|
default:
|
|
level = slog.LevelInfo
|
|
}
|
|
slog.SetDefault(slog.New(newColorHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
|
|
|
|
if *apiURL == "" || *token == "" {
|
|
fmt.Fprintln(os.Stderr, "error: --api-url and --token are required (or set TOWEROPS_API_URL and TOWEROPS_AGENT_TOKEN)")
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
slog.Info("towerops agent starting", "version", version)
|
|
|
|
// Convert HTTP(S) to WebSocket URL
|
|
wsURL := toWebSocketURL(*apiURL)
|
|
slog.Info("websocket url", "url", sanitizeURL(wsURL))
|
|
|
|
// Signal handling
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
defer cancel()
|
|
|
|
// Run agent with reconnect loop
|
|
runAgent(ctx, wsURL, *token)
|
|
|
|
slog.Info("towerops agent stopped")
|
|
}
|
|
|
|
// toWebSocketURL converts an HTTP(S) URL to a WebSocket URL.
|
|
// Rejects plaintext ws:// unless --insecure is set.
|
|
func toWebSocketURL(rawURL string) string {
|
|
var result string
|
|
switch {
|
|
case strings.HasPrefix(rawURL, "http://"):
|
|
result = "ws://" + strings.TrimPrefix(rawURL, "http://")
|
|
case strings.HasPrefix(rawURL, "https://"):
|
|
result = "wss://" + strings.TrimPrefix(rawURL, "https://")
|
|
case strings.HasPrefix(rawURL, "ws://"), strings.HasPrefix(rawURL, "wss://"):
|
|
result = rawURL
|
|
default:
|
|
result = "wss://" + rawURL
|
|
}
|
|
|
|
if strings.HasPrefix(result, "ws://") && !insecureFlag {
|
|
slog.Error("plaintext ws:// connection rejected — use wss:// or pass --insecure to allow")
|
|
osExit(1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// isFlagSet returns true if a flag was explicitly set on the command line.
|
|
func isFlagSet(name string) bool {
|
|
found := false
|
|
flag.Visit(func(f *flag.Flag) {
|
|
if f.Name == name {
|
|
found = true
|
|
}
|
|
})
|
|
return found
|
|
}
|
|
|
|
// sanitizeURL masks query parameters to prevent credential leakage in logs.
|
|
func sanitizeURL(rawURL string) string {
|
|
if rawURL == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return "[invalid URL]"
|
|
}
|
|
if u.RawQuery != "" {
|
|
u.RawQuery = "***"
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func envOrDefault(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|