diff --git a/internal/app/app.go b/internal/app/app.go index d8e792b..b07f246 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,16 +15,18 @@ import ( "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/syslog" "github.com/nemunaire/repeater/internal/wifi" ) // App represents the application type App struct { - Status models.SystemStatus - StatusMutex sync.RWMutex - StartTime time.Time - Assets embed.FS - Config *config.Config + Status models.SystemStatus + StatusMutex sync.RWMutex + StartTime time.Time + Assets embed.FS + Config *config.Config + SyslogTailer *syslog.SyslogTailer } // New creates a new application instance @@ -60,6 +62,19 @@ func (a *App) Initialize(cfg *config.Config) error { // Don't fail - polling fallback still works } + // Start syslog tailing if enabled + if cfg.SyslogEnabled { + a.SyslogTailer = syslog.NewSyslogTailer( + cfg.SyslogPath, + cfg.SyslogFilter, + cfg.SyslogSource, + ) + if err := a.SyslogTailer.Start(); err != nil { + log.Printf("Warning: Failed to start syslog tailing: %v", err) + // Don't fail - app continues without syslog + } + } + // Start periodic tasks go a.periodicStatusUpdate() go a.periodicDeviceUpdate() @@ -78,6 +93,11 @@ func (a *App) Run(addr string) error { // Shutdown gracefully shuts down the application func (a *App) Shutdown() { + // Stop syslog tailing if running + if a.SyslogTailer != nil { + a.SyslogTailer.Stop() + } + wifi.StopEventMonitoring() wifi.Close() logging.AddLog("Système", "Application arrêtée") diff --git a/internal/config/cli.go b/internal/config/cli.go index f61db13..9907f0c 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -11,6 +11,10 @@ func declareFlags(o *Config) { flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") + flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") + flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file") + flag.StringVar(&o.SyslogFilter, "syslog-filter", "daemon.info iwd:", "Filter string for syslog lines") + flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs") } // parseCLI parse the flags and treats extra args as configuration filename. diff --git a/internal/config/config.go b/internal/config/config.go index 329354b..d4a60fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,10 @@ type Config struct { UseARPDiscovery bool DHCPLeasesPath string ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter string + SyslogSource string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -28,6 +32,10 @@ func ConsolidateConfig() (opts *Config, err error) { UseARPDiscovery: true, DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", ARPTablePath: "/proc/net/arp", + SyslogEnabled: false, + SyslogPath: "/var/log/messages", + SyslogFilter: "daemon.info iwd:", + SyslogSource: "iwd", } declareFlags(opts) diff --git a/internal/syslog/parser.go b/internal/syslog/parser.go new file mode 100644 index 0000000..10c07b6 --- /dev/null +++ b/internal/syslog/parser.go @@ -0,0 +1,32 @@ +package syslog + +import ( + "strings" +) + +// ParseSyslogLine extracts the message content from a syslog line. +// It looks for the daemon prefix in the line and returns the message after it. +// +// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk" +// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true +// +// Returns the message and a boolean indicating if the line was successfully parsed. +func ParseSyslogLine(line, daemonPrefix string) (string, bool) { + // Find the daemon prefix in the line (e.g., "iwd:") + idx := strings.Index(line, daemonPrefix) + if idx == -1 { + return "", false + } + + // Extract everything after the daemon prefix + message := line[idx+len(daemonPrefix):] + + // Trim leading/trailing whitespace + message = strings.TrimSpace(message) + + if message == "" { + return "", false + } + + return message, true +} diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go new file mode 100644 index 0000000..35aa352 --- /dev/null +++ b/internal/syslog/syslog.go @@ -0,0 +1,218 @@ +package syslog + +import ( + "bufio" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/logging" +) + +// SyslogTailer tails a syslog file and filters messages to the logging system. +type SyslogTailer struct { + path string + filter string + source string + + file *os.File + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + running bool +} + +// NewSyslogTailer creates a new syslog tailer. +// path: Path to the syslog file (e.g., "/var/log/messages") +// filter: Filter string to match in lines (e.g., "daemon.info iwd:") +// source: Source name for logging (e.g., "iwd") +func NewSyslogTailer(path, filter, source string) *SyslogTailer { + return &SyslogTailer{ + path: path, + filter: filter, + source: source, + done: make(chan struct{}), + } +} + +// Start opens the syslog file and begins tailing it. +func (t *SyslogTailer) Start() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.running { + return nil + } + + // Try to open the file + file, err := os.Open(t.path) + if err != nil { + // File might not exist yet, we'll retry in the goroutine + log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err) + } else { + // Seek to the end to only read new entries + _, err = file.Seek(0, io.SeekEnd) + if err != nil { + file.Close() + return err + } + t.file = file + } + + t.running = true + t.wg.Add(1) + go t.tail() + + return nil +} + +// Stop signals the tailer to stop and waits for it to finish. +func (t *SyslogTailer) Stop() { + t.mu.Lock() + if !t.running { + t.mu.Unlock() + return + } + t.mu.Unlock() + + close(t.done) + t.wg.Wait() + + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.running = false + t.mu.Unlock() +} + +// tail is the main loop that reads from the syslog file. +func (t *SyslogTailer) tail() { + defer t.wg.Done() + + retryDelay := 1 * time.Second + maxRetryDelay := 30 * time.Second + + for { + select { + case <-t.done: + return + default: + } + + // Check if we have a file open + t.mu.Lock() + file := t.file + t.mu.Unlock() + + if file == nil { + // Try to open the file + newFile, err := os.Open(t.path) + if err != nil { + // File doesn't exist or can't be opened, wait and retry + select { + case <-t.done: + return + case <-time.After(retryDelay): + // Exponential backoff + retryDelay *= 2 + if retryDelay > maxRetryDelay { + retryDelay = maxRetryDelay + } + continue + } + } + + // Seek to the end + _, err = newFile.Seek(0, io.SeekEnd) + if err != nil { + log.Printf("Error seeking syslog file: %v", err) + newFile.Close() + time.Sleep(retryDelay) + continue + } + + t.mu.Lock() + t.file = newFile + file = newFile + t.mu.Unlock() + + retryDelay = 1 * time.Second + log.Printf("Syslog tailer: opened %s", t.path) + } + + // Read lines from the file + if err := t.readLines(file); err != nil { + if err == io.EOF { + // End of file, wait a bit and try again + time.Sleep(100 * time.Millisecond) + continue + } + + // Other error, close the file and retry + log.Printf("Error reading syslog file: %v", err) + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.mu.Unlock() + + time.Sleep(retryDelay) + } + } +} + +// readLines reads and processes lines from the file. +func (t *SyslogTailer) readLines(file *os.File) error { + scanner := bufio.NewScanner(file) + + // Increase buffer size to handle long log lines + const maxCapacity = 512 * 1024 + buf := make([]byte, maxCapacity) + scanner.Buffer(buf, maxCapacity) + + for scanner.Scan() { + select { + case <-t.done: + return nil + default: + } + + line := scanner.Text() + + // Check if the line contains the filter string + if !strings.Contains(line, t.filter) { + continue + } + + // Parse the syslog line to extract the message + // We look for "iwd:" (or whatever comes after the filter) + // The filter is "daemon.info iwd:" so we want to extract text after "iwd:" + daemonPrefix := extractDaemonPrefix(t.filter) + message, ok := ParseSyslogLine(line, daemonPrefix) + if !ok { + // Couldn't parse the line, skip it + continue + } + + // Add to logging system + logging.AddLog(t.source, message) + } + + return scanner.Err() +} + +// extractDaemonPrefix extracts the daemon prefix from the filter string. +// For example, "daemon.info iwd:" returns "iwd:" +func extractDaemonPrefix(filter string) string { + parts := strings.Fields(filter) + if len(parts) > 0 { + return parts[len(parts)-1] + } + return filter +}