Skip to content

Commit b769f5d

Browse files
author
Mario Macias
committed
rate limiting
1 parent 5cfa910 commit b769f5d

File tree

13 files changed

+367
-16
lines changed

13 files changed

+367
-16
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### v2.x
44

5+
* Added configurable per-client rate limiter
56
* Updated goldmark rendering.
67
* Support github-flavored tables.
78

TODO.md

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
* Configure which assets are cacheable or not (e.g. by route, by extension, by size...)
2-
* Improve HTTP cache control
3-
* Cache static assets
4-
* Log each URL access
52
* Configure some aspects via yaml (title, navbars, meta...)
6-
* Detect changes in the filesystem and recompile markdowns and templates.
73
* Add github hooks. Reload blogs from hooks.
84
* Read linked images from markdown folder (for an easier edition).
95
* Markdown Tags processing. Adding entry tags to header
106
* Support Favicon
117
* Don't need to set hours in file timestamps
12-
*
8+
9+
10+
## Save bandwidth of my blog
11+
12+
* Compress HTML files
13+
* Put font-awesome stuff in a CDN
14+
* Mark images, fonts, favicons, etc... as cacheable by the browser

docs/arch_v2.puml

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ interface WebAssetGenerator {
1818
}
1919

2020
TLSServer -> HTTPServer: wraps
21-
HTTPServer -> CachedHandler
21+
HTTPServer -> RateLimiter
22+
RateLimiter -> CachedHandler
2223
WebAssetGenerator .> WebAsset: <<creates>>
2324
CachedHandler --> "*" WebAssetGenerator: "selects\nby route"
2425
CachedHandler --> "*" WebAsset: stores

etc/config-localtest.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ rootPath: ./etc/macias.info
22
domain: localhost
33
redirect:
44
"/about.md": "/entry/about.md"
5-
entriesPerPage: 10
5+
entriesPerPage: 10
6+
maxRequests:
7+
number: 60

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
github.com/caarlos0/env/v6 v6.9.1
77
github.com/floscodes/golang-tools v0.0.0-20210816125844-1d6c9227bad8
88
github.com/fsnotify/fsnotify v1.5.4
9-
github.com/mariomac/guara v0.0.0-20220617140441-a74253bb0c8b
9+
github.com/mariomac/guara v0.0.0-20221222112709-f95b15506aee
1010
github.com/sirupsen/logrus v1.8.1
1111
github.com/stretchr/testify v1.7.1
1212
github.com/yuin/goldmark v1.5.3

go.sum

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
1919
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
2020
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2121
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
22-
github.com/mariomac/guara v0.0.0-20220617140441-a74253bb0c8b h1:4DNXnhPSwCx17B34vWrYfkgnMGkFcPp6unoq04ZKJLY=
23-
github.com/mariomac/guara v0.0.0-20220617140441-a74253bb0c8b/go.mod h1:TMuTALUh4SPCDJdYOuOYIEwBgH2rzCDP1nNgj+lI8AE=
22+
github.com/mariomac/guara v0.0.0-20221222112709-f95b15506aee h1:eV2ZsJnNptnwRkLkm4TKqklf93VQTKrj0+4ulQ21USA=
23+
github.com/mariomac/guara v0.0.0-20221222112709-f95b15506aee/go.mod h1:TMuTALUh4SPCDJdYOuOYIEwBgH2rzCDP1nNgj+lI8AE=
2424
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
2525
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
2626
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

src/blog.go

+8-3
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,16 @@ func main() {
6565
"automatically updated if you change any file")
6666
}
6767

68-
var globalHandler http.Handler
68+
var globalHandler http.HandlerFunc
6969
if len(cfg.Redirect) == 0 {
70-
globalHandler = mux
70+
globalHandler = mux.ServeHTTP
7171
} else {
72-
globalHandler = legacy.NewRedirector(cfg.Redirect, mux)
72+
globalHandler = legacy.NewRedirector(cfg.Redirect, mux).ServeHTTP
73+
}
74+
75+
if cfg.MaxRequests.Number > 0 && cfg.MaxRequests.Period > 0 {
76+
globalHandler = conn.ClientRateLimitHandler(globalHandler,
77+
cfg.MaxRequests.Number, cfg.MaxRequests.Period, cfg.MaxRequests.Period)
7378
}
7479

