// gridpoint-scan: continuously scans for Wi-Fi APs whose SSID starts with a // prefix (default "GRIDPOINT"), connects, fetches a JSON blob, prints it, and // records the SSID in SQLite so it is ignored in the future. // // Requires Linux with NetworkManager (nmcli). package main import ( "bytes" "context" "database/sql" "encoding/json" "errors" "flag" "fmt" "io" "log" "net/http" "os" "os/exec" "os/signal" "strings" "syscall" "time" _ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo) ) type config struct { prefix string password string url string iface string dbPath string interval time.Duration } func main() { var cfg config flag.StringVar(&cfg.prefix, "prefix", "GRIDPOINT", "SSID prefix to look for") flag.StringVar(&cfg.password, "password", "gridpoint", "Wi-Fi password") flag.StringVar(&cfg.url, "url", "http://172.30.1.1", "URL to fetch JSON from once connected") flag.StringVar(&cfg.iface, "iface", "", "Wi-Fi interface (default: let NetworkManager choose)") flag.StringVar(&cfg.dbPath, "db", "gridpoint.db", "SQLite database path") flag.DurationVar(&cfg.interval, "interval", 5*time.Second, "time between scans") flag.Parse() if _, err := exec.LookPath("nmcli"); err != nil { log.Fatal("nmcli not found: this tool requires NetworkManager") } db, err := sql.Open("sqlite", cfg.dbPath) if err != nil { log.Fatalf("open db: %v", err) } defer db.Close() if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ignored_aps ( ssid TEXT PRIMARY KEY, added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); err != nil { log.Fatalf("create table: %v", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() log.Printf("watching for SSIDs starting with %q (every %s)", cfg.prefix, cfg.interval) for { if err := scanOnce(ctx, db, cfg); err != nil && ctx.Err() == nil { log.Printf("scan error: %v", err) } select { case <-ctx.Done(): log.Println("shutting down") return case <-time.After(cfg.interval): } } } func scanOnce(ctx context.Context, db *sql.DB, cfg config) error { ssids, err := listSSIDs(ctx, cfg.iface) if err != nil { return err } for _, ssid := range ssids { if !strings.HasPrefix(ssid, cfg.prefix) { continue } ignored, err := isIgnored(db, ssid) if err != nil { return err } if ignored { continue } log.Printf("found new AP %q, connecting...", ssid) if err := handleAP(ctx, db, cfg, ssid); err != nil { // Not added to the ignore list, so it will be retried next scan. log.Printf("%q: %v", ssid, err) } if ctx.Err() != nil { return ctx.Err() } } return nil } func handleAP(ctx context.Context, db *sql.DB, cfg config, ssid string) error { // Always tear down the connection and delete the saved profile afterwards, // even on failure or Ctrl-C, so we don't leave stale auto-connect profiles. defer func() { cctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() _ = exec.CommandContext(cctx, "nmcli", "connection", "down", "id", ssid).Run() _ = exec.CommandContext(cctx, "nmcli", "connection", "delete", "id", ssid).Run() }() if err := connect(ctx, cfg, ssid); err != nil { return fmt.Errorf("connect: %w", err) } body, err := fetchWithRetry(ctx, cfg.url, 6, 2*time.Second) if err != nil { return fmt.Errorf("fetch: %w", err) } fmt.Printf("\n=== %s ===\n%s\n\n", ssid, prettyJSON(body)) if _, err := db.Exec(`INSERT OR IGNORE INTO ignored_aps (ssid) VALUES (?)`, ssid); err != nil { return fmt.Errorf("record ssid: %w", err) } log.Printf("%q added to ignore list", ssid) return nil } func listSSIDs(ctx context.Context, iface string) ([]string, error) { args := []string{"-t", "-f", "SSID", "device", "wifi", "list"} if iface != "" { args = append(args, "ifname", iface) } args = append(args, "--rescan", "yes") out, err := exec.CommandContext(ctx, "nmcli", args...).Output() if err != nil { return nil, fmt.Errorf("nmcli list: %w", err) } seen := map[string]bool{} var ssids []string for _, line := range strings.Split(string(out), "\n") { // Terse mode escapes ':' and '\' with a backslash. ssid := strings.NewReplacer(`\:`, ":", `\\`, `\`).Replace(strings.TrimSpace(line)) if ssid == "" || seen[ssid] { continue } seen[ssid] = true ssids = append(ssids, ssid) } return ssids, nil } func connect(ctx context.Context, cfg config, ssid string) error { args := []string{"--wait", "30", "device", "wifi", "connect", ssid, "password", cfg.password} if cfg.iface != "" { args = append(args, "ifname", cfg.iface) } cmd := exec.CommandContext(ctx, "nmcli", args...) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return nil } func fetchWithRetry(ctx context.Context, url string, attempts int, delay time.Duration) ([]byte, error) { client := &http.Client{Timeout: 5 * time.Second} var lastErr error for i := 0; i < attempts; i++ { body, err := fetch(ctx, client, url) if err == nil { return body, nil } lastErr = err select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): } } return nil, lastErr } func fetch(ctx context.Context, client *http.Client, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %s", resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { return nil, err } if !json.Valid(body) { return nil, errors.New("response is not valid JSON") } return body, nil } func prettyJSON(b []byte) string { var buf bytes.Buffer if err := json.Indent(&buf, b, "", " "); err != nil { return string(b) } return buf.String() } func isIgnored(db *sql.DB, ssid string) (bool, error) { var n int err := db.QueryRow(`SELECT COUNT(1) FROM ignored_aps WHERE ssid = ?`, ssid).Scan(&n) return n > 0, err }