// Command approvalens-agent: the optional Approvalens server agent.
//
// It reads /proc, /sys, statfs and a few files (crontabs, authorized_keys, the setuid bits
// of binaries, the size and time of files in configured web roots), aggregates a sample
// taken every 60 seconds and posts a compact report every 5 minutes to approvalens.com.
// It never executes commands, never runs code it receives, opens no listening socket and
// changes nothing on the machine apart from its own state directory.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"os"
	"os/signal"
	"runtime/debug"
	"strings"
	"syscall"
	"time"
)

var version = "dev" // set by build.sh (-ldflags "-X main.version=...")

func usage() {
	fmt.Fprintf(os.Stderr, `approvalens-agent %s

Usage:
  approvalens-agent run     [--config FILE]   sample every 60 s, report every 5 min (the systemd service)
  approvalens-agent check   [--config FILE]   take one sample and print the report it would send (sends nothing)
  approvalens-agent version

Config: %s (token, url, web_roots, intervals). See https://approvalens.com/monitoring
`, version, DefaultConfigPath)
}

func main() {
	log.SetFlags(0)
	if len(os.Args) < 2 {
		usage()
		os.Exit(2)
	}
	cmd := os.Args[1]
	fs := flag.NewFlagSet(cmd, flag.ExitOnError)
	cfgPath := fs.String("config", envOr("APPROVALENS_AGENT_CONFIG", DefaultConfigPath), "config file")
	switch cmd {
	case "version", "--version", "-v":
		fmt.Println("approvalens-agent", version)
		return
	case "run":
		_ = fs.Parse(os.Args[2:])
		os.Exit(run(*cfgPath))
	case "check":
		_ = fs.Parse(os.Args[2:])
		os.Exit(check(*cfgPath))
	case "help", "-h", "--help":
		usage()
	default:
		usage()
		os.Exit(2)
	}
}

func envOr(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}

// Memory stays small: a soft limit well under the unit's MemoryMax and an eager GC.
func tuneRuntime() {
	debug.SetGCPercent(50)
	debug.SetMemoryLimit(24 << 20)
}

func run(path string) int {
	tuneRuntime()
	cfg, err := LoadConfig(path)
	if err != nil {
		log.Printf("config: %v", err)
		return 1
	}
	c := NewCollector(cfg, version)
	o := NewOutbox(c, true)
	s := NewSender(cfg, version)
	s.OnResync = o.Resync
	s.LoadSpool()
	c.LoadWebState()
	log.Printf("approvalens-agent %s: sampling every %s; heartbeat every %s, rollup every %s, events at once (min gap %s) to %s (uid %d)",
		version, cfg.SampleInterval, cfg.HeartbeatInterval, cfg.RollupInterval, cfg.EventGap, cfg.URL, os.Geteuid())
	if sk := c.Skipped(); len(sk) > 0 {
		log.Printf("checks skipped with these permissions: %s", strings.Join(sk, ", "))
	}

	slow := make(chan string, 4)
	go func() { // setuid and web root scans: slow file walks, off the sampling loop
		for job := range slow {
			switch job {
			case "setuid":
				c.ScanSetuidNow()
			case "web":
				c.ScanWebNow(true)
			}
		}
	}()
	queue := func(job string) {
		select {
		case slow <- job:
		default:
		}
	}

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	tick := time.NewTicker(cfg.SampleInterval)
	defer tick.Stop()

	start := time.Now()
	c.Sample(start)
	o.RefreshInventory()
	s.Enqueue(o.Heartbeat(start.UTC(), s.Pending())) // the account shows "connected" right away
	if o.NeedFull() {
		s.Enqueue(o.Full(start.UTC()))
	}
	nextHB := start.Add(cfg.HeartbeatInterval)
	nextRollup := start.Add(time.Minute) // the first chart points soon after install, then every 15 min
	nextInv := start.Add(cfg.HeartbeatInterval)
	nextSetuid := start.Add(2 * time.Minute)
	nextWeb := start
	lastStats := start
	for {
		select {
		case <-stop:
			msgs, bytes := s.Sent()
			log.Printf("stopping (%d messages, %d bytes sent since start)", msgs, bytes)
			return 0
		case now := <-tick.C:
			c.Sample(now)
			if !now.Before(nextSetuid) {
				nextSetuid = now.Add(cfg.SetuidInterval)
				queue("setuid")
			}
			if len(cfg.WebRoots) > 0 && !now.Before(nextWeb) {
				nextWeb = now.Add(cfg.WebRootInterval)
				queue("web")
			}
			if !now.Before(nextInv) { // crontabs and SSH keys: read every heartbeat interval
				nextInv = now.Add(cfg.HeartbeatInterval)
				o.RefreshInventory()
			}
			utc := now.UTC()
			if o.NeedFull() {
				o.RefreshInventory()
				s.Enqueue(o.Full(utc))
			}
			if m := o.Event(utc, cfg.EventGap); m != nil {
				s.Enqueue(*m)
			}
			if !now.Before(nextRollup) {
				nextRollup = now.Add(cfg.RollupInterval)
				s.Enqueue(o.Rollup(utc))
			}
			if !now.Before(nextHB) {
				nextHB = now.Add(cfg.HeartbeatInterval)
				s.Enqueue(o.Heartbeat(utc, s.Pending()))
			}
			if now.Sub(lastStats) >= 24*time.Hour {
				msgs, bytes := s.Sent()
				log.Printf("sent %d messages, %d bytes since start", msgs, bytes)
				lastStats = now
			}
		}
	}
}

