mirror of
https://github.com/skinnyrad/Lora-Scanner.git
synced 2026-11-04 04:42:28 +01:00
240 lines
5.7 KiB
Go
240 lines
5.7 KiB
Go
// Package gateway stores gateway IPs and scrapes Dragino traffic logs.
|
|
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrDuplicate = errors.New("gateway already added")
|
|
ErrNotFound = errors.New("gateway not found")
|
|
)
|
|
|
|
const (
|
|
DefaultPollInterval = 30 * time.Second
|
|
MinPollInterval = 5 * time.Second
|
|
MaxPollInterval = time.Hour
|
|
)
|
|
|
|
var ErrBadInterval = fmt.Errorf("poll interval must be between %d and %d seconds",
|
|
int(MinPollInterval/time.Second), int(MaxPollInterval/time.Second))
|
|
|
|
// Config is a mutex-guarded list of gateway IPv4 addresses plus the poll
|
|
// interval, persisted as {"gateways": ["192.168.8.101", ...], "pollIntervalSeconds": 30}.
|
|
type Config struct {
|
|
mu sync.RWMutex
|
|
path string
|
|
ips []string
|
|
interval time.Duration
|
|
}
|
|
|
|
func NewConfig(path string) *Config {
|
|
return &Config{path: path, interval: DefaultPollInterval}
|
|
}
|
|
|
|
// PollInterval is how often gateways are scraped.
|
|
func (c *Config) PollInterval() time.Duration {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.interval
|
|
}
|
|
|
|
func (c *Config) SetPollInterval(d time.Duration) error {
|
|
if d < MinPollInterval || d > MaxPollInterval {
|
|
return ErrBadInterval
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if err := c.save(c.ips, d); err != nil {
|
|
return err
|
|
}
|
|
c.interval = d
|
|
return nil
|
|
}
|
|
|
|
// DefaultPath is <user config dir>/lora-scanner/gateways.json (e.g.
|
|
// ~/.config on Linux, ~/Library/Application Support on macOS, %AppData% on
|
|
// Windows), so the binary can live anywhere, such as a read-only bin directory.
|
|
// It falls back to the working directory if there is no user config dir.
|
|
func DefaultPath() string {
|
|
if dir, err := os.UserConfigDir(); err == nil {
|
|
return filepath.Join(dir, "lora-scanner", "gateways.json")
|
|
}
|
|
return "gateways.json"
|
|
}
|
|
|
|
// Load reads the file. If it doesn't exist, the first existing legacyPaths
|
|
// entry (the Flask app's flat {"gateway1": ..., "gateway10": ...} file) is
|
|
// imported read-only: the old file is never modified. A missing file is not an error.
|
|
func (c *Config) Load(legacyPaths ...string) error {
|
|
b, err := os.ReadFile(c.path)
|
|
if err == nil {
|
|
return c.parse(b, c.path)
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
for _, p := range legacyPaths {
|
|
b, err := os.ReadFile(p)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.parse(b, p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parse accepts {"gateways": [...]} or the legacy flat gateway1..N object.
|
|
func (c *Config) parse(b []byte, name string) error {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
if list, ok := raw["gateways"]; ok {
|
|
var ips []string
|
|
if err := json.Unmarshal(list, &ips); err != nil {
|
|
return fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
if v, ok := raw["pollIntervalSeconds"]; ok {
|
|
var secs int
|
|
if err := json.Unmarshal(v, &secs); err != nil {
|
|
return fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
if d := time.Duration(secs) * time.Second; d >= MinPollInterval && d <= MaxPollInterval {
|
|
c.mu.Lock()
|
|
c.interval = d
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
return c.setAll(ips)
|
|
}
|
|
type kv struct {
|
|
n int
|
|
ip string
|
|
}
|
|
var found []kv
|
|
for k, v := range raw {
|
|
var ip string
|
|
n, err := strconv.Atoi(strings.TrimPrefix(k, "gateway"))
|
|
if err == nil && strings.HasPrefix(k, "gateway") && json.Unmarshal(v, &ip) == nil && strings.TrimSpace(ip) != "" {
|
|
found = append(found, kv{n, ip})
|
|
}
|
|
}
|
|
sort.Slice(found, func(i, j int) bool { return found[i].n < found[j].n })
|
|
ips := make([]string, 0, len(found))
|
|
for _, f := range found {
|
|
ips = append(ips, f.ip)
|
|
}
|
|
return c.setAll(ips)
|
|
}
|
|
|
|
// setAll validates and replaces the list in memory (no save).
|
|
func (c *Config) setAll(in []string) error {
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(in))
|
|
for _, raw := range in {
|
|
ip, err := normalize(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !seen[ip] {
|
|
seen[ip] = true
|
|
out = append(out, ip)
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
c.ips = out
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func normalize(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
ip := net.ParseIP(raw)
|
|
if ip == nil || ip.To4() == nil {
|
|
return "", fmt.Errorf("%q is not a valid IPv4 address", raw)
|
|
}
|
|
return ip.To4().String(), nil
|
|
}
|
|
|
|
// List returns the gateway IPs in the order they were added.
|
|
func (c *Config) List() []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return append([]string{}, c.ips...)
|
|
}
|
|
|
|
// Add validates, de-duplicates and persists. It returns the normalized IP.
|
|
func (c *Config) Add(raw string) (string, error) {
|
|
ip, err := normalize(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, have := range c.ips {
|
|
if have == ip {
|
|
return "", ErrDuplicate
|
|
}
|
|
}
|
|
next := append(append([]string{}, c.ips...), ip)
|
|
if err := c.save(next, c.interval); err != nil {
|
|
return "", err
|
|
}
|
|
c.ips = next
|
|
return ip, nil
|
|
}
|
|
|
|
func (c *Config) Remove(raw string) error {
|
|
ip, err := normalize(raw)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
next := make([]string, 0, len(c.ips))
|
|
found := false
|
|
for _, have := range c.ips {
|
|
if have == ip {
|
|
found = true
|
|
continue
|
|
}
|
|
next = append(next, have)
|
|
}
|
|
if !found {
|
|
return ErrNotFound
|
|
}
|
|
if err := c.save(next, c.interval); err != nil {
|
|
return err
|
|
}
|
|
c.ips = next
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) save(ips []string, interval time.Duration) error {
|
|
b, err := json.MarshalIndent(struct {
|
|
Gateways []string `json:"gateways"`
|
|
PollIntervalSeconds int `json:"pollIntervalSeconds"`
|
|
}{ips, int(interval / time.Second)}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(c.path, append(b, '\n'), 0o644)
|
|
}
|