Skip to content

Commit 8f0fa7e

Browse files
authored
feat: add persistent cache with auto-refresh support (#3)
Implement generic persistent cache using Otter pkg with the following features: * Cache SSLBL records to "~/.cache/sebel/records.gob". * Embed `otter.Cache` directly for full API access. * Add `DataRefreshInterval` option for background refresh. * Best-effort persistence (graceful fallback in containers). The cache package is generic (`Cache[K, V]`) and low-level, exposing `otter.Options` for full configurability. Add `Close()` method to `Sebel` for stopping background goroutine. Signed-off-by: Dwi Siswanto <git@dw1.io>
1 parent e066f3a commit 8f0fa7e

File tree

10 files changed

+473
-21
lines changed

10 files changed

+473
-21
lines changed

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import "github.com/teler-sh/sebel"
1616
// ...
1717

1818
s := sebel.New(Options{/* ... */})
19+
defer s.Close() // Stop background refresh if enabled
1920
```
2021

2122
> [!NOTE]
22-
> The `Options` parameter is optional. Currently, the only supported option is disabling the SSL blacklist. See [TODO](#TODO).
23+
> The `Options` parameter is optional. See [Options](#options) for available configuration.
2324
2425
### Examples
2526

@@ -28,6 +29,7 @@ Next, set the transport for the HTTP client you are using:
2829
```go
2930
// initialize Sebel (fetch SSLBL data)
3031
s := sebel.New()
32+
defer s.Close()
3133

3234
client := &http.Client{
3335
Transport: s.RoundTripper(http.DefaultTransport),
@@ -79,11 +81,27 @@ if err != nil && sebel.IsBlacklist(err) {
7981
}
8082
```
8183

84+
#### Background Refresh
85+
86+
To keep the SSLBL data up-to-date automatically:
87+
88+
```go
89+
s := sebel.New(sebel.Options{
90+
DataRefreshInterval: 5 * time.Minute,
91+
})
92+
defer s.Close() // Important: stop the background goroutine
93+
94+
client := &http.Client{
95+
Transport: s.RoundTripper(http.DefaultTransport),
96+
}
97+
```
98+
8299
These examples demonstrate various ways to set up Sebel and integrate it with HTTP clients for SSL/TLS certificate checks.
83100

84101
## TODO
85102

86-
* [ ] Caching SSLBL data under user-specific cache directory.
103+
* [x] Caching SSLBL data under user-specific cache directory.
104+
* [x] Background refresh to keep data up-to-date.
87105
* [x] Add `io.Writer` option.
88106
* [ ] ~Add `CheckIP` method.~ Not planned, instead:
89107
* [x] Add `CheckHost` method.

go.mod

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ module github.com/teler-sh/sebel
22

33
go 1.24.0
44

5-
require github.com/samber/lo v1.52.0
5+
require (
6+
github.com/maypok86/otter/v2 v2.2.1
7+
github.com/samber/lo v1.52.0
8+
)
69

7-
require golang.org/x/text v0.32.0 // indirect
10+
require (
11+
golang.org/x/sys v0.34.0 // indirect
12+
golang.org/x/text v0.22.0 // indirect
13+
)

go.sum

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI=
4+
github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs=
5+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
6+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
28
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
3-
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
4-
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
9+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
10+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
11+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
12+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
13+
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
14+
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
15+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
16+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/cache/cache.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Package cache provides a generic persistent cache using Otter.
2+
package cache
3+
4+
import (
5+
"os"
6+
"sync"
7+
"time"
8+
9+
"github.com/maypok86/otter/v2"
10+
)
11+
12+
// Options configures a Cache instance.
13+
// It embeds otter.Options and adds persistence support.
14+
type Options[K comparable, V any] struct {
15+
otter.Options[K, V]
16+
17+
// Filepath is the path for persisting the cache.
18+
// If empty, persistence is disabled.
19+
Filepath string
20+
}
21+
22+
// Cache is a generic persistent cache that embeds [otter.Cache].
23+
// It adds persistence and background refresh capabilities.
24+
type Cache[K comparable, V any] struct {
25+
*otter.Cache[K, V]
26+
27+
cacheFilepath string
28+
29+
refreshStop chan struct{}
30+
refreshOnce sync.Once
31+
refreshMu sync.Mutex
32+
}
33+
34+
// New creates a new [Cache] instance with the given options.
35+
func New[K comparable, V any](opts *Options[K, V]) (*Cache[K, V], error) {
36+
otterCache, err := otter.New(&opts.Options)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
c := &Cache[K, V]{
42+
Cache: otterCache,
43+
cacheFilepath: opts.Filepath,
44+
}
45+
46+
if c.cacheFilepath != "" {
47+
if _, err := os.Stat(c.cacheFilepath); err == nil {
48+
_ = otter.LoadCacheFromFile(c.Cache, c.cacheFilepath)
49+
}
50+
}
51+
52+
return c, nil
53+
}
54+
55+
// Must is like [New] but panics on error.
56+
func Must[K comparable, V any](opts *Options[K, V]) *Cache[K, V] {
57+
c, err := New(opts)
58+
if err != nil {
59+
panic(err)
60+
}
61+
62+
return c
63+
}
64+
65+
// Save persists the cache to disk.
66+
// Returns nil if persistence is not configured.
67+
func (c *Cache[K, V]) Save() error {
68+
if c.cacheFilepath == "" {
69+
return nil
70+
}
71+
72+
return otter.SaveCacheToFile(c.Cache, c.cacheFilepath)
73+
}
74+
75+
// Load loads the cache from disk.
76+
// Returns nil if persistence is not configured.
77+
func (c *Cache[K, V]) Load() error {
78+
if c.cacheFilepath == "" {
79+
return nil
80+
}
81+
82+
return otter.LoadCacheFromFile(c.Cache, c.cacheFilepath)
83+
}
84+
85+
// FetchFunc is a function that fetches fresh data and populates the cache.
86+
type FetchFunc func() error
87+
88+
// StartBackgroundRefresh starts a goroutine that periodically calls fetchFn.
89+
// Call StopBackgroundRefresh to stop.
90+
func (c *Cache[K, V]) StartBackgroundRefresh(interval time.Duration, fetchFn FetchFunc) {
91+
if interval <= 0 || fetchFn == nil {
92+
return
93+
}
94+
95+
c.refreshOnce.Do(func() {
96+
c.refreshStop = make(chan struct{})
97+
go c.backgroundRefresh(interval, fetchFn)
98+
})
99+
}
100+
101+
// StopBackgroundRefresh stops the background refresh goroutine.
102+
func (c *Cache[K, V]) StopBackgroundRefresh() {
103+
c.refreshMu.Lock()
104+
defer c.refreshMu.Unlock()
105+
106+
if c.refreshStop != nil {
107+
close(c.refreshStop)
108+
c.refreshStop = nil
109+
c.refreshOnce = sync.Once{}
110+
}
111+
}
112+
113+
// backgroundRefresh periodically calls fetchFn.
114+
func (c *Cache[K, V]) backgroundRefresh(interval time.Duration, fetchFn FetchFunc) {
115+
ticker := time.NewTicker(interval)
116+
defer ticker.Stop()
117+
118+
for {
119+
select {
120+
case <-ticker.C:
121+
_ = fetchFn()
122+
case <-c.refreshStop:
123+
return
124+
}
125+
}
126+
}

internal/cache/cache_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package cache
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/maypok86/otter/v2"
9+
)
10+
11+
func TestCacheSetAndGet(t *testing.T) {
12+
c := newTestCache[string, string](t)
13+
14+
c.Set("key1", "value1")
15+
16+
got, ok := c.GetIfPresent("key1")
17+
if !ok {
18+
t.Fatal("key not found in cache")
19+
}
20+
21+
if got != "value1" {
22+
t.Errorf("value mismatch: got %s, want %s", got, "value1")
23+
}
24+
}
25+
26+
func TestCacheSize(t *testing.T) {
27+
c := newTestCache[string, int](t)
28+
29+
c.Set("a", 1)
30+
c.Set("b", 2)
31+
c.Set("c", 3)
32+
33+
if c.EstimatedSize() != 3 {
34+
t.Errorf("expected size 3, got %d", c.EstimatedSize())
35+
}
36+
}
37+
38+
func TestCacheInvalidate(t *testing.T) {
39+
c := newTestCache[string, string](t)
40+
41+
c.Set("key1", "value1")
42+
c.Set("key2", "value2")
43+
44+
if c.EstimatedSize() == 0 {
45+
t.Fatal("cache should have entries before invalidate")
46+
}
47+
48+
c.InvalidateAll()
49+
50+
if c.EstimatedSize() != 0 {
51+
t.Errorf("cache should be empty after invalidate, got size %d", c.EstimatedSize())
52+
}
53+
}
54+
55+
func TestCachePersistence(t *testing.T) {
56+
tmpDir := t.TempDir()
57+
tmpFile := filepath.Join(tmpDir, "test.gob")
58+
59+
// Create and populate cache
60+
c1, err := New(&Options[string, string]{Filepath: tmpFile})
61+
if err != nil {
62+
t.Fatalf("New failed: %v", err)
63+
}
64+
65+
c1.Set("persist1", "value1")
66+
c1.Set("persist2", "value2")
67+
68+
if err := c1.Save(); err != nil {
69+
t.Fatalf("Save failed: %v", err)
70+
}
71+
72+
// Verify file was created
73+
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
74+
t.Fatal("cache file was not created")
75+
}
76+
77+
// Create new cache and load
78+
c2, err := New(&Options[string, string]{Filepath: tmpFile})
79+
if err != nil {
80+
t.Fatalf("New failed: %v", err)
81+
}
82+
83+
if err := c2.Load(); err != nil {
84+
t.Fatalf("Load failed: %v", err)
85+
}
86+
87+
// Verify data was loaded
88+
if got, ok := c2.GetIfPresent("persist1"); !ok || got != "value1" {
89+
t.Errorf("persist1: got %q, want %q", got, "value1")
90+
}
91+
if got, ok := c2.GetIfPresent("persist2"); !ok || got != "value2" {
92+
t.Errorf("persist2: got %q, want %q", got, "value2")
93+
}
94+
}
95+
96+
func TestCacheAll(t *testing.T) {
97+
c := newTestCache[string, int](t)
98+
99+
c.Set("a", 1)
100+
c.Set("b", 2)
101+
c.Set("c", 3)
102+
103+
count := 0
104+
sum := 0
105+
for _, v := range c.All() {
106+
count++
107+
sum += v
108+
}
109+
110+
if count != 3 {
111+
t.Errorf("expected 3 iterations, got %d", count)
112+
}
113+
if sum != 6 {
114+
t.Errorf("expected sum 6, got %d", sum)
115+
}
116+
}
117+
118+
func TestCacheWithOtterOptions(t *testing.T) {
119+
tmpDir := t.TempDir()
120+
121+
c, err := New(&Options[string, string]{
122+
Options: otter.Options[string, string]{
123+
InitialCapacity: 1000,
124+
},
125+
Filepath: filepath.Join(tmpDir, "test.gob"),
126+
})
127+
if err != nil {
128+
t.Fatalf("New failed: %v", err)
129+
}
130+
131+
c.Set("key", "value")
132+
133+
got, ok := c.GetIfPresent("key")
134+
if !ok || got != "value" {
135+
t.Errorf("got %q, want %q", got, "value")
136+
}
137+
}
138+
139+
// newTestCache creates a cache with a temp directory for testing.
140+
func newTestCache[K comparable, V any](t *testing.T) *Cache[K, V] {
141+
t.Helper()
142+
143+
tmpDir := t.TempDir()
144+
c, err := New(&Options[K, V]{
145+
Filepath: filepath.Join(tmpDir, "test.gob"),
146+
})
147+
if err != nil {
148+
t.Fatalf("failed to create test cache: %v", err)
149+
}
150+
151+
return c
152+
}

internal/cache/utils.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cache
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
)
7+
8+
// BuildCacheFilepath constructs a cache filepath in the user's cache directory.
9+
//
10+
// Returns empty string if the path cannot be created.
11+
func BuildCacheFilepath(dir, file string) string {
12+
if dir == "" || file == "" {
13+
return ""
14+
}
15+
16+
userCacheDir, err := os.UserCacheDir()
17+
if err != nil {
18+
return ""
19+
}
20+
21+
fullDir := filepath.Join(userCacheDir, dir)
22+
if err := os.MkdirAll(fullDir, 0o755); err != nil {
23+
return ""
24+
}
25+
26+
return filepath.Join(fullDir, file)
27+
}

0 commit comments

Comments
 (0)