Skip to content

Commit 16e1206

Browse files
committed
Add TCP file list
1 parent adbb3a7 commit 16e1206

4 files changed

Lines changed: 229 additions & 10 deletions

File tree

.traefik.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ iconPath: .assets/icon.png
44

55
import: github.com/CAJIO/allowiprequest
66

7-
summary: '[CAJIO] Allow IP Request on request path /knock-knock'
7+
summary: 'Its plugin that allows you to whitelist your ip address by visiting a secret URL. It protects your services by blocking all requests by default while allowing users to self-whitelist their IP address by visiting a secret URL.'
88

99
testData:
1010
KnockURL: "/knock-knock"
@@ -13,3 +13,5 @@ testData:
1313
- "192.168.0.0/16"
1414
- "10.0.0.0/8"
1515
- "127.0.0.0/8"
16+
SyncAllowlist: true
17+
AllowlistFile: "/etc/traefik/conf.d/allowlist.yml"

allowiprequest.go

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,22 @@ package allowiprequest
33
import (
44
"context"
55
"fmt"
6+
"log"
67
"net"
78
"net/http"
9+
"os"
10+
"path/filepath"
811
"strings"
912
"sync"
10-
"text/template"
1113
"time"
1214
)
1315

1416
type Config struct {
1517
KnockURL string `json:"knockUrl,omitempty"`
1618
WhitelistDuration string `json:"whitelistDuration,omitempty"`
1719
AllowedSubnets []string `json:"allowedSubnets,omitempty"`
20+
SyncAllowlist bool `json:"syncAllowlist,omitempty"`
21+
AllowlistFile string `json:"allowlistFile,omitempty"`
1822
}
1923

2024
func CreateConfig() *Config {
@@ -25,12 +29,15 @@ func CreateConfig() *Config {
2529
}
2630
}
2731

28-
type Demo struct {
32+
type AllowIpR struct {
2933
next http.Handler
3034
name string
3135
knockURL string
3236
whitelistDuration time.Duration
3337
allowedIPNets []*net.IPNet
38+
allowedSubnets []string
39+
syncAllowlist bool
40+
allowlistFile string
3441
whitelist map[string]time.Time
3542
mu sync.RWMutex
3643
}
@@ -54,17 +61,30 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
5461
allowedIPNets = append(allowedIPNets, ipNet)
5562
}
5663

57-
return &Demo{
64+
d := &AllowIpR{
5865
next: next,
5966
name: name,
6067
knockURL: config.KnockURL,
6168
whitelistDuration: duration,
6269
allowedIPNets: allowedIPNets,
70+
allowedSubnets: config.AllowedSubnets,
71+
syncAllowlist: config.SyncAllowlist,
72+
allowlistFile: config.AllowlistFile,
6373
whitelist: make(map[string]time.Time),
64-
}, nil
74+
}
75+
76+
if d.syncAllowlist {
77+
if d.allowlistFile == "" {
78+
return nil, fmt.Errorf("allowlistFile must be set when syncAllowlist is enabled")
79+
}
80+
log.Printf("[allowiprequest] allowlist sync enabled, file: %s", d.allowlistFile)
81+
d.writeAllowlist()
82+
}
83+
84+
return d, nil
6585
}
6686

67-
func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
87+
func (a *AllowIpR) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
6888
// Extract IP (without port)
6989
host, _, err := net.SplitHostPort(req.RemoteAddr)
7090
if err != nil {
@@ -98,6 +118,9 @@ func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
98118
a.mu.Lock()
99119
a.whitelist[host] = time.Now().Add(a.whitelistDuration)
100120
a.mu.Unlock()
121+
if a.syncAllowlist {
122+
a.writeAllowlist()
123+
}
101124
a.serveSuccessPage(rw, host)
102125
return
103126
}
@@ -122,14 +145,17 @@ func (a *Demo) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
122145
a.mu.Lock()
123146
delete(a.whitelist, host)
124147
a.mu.Unlock()
148+
if a.allowlistFile != "" {
149+
a.writeAllowlist()
150+
}
125151
}
126152
}
127153

128154
// Block by default
129155
http.Error(rw, "Forbidden", http.StatusForbidden)
130156
}
131157