7580
log.Printf("Redirecting insecure traffic from port %v", cfg.InsecurePort)

src/conn/limiter.go

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package conn
2+
3+
import (
4+
"bytes"
5+
"container/list"
6+
"fmt"
7+
"github.com/mariomac/guara/pkg/rate"
8+
"hash/fnv"
9+
"math"
10+
"net/http"
11+
"sync"
12+
13+
"time"
14+
)
15+
16+
var clock = time.Now
17+
18+
func ClientRateLimitHandler(inner http.HandlerFunc, maxReqs int, period, clientExpiry time.Duration) http.HandlerFunc {
19+
retryAfterVal := fmt.Sprint(int(math.Ceil(0.1 * period.Seconds())))
20+
limiter := NewLimiter(maxReqs, period, clientExpiry)
21+
return func(rw http.ResponseWriter, req *http.Request) {
22+
// assumes we are using the http.Server, which sets RemoteAddr to IP:port
23+
rAddr := []byte(req.RemoteAddr)
24+
li := bytes.LastIndexByte(rAddr, ':')
25+
// we don't check li < 0 as long as we are sure the server package adds :port
26+
if !limiter.Accept(rAddr[:li]) {
27+
rw.WriteHeader(http.StatusTooManyRequests)
28+
rw.Header().Add("Retry-After", retryAfterVal)
29+
} else {
30+
inner(rw, req)
31+
}
32+
}
33+
}
34+
35+
36+
// To avoid storing all the possible IPs, we hash it to 65K concurrent IPs
37+
// TODO: make size configurable for larger sites
38+
type IPHash uint16
39+
40+
type Limiter struct {
41+
maxReqs float64
42+
period time.Duration
43+
44+
cache *TimedLRU[IPHash, *rate.Accepter, bool]
45+
}
46+
47+
func NewLimiter(maxReqs int, period, clientExpiry time.Duration) *Limiter {
48+
return &Limiter{
49+
maxReqs: float64(maxReqs),
50+
period: period,
51+
cache: NewTimedLRU[IPHash, *rate.Accepter, bool](clientExpiry),
52+
}
53+
}
54+
55+
func (l *Limiter) ClearOldEntries() {
56+
l.cache.RemoveOldItems()
57+
}
58+
59+
func (l *Limiter) Accept(ip []byte) bool {
60+
hasher := fnv.New32()
61+
_, _ = hasher.Write(ip)
62+
hash32 := hasher.Sum32()
63+
ipHash := IPHash(hash32 >> 16 ^ hash32)
64+
65+
return l.cache.QueryUpdateOrCreate(ipHash, func(r **rate.Accepter) bool {
66+
return (*r).Accept()
67+
}, func() (*rate.Accepter, bool) {
68+
return rate.NewAccepter(l.maxReqs, l.period), true
69+
})
70+
}
71+
72+
73+
74+
type TimedLRU[K comparable, V, Q any] struct {
75+
mt sync.Mutex
76+
maxTime time.Duration
77+
ll *list.List
78+
cache map[K]*list.Element
79+
}
80+
81+
type entry[K comparable, V any] struct {
82+
key K
83+
value V
84+
lastTime time.Time
85+
}
86+
87+
func NewTimedLRU[K comparable, V, Q any](maxTime time.Duration) *TimedLRU[K, V, Q] {
88+
return &TimedLRU[K, V, Q]{
89+
maxTime: maxTime,
90+
ll: list.New(),
91+
cache: map[K]*list.Element{},
92+
}
93+
}
94+
95+
func (c *TimedLRU[K, V, Q]) QueryUpdateOrCreate(key K, queryUpdate func(*V) Q, create func() (V, Q)) Q {
96+
c.mt.Lock()
97+
defer c.mt.Unlock()
98+
ee, ok := c.cache[key]
99+
if ok {
100+
ee.Value.(*entry[K, V]).lastTime = clock()
101+
return queryUpdate(&ee.Value.(*entry[K, V]).value)
102+
}
103+
cr, qu := create()
104+
ele := c.ll.PushFront(&entry[K, V]{key: key, value: cr, lastTime: clock()})
105+
c.cache[key] = ele
106+
return qu
107+
}
108+
109+
func (c *TimedLRU[K, V, Q]) RemoveOldItems() {
110+
oldDate := clock().Add(-c.maxTime)
111+
for c.removeIfOld(oldDate) {
112+
// hello!
113+
}
114+
}
115+
116+
func (c *TimedLRU[K, V, Q]) removeIfOld(oldDate time.Time) bool {
117+
c.mt.Lock()
118+
defer c.mt.Unlock()
119+
120+
last := c.ll.Back()
121+
if last == nil {
122+
return false
123+
}
124+
125+
ve := last.Value.(*entry[K, V])
126+
if ve.lastTime.After(oldDate) {
127+
return false
128+
}
129+
130+
c.ll.Remove(last)
131+
delete(c.cache, ve.key)
132+
return true
133+
}

