chore: cleanup — untrack binaries, consolidate Go dirs, dedupe tools

- Untrack and delete compiled binaries (tarsum, gosoft.exe, rust uniq/uniq2);
  ignore build outputs (dist/, bin/, *.exe, *.test, .ruff_cache/)
- Merge tools/go/ and projects/go-tools/go/ into projects/go-tools/<name>/
- Fix goipgrep .gitignore: bare 'ipgrep' pattern was ignoring cmd/ipgrep/,
  so the main entrypoint was never tracked; now anchored to /ipgrep
- Archive duplicate implementations to archive/experimental/{rust,go}/
  (uniq, between, tarsum rewrites); canonical versions stay in tools/
- Update README tool catalog to match new layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tobias
2026-06-10 13:42:45 +02:00
parent 401b3e1781
commit 1cbf8afb4a
39 changed files with 449 additions and 27 deletions
+26
View File
@@ -0,0 +1,26 @@
name: ci
on:
push:
pull_request:
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go test (linux)
if: matrix.os == 'ubuntu-latest'
run: go test ./...
- name: go test (macos)
if: matrix.os == 'macos-latest'
run: go test -ldflags='-linkmode=external' ./...
- run: go vet ./...
- run: ./scripts/build.sh
+4
View File
@@ -0,0 +1,4 @@
dist/
/ipgrep
*.test
+22
View File
@@ -0,0 +1,22 @@
.PHONY: build test fmt lint clean
UNAME_S := $(shell uname -s)
TEST_LDFLAGS :=
ifeq ($(UNAME_S),Darwin)
TEST_LDFLAGS := -ldflags='-linkmode=external'
endif
build:
./scripts/build.sh
test:
go test $(TEST_LDFLAGS) ./...
fmt:
gofmt -w .
lint:
go vet ./...
clean:
rm -rf dist
+52
View File
@@ -0,0 +1,52 @@
# ipgrep
Extract IP addresses (and MAC addresses) from text, with optional reachability checks, reverse DNS, and ipinfo.io lookups.
## Build
```sh
./scripts/build.sh
./dist/ipgrep --help
```
## Install (local)
```sh
./scripts/build.sh
sudo install -m 0755 ./dist/ipgrep /usr/local/bin/ipgrep
```
## Usage
```sh
# Extract IPv4 addresses from stdin
cat file.txt | ipgrep
# Extract and uniq/sort
ipgrep -u -s file.txt
# Extract MAC addresses
ipgrep -m file.txt
# Reachability filter (auto chooses ICMP if permitted, otherwise TCP probe)
ipgrep -p file.txt
# Reverse DNS (IP only)
ipgrep -r file.txt
# Lookup ipinfo.io and output CSV (default header included)
ipgrep -l --format=csv file.txt
```
## Notes
- `-p/--pingable`:
- `--ping-mode=auto` (default) uses ICMP echo when raw sockets are available, otherwise falls back to a TCP connect probe.
- ICMP echo typically requires root or `cap_net_raw` on Linux:
- `sudo setcap cap_net_raw+ep /path/to/ipgrep`
- `-l/--lookup` cache:
- defaults to the OS cache dir (override with `--cache-path`)
- control size and freshness with `--cache-max-entries` and `--cache-ttl`
- MAC `-r/--resolve` and MAC `-p/--pingable` are best-effort and rely on the local neighbor/ARP table:
- Linux: `/proc/net/arp`
- Other OSes: currently unsupported without external tooling
@@ -0,0 +1,418 @@
package main
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"runtime"
"sort"
"strings"
"syscall"
"time"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/cache"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/extract"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/ipinfo"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/normalize"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/ping"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/resolve"
)
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
type stringSlice []string
func (s *stringSlice) String() string { return strings.Join(*s, ",") }
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
func main() {
var (
// legacy short flags (kept for compatibility)
shortUniq bool
shortSort bool
shortMAC bool
shortPing bool
shortResolve bool
shortLookup bool
shortFile string
// long flags
longUniq bool
longSort bool
longMAC bool
longPing bool
longResolve bool
longLookup bool
longFiles stringSlice
format string
jobs int
timeout time.Duration
pingMode string
ipv6 bool
cachePath string
noCache bool
clearCache bool
cacheTTL time.Duration
cacheMax int
ipinfoToken string
showVersion bool
)
flag.BoolVar(&shortUniq, "u", false, "only show uniq IPs/MACs (implies -s)")
flag.BoolVar(&shortSort, "s", false, "sort output")
flag.BoolVar(&shortMAC, "m", false, "grep MAC addresses instead of IP addresses")
flag.BoolVar(&shortPing, "p", false, "only show entries considered reachable (see --ping-mode)")
flag.BoolVar(&shortResolve, "r", false, "resolve: IP reverse DNS; MAC best-effort via neighbor/ARP table (Linux only)")
flag.BoolVar(&shortLookup, "l", false, "lookup ip info using ipinfo.io (IP only)")
flag.StringVar(&shortFile, "f", "", "input file (legacy; prefer --file)")
flag.BoolVar(&longUniq, "uniq", false, "only show uniq IPs/MACs (implies --sort)")
flag.BoolVar(&longSort, "sort", false, "sort output")
flag.BoolVar(&longMAC, "mac", false, "grep MAC addresses instead of IP addresses")
flag.BoolVar(&longPing, "pingable", false, "only show entries considered reachable (see --ping-mode)")
flag.BoolVar(&longResolve, "resolve", false, "resolve: IP reverse DNS; MAC best-effort via neighbor/ARP table (Linux only)")
flag.BoolVar(&longLookup, "lookup", false, "lookup ip info using ipinfo.io (IP only)")
flag.Var(&longFiles, "file", "input file (repeatable)")
flag.StringVar(&format, "format", "text", "output format: text|csv|json")
flag.IntVar(&jobs, "jobs", runtime.GOMAXPROCS(0), "max concurrent workers for network operations")
flag.DurationVar(&timeout, "timeout", 2*time.Second, "per-operation timeout for -p/-r/-l")
flag.StringVar(&pingMode, "ping-mode", "auto", "reachability mode for -p: auto|icmp|tcp")
flag.BoolVar(&ipv6, "ipv6", false, "also match IPv6 addresses (default: IPv4 only)")
flag.StringVar(&cachePath, "cache-path", "", "cache path for ipinfo lookups (default: OS cache dir)")
flag.BoolVar(&noCache, "no-cache", false, "disable reading/writing the ipinfo cache")
flag.BoolVar(&clearCache, "clear-cache", false, "clear the ipinfo cache then exit")
flag.DurationVar(&cacheTTL, "cache-ttl", 30*24*time.Hour, "ipinfo cache TTL (0 disables expiry)")
flag.IntVar(&cacheMax, "cache-max-entries", 50000, "max cached IPs for ipinfo lookups (0 disables pruning)")
flag.StringVar(&ipinfoToken, "ipinfo-token", "", "ipinfo.io token (also read from IPINFO_TOKEN)")
flag.BoolVar(&showVersion, "version", false, "print version and exit")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] [file...]\n\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if showVersion {
fmt.Printf("ipgrep %s (%s) %s\n", version, commit, date)
return
}
uniqFlag := shortUniq || longUniq
sortFlag := shortSort || longSort
macFlag := shortMAC || longMAC
pingable := shortPing || longPing
resolveFlag := shortResolve || longResolve
lookupFlag := shortLookup || longLookup
// keep old behavior: pingable/lookup implies uniq+sort
if pingable || lookupFlag {
sortFlag = true
uniqFlag = true
}
if uniqFlag {
sortFlag = true
}
if lookupFlag && macFlag {
fatalf("lookup mode (-l/--lookup) only works for IP addresses, not MAC addresses")
}
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "text", "csv", "json":
default:
fatalf("invalid --format %q (want text|csv|json)", format)
}
pingMode = strings.ToLower(strings.TrimSpace(pingMode))
switch pingMode {
case "auto", "icmp", "tcp":
default:
fatalf("invalid --ping-mode %q (want auto|icmp|tcp)", pingMode)
}
if jobs < 1 {
fatalf("--jobs must be >= 1")
}
if timeout <= 0 {
fatalf("--timeout must be > 0")
}
var files []string
if shortFile != "" {
files = append(files, shortFile)
}
files = append(files, longFiles...)
files = append(files, flag.Args()...)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// cache management for lookup mode
if lookupFlag && ipinfoToken == "" {
ipinfoToken = os.Getenv("IPINFO_TOKEN")
}
// Resolve cache default path.
if cachePath == "" {
cachePath = cache.DefaultPath()
}
if clearCache {
if err := cache.Clear(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
fatalf("clear cache: %v", err)
}
return
}
mode := extract.ModeIP
if macFlag {
mode = extract.ModeMAC
}
// Extraction stage (stream input, collect matches as needed).
var matches []string
seen := map[string]struct{}{}
emit := func(s string) {
if uniqFlag {
if _, ok := seen[s]; ok {
return
}
seen[s] = struct{}{}
}
matches = append(matches, s)
}
exOpts := extract.Options{Mode: mode, IPv6: ipv6}
if len(files) == 0 {
if err := extract.Grep(ctx, os.Stdin, exOpts, func(raw string) {
if mode == extract.ModeMAC {
if norm, ok := normalize.NormalizeMAC(raw); ok {
emit(norm)
}
return
}
if norm, ok := normalize.NormalizeIP(raw, ipv6); ok {
emit(norm)
}
}); err != nil {
fatalf("read stdin: %v", err)
}
} else {
for _, f := range files {
r, err := os.Open(f)
if err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: open %s: %v\n", f, err)
continue
}
err = extract.Grep(ctx, r, exOpts, func(raw string) {
if mode == extract.ModeMAC {
if norm, ok := normalize.NormalizeMAC(raw); ok {
emit(norm)
}
return
}
if norm, ok := normalize.NormalizeIP(raw, ipv6); ok {
emit(norm)
}
})
_ = r.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: read %s: %v\n", f, err)
}
}
}
if len(matches) == 0 {
return
}
if sortFlag {
sort.Strings(matches)
}
// -p reachability filter
if pingable {
popts := ping.Options{
Mode: pingMode,
Timeout: timeout,
Jobs: jobs,
}
if macFlag {
var err error
matches, err = ping.FilterMACs(ctx, matches, popts)
if err != nil {
fatalf("%v", err)
}
} else {
var err error
matches, err = ping.FilterIPs(ctx, matches, popts)
if err != nil {
fatalf("%v", err)
}
}
if sortFlag {
sort.Strings(matches)
}
}
// -l ipinfo lookup
if lookupFlag {
if format == "csv" {
w := csv.NewWriter(os.Stdout)
must(w.Write([]string{"ip", "country", "region", "city", "org", "hostname"}))
client := ipinfo.Client{
Token: ipinfoToken,
Timeout: timeout,
}
var store *cache.Store
if !noCache {
s, err := cache.Load(cachePath, cacheTTL, cacheMax)
if err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: cache load: %v\n", err)
} else {
store = s
}
}
results := ipinfo.LookupAll(ctx, matches, client, store, jobs)
sort.Slice(results, func(i, j int) bool { return results[i].IP < results[j].IP })
for _, info := range results {
must(w.Write([]string{info.IP, info.Country, info.Region, info.City, info.Org, info.Hostname}))
}
w.Flush()
if err := w.Error(); err != nil {
fatalf("csv: %v", err)
}
if store != nil && store.Changed() {
if err := store.Save(); err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: cache save: %v\n", err)
}
}
return
}
client := ipinfo.Client{
Token: ipinfoToken,
Timeout: timeout,
}
var store *cache.Store
if !noCache {
s, err := cache.Load(cachePath, cacheTTL, cacheMax)
if err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: cache load: %v\n", err)
} else {
store = s
}
}
results := ipinfo.LookupAll(ctx, matches, client, store, jobs)
sort.Slice(results, func(i, j int) bool { return results[i].IP < results[j].IP })
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(results))
} else {
for _, info := range results {
fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\n", info.IP, info.Country, info.Region, info.City, info.Org, info.Hostname)
}
}
if store != nil && store.Changed() {
if err := store.Save(); err != nil {
fmt.Fprintf(os.Stderr, "ipgrep: cache save: %v\n", err)
}
}
return
}
// -r resolution
if resolveFlag {
if macFlag {
res, err := resolve.ResolveMACs(matches)
if err != nil {
fatalf("%v", err)
}
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(res))
return
}
for _, row := range res {
// one line per mapping
for _, ip := range row.IPs {
fmt.Printf("%s\t%s\n", row.MAC, ip)
}
if len(row.IPs) == 0 {
fmt.Println(row.MAC)
}
}
return
}
res := resolve.ReverseLookupAll(ctx, matches, timeout, jobs)
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(res))
return
}
for _, row := range res {
if row.Hostname != "" {
fmt.Printf("%s\t%s\n", row.IP, row.Hostname)
} else {
fmt.Println(row.IP)
}
}
return
}
// plain output
if format == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(matches))
return
}
if format == "csv" {
// csv doesn't make much sense for plain extraction, but keep it consistent.
w := csv.NewWriter(os.Stdout)
must(w.Write([]string{"value"}))
for _, m := range matches {
must(w.Write([]string{m}))
}
w.Flush()
if err := w.Error(); err != nil {
fatalf("csv: %v", err)
}
return
}
for _, m := range matches {
fmt.Println(m)
}
}
func must(err error) {
if err != nil {
fatalf("%v", err)
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "ipgrep: "+format+"\n", args...)
os.Exit(1)
}
+4
View File
@@ -0,0 +1,4 @@
module git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep
go 1.22
+183
View File
@@ -0,0 +1,183 @@
package cache
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/ipinfo"
)
type entry struct {
Info ipinfo.Info `json:"info"`
FetchedAt time.Time `json:"fetched_at"`
}
type fileFormat struct {
Version int `json:"version"`
Entries map[string]entry `json:"entries"`
}
type Store struct {
path string
ttl time.Duration
max int
changed bool
data fileFormat
mu sync.RWMutex
}
func DefaultPath() string {
dir, err := os.UserCacheDir()
if err != nil {
home, _ := os.UserHomeDir()
if home == "" {
return ".ipgrep.ipinfo.json"
}
return filepath.Join(home, ".cache", "ipgrep", "ipinfo.json")
}
return filepath.Join(dir, "ipgrep", "ipinfo.json")
}
func Clear(path string) error {
return os.Remove(path)
}
func Load(path string, ttl time.Duration, maxEntries int) (*Store, error) {
s := &Store{
path: path,
ttl: ttl,
max: maxEntries,
data: fileFormat{Version: 1, Entries: make(map[string]entry)},
}
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return nil, err
}
// Try v1 format.
var ff fileFormat
if err := json.Unmarshal(b, &ff); err == nil && ff.Version == 1 && ff.Entries != nil {
s.data = ff
return s, nil
}
// Try legacy format: map[ip]ipinfo.Info.
var legacy map[string]ipinfo.Info
if err := json.Unmarshal(b, &legacy); err == nil && legacy != nil {
now := time.Time{}
for ip, info := range legacy {
s.data.Entries[ip] = entry{Info: info, FetchedAt: now}
}
// Don't mark changed just because we loaded legacy.
return s, nil
}
return nil, fmt.Errorf("unrecognized cache format: %s", path)
}
func (s *Store) Get(ip string) (ipinfo.Info, bool) {
s.mu.RLock()
e, ok := s.data.Entries[ip]
s.mu.RUnlock()
if !ok {
return ipinfo.Info{}, false
}
if s.ttl > 0 && !e.FetchedAt.IsZero() && time.Since(e.FetchedAt) > s.ttl {
return ipinfo.Info{}, false
}
return e.Info, true
}
func (s *Store) Put(ip string, info ipinfo.Info) {
s.mu.Lock()
s.data.Entries[ip] = entry{Info: info, FetchedAt: time.Now().UTC()}
s.changed = true
s.mu.Unlock()
}
func (s *Store) Changed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.changed
}
func (s *Store) Save() error {
if s.path == "" {
return errors.New("cache path is empty")
}
s.prune()
s.mu.RLock()
payload := s.data
s.mu.RUnlock()
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, "ipgrep-cache-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
enc := json.NewEncoder(tmp)
enc.SetIndent("", " ")
if err := enc.Encode(payload); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, s.path); err != nil {
_ = os.Remove(tmpName)
return err
}
s.mu.Lock()
s.changed = false
s.mu.Unlock()
return nil
}
func (s *Store) prune() {
if s.max <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.data.Entries) <= s.max {
return
}
type kv struct {
k string
t time.Time
}
all := make([]kv, 0, len(s.data.Entries))
for k, v := range s.data.Entries {
all = append(all, kv{k: k, t: v.FetchedAt})
}
sort.Slice(all, func(i, j int) bool {
// zero timestamps (legacy) sort oldest.
return all[i].t.Before(all[j].t)
})
for len(s.data.Entries) > s.max {
k := all[0].k
all = all[1:]
delete(s.data.Entries, k)
s.changed = true
}
}
+54
View File
@@ -0,0 +1,54 @@
package cache
import (
"os"
"path/filepath"
"testing"
"time"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/ipinfo"
)
func TestCacheSaveLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ipinfo.json")
s, err := Load(path, 0, 0)
if err != nil {
t.Fatalf("Load: %v", err)
}
s.Put("1.2.3.4", ipinfo.Info{IP: "1.2.3.4", Country: "US"})
if !s.Changed() {
t.Fatalf("expected changed")
}
if err := s.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
s2, err := Load(path, 0, 0)
if err != nil {
t.Fatalf("Load2: %v", err)
}
info, ok := s2.Get("1.2.3.4")
if !ok || info.Country != "US" {
t.Fatalf("Get: ok=%v info=%#v", ok, info)
}
}
func TestCacheTTL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ipinfo.json")
// Write a v1 cache with an old fetched_at.
b := []byte(`{"version":1,"entries":{"1.2.3.4":{"info":{"ip":"1.2.3.4","country":"US"},"fetched_at":"2000-01-01T00:00:00Z"}}}`)
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
s, err := Load(path, 24*time.Hour, 0)
if err != nil {
t.Fatalf("Load: %v", err)
}
if _, ok := s.Get("1.2.3.4"); ok {
t.Fatalf("expected TTL miss")
}
}
@@ -0,0 +1,79 @@
package extract
import (
"bufio"
"context"
"errors"
"io"
"regexp"
)
type Mode int
const (
ModeIP Mode = iota
ModeMAC
)
type Options struct {
Mode Mode
IPv6 bool
}
// Grep scans r line-by-line and emits raw matches via emit.
// It avoids reading the full input into memory.
func Grep(ctx context.Context, r io.Reader, opts Options, emit func(string)) error {
if emit == nil {
return errors.New("emit func is nil")
}
var re *regexp.Regexp
switch opts.Mode {
case ModeMAC:
re = macRE
case ModeIP:
if opts.IPv6 {
re = ipAnyRE
} else {
re = ipv4RE
}
default:
return errors.New("unknown mode")
}
sc := bufio.NewScanner(r)
// Large-ish buffer for long lines (logs, JSON blobs, etc.).
buf := make([]byte, 64*1024)
sc.Buffer(buf, 1024*1024)
for sc.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := sc.Bytes()
matches := re.FindAll(line, -1)
for _, m := range matches {
emit(string(m))
}
}
if err := sc.Err(); err != nil {
return err
}
return nil
}
// Note: These regexes intentionally find candidates only; callers should
// validate/normalize as needed.
var (
// \b based candidate matcher, validated later.
ipv4RE = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
// Rough IPv6 candidate matcher (validation happens later if caller chooses to).
ipv6RE = regexp.MustCompile(`(?i)\b(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}\b`)
ipAnyRE = regexp.MustCompile(`(?i)(?:` + `\b(?:\d{1,3}\.){3}\d{1,3}\b` + `|` + `\b(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}\b` + `)`)
macRE = regexp.MustCompile(`(?i)\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b|\b(?:[0-9a-f]{4}\.){2}[0-9a-f]{4}\b`)
)
@@ -0,0 +1,36 @@
package extract
import (
"context"
"strings"
"testing"
)
func TestGrepIPv4(t *testing.T) {
in := "a 1.2.3.4 b 999.2.3.4 c\nx 10.0.0.1 y"
var got []string
err := Grep(context.Background(), strings.NewReader(in), Options{Mode: ModeIP}, func(s string) {
got = append(got, s)
})
if err != nil {
t.Fatalf("Grep: %v", err)
}
// Candidate matcher intentionally returns 999.2.3.4 too; validation happens elsewhere.
if len(got) != 3 {
t.Fatalf("got %d matches: %#v", len(got), got)
}
}
func TestGrepMAC(t *testing.T) {
in := "aa:bb:cc:dd:ee:ff aabb.ccdd.eeff 00-11-22-33-44-55"
var got []string
err := Grep(context.Background(), strings.NewReader(in), Options{Mode: ModeMAC}, func(s string) {
got = append(got, s)
})
if err != nil {
t.Fatalf("Grep: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d matches: %#v", len(got), got)
}
}
@@ -0,0 +1,131 @@
package ipinfo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"runtime"
"sort"
"sync"
"time"
)
type Info struct {
IP string `json:"ip"`
Hostname string `json:"hostname,omitempty"`
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
Org string `json:"org,omitempty"`
}
type Client struct {
Token string
Timeout time.Duration
}
type Cache interface {
Get(ip string) (Info, bool)
Put(ip string, info Info)
}
func (c Client) Lookup(ctx context.Context, ip string) (Info, error) {
u := fmt.Sprintf("https://ipinfo.io/%s/json", url.PathEscape(ip))
if c.Token != "" {
u += "?token=" + url.QueryEscape(c.Token)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return Info{}, err
}
req.Header.Set("Accept", "application/json")
hc := &http.Client{Timeout: c.Timeout}
resp, err := hc.Do(req)
if err != nil {
return Info{}, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return Info{}, fmt.Errorf("ipinfo: %s", resp.Status)
}
var info Info
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return Info{}, err
}
if info.IP == "" {
// Some error payloads decode but don't include the IP.
info.IP = ip
}
return info, nil
}
func LookupAll(ctx context.Context, ips []string, client Client, store Cache, jobs int) []Info {
if jobs <= 0 {
jobs = runtime.GOMAXPROCS(0)
}
in := make(chan string)
out := make(chan Info)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for ip := range in {
if store != nil {
if info, ok := store.Get(ip); ok {
select {
case out <- info:
case <-ctx.Done():
return
}
continue
}
}
cctx, cancel := context.WithTimeout(ctx, client.Timeout)
info, err := client.Lookup(cctx, ip)
cancel()
if err == nil && info.IP != "" {
if store != nil {
store.Put(ip, info)
}
select {
case out <- info:
case <-ctx.Done():
return
}
}
}
}
wg.Add(jobs)
for i := 0; i < jobs; i++ {
go worker()
}
go func() {
defer close(in)
for _, ip := range ips {
select {
case in <- ip:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
var res []Info
for info := range out {
res = append(res, info)
}
sort.Slice(res, func(i, j int) bool { return res[i].IP < res[j].IP })
return res
}
@@ -0,0 +1,27 @@
package normalize
import (
"fmt"
"net"
"strings"
)
// NormalizeIP validates an IP string and returns a canonical string form.
// If allowIPv6 is false, only IPv4 is accepted.
func NormalizeIP(s string, allowIPv6 bool) (string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
ip := net.ParseIP(s)
if ip == nil {
return "", false
}
if v4 := ip.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.%d.%d", v4[0], v4[1], v4[2], v4[3]), true
}
if !allowIPv6 {
return "", false
}
return ip.String(), true
}
@@ -0,0 +1,43 @@
package normalize
import (
"strings"
)
// NormalizeMAC converts supported MAC forms to canonical lower-case colon-separated form.
// Accepts: xx:xx:xx:xx:xx:xx, xx-xx-xx-xx-xx-xx, xxxx.xxxx.xxxx
func NormalizeMAC(s string) (string, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
s = strings.ToLower(s)
// remove common separators
s = strings.ReplaceAll(s, ":", "")
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, ".", "")
if len(s) != 12 {
return "", false
}
for i := 0; i < 12; i++ {
c := s[i]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
continue
}
return "", false
}
// canonical form aa:bb:cc:dd:ee:ff
var b strings.Builder
b.Grow(17)
for i := 0; i < 12; i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteByte(s[i])
b.WriteByte(s[i+1])
}
return b.String(), true
}
@@ -0,0 +1,23 @@
package normalize
import "testing"
func TestNormalizeMAC(t *testing.T) {
cases := map[string]string{
"AA:BB:CC:DD:EE:FF": "aa:bb:cc:dd:ee:ff",
"aa-bb-cc-dd-ee-ff": "aa:bb:cc:dd:ee:ff",
"aabb.ccdd.eeff": "aa:bb:cc:dd:ee:ff",
}
for in, want := range cases {
got, ok := NormalizeMAC(in)
if !ok {
t.Fatalf("NormalizeMAC(%q) not ok", in)
}
if got != want {
t.Fatalf("NormalizeMAC(%q)=%q want %q", in, got, want)
}
}
if _, ok := NormalizeMAC("not-a-mac"); ok {
t.Fatalf("expected invalid mac")
}
}
@@ -0,0 +1,332 @@
package ping
import (
"context"
"errors"
"fmt"
"net"
"os"
"runtime"
"strings"
"sync"
"syscall"
"time"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/resolve"
)
type Options struct {
Mode string
Timeout time.Duration
Jobs int
}
func FilterIPs(ctx context.Context, ips []string, opts Options) ([]string, error) {
if len(ips) == 0 {
return nil, nil
}
mode, warn := selectMode(opts.Mode)
if warn != "" {
fmt.Fprintln(os.Stderr, "ipgrep:", warn)
}
j := opts.Jobs
if j <= 0 {
j = runtime.GOMAXPROCS(0)
}
in := make(chan string)
out := make(chan string)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for ip := range in {
if reachable(ctx, ip, mode, opts.Timeout) {
select {
case out <- ip:
case <-ctx.Done():
return
}
}
}
}
wg.Add(j)
for i := 0; i < j; i++ {
go worker()
}
go func() {
defer close(in)
for _, ip := range ips {
select {
case in <- ip:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
var res []string
for ip := range out {
res = append(res, ip)
}
return res, nil
}
// FilterMACs resolves each MAC to one or more local-neighbor IPs then applies IP reachability.
// On non-Linux, neighbor resolution may be unsupported without external tools.
func FilterMACs(ctx context.Context, macs []string, opts Options) ([]string, error) {
tab, err := resolve.LoadNeighborTable()
if err != nil {
return nil, err
}
mode, warn := selectMode(opts.Mode)
if warn != "" {
fmt.Fprintln(os.Stderr, "ipgrep:", warn)
}
j := opts.Jobs
if j <= 0 {
j = runtime.GOMAXPROCS(0)
}
in := make(chan string)
out := make(chan string)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for mac := range in {
ips := tab.ByMAC[mac]
ok := false
for _, ip := range ips {
if reachable(ctx, ip, mode, opts.Timeout) {
ok = true
break
}
}
if ok {
select {
case out <- mac:
case <-ctx.Done():
return
}
}
}
}
wg.Add(j)
for i := 0; i < j; i++ {
go worker()
}
go func() {
defer close(in)
for _, mac := range macs {
select {
case in <- mac:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
var res []string
for mac := range out {
res = append(res, mac)
}
return res, nil
}
func selectMode(mode string) (string, string) {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "icmp":
return "icmp", ""
case "tcp":
return "tcp", ""
case "auto", "":
if canICMP() {
return "icmp", ""
}
return "tcp", "ICMP not permitted/available; falling back to TCP probe (use --ping-mode=tcp to silence)"
default:
return "auto", ""
}
}
func canICMP() bool {
c, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err == nil {
_ = c.Close()
return true
}
// Permission failures are common; everything else treat as unavailable too.
return false
}
func reachable(ctx context.Context, ip string, mode string, timeout time.Duration) bool {
switch mode {
case "icmp":
ok, err := icmpEcho(ctx, ip, timeout)
if err == nil {
return ok
}
// ICMP implementation is IPv4-only; use TCP probe for non-IPv4.
if err != nil && strings.Contains(err.Error(), "not an IPv4") {
return tcpProbe(ctx, ip, timeout)
}
if isPerm(err) {
// If user forced icmp, don't silently fall back.
return false
}
return false
case "tcp":
return tcpProbe(ctx, ip, timeout)
default:
return tcpProbe(ctx, ip, timeout)
}
}
func icmpEcho(ctx context.Context, dst string, timeout time.Duration) (bool, error) {
ip := net.ParseIP(dst)
if ip == nil || ip.To4() == nil {
return false, errors.New("not an IPv4 address")
}
c, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return false, err
}
defer c.Close()
// Echo request: type 8, code 0.
id := os.Getpid() & 0xffff
seq := int(time.Now().UnixNano() & 0xffff)
msg := make([]byte, 8+16)
msg[0] = 8 // type
msg[1] = 0 // code
// checksum at [2:4]
msg[4] = byte(id >> 8)
msg[5] = byte(id)
msg[6] = byte(seq >> 8)
msg[7] = byte(seq)
copy(msg[8:], []byte("goipgrep-icmp\000"))
cs := checksum(msg)
msg[2] = byte(cs >> 8)
msg[3] = byte(cs)
deadline := time.Now().Add(timeout)
_ = c.SetDeadline(deadline)
dstAddr := &net.IPAddr{IP: ip}
if _, err := c.WriteTo(msg, dstAddr); err != nil {
return false, err
}
buf := make([]byte, 1500)
for {
select {
case <-ctx.Done():
return false, ctx.Err()
default:
}
n, _, err := c.ReadFrom(buf)
if err != nil {
return false, err
}
b := buf[:n]
// Some OSes may include IPv4 header; strip if present.
if len(b) >= 20 && (b[0]>>4) == 4 {
ihl := int(b[0]&0x0f) * 4
if ihl <= len(b) {
b = b[ihl:]
}
}
if len(b) < 8 {
continue
}
typ := b[0]
code := b[1]
if typ != 0 || code != 0 { // echo reply
continue
}
rid := int(b[4])<<8 | int(b[5])
rseq := int(b[6])<<8 | int(b[7])
if rid == id && rseq == seq {
return true, nil
}
}
}
func checksum(b []byte) uint16 {
var sum uint32
for i := 0; i+1 < len(b); i += 2 {
sum += uint32(b[i])<<8 | uint32(b[i+1])
}
if len(b)%2 == 1 {
sum += uint32(b[len(b)-1]) << 8
}
for (sum >> 16) != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
func tcpProbe(ctx context.Context, ip string, timeout time.Duration) bool {
ports := []int{443, 80}
d := net.Dialer{Timeout: timeout}
for _, p := range ports {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", p))
c, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
_ = c.Close()
return true
}
if isConnRefused(err) {
return true
}
}
return false
}
func isPerm(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrPermission) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if errors.Is(opErr.Err, syscall.EPERM) || errors.Is(opErr.Err, syscall.EACCES) {
return true
}
}
return false
}
func isConnRefused(err error) bool {
var opErr *net.OpError
if errors.As(err, &opErr) {
// On some platforms, opErr.Err is *os.SyscallError wrapping ECONNREFUSED.
var se *os.SyscallError
if errors.As(opErr.Err, &se) {
return errors.Is(se.Err, syscall.ECONNREFUSED)
}
return errors.Is(opErr.Err, syscall.ECONNREFUSED)
}
return false
}
@@ -0,0 +1,75 @@
//go:build linux
package resolve
import (
"bufio"
"io"
"os"
"strings"
"git.ktf.ninja/tabledevil/gists/projects/go-tools/go/goipgrep/internal/normalize"
)
type NeighborTable struct {
ByMAC map[string][]string
}
func LoadNeighborTable() (*NeighborTable, error) {
f, err := os.Open("/proc/net/arp")
if err != nil {
return nil, err
}
defer f.Close()
return parseProcNetARP(f), nil
}
func ResolveMACs(macs []string) ([]MACResolution, error) {
tab, err := LoadNeighborTable()
if err != nil {
return nil, err
}
var res []MACResolution
for _, mac := range macs {
res = append(res, MACResolution{MAC: mac, IPs: append([]string(nil), tab.ByMAC[mac]...)})
}
return res, nil
}
type MACResolution struct {
MAC string `json:"mac"`
IPs []string `json:"ips,omitempty"`
}
func parseProcNetARP(r io.Reader) *NeighborTable {
nt := &NeighborTable{ByMAC: make(map[string][]string)}
sc := bufio.NewScanner(r)
first := true
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if first {
// header
first = false
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
ip := fields[0]
macRaw := fields[3]
mac, ok := normalize.NormalizeMAC(macRaw)
if !ok {
continue
}
// Skip incomplete/placeholder entries.
if mac == "00:00:00:00:00:00" {
continue
}
nt.ByMAC[mac] = append(nt.ByMAC[mac], ip)
}
return nt
}
@@ -0,0 +1,23 @@
//go:build !linux
package resolve
import "fmt"
type NeighborTable struct {
ByMAC map[string][]string
}
type MACResolution struct {
MAC string `json:"mac"`
IPs []string `json:"ips,omitempty"`
}
func LoadNeighborTable() (*NeighborTable, error) {
return nil, fmt.Errorf("MAC neighbor/ARP resolution is only supported on Linux without external tools")
}
func ResolveMACs(macs []string) ([]MACResolution, error) {
_ = macs
return nil, fmt.Errorf("MAC resolution is only supported on Linux without external tools")
}
@@ -0,0 +1,74 @@
package resolve
import (
"context"
"net"
"runtime"
"sort"
"strings"
"sync"
"time"
)
type ReverseResult struct {
IP string `json:"ip"`
Hostname string `json:"hostname,omitempty"`
}
func ReverseLookupAll(ctx context.Context, ips []string, timeout time.Duration, jobs int) []ReverseResult {
if jobs <= 0 {
jobs = runtime.GOMAXPROCS(0)
}
in := make(chan string)
out := make(chan ReverseResult)
var wg sync.WaitGroup
worker := func() {
defer wg.Done()
for ip := range in {
r := ReverseResult{IP: ip}
cctx, cancel := context.WithTimeout(ctx, timeout)
names, err := net.DefaultResolver.LookupAddr(cctx, ip)
cancel()
if err == nil && len(names) > 0 {
// trim trailing dot for readability
r.Hostname = strings.TrimSuffix(names[0], ".")
}
select {
case out <- r:
case <-ctx.Done():
return
}
}
}
wg.Add(jobs)
for i := 0; i < jobs; i++ {
go worker()
}
go func() {
defer close(in)
for _, ip := range ips {
select {
case in <- ip:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(out)
}()
var res []ReverseResult
for r := range out {
res = append(res, r)
}
sort.Slice(res, func(i, j int) bool { return res[i].IP < res[j].IP })
return res
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/.."
mkdir -p dist
VERSION="${VERSION:-dev}"
COMMIT="${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}"
DATE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
EXTRA_LDFLAGS=""
GOOS="$(go env GOOS)"
if [ "$GOOS" = "darwin" ]; then
# Some macOS environments treat missing LC_UUID as fatal; external linkmode
# ensures the toolchain emits LC_UUID.
EXTRA_LDFLAGS="-linkmode=external"
fi
go build \
-trimpath \
-ldflags "-s -w ${EXTRA_LDFLAGS} -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
-o dist/ipgrep \
./cmd/ipgrep
echo "built dist/ipgrep"