132-
func (a *Demo) serveSuccessPage(rw http.ResponseWriter, ip string) {
158+
func (a *AllowIpR) serveSuccessPage(rw http.ResponseWriter, ip string) {
133159
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
134160
rw.WriteHeader(http.StatusOK)
135161
html := fmt.Sprintf(`
@@ -157,7 +183,7 @@ func (a *Demo) serveSuccessPage(rw http.ResponseWriter, ip string) {
157183
rw.Write([]byte(html))
158184
}
159185

160-
func (a *Demo) serveAdminPage(rw http.ResponseWriter) {
186+
func (a *AllowIpR) serveAdminPage(rw http.ResponseWriter) {
161187
a.mu.RLock()
162188
// Copy whitelist to avoid holding lock while rendering
163189
ips := make(map[string]time.Time, len(a.whitelist))
@@ -217,3 +243,66 @@ func (a *Demo) serveAdminPage(rw http.ResponseWriter) {
217243

218244
rw.Write([]byte(html))
219245
}
246+
247+
func (a *AllowIpR) writeAllowlist() {
248+
var sb strings.Builder
249+
sb.WriteString("tcp:\n")
250+
sb.WriteString(" middlewares:\n")
251+
sb.WriteString(" local-whitelist:\n")
252+
sb.WriteString(" IPAllowList:\n")
253+
sb.WriteString(" sourceRange:\n")
254+
255+
for _, subnet := range a.allowedSubnets {
256+
sb.WriteString(fmt.Sprintf(" - \"%s\"\n", subnet))
257+
}
258+
259+
now := time.Now()
260+
a.mu.RLock()
261+
for ip, expiry := range a.whitelist {
262+
if now.Before(expiry) {
263+
parsed := net.ParseIP(ip)
264+
if parsed == nil {
265+
continue
266+
}
267+
if parsed.To4() != nil {
268+
sb.WriteString(fmt.Sprintf(" - \"%s/32\"\n", ip))
269+
} else {
270+
sb.WriteString(fmt.Sprintf(" - \"%s/128\"\n", ip))
271+
}
272+
}
273+
}
274+
a.mu.RUnlock()
275+
276+
dir := filepath.Dir(a.allowlistFile)
277+
if err := os.MkdirAll(dir, 0o755); err != nil {
278+
log.Printf("[allowiprequest] failed to create directory %s: %v", dir, err)
279+
return
280+
}
281+
282+
tmp, err := os.CreateTemp(dir, ".allowlist-*.yml.tmp")
283+
if err != nil {
284+
log.Printf("[allowiprequest] failed to create temp file in %s: %v", dir, err)
285+
return
286+
}
287+
tmpName := tmp.Name()
288+
289+
if _, err := tmp.WriteString(sb.String()); err != nil {
290+
tmp.Close()
291+
os.Remove(tmpName)
292+
log.Printf("[allowiprequest] failed to write temp file: %v", err)
293+
return
294+
}
295+
if err := tmp.Close(); err != nil {
296+
os.Remove(tmpName)
297+
log.Printf("[allowiprequest] failed to close temp file: %v", err)
298+
return
299+
}
300+
301+
if err := os.Rename(tmpName, a.allowlistFile); err != nil {
302+
os.Remove(tmpName)
303+
log.Printf("[allowiprequest] failed to rename %s -> %s: %v", tmpName, a.allowlistFile, err)
304+
return
305+
}
306+
307+
log.Printf("[allowiprequest] allowlist written to %s", a.allowlistFile)
308+
}

allowiprequest_test.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import (
44
"context"
55
"net/http"
66
"net/http/httptest"
7+
"os"
8+
"path/filepath"
79
"strings"
810
"testing"
911
"time"
1012

1113
"github.com/CAJIO/allowiprequest"
1214
)
1315

14-
func TestDemo(t *testing.T) {
16+
func TestAllowIpR(t *testing.T) {
1517
cfg := allowiprequest.CreateConfig()
1618
cfg.KnockURL = "/knock-knock"
1719
cfg.WhitelistDuration = "1s" // Short duration for testing
@@ -23,7 +25,7 @@ func TestDemo(t *testing.T) {
2325
rw.Write([]byte("OK"))
2426
})
2527

26-
handler, err := allowiprequest.New(ctx, next, cfg, "demo-plugin")
28+
handler, err := allowiprequest.New(ctx, next, cfg, "allowiprequest-plugin")
2729
if err != nil {
2830
t.Fatal(err)
2931
}
@@ -109,3 +111,72 @@ func TestDemo(t *testing.T) {
109111
t.Errorf("expected 403 Forbidden for admin view from external IP, got %d", recorder.Code)
110112
}
111113
}
114+
115+
func TestAllowlistFileWrite(t *testing.T) {
116+
tmpDir := t.TempDir()
117+
allowlistPath := filepath.Join(tmpDir, "conf.d", "allowlist.yml")
118+
119+
cfg := allowiprequest.CreateConfig()
120+
cfg.KnockURL = "/knock-knock"
121+
cfg.WhitelistDuration = "1s"
122+
cfg.AllowedSubnets = []string{"192.168.0.0/16"}
123+
cfg.SyncAllowlist = true
124+
cfg.AllowlistFile = allowlistPath
125+
126+
ctx := context.Background()
127+
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
128+
rw.WriteHeader(http.StatusOK)
129+
})
130+
131+
handler, err := allowiprequest.New(ctx, next, cfg, "allowiprequest-plugin")
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
136+
// File should be created on init with static subnets
137+
data, err := os.ReadFile(allowlistPath)
138+
if err != nil {
139+
t.Fatalf("allowlist file not created on init: %v", err)
140+
}
141+
content := string(data)
142+
if !strings.Contains(content, "192.168.0.0/16") {
143+
t.Errorf("allowlist file missing static subnet, got:\n%s", content)
144+
}
145+
if !strings.Contains(content, "IPAllowList") {
146+
t.Errorf("allowlist file missing IPAllowList key, got:\n%s", content)
147+
}
148+
149+
// Knock to add dynamic IP
150+
req := httptest.NewRequest(http.MethodGet, "http://localhost/knock-knock", nil)
151+
req.RemoteAddr = "10.0.0.2:4321"
152+
recorder := httptest.NewRecorder()
153+
handler.ServeHTTP(recorder, req)
154+
155+
data, err = os.ReadFile(allowlistPath)
156+
if err != nil {
157+
t.Fatalf("failed to read allowlist after knock: %v", err)
158+
}
159+
content = string(data)
160+
if !strings.Contains(content, "10.0.0.2/32") {
161+
t.Errorf("allowlist file missing knocked IP, got:\n%s", content)
162+
}
163+
if !strings.Contains(content, "192.168.0.0/16") {
164+
t.Errorf("allowlist file lost static subnet after knock, got:\n%s", content)
165+
}
166+
167+
// Wait for expiry, then trigger cleanup
168+
time.Sleep(1100 * time.Millisecond)
169+
req = httptest.NewRequest(http.MethodGet, "http://localhost/", nil)
170+
req.RemoteAddr = "10.0.0.2:4321"
171+
recorder = httptest.NewRecorder()
172+
handler.ServeHTTP(recorder, req)
173+
174+
data, err = os.ReadFile(allowlistPath)
175+
if err != nil {
176+
t.Fatalf("failed to read allowlist after expiry: %v", err)
177+
}
178+
content = string(data)
179+
if strings.Contains(content, "10.0.0.2/32") {
180+
t.Errorf("allowlist file still contains expired IP, got:\n%s", content)
181+
}
182+
}

readme.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ To use this plugin, you must configure it in your Traefik dynamic configuration.
2525
| `knockUrl` | `string` | `"/knock-knock"` | The specific URL path users must visit to whitelist their IP. |
2626
| `whitelistDuration` | `string` | `"24h"` | How long the IP remains whitelisted (e.g., "1h", "30m"). |
2727
| `allowedSubnets` | `[]string` | `["192.168.0.0/16", ...]` | List of CIDR ranges that bypass the knock check and can view the admin page. |
28+
| `syncAllowlist` | `bool` | `false` | Enable writing the current allowlist to a YAML file for use as a Traefik TCP middleware. |
29+
| `allowlistFile` | `string` | `""` | Path to the YAML file (required when `syncAllowlist` is `true`). |
2830

2931
### Add the plugin to Traefik
3032
```yaml
@@ -49,6 +51,61 @@ http:
4951
- "192.168.1.0/24"
5052
```
5153
54+
### TCP Allowlist Sync
55+
56+
The plugin can write a YAML file that Traefik's [file provider](https://doc.traefik.io/traefik/providers/file/) picks up as a TCP `IPAllowList` middleware. This lets you reuse the same dynamic allowlist for TCP routers (e.g. databases, mail servers) that don't support HTTP middleware plugins.
57+
58+
Enable it by setting `syncAllowlist: true` and providing the output path:
59+
60+
```yaml
61+
http:
62+
middlewares:
63+
my-ip-allowlist:
64+
plugin:
65+
allowiprequest:
66+
knockUrl: "/knock-knock"
67+
whitelistDuration: "24h"
68+
allowedSubnets:
69+
- "127.0.0.1/32"
70+
- "192.168.1.0/24"
71+
syncAllowlist: true
72+
allowlistFile: "/etc/traefik/conf.d/allowlist.yml"
73+
```
74+
75+
The generated file has the following structure and is updated atomically on every change (knock / expiry):
76+
77+
```yaml
78+
tcp:
79+
middlewares:
80+
local-whitelist:
81+
IPAllowList:
82+
sourceRange:
83+
- "127.0.0.1/32"
84+
- "192.168.1.0/24"
85+
- "1.2.3.4/32" # dynamically added via knock
86+
```
87+
88+
To use it, configure Traefik's file provider to watch the output directory and reference the middleware in a TCP router:
89+
90+
```yaml
91+
# traefik.yml (static config)
92+
providers:
93+
file:
94+
directory: /etc/traefik/conf.d
95+
watch: true
96+
```
97+
98+
```yaml
99+
# TCP router example
100+
tcp:
101+
routers:
102+
my-tcp-service:
103+
rule: "HostSNI(`*`)"
104+
middlewares:
105+
- local-whitelist
106+
service: my-tcp-backend
107+
```
108+
52109
### Router Configuration
53110
```yaml
54111
http:

0 commit comments

Comments
 (0)