// check prints the messages the agent would send now. Nothing is sent and no state is written.
func check(path string) int {
	cfg, err := LoadConfig(path)
	if err != nil {
		// Transparency first: show what would be collected even without a valid config.
		fmt.Fprintf(os.Stderr, "config: %v (continuing with defaults; nothing is sent by `check` anyway)\n", err)
		cfg = DefaultConfig()
		cfg.path = path
	}
	c := NewCollector(cfg, version)
	c.LoadWebState()
	o := NewOutbox(c, false)
	c.Sample(time.Now())
	time.Sleep(2 * time.Second) // a second sample turns counters into rates
	c.Sample(time.Now())
	c.ScanSetuidNow()
	c.ScanWebNow(false)
	o.RefreshInventory()
	now := time.Now().UTC()
	msgs := []any{o.Heartbeat(now, 0), o.Full(now), o.Rollup(now)}
	if m := o.Event(now, 0); m != nil {
		msgs = append(msgs, *m)
	}
	total := 0
	for _, m := range msgs {
		b, _ := json.MarshalIndent(m, "", "  ")
		gz, _ := Encode(m)
		total += len(gz)
		fmt.Println(string(b))
		fmt.Fprintf(os.Stderr, "-- %s: %d bytes gzip (%d bytes JSON)\n", m.(interface{ kind() string }).kind(), len(gz), len(b))
	}
	tok := cfg.Token
	if len(tok) > 9 {
		tok = tok[:9] + "…"
	}
	fmt.Fprintf(os.Stderr, "\nWould POST these to %s with token %s. Schedule: heartbeat every %s, rollup every %s, events at once (at most one per %s), the full inventory only after install or when the server asks for a resync.\n",
		cfg.Endpoint(), tok, cfg.HeartbeatInterval, cfg.RollupInterval, cfg.EventGap)
	fmt.Fprintf(os.Stderr, "Running as uid %d. Checks skipped with these permissions: %s\n", os.Geteuid(), joinOr(c.Skipped(), "none"))
	fmt.Fprintln(os.Stderr, "Nothing was sent. The agent never executes commands and has no listening socket.")
	return 0
}

func joinOr(xs []string, d string) string {
	if len(xs) == 0 {
		return d
	}
	return strings.Join(xs, ", ")
}