src/conn/limiter_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package conn
2+
3+
import (
4+
"fmt"
5+
"github.com/stretchr/testify/assert"
6+
"net"
7+
"runtime"
8+
"sync"
9+
"sync/atomic"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestNewLimiter(t *testing.T) {
15+
l := NewLimiter(60, 100 * time.Millisecond, time.Minute)
16+
17+
ip1 := net.IPv4(1,2,3,4)
18+
ip2 := net.IPv4(4,3,2,1)
19+
20+
start := time.Now()
21+
22+
// should accept all the queries as long as there are less than 60/100ms
23+
for time.Now().Sub(start) < 30 * time.Millisecond {
24+
assert.True(t, l.Accept(ip1))
25+
time.Sleep(time.Millisecond)
26+
}
27+
// should end up rejecting queries
28+
last := true
29+
for time.Now().Sub(start) < 90 * time.Millisecond {
30+
last = last && l.Accept(ip1)
31+
}
32+
assert.False(t, last)
33+
34+
time.Sleep(30 * time.Millisecond)
35+
l.ClearOldEntries()
36+
37+
// after some time, there is more room to get enough queries
38+
start = time.Now()
39+
for i := 0 ; i < 10 ; i++ {
40+
assert.True(t, l.Accept(ip1))
41+
assert.True(t, l.Accept(ip2))
42+
time.Sleep(time.Millisecond)
43+
}
44+
}
45+
46+
func TestTimedLRU(t *testing.T) {
47+
now := time.Now()
48+
clock = func() time.Time {
49+
return now
50+
}
51+
tlru := NewTimedLRU[string, string, int](time.Second)
52+
53+
qu := func(i *string) int {
54+
*i = *i + "-"
55+
return len(*i)
56+
}
57+
cr := func () (string, int) {
58+
return "", 0
59+
}
60+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("hi", qu, cr))
61+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("ho", qu, cr))
62+
now = now.Add(800 * time.Millisecond)
63+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("foo", qu, cr))
64+
assert.Equal(t, 1, tlru.QueryUpdateOrCreate("ho", qu, cr))
65+
now = now.Add(800 * time.Millisecond)
66+
tlru.RemoveOldItems()
67+
// "hi" has been deleted then it returns a fresh one
68+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("hi", qu, cr))
69+
assert.Equal(t, 2, tlru.QueryUpdateOrCreate("ho", qu, cr))
70+
assert.Equal(t, 1, tlru.QueryUpdateOrCreate("foo", qu, cr))
71+
now = now.Add(10000 * time.Millisecond)
72+
tlru.RemoveOldItems()
73+
// all entries have been deleted then it returns fresh instances
74+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("hi", qu, cr))
75+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("ho", qu, cr))
76+
assert.Equal(t, 0, tlru.QueryUpdateOrCreate("foo", qu, cr))
77+
}
78+
79+
func TestTimedLRU_ConcurrencyRaces(t *testing.T) {
80+
timeBias := int64(0)
81+
clock = func() time.Time {
82+
return time.Now().Add(time.Duration(atomic.LoadInt64(&timeBias)))
83+
}
84+
tlru := NewTimedLRU[string, int64, bool](time.Second)
85+
wg := sync.WaitGroup{}
86+
wg.Add(4)
87+
for i:= 0 ; i < 4 ; i++ {
88+
thread := i
89+
go func() {
90+
start := time.Now()
91+
defer wg.Done()
92+
for time.Now().Sub(start) < 100 * time.Millisecond {
93+
clk := clock()
94+
key := fmt.Sprint(clk.UnixMilli())
95+
tlru.QueryUpdateOrCreate(key, func(v *int64) bool {
96+
atomic.AddInt64(v, 1)
97+
return true
98+
}, func() (int64, bool) {
99+
return 0, true
100+
})
101+
tlru.RemoveOldItems()
102+
runtime.Gosched()
103+
}
104+
if thread == 0 {
105+
atomic.StoreInt64(&timeBias, int64(950 * time.Millisecond))
106+
tlru.RemoveOldItems()
107+
} else {
108+
for time.Now().Sub(start) < 200 * time.Millisecond {
109+
clk := clock()
110+
key := fmt.Sprint(clk.UnixMilli())
111+
tlru.QueryUpdateOrCreate(key, func(v *int64) bool {
112+
atomic.AddInt64(v, 1)
113+
return true
114+
}, func() (int64, bool) {
115+
return 0, true
116+
})
117+
tlru.RemoveOldItems()
118+
runtime.Gosched()
119+
}
120+
}
121+
}()
122+
}
123+
wg.Wait()
124+
fmt.Println("hoe!")
125+
}

