Something went wrong. Try again.
This string has no description
Something went wrong. Try again.
6.2 kB · 238 lines · Created 1d ago Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238// 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}