goipgrep: refactor into module; pure-Go ping/resolve; cache+CI; drop binary
This commit is contained in:
183
projects/go-tools/go/goipgrep/internal/cache/cache.go
vendored
Normal file
183
projects/go-tools/go/goipgrep/internal/cache/cache.go
vendored
Normal 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
projects/go-tools/go/goipgrep/internal/cache/cache_test.go
vendored
Normal file
54
projects/go-tools/go/goipgrep/internal/cache/cache_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user