src/install/config.go

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package install
22

33
import (
4-
"io/ioutil"
4+
"os"
5+
"time"
56

67
"github.com/caarlos0/env/v6"
78
"gopkg.in/yaml.v2"
89
)
910

11+
type MaxRequestsCfg struct {
12+
Number int `env:"GOBLOG_MAX_REQUESTS_NUMBER" yaml:"number"`
13+
Period time.Duration `env:"GOBLOG_MAX_REQUESTS_PERIOD" yaml:"period"`
14+
}
15+
1016
// Config of the blog installation. Via file or env vars.
1117
type Config struct {
1218
RootPath string `env:"GOBLOG_ROOT" yaml:"rootPath"`
@@ -18,6 +24,7 @@ type Config struct {
1824
Redirect map[string]string `env:"GOBLOG_REDIRECT" yaml:"redirect"`
1925
CacheSizeBytes int `env:"GOBLOG_CACHE_SIZE_BYTES" yaml:"cacheSizeBytes"`
2026
EntriesPerPage int `env:"GOBLOG_ENTRIES_PER_PAGE" yaml:"entriesPerPage"`
27+
MaxRequests MaxRequestsCfg `yaml:"maxRequests"`
2128
}
2229

2330
// ReadConfig gets a Config object from the environment and the provided yamlPath (optional)
@@ -31,11 +38,14 @@ func ReadConfig(yamlPath string) (Config, error) {
3138
TLSCertPath: "",
3239
CacheSizeBytes: 32 * 1024 * 1024, // 32 MB
3340
EntriesPerPage: 5,
41+
MaxRequests: MaxRequestsCfg{
42+
Period: time.Minute,
43+
},
3444
}
3545

3646
// override them with YAML
3747
if yamlPath != "" {
38-
yf, err := ioutil.ReadFile(yamlPath)
48+
yf, err := os.ReadFile(yamlPath)
3949
if err != nil {
4050
return cfg, err
4151
}

0 commit comments

Comments
 (0)