From 9d1806e7e48eb9954444b7c52a2d4de9a9997e7d Mon Sep 17 00:00:00 2001 From: Samim Mirhosseini Date: Thu, 5 Mar 2026 13:38:04 -0500 Subject: [PATCH] feat(monitor): add real-time user monitoring with Prometheus metrics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a complete monitoring system for dnstt DNS tunnels that tracks real users by extracting TurbotTunnel ClientIDs from DNS query labels, rather than counting resolver IPs (which wildly overcounts). Architecture: ┌──────────────┐ AF_PACKET ┌──────────────┐ │ DNS Traffic │ ──────────────────▶│ Sniffer │ │ (port 53) │ raw socket │ (dnstm sniff)│ └──────────────┘ └──────┬───────┘ │ ┌───────────────────┼───────────────────┐ │ │ │ ▼ ▼ ▼ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │ JSON file │ │ Prometheus │ │ Collector │ │ (2s tick) │ │ /metrics │ │ (in-mem) │ └─────┬──────┘ │ (on scrape) │ └────────────┘ │ └──────────────┘ ▼ ┌──────────────────┐ │ CLI / Live TUI │ │ (reads JSON) │ └──────────────────┘ Components: - internal/monitor/capture.go: AF_PACKET raw socket capture (linux). Parses IP→UDP→DNS, base32-decodes subdomain labels, extracts 8-byte dnstt ClientIDs. Zero external dependencies. - internal/monitor/store.go: In-memory Collector tracking per-ClientID stats (bytes in/out, query count, first/last seen). 30s activity timeout for active/inactive detection. Peak client tracking, session duration stats (min/median/max), sparkline time series (900 points = 30min at 2s intervals). - internal/monitor/service.go: Pure Go process manager for the sniffer. Detached process groups, PID files, signal(0) liveness, SIGTERM shutdown. No systemd dependency. Persists metrics address config. - internal/monitor/metrics.go: Hand-rolled Prometheus text exposition format (no prometheus/client_golang). Exposes gauges (active_clients, peak_clients, uptime), counters (queries_total, bytes_in/out_total, sessions_total), and histograms (session_duration_seconds, session_bytes) all labeled by domain. Zero overhead between scrapes. - internal/monitor/reader.go: JSON stats file reader for CLI/TUI. - internal/livestats/livestats.go: Bubbletea live TUI model refreshing every 1s. Shows connected/peak/total users, bandwidth, per-user traffic stats, session durations, unicode sparkline graph (▁▂▃▄▅▆▇█), and scrollable user list with active/inactive markers. - cmd/sniff.go: Hidden `dnstm sniff` command with --tag, --port, --metrics-address flags. Writes stats JSON every 2s, restores previous state on restart. - internal/handlers/tunnel_stats.go: Stats handler auto-starting the sniffer. CLI mode prints one-shot stats; TUI mode launches livestats. - internal/handlers/tunnel_metrics.go: Toggle Prometheus metrics on/off. CLI: `dnstm tunnel metrics -t TAG --enable -a :9100`. TUI: menu-driven enable/disable with address prompt. Restarts sniffer with updated config. Integration points: - tunnel add: auto-starts sniffer for dnstt tunnels (non-fatal) - tunnel remove: stops sniffer and cleans up all runtime files - TUI menu: Stats and Metrics options in tunnel management --- cmd/sniff.go | 151 +++++++++++ internal/actions/ids.go | 22 +- internal/actions/tunnel.go | 53 ++++ internal/handlers/tunnel_add.go | 10 + internal/handlers/tunnel_metrics.go | 148 +++++++++++ internal/handlers/tunnel_remove.go | 5 + internal/handlers/tunnel_stats.go | 198 ++++++++++++++ internal/livestats/livestats.go | 391 ++++++++++++++++++++++++++++ internal/menu/adapter.go | 3 +- internal/menu/main.go | 5 +- internal/monitor/capture.go | 202 ++++++++++++++ internal/monitor/capture_other.go | 23 ++ internal/monitor/metrics.go | 188 +++++++++++++ internal/monitor/reader.go | 33 +++ internal/monitor/service.go | 197 ++++++++++++++ internal/monitor/store.go | 371 ++++++++++++++++++++++++++ internal/service/systemd.go | 35 ++- 17 files changed, 2018 insertions(+), 17 deletions(-) create mode 100644 cmd/sniff.go create mode 100644 internal/handlers/tunnel_metrics.go create mode 100644 internal/handlers/tunnel_stats.go create mode 100644 internal/livestats/livestats.go create mode 100644 internal/monitor/capture.go create mode 100644 internal/monitor/capture_other.go create mode 100644 internal/monitor/metrics.go create mode 100644 internal/monitor/reader.go create mode 100644 internal/monitor/service.go create mode 100644 internal/monitor/store.go diff --git a/cmd/sniff.go b/cmd/sniff.go new file mode 100644 index 0000000..d207721 --- /dev/null +++ b/cmd/sniff.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/net2share/dnstm/internal/monitor" + "github.com/spf13/cobra" +) + +var sniffCmd = &cobra.Command{ + Use: "sniff", + Short: "Sniff DNS traffic and write stats (used internally)", + Hidden: true, + Args: cobra.MinimumNArgs(1), + RunE: runSniff, +} + +func init() { + rootCmd.AddCommand(sniffCmd) + sniffCmd.Flags().String("tag", "", "Tunnel tag (used for stats file naming)") + sniffCmd.Flags().Int("port", 53, "DNS port to sniff") + sniffCmd.Flags().String("metrics-address", "", "Address to serve Prometheus metrics on (e.g. :9100)") +} + +func runSniff(cmd *cobra.Command, args []string) error { + tag, _ := cmd.Flags().GetString("tag") + port, _ := cmd.Flags().GetInt("port") + domains := args + + if tag == "" { + // Derive tag from first domain + tag = strings.ReplaceAll(domains[0], ".", "-") + } + + statsFile := monitor.StatsFilePath(tag) + + log.Printf("Sniffing port %d for domains: %v", port, domains) + log.Printf("Writing stats to: %s", statsFile) + + // Ensure stats dir exists + _ = os.MkdirAll(monitor.RunDir, 0755) + + // Open raw socket once — keep it for the lifetime of the process + fd, err := monitor.OpenRawSocket() + if err != nil { + return fmt.Errorf("failed to open raw socket: %w", err) + } + defer syscall.Close(fd) + + metricsAddr, _ := cmd.Flags().GetString("metrics-address") + + coll := monitor.NewCollector(domains) + + // Restore previous stats so history survives restarts + var prevDuration time.Duration + var history []monitor.DataPoint + if prev, err := monitor.ReadStats(tag); err == nil && prev != nil { + coll.Restore(prev) + prevDuration = prev.Duration + history = prev.History + log.Printf("Restored previous stats: %d queries, %d sessions, peak %d, uptime %s, %d history points", + prev.TotalQueries, prev.TotalClients, prev.PeakClients, prev.Duration.Round(time.Second), len(history)) + } + + start := time.Now() + + // Start Prometheus metrics HTTP server if address was provided + if metricsAddr != "" { + mux := http.NewServeMux() + mux.Handle("/metrics", monitor.MetricsHandler(coll, start)) + srv := &http.Server{Addr: metricsAddr, Handler: mux} + go func() { + log.Printf("Serving Prometheus metrics on %s/metrics", metricsAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Metrics server error: %v", err) + } + }() + } + + // Signal handling + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Write stats periodically in background + writeTicker := time.NewTicker(2 * time.Second) + defer writeTicker.Stop() + + // Packet capture in background + stopCh := make(chan struct{}) + go func() { + monitor.CaptureLoop(fd, port, coll, stopCh) + }() + + log.Printf("Sniffer running.") + + for { + select { + case <-writeTicker.C: + result := coll.Result(prevDuration + time.Since(start)) + // Append data point to history + history = append(history, monitor.DataPoint{ + Time: time.Now(), + ActiveClients: result.ActiveClients, + }) + // Trim to max history size + if len(history) > monitor.MaxHistory { + history = history[len(history)-monitor.MaxHistory:] + } + result.History = history + writeStats(statsFile, result) + case <-sigCh: + log.Printf("Shutting down...") + close(stopCh) + // Final write + result := coll.Result(prevDuration + time.Since(start)) + history = append(history, monitor.DataPoint{ + Time: time.Now(), + ActiveClients: result.ActiveClients, + }) + if len(history) > monitor.MaxHistory { + history = history[len(history)-monitor.MaxHistory:] + } + result.History = history + writeStats(statsFile, result) + return nil + } + } +} + +func writeStats(path string, result *monitor.CaptureResult) { + data, err := json.Marshal(result) + if err != nil { + log.Printf("Failed to marshal stats: %v", err) + return + } + // Write atomically via temp file + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + log.Printf("Failed to write stats: %v", err) + return + } + os.Rename(tmp, path) +} diff --git a/internal/actions/ids.go b/internal/actions/ids.go index 2f72719..cdd3d19 100644 --- a/internal/actions/ids.go +++ b/internal/actions/ids.go @@ -11,16 +11,18 @@ const ( ActionBackendStatus = "backend.status" // Tunnel actions - ActionTunnel = "tunnel" - ActionTunnelList = "tunnel.list" - ActionTunnelAdd = "tunnel.add" - ActionTunnelRemove = "tunnel.remove" - ActionTunnelStart = "tunnel.start" - ActionTunnelStop = "tunnel.stop" - ActionTunnelRestart = "tunnel.restart" - ActionTunnelStatus = "tunnel.status" - ActionTunnelLogs = "tunnel.logs" - ActionTunnelShare = "tunnel.share" + ActionTunnel = "tunnel" + ActionTunnelList = "tunnel.list" + ActionTunnelAdd = "tunnel.add" + ActionTunnelRemove = "tunnel.remove" + ActionTunnelStart = "tunnel.start" + ActionTunnelStop = "tunnel.stop" + ActionTunnelRestart = "tunnel.restart" + ActionTunnelStatus = "tunnel.status" + ActionTunnelLogs = "tunnel.logs" + ActionTunnelShare = "tunnel.share" + ActionTunnelStats = "tunnel.stats" + ActionTunnelMetrics = "tunnel.metrics" // Router actions ActionRouter = "router" diff --git a/internal/actions/tunnel.go b/internal/actions/tunnel.go index aa8ce22..bc76eed 100644 --- a/internal/actions/tunnel.go +++ b/internal/actions/tunnel.go @@ -201,6 +201,59 @@ func init() { }, }) + // Register tunnel.stats action + Register(&Action{ + ID: ActionTunnelStats, + Parent: ActionTunnel, + Use: "stats", + Short: "Show tunnel usage statistics", + Long: "Show live usage statistics including active clients, connections, and bandwidth", + MenuLabel: "Stats", + RequiresRoot: true, + RequiresInstalled: true, + Args: &ArgsSpec{ + Name: "tag", + Description: "Tunnel tag (optional, shows all tunnels if omitted)", + Required: false, + PickerFunc: TunnelPicker, + }, + }) + + // Register tunnel.metrics action + Register(&Action{ + ID: ActionTunnelMetrics, + Parent: ActionTunnel, + Use: "metrics", + Short: "Configure Prometheus metrics endpoint", + Long: "Enable or disable the Prometheus metrics endpoint for a tunnel's monitor", + MenuLabel: "Metrics", + RequiresRoot: true, + RequiresInstalled: true, + Args: &ArgsSpec{ + Name: "tag", + Description: "Tunnel tag", + Required: true, + PickerFunc: TunnelPicker, + }, + Inputs: []InputField{ + { + Name: "enable", + Label: "Enable Metrics", + Type: InputTypeBool, + Description: "Enable or disable the Prometheus metrics endpoint", + }, + { + Name: "address", + Label: "Metrics Address", + ShortFlag: 'a', + Type: InputTypeText, + Default: ":9100", + Placeholder: ":9100", + Description: "Address to serve Prometheus metrics on (e.g. :9100)", + }, + }, + }) + // Register tunnel.add action Register(&Action{ ID: ActionTunnelAdd, diff --git a/internal/handlers/tunnel_add.go b/internal/handlers/tunnel_add.go index 67147dd..625c777 100644 --- a/internal/handlers/tunnel_add.go +++ b/internal/handlers/tunnel_add.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log" "os" "path/filepath" "strconv" @@ -10,6 +11,7 @@ import ( "github.com/net2share/dnstm/internal/certs" "github.com/net2share/dnstm/internal/config" "github.com/net2share/dnstm/internal/keys" + "github.com/net2share/dnstm/internal/monitor" "github.com/net2share/dnstm/internal/router" "github.com/net2share/dnstm/internal/system" "github.com/net2share/dnstm/internal/transport" @@ -511,5 +513,13 @@ func createTunnelService(tunnelCfg *config.TunnelConfig, backend *config.Backend return err } + // Start paired sniffer process (auto-captures DNS traffic for user stats) + if tunnelCfg.Transport == "dnstt" { + if err := monitor.StartSniffer(tunnel.Tag, []string{tunnelCfg.Domain}, monitor.ReadMetricsConf(tunnel.Tag)); err != nil { + // Non-fatal — tunnel still works without monitoring + log.Printf("Warning: failed to start sniffer: %v", err) + } + } + return nil } diff --git a/internal/handlers/tunnel_metrics.go b/internal/handlers/tunnel_metrics.go new file mode 100644 index 0000000..a1a9b7a --- /dev/null +++ b/internal/handlers/tunnel_metrics.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "fmt" + + "github.com/net2share/dnstm/internal/actions" + "github.com/net2share/dnstm/internal/config" + "github.com/net2share/dnstm/internal/monitor" + "github.com/net2share/go-corelib/tui" +) + +func init() { + actions.SetTunnelHandler(actions.ActionTunnelMetrics, HandleTunnelMetrics) +} + +// HandleTunnelMetrics enables or disables the Prometheus metrics endpoint for a tunnel. +func HandleTunnelMetrics(ctx *actions.Context) error { + _, err := RequireConfig(ctx) + if err != nil { + return err + } + + tag, err := RequireTag(ctx, "tunnel") + if err != nil { + return err + } + + tc, err := GetTunnelByTag(ctx, tag) + if err != nil { + return err + } + + if tc.Transport != config.TransportDNSTT { + return fmt.Errorf("metrics monitoring is only available for dnstt tunnels") + } + + currentAddr := monitor.ReadMetricsConf(tag) + + if ctx.IsInteractive { + return handleMetricsInteractive(ctx, tag, tc, currentAddr) + } + return handleMetricsCLI(ctx, tag, tc, currentAddr) +} + +func handleMetricsCLI(ctx *actions.Context, tag string, tc *config.TunnelConfig, currentAddr string) error { + enable := ctx.GetBool("enable") + address := ctx.GetString("address") + + if currentAddr != "" { + ctx.Output.Printf("Metrics currently enabled on %s/metrics\n", currentAddr) + } else { + ctx.Output.Printf("Metrics currently disabled\n") + } + + if !enable { + // Disable metrics + if currentAddr == "" { + ctx.Output.Printf("Already disabled, nothing to do.\n") + return nil + } + return restartSniffer(ctx, tag, tc, "") + } + + // Enable metrics + return restartSniffer(ctx, tag, tc, address) +} + +func handleMetricsInteractive(ctx *actions.Context, tag string, tc *config.TunnelConfig, currentAddr string) error { + // Show current status + if currentAddr != "" { + ctx.Output.Printf("Metrics currently enabled on %s/metrics\n\n", currentAddr) + } else { + ctx.Output.Printf("Metrics currently disabled\n\n") + } + + // Choose enable or disable + options := []tui.MenuOption{ + {Label: "Enable", Value: "enable"}, + {Label: "Disable", Value: "disable"}, + {Label: "Back", Value: "back"}, + } + + choice, err := tui.RunMenu(tui.MenuConfig{ + Title: "Prometheus Metrics", + Description: fmt.Sprintf("Tunnel: %s (%s)", tag, tc.Domain), + Options: options, + }) + if err != nil || choice == "" || choice == "back" { + return nil + } + + if choice == "disable" { + if currentAddr == "" { + ctx.Output.Printf("Already disabled.\n") + return nil + } + return restartSniffer(ctx, tag, tc, "") + } + + // Enable — prompt for address + defaultAddr := currentAddr + if defaultAddr == "" { + defaultAddr = ":9100" + } + + address, confirmed, err := tui.RunInput(tui.InputConfig{ + Title: "Metrics Address", + Description: "Address to serve Prometheus metrics on", + Placeholder: ":9100", + Value: defaultAddr, + }) + if err != nil || !confirmed { + return nil + } + if address == "" { + address = ":9100" + } + + return restartSniffer(ctx, tag, tc, address) +} + +func restartSniffer(ctx *actions.Context, tag string, tc *config.TunnelConfig, metricsAddr string) error { + // Stop existing sniffer + if monitor.IsSnifferRunning(tag) { + ctx.Output.Printf("Stopping monitor...\n") + if err := monitor.StopSniffer(tag); err != nil { + return fmt.Errorf("failed to stop sniffer: %w", err) + } + } + + // Update persisted config + if err := monitor.WriteMetricsConf(tag, metricsAddr); err != nil { + return fmt.Errorf("failed to write metrics config: %w", err) + } + + // Start with new config + ctx.Output.Printf("Starting monitor...\n") + if err := monitor.StartSniffer(tag, []string{tc.Domain}, metricsAddr); err != nil { + return fmt.Errorf("failed to start sniffer: %w", err) + } + + if metricsAddr != "" { + ctx.Output.Success(fmt.Sprintf("Metrics enabled on %s/metrics", metricsAddr)) + } else { + ctx.Output.Success("Metrics disabled") + } + return nil +} diff --git a/internal/handlers/tunnel_remove.go b/internal/handlers/tunnel_remove.go index 0c886d4..13b5eee 100644 --- a/internal/handlers/tunnel_remove.go +++ b/internal/handlers/tunnel_remove.go @@ -5,6 +5,7 @@ import ( "github.com/net2share/dnstm/internal/actions" "github.com/net2share/dnstm/internal/config" + "github.com/net2share/dnstm/internal/monitor" "github.com/net2share/dnstm/internal/router" ) @@ -50,6 +51,10 @@ func HandleTunnelRemove(ctx *actions.Context) error { currentStep++ ctx.Output.Step(currentStep, totalSteps, "Removing service...") tunnel := router.NewTunnel(tunnelCfg) + + // Stop and remove paired sniffer process + _ = monitor.RemoveSniffer(tunnel.Tag) + if err := tunnel.RemoveService(); err != nil { ctx.Output.Warning("Service removal warning: " + err.Error()) } else { diff --git a/internal/handlers/tunnel_stats.go b/internal/handlers/tunnel_stats.go new file mode 100644 index 0000000..bc20346 --- /dev/null +++ b/internal/handlers/tunnel_stats.go @@ -0,0 +1,198 @@ +package handlers + +import ( + "fmt" + "strings" + "time" + + "github.com/net2share/dnstm/internal/actions" + "github.com/net2share/dnstm/internal/config" + "github.com/net2share/dnstm/internal/livestats" + "github.com/net2share/dnstm/internal/monitor" + "github.com/net2share/dnstm/internal/router" + "github.com/net2share/dnstm/internal/service" +) + +func init() { + actions.SetTunnelHandler(actions.ActionTunnelStats, HandleTunnelStats) +} + +// HandleTunnelStats shows tunnel usage stats from the background sniffer. +func HandleTunnelStats(ctx *actions.Context) error { + cfg, err := RequireConfig(ctx) + if err != nil { + return err + } + + tag := ctx.GetString("tag") + + var tunnelCfgs []config.TunnelConfig + var tunnels []*router.Tunnel + if tag != "" { + tc, err := GetTunnelByTag(ctx, tag) + if err != nil { + return err + } + tunnelCfgs = []config.TunnelConfig{*tc} + tunnels = []*router.Tunnel{router.NewTunnel(tc)} + } else { + if len(cfg.Tunnels) == 0 { + return actions.NoTunnelsError() + } + tunnelCfgs = cfg.Tunnels + for i := range cfg.Tunnels { + tunnels = append(tunnels, router.NewTunnel(&cfg.Tunnels[i])) + } + } + + // Auto-setup: if any dnstt tunnel doesn't have a running sniffer, start one + snifferJustStarted := false + for i, t := range tunnels { + tc := tunnelCfgs[i] + if tc.Transport != config.TransportDNSTT { + continue + } + if !monitor.IsSnifferRunning(t.Tag) { + ctx.Output.Printf("Starting monitor for %s...\n", t.Tag) + if err := monitor.StartSniffer(t.Tag, []string{tc.Domain}, monitor.ReadMetricsConf(t.Tag)); err != nil { + ctx.Output.Printf(" Warning: failed to start sniffer: %v\n", err) + } else { + snifferJustStarted = true + } + } + } + + // If we just started a sniffer in CLI mode, give it a moment to collect data + if snifferJustStarted && !ctx.IsInteractive { + ctx.Output.Printf("Waiting for data...\n") + time.Sleep(3 * time.Second) + } + + if ctx.IsInteractive { + return showStatsInteractive(ctx, tunnels) + } + return showStatsCLI(ctx, tunnels) +} + +func showStatsInteractive(ctx *actions.Context, tunnels []*router.Tunnel) error { + return livestats.Run(tunnels) +} + +func showStatsCLI(ctx *actions.Context, tunnels []*router.Tunnel) error { + ctx.Output.Println() + + for _, t := range tunnels { + printTunnelCLI(ctx, t) + } + + return nil +} + +func printTunnelCLI(ctx *actions.Context, t *router.Tunnel) { + active := service.IsServiceActive(t.ServiceName) + status := "Stopped" + if active { + status = "Running" + } + + ctx.Output.Printf("--- %s [%s] ---\n", t.Tag, status) + ctx.Output.Printf(" Domain: %s\n", t.Domain) + + result, err := monitor.ReadStats(t.Tag) + if err != nil { + ctx.Output.Printf(" Stats: Error reading stats: %v\n\n", err) + return + } + if result == nil { + if !monitor.IsSnifferRunning(t.Tag) { + ctx.Output.Printf(" Monitor: Not running\n") + ctx.Output.Printf(" Hint: Run 'dnstm tunnel stats -t %s' to start it\n\n", t.Tag) + } else { + ctx.Output.Printf(" Stats: Waiting for data...\n\n") + } + return + } + + tr := findTunnelResult(t.Domain, result) + if tr == nil || tr.TotalQueries == 0 { + ctx.Output.Printf(" (no traffic yet)\n\n") + return + } + + ctx.Output.Printf(" Uptime: %s\n", result.Duration.Round(1e9)) // round to seconds + ctx.Output.Printf(" Queries: %d (%.1f/sec)\n", tr.TotalQueries, tr.QueriesPerSec) + ctx.Output.Printf(" Bandwidth In: %s\n", monitor.FormatBytes(tr.TotalBytesIn)) + ctx.Output.Printf(" Bandwidth Out: %s\n", monitor.FormatBytes(tr.TotalBytesOut)) + ctx.Output.Printf(" Connected Users: %d\n", tr.ActiveClients) + ctx.Output.Printf(" Peak Concurrent: %d\n", tr.PeakClients) + if tr.TotalClients > tr.ActiveClients { + ctx.Output.Printf(" Total Sessions: %d\n", tr.TotalClients) + } + + if s := tr.Summary(); s.Count > 0 { + ctx.Output.Println() + ctx.Output.Println(" Per-User Traffic:") + ctx.Output.Printf(" Min: %s (%d queries)\n", monitor.FormatBytes(s.MinBytesTotal), s.MinQueries) + ctx.Output.Printf(" Median: %s (%d queries)\n", monitor.FormatBytes(s.MedianBytes), s.MedianQueries) + ctx.Output.Printf(" Max: %s (%d queries)\n", monitor.FormatBytes(s.MaxBytesTotal), s.MaxQueries) + ctx.Output.Println() + ctx.Output.Println(" Session Length:") + ctx.Output.Printf(" Min: %s\n", formatDuration(s.MinDuration)) + ctx.Output.Printf(" Median: %s\n", formatDuration(s.MedianDuration)) + ctx.Output.Printf(" Max: %s\n", formatDuration(s.MaxDuration)) + } + + if len(tr.Clients) > 0 { + ctx.Output.Println() + ctx.Output.Println(" Users (by traffic):") + limit := len(tr.Clients) + if limit > 15 { + limit = 15 + } + for _, c := range tr.Clients[:limit] { + marker := " " + if !c.Active { + marker = "-" + } + ctx.Output.Printf(" %s %s %s (%d queries)\n", marker, c.ClientID, monitor.FormatBytes(c.BytesTotal), c.Queries) + } + if len(tr.Clients) > 15 { + ctx.Output.Printf(" ... and %d more\n", len(tr.Clients)-15) + } + } + ctx.Output.Println() +} + +func formatDuration(d time.Duration) string { + if d < time.Second { + return "<1s" + } + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func findTunnelResult(domain string, result *monitor.CaptureResult) *monitor.TunnelResult { + if result == nil || result.Tunnels == nil { + return nil + } + domain = strings.ToLower(domain) + + if tr, ok := result.Tunnels[domain]; ok { + return tr + } + for d, tr := range result.Tunnels { + if strings.HasSuffix(domain, "."+d) || strings.HasSuffix(d, "."+domain) { + return tr + } + } + return nil +} diff --git a/internal/livestats/livestats.go b/internal/livestats/livestats.go new file mode 100644 index 0000000..642d3c3 --- /dev/null +++ b/internal/livestats/livestats.go @@ -0,0 +1,391 @@ +package livestats + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/net2share/dnstm/internal/monitor" + "github.com/net2share/dnstm/internal/router" + "github.com/net2share/dnstm/internal/service" + "github.com/net2share/go-corelib/tui" +) + +// RefreshInterval is how often the stats view reloads data. +const RefreshInterval = 1 * time.Second + +// tickMsg signals a refresh. +type tickMsg time.Time + +// Model is the bubbletea model for live-updating stats. +type Model struct { + tunnels []*router.Tunnel + width int + height int + scroll int + lines []string + quitting bool +} + +// New creates a new live stats model. +func New(tunnels []*router.Tunnel) Model { + m := Model{tunnels: tunnels} + m.lines = m.buildLines() + return m +} + +func (m Model) Init() tea.Cmd { + return tea.Tick(RefreshInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.scroll > 0 { + m.scroll-- + } + case "down", "j": + if m.scroll < m.maxScroll() { + m.scroll++ + } + case "home": + m.scroll = 0 + case "end": + m.scroll = m.maxScroll() + case "pgup": + m.scroll -= m.visibleLines() + if m.scroll < 0 { + m.scroll = 0 + } + case "pgdown": + m.scroll += m.visibleLines() + if m.scroll > m.maxScroll() { + m.scroll = m.maxScroll() + } + } + case tickMsg: + m.lines = m.buildLines() + return m, tea.Tick(RefreshInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + return m, nil +} + +func (m Model) View() string { + if m.quitting { + return "" + } + + boxWidth := m.width - 10 + if boxWidth > 90 { + boxWidth = 90 + } + if boxWidth < 40 { + boxWidth = 40 + } + + // Title + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + mutedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + var b strings.Builder + b.WriteString(titleStyle.Render("Tunnel Statistics")) + b.WriteString("\n") + b.WriteString(mutedStyle.Render("Live — refreshing every 1s")) + b.WriteString("\n\n") + + // Apply scroll window + visible := m.visibleLines() + start := m.scroll + end := start + visible + if end > len(m.lines) { + end = len(m.lines) + } + + if start > 0 { + b.WriteString(mutedStyle.Render(" ↑ more above")) + b.WriteString("\n") + } + + for _, line := range m.lines[start:end] { + b.WriteString(line) + b.WriteString("\n") + } + + if end < len(m.lines) { + b.WriteString(mutedStyle.Render(" ↓ more below")) + b.WriteString("\n") + } + + // Help line + b.WriteString("\n") + b.WriteString(mutedStyle.Render(" ↑/↓ scroll • q/esc close")) + + // Wrap in box + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(1, 2). + Width(boxWidth). + Render(b.String()) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} + +func (m Model) visibleLines() int { + v := m.height - 15 + if v < 5 { + v = 5 + } + return v +} + +func (m Model) maxScroll() int { + max := len(m.lines) - m.visibleLines() + if max < 0 { + return 0 + } + return max +} + +// buildLines reads stats from disk and formats them. +func (m Model) buildLines() []string { + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + valStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) + activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + var lines []string + + for _, t := range m.tunnels { + active := service.IsServiceActive(t.ServiceName) + status := inactiveStyle.Render("Stopped") + if active { + status = activeStyle.Render("Running") + } + + lines = append(lines, sectionStyle.Render(fmt.Sprintf("─── %s ", t.Tag))+status) + lines = append(lines, kv(keyStyle, valStyle, "Domain", t.Domain)) + + // Show metrics endpoint status + if metricsAddr := monitor.ReadMetricsConf(t.Tag); metricsAddr != "" { + lines = append(lines, kv(keyStyle, valStyle, "Metrics", metricsAddr+"/metrics")) + } + + result, err := monitor.ReadStats(t.Tag) + if err != nil { + lines = append(lines, kv(keyStyle, valStyle, "Error", err.Error())) + lines = append(lines, "") + continue + } + if result == nil { + if !monitor.IsSnifferRunning(t.Tag) { + lines = append(lines, kv(keyStyle, valStyle, "Monitor", "Not running")) + } else { + lines = append(lines, kv(keyStyle, valStyle, "Status", "Waiting for data...")) + } + lines = append(lines, "") + continue + } + + tr := findTunnelResult(t.Domain, result) + if tr == nil || tr.TotalQueries == 0 { + lines = append(lines, kv(keyStyle, valStyle, "Traffic", "(no traffic yet)")) + lines = append(lines, "") + continue + } + + lines = append(lines, kv(keyStyle, valStyle, "Uptime", result.Duration.Round(time.Second).String())) + lines = append(lines, kv(keyStyle, valStyle, "Queries", fmt.Sprintf("%d (%.1f/sec)", tr.TotalQueries, tr.QueriesPerSec))) + lines = append(lines, kv(keyStyle, valStyle, "Bandwidth In", monitor.FormatBytes(tr.TotalBytesIn))) + lines = append(lines, kv(keyStyle, valStyle, "Bandwidth Out", monitor.FormatBytes(tr.TotalBytesOut))) + + connStr := fmt.Sprintf("%d", tr.ActiveClients) + if tr.TotalClients > tr.ActiveClients { + connStr = fmt.Sprintf("%d "+keyStyle.Render("(%d total seen)"), tr.ActiveClients, tr.TotalClients) + } + lines = append(lines, kv(keyStyle, valStyle, "Connected", connStr)) + lines = append(lines, kv(keyStyle, valStyle, "Peak", fmt.Sprintf("%d", tr.PeakClients))) + + // Sparkline graph of connected users over time + if len(result.History) > 1 { + lines = append(lines, "") + lines = append(lines, keyStyle.Render(" Users over time:")) + lines = append(lines, " "+renderSparkline(result.History, 50, valStyle, keyStyle)) + } + + if s := tr.Summary(); s.Count > 0 { + lines = append(lines, "") + lines = append(lines, keyStyle.Render(" Per-User Traffic:")) + lines = append(lines, fmt.Sprintf(" %s %s %s", + keyStyle.Render("min ")+valStyle.Render(monitor.FormatBytes(s.MinBytesTotal)), + keyStyle.Render("med ")+valStyle.Render(monitor.FormatBytes(s.MedianBytes)), + keyStyle.Render("max ")+valStyle.Render(monitor.FormatBytes(s.MaxBytesTotal)), + )) + lines = append(lines, keyStyle.Render(" Session Length:")) + lines = append(lines, fmt.Sprintf(" %s %s %s", + keyStyle.Render("min ")+valStyle.Render(formatDuration(s.MinDuration)), + keyStyle.Render("med ")+valStyle.Render(formatDuration(s.MedianDuration)), + keyStyle.Render("max ")+valStyle.Render(formatDuration(s.MaxDuration)), + )) + } + + if len(tr.Clients) > 0 { + lines = append(lines, "") + lines = append(lines, keyStyle.Render(" Users:")) + limit := len(tr.Clients) + if limit > 15 { + limit = 15 + } + for _, c := range tr.Clients[:limit] { + marker := activeStyle.Render("●") + if !c.Active { + marker = inactiveStyle.Render("○") + } + lines = append(lines, fmt.Sprintf(" %s %s %s %s", + marker, + valStyle.Render(c.ClientID), + valStyle.Render(monitor.FormatBytes(c.BytesTotal)), + keyStyle.Render(fmt.Sprintf("(%d q)", c.Queries)), + )) + } + if len(tr.Clients) > 15 { + lines = append(lines, keyStyle.Render(fmt.Sprintf(" ... and %d more", len(tr.Clients)-15))) + } + } + + lines = append(lines, "") + } + + return lines +} + +// renderSparkline renders a compact ASCII sparkline graph from history data points. +// width is the number of columns. If there are more data points than width, +// they are downsampled by averaging buckets. +func renderSparkline(history []monitor.DataPoint, width int, valStyle, keyStyle lipgloss.Style) string { + if len(history) == 0 { + return "" + } + + // Downsample or use raw values + values := make([]float64, 0, width) + if len(history) <= width { + for _, dp := range history { + values = append(values, float64(dp.ActiveClients)) + } + } else { + // Bucket and average + bucketSize := float64(len(history)) / float64(width) + for i := 0; i < width; i++ { + start := int(float64(i) * bucketSize) + end := int(float64(i+1) * bucketSize) + if end > len(history) { + end = len(history) + } + sum := 0.0 + for _, dp := range history[start:end] { + sum += float64(dp.ActiveClients) + } + values = append(values, sum/float64(end-start)) + } + } + + // Find max for scaling + maxVal := 0.0 + for _, v := range values { + if v > maxVal { + maxVal = v + } + } + + blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + var sb strings.Builder + + for _, v := range values { + if maxVal == 0 { + sb.WriteRune(blocks[0]) + continue + } + idx := int(v / maxVal * float64(len(blocks)-1)) + if idx >= len(blocks) { + idx = len(blocks) - 1 + } + sb.WriteRune(blocks[idx]) + } + + graph := valStyle.Render(sb.String()) + + // Time labels + elapsed := time.Since(history[0].Time).Round(time.Second) + timeLabel := keyStyle.Render(fmt.Sprintf(" ← %s ago", elapsed)) + + return graph + timeLabel +} + +// formatDuration formats a duration into a compact human-readable string. +func formatDuration(d time.Duration) string { + if d < time.Second { + return "<1s" + } + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh%dm", h, m) + } + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +func kv(keyStyle, valStyle lipgloss.Style, key, value string) string { + return fmt.Sprintf(" %s %s", keyStyle.Render(fmt.Sprintf("%-16s", key+":")), valStyle.Render(value)) +} + +func findTunnelResult(domain string, result *monitor.CaptureResult) *monitor.TunnelResult { + if result == nil || result.Tunnels == nil { + return nil + } + domain = strings.ToLower(domain) + if tr, ok := result.Tunnels[domain]; ok { + return tr + } + for d, tr := range result.Tunnels { + if strings.HasSuffix(domain, "."+d) || strings.HasSuffix(d, "."+domain) { + return tr + } + } + return nil +} + +// Run launches the live stats TUI. +func Run(tunnels []*router.Tunnel) error { + m := New(tunnels) + var p *tea.Program + if tui.InSession() { + // Already in alt-screen from the TUI menu — clear and run inline + fmt.Print("\033[H\033[2J") + p = tea.NewProgram(m) + } else { + p = tea.NewProgram(m, tea.WithAltScreen()) + } + _, err := p.Run() + return err +} diff --git a/internal/menu/adapter.go b/internal/menu/adapter.go index 47dd417..5f9038c 100644 --- a/internal/menu/adapter.go +++ b/internal/menu/adapter.go @@ -16,7 +16,8 @@ func isInfoViewAction(actionID string) bool { switch actionID { // Info views case actions.ActionRouterStatus, actions.ActionTunnelStatus, actions.ActionTunnelShare, - actions.ActionBackendStatus, actions.ActionBackendAvailable, actions.ActionBackendAdd: + actions.ActionBackendStatus, actions.ActionBackendAvailable, actions.ActionBackendAdd, + actions.ActionTunnelStats, actions.ActionTunnelMetrics: return true // Progress views case actions.ActionRouterSwitch, actions.ActionRouterStart, actions.ActionRouterStop, diff --git a/internal/menu/main.go b/internal/menu/main.go index ea4d767..4f46c05 100644 --- a/internal/menu/main.go +++ b/internal/menu/main.go @@ -401,6 +401,8 @@ func runTunnelManageMenu(tag string) error { // Build context-aware options options := []tui.MenuOption{ {Label: "Status", Value: "status"}, + {Label: "Stats", Value: "stats"}, + {Label: "Metrics", Value: "metrics"}, {Label: "Share", Value: "share"}, {Label: "Logs", Value: "logs"}, } @@ -460,7 +462,8 @@ func runTunnelAction(actionID, tunnelTag string) error { // Special handling for actions that need the tunnel tag switch actionID { case actions.ActionTunnelStatus, actions.ActionTunnelShare, actions.ActionTunnelLogs, - actions.ActionTunnelStart, actions.ActionTunnelStop, actions.ActionTunnelRestart, actions.ActionTunnelRemove: + actions.ActionTunnelStart, actions.ActionTunnelStop, actions.ActionTunnelRestart, + actions.ActionTunnelRemove, actions.ActionTunnelStats, actions.ActionTunnelMetrics: return runActionWithArgs(actionID, []string{tunnelTag}) default: return RunAction(actionID) diff --git a/internal/monitor/capture.go b/internal/monitor/capture.go new file mode 100644 index 0000000..85b8890 --- /dev/null +++ b/internal/monitor/capture.go @@ -0,0 +1,202 @@ +//go:build linux + +package monitor + +import ( + "encoding/base32" + "encoding/binary" + "fmt" + "net" + "strings" + "syscall" + "time" +) + +var base32Encoding = base32.StdEncoding.WithPadding(base32.NoPadding) + +const clientIDLen = 8 + +// OpenRawSocket creates an AF_PACKET raw socket for sniffing IP packets. +// Must be run as root (needs CAP_NET_RAW). +func OpenRawSocket() (int, error) { + fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_DGRAM, int(htons(syscall.ETH_P_IP))) + if err != nil { + return -1, fmt.Errorf("failed to create raw socket: %w (are you running as root?)", err) + } + + // 500ms read timeout for poll-style reads + tv := syscall.Timeval{Sec: 0, Usec: 500000} + _ = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv) + + return fd, nil +} + +// CaptureLoop reads packets from a raw socket and records them in the collector. +// Blocks until stopCh is closed. +func CaptureLoop(fd int, port int, coll *Collector, stopCh <-chan struct{}) { + buf := make([]byte, 65535) + + for { + select { + case <-stopCh: + return + default: + } + + n, _, err := syscall.Recvfrom(fd, buf, 0) + if err != nil { + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK || err == syscall.EINTR { + continue + } + return // socket closed or fatal error + } + if n < 20 { + continue + } + processPacket(buf[:n], port, coll) + } +} + +// Capture sniffs DNS traffic for the specified duration and returns results. +// Convenience function for one-shot capture (used by tunnel stats). +func Capture(port int, domains []string, duration time.Duration) (*CaptureResult, error) { + fd, err := OpenRawSocket() + if err != nil { + return nil, err + } + defer syscall.Close(fd) + + coll := NewCollector(domains) + start := time.Now() + stopCh := make(chan struct{}) + + go func() { + time.Sleep(duration) + close(stopCh) + }() + + CaptureLoop(fd, port, coll, stopCh) + + return coll.Result(time.Since(start)), nil +} + +func processPacket(data []byte, port int, coll *Collector) { + if len(data) < 20 || data[0]>>4 != 4 { + return + } + + ihl := int(data[0]&0x0f) * 4 + if ihl < 20 || len(data) < ihl { + return + } + if data[9] != 17 { + return + } + + srcIP := net.IPv4(data[12], data[13], data[14], data[15]).String() + dstIP := net.IPv4(data[16], data[17], data[18], data[19]).String() + + udpData := data[ihl:] + if len(udpData) < 8 { + return + } + + srcPort := binary.BigEndian.Uint16(udpData[0:2]) + dstPort := binary.BigEndian.Uint16(udpData[2:4]) + udpLen := int(binary.BigEndian.Uint16(udpData[4:6])) + dnsPayload := udpData[8:] + + if len(dnsPayload) < 12 { + return + } + + if int(dstPort) == port { + domain, clientID := extractDnsttQuery(dnsPayload, coll) + if domain != "" { + coll.RecordQuery(domain, clientID, srcIP, udpLen) + } + } else if int(srcPort) == port { + domain := extractQueryDomain(dnsPayload) + if domain != "" { + coll.RecordResponse(domain, dstIP, udpLen) + } + } +} + +func extractDnsttQuery(dns []byte, coll *Collector) (string, string) { + if len(dns) < 12 { + return "", "" + } + + labels := parseDNSLabels(dns[12:]) + if len(labels) < 2 { + return "", "" + } + + fullDomain := strings.ToLower(strings.Join(labels, ".")) + + for tunnelDomain := range coll.Domains { + suffix := "." + tunnelDomain + if strings.HasSuffix(fullDomain, suffix) { + tunnelLabels := strings.Count(tunnelDomain, ".") + 1 + if len(labels) <= tunnelLabels { + continue + } + prefixLabels := labels[:len(labels)-tunnelLabels] + encoded := strings.ToUpper(strings.Join(prefixLabels, "")) + + decoded := make([]byte, base32Encoding.DecodedLen(len(encoded))) + n, err := base32Encoding.Decode(decoded, []byte(encoded)) + if err != nil || n < clientIDLen { + return tunnelDomain, "" + } + + clientID := fmt.Sprintf("%x", decoded[:clientIDLen]) + return tunnelDomain, clientID + } + + if fullDomain == tunnelDomain { + return tunnelDomain, "" + } + } + + return "", "" +} + +func extractQueryDomain(dns []byte) string { + if len(dns) < 12 { + return "" + } + labels := parseDNSLabels(dns[12:]) + if len(labels) == 0 { + return "" + } + return strings.ToLower(strings.Join(labels, ".")) +} + +func parseDNSLabels(data []byte) []string { + var labels []string + offset := 0 + + for offset < len(data) { + labelLen := int(data[offset]) + if labelLen == 0 { + break + } + if labelLen&0xc0 == 0xc0 { + break + } + offset++ + if offset+labelLen > len(data) { + return nil + } + labels = append(labels, string(data[offset:offset+labelLen])) + offset += labelLen + } + + return labels +} + +func htons(v uint16) uint16 { + return (v << 8) | (v >> 8) +} diff --git a/internal/monitor/capture_other.go b/internal/monitor/capture_other.go new file mode 100644 index 0000000..0597569 --- /dev/null +++ b/internal/monitor/capture_other.go @@ -0,0 +1,23 @@ +//go:build !linux + +package monitor + +import ( + "fmt" + "runtime" + "time" +) + +// OpenRawSocket is not supported on non-Linux platforms. +func OpenRawSocket() (int, error) { + return -1, fmt.Errorf("raw socket capture requires Linux (current: %s)", runtime.GOOS) +} + +// CaptureLoop is not supported on non-Linux platforms. +func CaptureLoop(fd int, port int, coll *Collector, stopCh <-chan struct{}) { +} + +// Capture is not supported on non-Linux platforms. +func Capture(port int, domains []string, duration time.Duration) (*CaptureResult, error) { + return nil, fmt.Errorf("packet capture requires Linux (current: %s)", runtime.GOOS) +} diff --git a/internal/monitor/metrics.go b/internal/monitor/metrics.go new file mode 100644 index 0000000..6aa9bed --- /dev/null +++ b/internal/monitor/metrics.go @@ -0,0 +1,188 @@ +package monitor + +import ( + "fmt" + "math" + "net/http" + "sort" + "strings" + "time" +) + +// MetricsHandler returns an HTTP handler that serves Prometheus metrics +// from the given Collector. Zero overhead between scrapes — all computation +// happens on-demand when /metrics is hit. +func MetricsHandler(coll *Collector, startTime time.Time) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + duration := time.Since(startTime) + result := coll.Result(duration) + + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + + var b strings.Builder + + // -- Gauges -- + + writeHelp(&b, "dnstm_active_clients", "gauge", "Number of currently connected dnstt clients") + for domain, tr := range result.Tunnels { + writeGauge(&b, "dnstm_active_clients", float64(tr.ActiveClients), "domain", domain) + } + + writeHelp(&b, "dnstm_peak_clients", "gauge", "Peak concurrent dnstt clients observed") + for domain, tr := range result.Tunnels { + writeGauge(&b, "dnstm_peak_clients", float64(tr.PeakClients), "domain", domain) + } + + writeHelp(&b, "dnstm_uptime_seconds", "gauge", "Sniffer uptime in seconds") + writeGauge(&b, "dnstm_uptime_seconds", duration.Seconds()) + + // -- Counters -- + + writeHelp(&b, "dnstm_queries_total", "counter", "Total DNS queries observed") + for domain, tr := range result.Tunnels { + writeCounter(&b, "dnstm_queries_total", float64(tr.TotalQueries), "domain", domain) + } + + writeHelp(&b, "dnstm_bytes_in_total", "counter", "Total bytes received (query payloads)") + for domain, tr := range result.Tunnels { + writeCounter(&b, "dnstm_bytes_in_total", float64(tr.TotalBytesIn), "domain", domain) + } + + writeHelp(&b, "dnstm_bytes_out_total", "counter", "Total bytes sent (response payloads)") + for domain, tr := range result.Tunnels { + writeCounter(&b, "dnstm_bytes_out_total", float64(tr.TotalBytesOut), "domain", domain) + } + + writeHelp(&b, "dnstm_sessions_total", "counter", "Total unique client sessions observed") + for domain, tr := range result.Tunnels { + writeCounter(&b, "dnstm_sessions_total", float64(tr.TotalClients), "domain", domain) + } + + // -- Histograms -- + + // Session duration histogram (only for inactive/completed sessions) + // Active sessions are excluded since their duration is still growing + writeHelp(&b, "dnstm_session_duration_seconds", "histogram", "Duration of completed client sessions in seconds") + for domain, tr := range result.Tunnels { + var durations []float64 + now := time.Now() + for _, c := range tr.Clients { + var d time.Duration + if c.Active { + d = now.Sub(c.FirstSeen) + } else { + d = c.LastSeen.Sub(c.FirstSeen) + } + durations = append(durations, d.Seconds()) + } + writeHistogram(&b, "dnstm_session_duration_seconds", + durationBuckets, durations, "domain", domain) + } + + // Per-session traffic histogram + writeHelp(&b, "dnstm_session_bytes", "histogram", "Total bytes per client session") + for domain, tr := range result.Tunnels { + var bytesVals []float64 + for _, c := range tr.Clients { + bytesVals = append(bytesVals, float64(c.BytesTotal)) + } + writeHistogram(&b, "dnstm_session_bytes", + bytesBuckets, bytesVals, "domain", domain) + } + + w.Write([]byte(b.String())) + }) +} + +// Bucket boundaries for histograms. +// Duration: 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 6h, 12h, 24h +var durationBuckets = []float64{ + 10, 30, 60, 300, 900, 1800, 3600, 7200, 21600, 43200, 86400, +} + +// Bytes: 1KB, 10KB, 100KB, 1MB, 10MB, 100MB, 500MB, 1GB +var bytesBuckets = []float64{ + 1024, 10240, 102400, 1048576, 10485760, 104857600, 524288000, 1073741824, +} + +func writeHelp(b *strings.Builder, name, typ, help string) { + fmt.Fprintf(b, "# HELP %s %s\n", name, help) + fmt.Fprintf(b, "# TYPE %s %s\n", name, typ) +} + +func writeGauge(b *strings.Builder, name string, value float64, labels ...string) { + fmt.Fprintf(b, "%s%s %g\n", name, formatLabels(labels), value) +} + +func writeCounter(b *strings.Builder, name string, value float64, labels ...string) { + fmt.Fprintf(b, "%s%s %g\n", name, formatLabels(labels), value) +} + +func writeHistogram(b *strings.Builder, name string, buckets []float64, values []float64, labels ...string) { + sort.Float64s(values) + labelStr := formatLabels(labels) + + var sum float64 + for _, v := range values { + sum += v + } + + cumCount := 0 + vi := 0 + for _, bound := range buckets { + for vi < len(values) && values[vi] <= bound { + cumCount++ + vi++ + } + le := fmt.Sprintf("%g", bound) + if labels != nil { + // Merge le into existing labels + allLabels := append(labels, "le", le) + fmt.Fprintf(b, "%s_bucket%s %d\n", name, formatLabels(allLabels), cumCount) + } else { + fmt.Fprintf(b, "%s_bucket{le=\"%s\"} %d\n", name, le, cumCount) + } + } + // +Inf bucket + if labels != nil { + allLabels := append(labels, "le", "+Inf") + fmt.Fprintf(b, "%s_bucket%s %d\n", name, formatLabels(allLabels), len(values)) + } else { + fmt.Fprintf(b, "%s_bucket{le=\"+Inf\"} %d\n", name, len(values)) + } + + fmt.Fprintf(b, "%s_sum%s %g\n", name, labelStr, sum) + fmt.Fprintf(b, "%s_count%s %d\n", name, labelStr, len(values)) +} + +func formatLabels(pairs []string) string { + if len(pairs) == 0 { + return "" + } + var parts []string + for i := 0; i+1 < len(pairs); i += 2 { + parts = append(parts, fmt.Sprintf("%s=\"%s\"", pairs[i], escapeLabelValue(pairs[i+1]))) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func escapeLabelValue(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + s = strings.ReplaceAll(s, "\n", "\\n") + return s +} + +// FormatMetricValue formats a float64 for Prometheus output, handling special values. +func FormatMetricValue(v float64) string { + if math.IsNaN(v) { + return "NaN" + } + if math.IsInf(v, 1) { + return "+Inf" + } + if math.IsInf(v, -1) { + return "-Inf" + } + return fmt.Sprintf("%g", v) +} diff --git a/internal/monitor/reader.go b/internal/monitor/reader.go new file mode 100644 index 0000000..563aa76 --- /dev/null +++ b/internal/monitor/reader.go @@ -0,0 +1,33 @@ +package monitor + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// StatsFilePath returns the path to the stats JSON file for a tunnel tag. +func StatsFilePath(tag string) string { + return filepath.Join(RunDir, tag+"-stats.json") +} + +// ReadStats reads the accumulated stats from the sniffer's JSON file. +// Returns nil if the file doesn't exist (sniffer not running). +func ReadStats(tag string) (*CaptureResult, error) { + path := StatsFilePath(tag) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read stats: %w", err) + } + + var result CaptureResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse stats: %w", err) + } + + return &result, nil +} diff --git a/internal/monitor/service.go b/internal/monitor/service.go new file mode 100644 index 0000000..c439e67 --- /dev/null +++ b/internal/monitor/service.go @@ -0,0 +1,197 @@ +package monitor + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// RunDir is where stats JSON and PID files are stored. +// Lives under the config directory so it works cross-platform. +const RunDir = "/etc/dnstm/run" + +// SnifferName returns a label for a tunnel's sniffer process. +func SnifferName(tag string) string { + return tag + "-monitor" +} + +// pidFilePath returns the PID file path for a tunnel's sniffer. +func pidFilePath(tag string) string { + return filepath.Join(RunDir, tag+"-monitor.pid") +} + +// binaryPath returns the path to the currently running dnstm binary. +// Falls back to looking in PATH. +func binaryPath() (string, error) { + exe, err := os.Executable() + if err == nil { + // Resolve symlinks + exe, err = filepath.EvalSymlinks(exe) + if err == nil { + return exe, nil + } + } + // Fallback: find in PATH + return exec.LookPath("dnstm") +} + +// metricsConfPath returns the path to the metrics config file for a tunnel. +func metricsConfPath(tag string) string { + return filepath.Join(RunDir, tag+"-metrics.conf") +} + +// WriteMetricsConf persists the metrics address so the TUI can read it. +func WriteMetricsConf(tag, addr string) error { + if addr == "" { + os.Remove(metricsConfPath(tag)) + return nil + } + return os.WriteFile(metricsConfPath(tag), []byte(addr), 0644) +} + +// ReadMetricsConf returns the persisted metrics address for a tunnel (empty if none). +func ReadMetricsConf(tag string) string { + data, err := os.ReadFile(metricsConfPath(tag)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// StartSniffer launches "dnstm sniff" as a detached background process. +// It writes a PID file so we can check status and stop it later. +// If metricsAddr is non-empty, the sniffer will serve Prometheus metrics on that address. +func StartSniffer(tag string, domains []string, metricsAddr string) error { + if IsSnifferRunning(tag) { + return nil // already running + } + + bin, err := binaryPath() + if err != nil { + return fmt.Errorf("cannot find dnstm binary: %w", err) + } + + if err := os.MkdirAll(RunDir, 0755); err != nil { + return fmt.Errorf("failed to create run directory: %w", err) + } + + args := []string{"sniff", "--tag", tag} + if metricsAddr != "" { + args = append(args, "--metrics-address", metricsAddr) + } + args = append(args, domains...) + + // Persist metrics config so other commands can discover it + _ = WriteMetricsConf(tag, metricsAddr) + + cmd := exec.Command(bin, args...) + // Detach: new process group, no stdin/stdout/stderr + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + + // Direct logs to a file so we can debug if needed + logFile := filepath.Join(RunDir, tag+"-monitor.log") + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err == nil { + cmd.Stdout = f + cmd.Stderr = f + } + + if err := cmd.Start(); err != nil { + if f != nil { + f.Close() + } + return fmt.Errorf("failed to start sniffer: %w", err) + } + + // Write PID file + pid := cmd.Process.Pid + if err := os.WriteFile(pidFilePath(tag), []byte(strconv.Itoa(pid)), 0644); err != nil { + // Kill the process we just started since we can't track it + _ = cmd.Process.Kill() + if f != nil { + f.Close() + } + return fmt.Errorf("failed to write PID file: %w", err) + } + + // Release the process so it survives our exit + _ = cmd.Process.Release() + // Don't close the log file — the child process is using it + return nil +} + +// StopSniffer stops a running sniffer process by sending SIGTERM. +func StopSniffer(tag string) error { + pid, err := readPid(tag) + if err != nil { + return nil // no PID file = not running + } + + proc, err := os.FindProcess(pid) + if err != nil { + cleanupPid(tag) + return nil + } + + // Send SIGTERM for graceful shutdown (final stats write) + if err := proc.Signal(syscall.SIGTERM); err != nil { + cleanupPid(tag) + return nil // process already gone + } + + cleanupPid(tag) + return nil +} + +// IsSnifferRunning checks if the sniffer process is alive. +func IsSnifferRunning(tag string) bool { + pid, err := readPid(tag) + if err != nil { + return false + } + + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Signal 0 checks if process exists without actually sending a signal + err = proc.Signal(syscall.Signal(0)) + if err != nil { + cleanupPid(tag) + return false + } + return true +} + +// RemoveSniffer stops the sniffer and cleans up all its files. +func RemoveSniffer(tag string) error { + _ = StopSniffer(tag) + // Clean up files + os.Remove(pidFilePath(tag)) + os.Remove(StatsFilePath(tag)) + os.Remove(metricsConfPath(tag)) + os.Remove(filepath.Join(RunDir, tag+"-monitor.log")) + return nil +} + +func readPid(tag string) (int, error) { + data, err := os.ReadFile(pidFilePath(tag)) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(data))) +} + +func cleanupPid(tag string) { + os.Remove(pidFilePath(tag)) +} diff --git a/internal/monitor/store.go b/internal/monitor/store.go new file mode 100644 index 0000000..548193d --- /dev/null +++ b/internal/monitor/store.go @@ -0,0 +1,371 @@ +package monitor + +import ( + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// ClientTimeout is how long since last query before a client is considered disconnected. +// dnstt sends keepalive polls every few seconds even when idle, so 30s is safe. +const ClientTimeout = 30 * time.Second + +// CaptureResult holds the results of a packet capture session. +type CaptureResult struct { + Duration time.Duration `json:"duration"` + Tunnels map[string]*TunnelResult `json:"tunnels"` + TotalQueries uint64 `json:"total_queries"` + TotalBytesIn uint64 `json:"total_bytes_in"` + TotalBytesOut uint64 `json:"total_bytes_out"` + ActiveClients int `json:"active_clients"` + TotalClients int `json:"total_clients"` + PeakClients int `json:"peak_clients"` + History []DataPoint `json:"history,omitempty"` +} + +// DataPoint is a single time series sample of connected users. +type DataPoint struct { + Time time.Time `json:"t"` + ActiveClients int `json:"n"` +} + +// MaxHistory is the maximum number of data points kept. +// At 2s intervals, 900 points = 30 minutes of history. +const MaxHistory = 900 + +// TunnelResult holds capture results for a single tunnel domain. +type TunnelResult struct { + Domain string `json:"domain"` + TotalQueries uint64 `json:"total_queries"` + TotalBytesIn uint64 `json:"total_bytes_in"` + TotalBytesOut uint64 `json:"total_bytes_out"` + QueriesPerSec float64 `json:"queries_per_sec"` + ActiveClients int `json:"active_clients"` + TotalClients int `json:"total_clients"` + PeakClients int `json:"peak_clients"` + Clients []*ClientResult `json:"clients"` +} + +// ClientResult holds per-client stats. +// A "client" is identified by its dnstt ClientID (session), not by resolver IP. +type ClientResult struct { + ClientID string `json:"client_id"` + Queries uint64 `json:"queries"` + BytesIn uint64 `json:"bytes_in"` + BytesOut uint64 `json:"bytes_out"` + BytesTotal uint64 `json:"bytes_total"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + Active bool `json:"active"` +} + +// ClientSummary holds aggregate stats across all clients for a tunnel. +type ClientSummary struct { + Count int + MinBytesTotal uint64 + MaxBytesTotal uint64 + MedianBytes uint64 + MinQueries uint64 + MaxQueries uint64 + MedianQueries uint64 + MinDuration time.Duration + MaxDuration time.Duration + MedianDuration time.Duration +} + +// Collector accumulates packets during a capture session. +// Safe for concurrent use from capture goroutines. +type Collector struct { + mu sync.Mutex + Domains map[string]bool // registered tunnel domains (lowercase) + tunnels map[string]*tunnelCollector + totalQ atomic.Uint64 + totalIn atomic.Uint64 + totalOut atomic.Uint64 +} + +type tunnelCollector struct { + mu sync.Mutex + domain string + clients map[string]*clientCollector + peakClients int // highest concurrent active clients observed + queries atomic.Uint64 + bytesIn atomic.Uint64 + bytesOut atomic.Uint64 +} + +type clientCollector struct { + mu sync.Mutex + clientID string + queries uint64 + bytesIn uint64 + bytesOut uint64 + firstSeen time.Time + lastSeen time.Time +} + +// NewCollector creates a new packet collector for the given tunnel domains. +func NewCollector(domains []string) *Collector { + c := &Collector{ + Domains: make(map[string]bool), + tunnels: make(map[string]*tunnelCollector), + } + for _, d := range domains { + d = strings.ToLower(d) + c.Domains[d] = true + c.tunnels[d] = &tunnelCollector{ + domain: d, + clients: make(map[string]*clientCollector), + } + } + return c +} + +// Restore seeds the collector with previously saved stats so history survives restarts. +// Should be called before starting the capture loop. +func (c *Collector) Restore(prev *CaptureResult) { + if prev == nil { + return + } + + c.totalQ.Store(prev.TotalQueries) + c.totalIn.Store(prev.TotalBytesIn) + c.totalOut.Store(prev.TotalBytesOut) + + for domain, tr := range prev.Tunnels { + tc := c.findTunnel(domain) + if tc == nil { + continue + } + + tc.queries.Store(tr.TotalQueries) + tc.bytesIn.Store(tr.TotalBytesIn) + tc.bytesOut.Store(tr.TotalBytesOut) + tc.peakClients = tr.PeakClients + + tc.mu.Lock() + for _, cr := range tr.Clients { + tc.clients[cr.ClientID] = &clientCollector{ + clientID: cr.ClientID, + queries: cr.Queries, + bytesIn: cr.BytesIn, + bytesOut: cr.BytesOut, + firstSeen: cr.FirstSeen, + lastSeen: cr.LastSeen, + } + } + tc.mu.Unlock() + } +} + +// RecordQuery records an incoming DNS query with extracted dnstt ClientID. +func (c *Collector) RecordQuery(domain string, clientID string, resolverIP string, size int) { + c.totalQ.Add(1) + c.totalIn.Add(uint64(size)) + + tc := c.findTunnel(domain) + if tc == nil { + return + } + + tc.queries.Add(1) + tc.bytesIn.Add(uint64(size)) + + if clientID == "" { + return + } + + now := time.Now() + tc.mu.Lock() + cc, exists := tc.clients[clientID] + if !exists { + cc = &clientCollector{clientID: clientID, firstSeen: now} + tc.clients[clientID] = cc + } + tc.mu.Unlock() + + cc.mu.Lock() + cc.lastSeen = now + cc.queries++ + cc.bytesIn += uint64(size) + cc.mu.Unlock() +} + +// RecordResponse records an outgoing DNS response. +func (c *Collector) RecordResponse(domain string, dstIP string, size int) { + c.totalOut.Add(uint64(size)) + + tc := c.findTunnel(domain) + if tc == nil { + return + } + tc.bytesOut.Add(uint64(size)) +} + +func (c *Collector) findTunnel(queryDomain string) *tunnelCollector { + queryDomain = strings.ToLower(queryDomain) + + if tc, ok := c.tunnels[queryDomain]; ok { + return tc + } + + for d, tc := range c.tunnels { + if strings.HasSuffix(queryDomain, "."+d) { + return tc + } + } + + return nil +} + +// Result builds the CaptureResult snapshot from collected data. +// Can be called repeatedly — it reads atomics and takes locks momentarily. +func (c *Collector) Result(duration time.Duration) *CaptureResult { + secs := duration.Seconds() + if secs == 0 { + secs = 1 + } + + now := time.Now() + + result := &CaptureResult{ + Duration: duration, + Tunnels: make(map[string]*TunnelResult), + TotalQueries: c.totalQ.Load(), + TotalBytesIn: c.totalIn.Load(), + TotalBytesOut: c.totalOut.Load(), + } + + totalActive := 0 + totalSeen := 0 + totalPeak := 0 + + for domain, tc := range c.tunnels { + tr := &TunnelResult{ + Domain: domain, + TotalQueries: tc.queries.Load(), + TotalBytesIn: tc.bytesIn.Load(), + TotalBytesOut: tc.bytesOut.Load(), + QueriesPerSec: float64(tc.queries.Load()) / secs, + } + + tc.mu.Lock() + for _, cc := range tc.clients { + cc.mu.Lock() + active := now.Sub(cc.lastSeen) < ClientTimeout + cr := &ClientResult{ + ClientID: cc.clientID, + Queries: cc.queries, + BytesIn: cc.bytesIn, + BytesOut: cc.bytesOut, + BytesTotal: cc.bytesIn + cc.bytesOut, + FirstSeen: cc.firstSeen, + LastSeen: cc.lastSeen, + Active: active, + } + cc.mu.Unlock() + tr.Clients = append(tr.Clients, cr) + + if active { + tr.ActiveClients++ + } + } + tc.mu.Unlock() + + tr.TotalClients = len(tr.Clients) + + // Update peak concurrent clients for this tunnel + tc.mu.Lock() + if tr.ActiveClients > tc.peakClients { + tc.peakClients = tr.ActiveClients + } + tr.PeakClients = tc.peakClients + tc.mu.Unlock() + + // Sort: active clients first (by traffic desc), then inactive (by traffic desc) + sort.Slice(tr.Clients, func(i, j int) bool { + if tr.Clients[i].Active != tr.Clients[j].Active { + return tr.Clients[i].Active // active first + } + return tr.Clients[i].BytesTotal > tr.Clients[j].BytesTotal + }) + + totalActive += tr.ActiveClients + totalSeen += tr.TotalClients + if tr.PeakClients > totalPeak { + totalPeak = tr.PeakClients + } + result.Tunnels[domain] = tr + } + + result.ActiveClients = totalActive + result.TotalClients = totalSeen + result.PeakClients = totalPeak + return result +} + +// Summary computes min/max/median stats across all clients for a tunnel. +func (tr *TunnelResult) Summary() *ClientSummary { + n := len(tr.Clients) + if n == 0 { + return &ClientSummary{} + } + + now := time.Now() + bytesVals := make([]uint64, n) + queryVals := make([]uint64, n) + durVals := make([]time.Duration, n) + for i, c := range tr.Clients { + bytesVals[i] = c.BytesTotal + queryVals[i] = c.Queries + // For active clients, session is still ongoing + if c.Active { + durVals[i] = now.Sub(c.FirstSeen) + } else { + durVals[i] = c.LastSeen.Sub(c.FirstSeen) + } + } + + sort.Slice(bytesVals, func(i, j int) bool { return bytesVals[i] < bytesVals[j] }) + sort.Slice(queryVals, func(i, j int) bool { return queryVals[i] < queryVals[j] }) + sort.Slice(durVals, func(i, j int) bool { return durVals[i] < durVals[j] }) + + return &ClientSummary{ + Count: n, + MinBytesTotal: bytesVals[0], + MaxBytesTotal: bytesVals[n-1], + MedianBytes: bytesVals[n/2], + MinQueries: queryVals[0], + MaxQueries: queryVals[n-1], + MedianQueries: queryVals[n/2], + MinDuration: durVals[0], + MaxDuration: durVals[n-1], + MedianDuration: durVals[n/2], + } +} + +// FormatBytes formats a byte count into a human-readable string. +func FormatBytes(bytes uint64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + TB = 1024 * GB + ) + + switch { + case bytes >= TB: + return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB)) + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/internal/service/systemd.go b/internal/service/systemd.go index bc0226c..a3b2908 100644 --- a/internal/service/systemd.go +++ b/internal/service/systemd.go @@ -9,7 +9,7 @@ import ( // ServiceConfig contains configuration for a systemd service. type ServiceConfig struct { - Name string // Service name (e.g., "dnstt-server", "slipstream-server") + Name string // Service name (e.g., "dnstt-server", "slipstream-server") Description string User string Group string @@ -17,6 +17,10 @@ type ServiceConfig struct { ReadOnlyPaths []string // Paths that should be read-only ReadWritePaths []string // Paths that should be read-write BindToPrivileged bool // Whether service needs CAP_NET_BIND_SERVICE + Capabilities []string // Additional Linux capabilities (e.g., "CAP_NET_RAW") + RuntimeDirectory string // systemd RuntimeDirectory= value (creates /run/ owned by User) + BindsTo string // systemd BindsTo= (stop when target stops) + PartOf string // systemd PartOf= (restart/stop with target) } // RealSystemdManager implements SystemdManager using actual systemd commands. @@ -124,15 +128,36 @@ func CreateGenericService(cfg *ServiceConfig) error { } // Build capabilities section - var capsSection string + caps := append([]string{}, cfg.Capabilities...) if cfg.BindToPrivileged { - capsSection = "AmbientCapabilities=CAP_NET_BIND_SERVICE\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE\n" + caps = append(caps, "CAP_NET_BIND_SERVICE") + } + var capsSection string + if len(caps) > 0 { + capStr := strings.Join(caps, " ") + capsSection = fmt.Sprintf("AmbientCapabilities=%s\nCapabilityBoundingSet=%s\n", capStr, capStr) + } + + // Build runtime directory section + var runtimeSection string + if cfg.RuntimeDirectory != "" { + runtimeSection = fmt.Sprintf("RuntimeDirectory=%s\nRuntimeDirectoryMode=0755\n", cfg.RuntimeDirectory) + } + + // Build unit dependency section + var unitDeps string + if cfg.BindsTo != "" { + unitDeps += fmt.Sprintf("BindsTo=%s\nAfter=%s\n", cfg.BindsTo, cfg.BindsTo) + } + if cfg.PartOf != "" { + unitDeps += fmt.Sprintf("PartOf=%s\n", cfg.PartOf) } serviceContent := fmt.Sprintf(`[Unit] Description=%s After=network-online.target Wants=network-online.target +%s [Service] Type=simple @@ -149,7 +174,7 @@ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes -%s%sProtectKernelTunables=yes +%s%s%sProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictRealtime=yes @@ -159,7 +184,7 @@ LockPersonality=yes [Install] WantedBy=multi-user.target -`, cfg.Description, cfg.User, cfg.Group, cfg.ExecStart, pathsSection, capsSection) +`, cfg.Description, unitDeps, cfg.User, cfg.Group, cfg.ExecStart, pathsSection, capsSection, runtimeSection) if err := os.WriteFile(servicePath, []byte(serviceContent), 0644); err != nil { return fmt.Errorf("failed to write service file: %w", err)