Skip to content

Commit 2b868f8

Browse files
authored
Merge pull request #15 from bbrowning/allowed-clients-dns
Support DNS hostnames in PAUDE_PROXY_ALLOWED_CLIENTS for Kubernetes pod filtering
2 parents 850c235 + 9eb5840 commit 2b868f8

3 files changed

Lines changed: 237 additions & 15 deletions

File tree

cmd/paude-proxy/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ func main() {
8787
// Credential store and token vendor
8888
credStore, tokenVendor := buildCredentialStore(domainFilter)
8989

90+
// Start background hostname re-resolution (no-op if no hostnames configured)
91+
clientFilter.StartResolving()
92+
9093
// Create and start proxy
9194
srv := proxy.New(proxy.Config{
9295
ListenAddr: listenAddr,
@@ -113,6 +116,7 @@ func main() {
113116

114117
<-done
115118
log.Println("Shutting down...")
119+
clientFilter.Stop()
116120

117121
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
118122
defer cancel()

internal/proxy/proxy.go

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -105,20 +105,31 @@ func (bl *BlockedLogger) Close() error {
105105
return bl.file.Close()
106106
}
107107

108-
// ClientFilter validates client source IPs against an allowlist of IPs and CIDRs.
109-
// A nil or empty ClientFilter allows all clients.
108+
// ClientFilter validates client source IPs against an allowlist of IPs, CIDRs,
109+
// and DNS hostnames. Hostnames are resolved to IPs at startup and periodically
110+
// re-resolved in the background (every 30s) to handle dynamic IP assignments
111+
// (e.g., Kubernetes pods restarting). A nil or empty ClientFilter allows all clients.
110112
type ClientFilter struct {
111-
ips []net.IP
112-
nets []*net.IPNet
113+
ips []net.IP
114+
nets []*net.IPNet
115+
hostnames []string
116+
resolved map[string][]net.IP // hostname -> resolved IPs (protected by mu)
117+
mu sync.RWMutex
118+
stopCh chan struct{}
119+
stopOnce sync.Once
113120
}
114121

115-
// NewClientFilter parses a comma-separated list of IPs and CIDRs.
116-
// Returns nil if the input is empty (allow all).
122+
// NewClientFilter parses a comma-separated list of IPs, CIDRs, and DNS hostnames.
123+
// Returns nil if the input is empty (allow all). Hostnames are resolved immediately;
124+
// resolution failures are logged as warnings (the hostname may become resolvable later).
117125
func NewClientFilter(s string) (*ClientFilter, error) {
118126
if s == "" {
119127
return nil, nil
120128
}
121-
cf := &ClientFilter{}
129+
cf := &ClientFilter{
130+
stopCh: make(chan struct{}),
131+
resolved: make(map[string][]net.IP),
132+
}
122133
for _, part := range strings.Split(s, ",") {
123134
part = strings.TrimSpace(part)
124135
if part == "" {
@@ -130,18 +141,98 @@ func NewClientFilter(s string) (*ClientFilter, error) {
130141
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
131142
}
132143
cf.nets = append(cf.nets, ipNet)
133-
} else {
134-
ip := net.ParseIP(part)
135-
if ip == nil {
136-
return nil, fmt.Errorf("invalid IP %q", part)
137-
}
144+
} else if ip := net.ParseIP(part); ip != nil {
138145
cf.ips = append(cf.ips, ip)
146+
} else {
147+
cf.hostnames = append(cf.hostnames, part)
139148
}
140149
}
150+
151+
if len(cf.hostnames) > 0 {
152+
cf.resolveHostnames(true)
153+
}
154+
141155
return cf, nil
142156
}
143157

144-
// IsAllowed returns true if the given IP is in the allowlist.
158+
// resolveHostnames resolves all configured hostnames and updates the resolved IP map.
159+
// When initialResolve is true, all results are logged. Otherwise, only changes are logged.
160+
func (cf *ClientFilter) resolveHostnames(initialResolve bool) {
161+
newResolved := make(map[string][]net.IP, len(cf.hostnames))
162+
for _, hostname := range cf.hostnames {
163+
addrs, err := net.LookupHost(hostname)
164+
if err != nil {
165+
log.Printf("WARNING: failed to resolve allowed client hostname %q: %v", hostname, err)
166+
continue
167+
}
168+
var ips []net.IP
169+
for _, addr := range addrs {
170+
if ip := net.ParseIP(addr); ip != nil {
171+
ips = append(ips, ip)
172+
}
173+
}
174+
newResolved[hostname] = ips
175+
}
176+
177+
cf.mu.Lock()
178+
old := cf.resolved
179+
cf.resolved = newResolved
180+
cf.mu.Unlock()
181+
182+
for _, hostname := range cf.hostnames {
183+
newIPs := newResolved[hostname]
184+
oldIPs := old[hostname]
185+
if initialResolve || !ipsEqual(newIPs, oldIPs) {
186+
ipStrs := make([]string, len(newIPs))
187+
for i, ip := range newIPs {
188+
ipStrs[i] = ip.String()
189+
}
190+
log.Printf("Resolved allowed client hostname %q -> %s", hostname, strings.Join(ipStrs, ", "))
191+
}
192+
}
193+
}
194+
195+
// ipsEqual returns true if two IP slices contain the same IPs in the same order.
196+
func ipsEqual(a, b []net.IP) bool {
197+
if len(a) != len(b) {
198+
return false
199+
}
200+
for i := range a {
201+
if !a[i].Equal(b[i]) {
202+
return false
203+
}
204+
}
205+
return true
206+
}
207+
208+
// StartResolving starts a background goroutine that re-resolves all hostname
209+
// entries every 30 seconds to handle pods restarting with new IPs.
210+
func (cf *ClientFilter) StartResolving() {
211+
if cf == nil || len(cf.hostnames) == 0 {
212+
return
213+
}
214+
go func() {
215+
ticker := time.NewTicker(30 * time.Second)
216+
defer ticker.Stop()
217+
for {
218+
select {
219+
case <-ticker.C:
220+
cf.resolveHostnames(false)
221+
case <-cf.stopCh:
222+
return
223+
}
224+
}
225+
}()
226+
}
227+
228+
// Stop stops the background hostname re-resolution goroutine. Safe to call multiple times.
229+
func (cf *ClientFilter) Stop() {
230+
if cf == nil || len(cf.hostnames) == 0 {
231+
return
232+
}
233+
cf.stopOnce.Do(func() { close(cf.stopCh) })
234+
}
235+
145236
func (cf *ClientFilter) IsAllowed(ip net.IP) bool {
146237
if cf == nil {
147238
return true
@@ -156,10 +247,19 @@ func (cf *ClientFilter) IsAllowed(ip net.IP) bool {
156247
return true
157248
}
158249
}
250+
cf.mu.RLock()
251+
resolved := cf.resolved
252+
cf.mu.RUnlock()
253+
for _, ips := range resolved {
254+
for _, allowed := range ips {
255+
if allowed.Equal(ip) {
256+
return true
257+
}
258+
}
259+
}
159260
return false
160261
}
161262

162-
// String returns a human-readable representation of the filter.
163263
func (cf *ClientFilter) String() string {
164264
if cf == nil {
165265
return "disabled (all clients allowed)"
@@ -171,6 +271,22 @@ func (cf *ClientFilter) String() string {
171271
for _, ipNet := range cf.nets {
172272
parts = append(parts, ipNet.String())
173273
}
274+
if len(cf.hostnames) > 0 {
275+
cf.mu.RLock()
276+
resolved := cf.resolved
277+
cf.mu.RUnlock()
278+
for _, hostname := range cf.hostnames {
279+
if ips, ok := resolved[hostname]; ok && len(ips) > 0 {
280+
ipStrs := make([]string, len(ips))
281+
for i, ip := range ips {
282+
ipStrs[i] = ip.String()
283+
}
284+
parts = append(parts, fmt.Sprintf("%s (resolved: %s)", hostname, strings.Join(ipStrs, ", ")))
285+
} else {
286+
parts = append(parts, fmt.Sprintf("%s (unresolved)", hostname))
287+
}
288+
}
289+
}
174290
return strings.Join(parts, ", ")
175291
}
176292

internal/proxy/proxy_test.go

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func TestNewClientFilter(t *testing.T) {
157157
{"CIDR", "10.0.0.0/24", false, false},
158158
{"mixed", "10.0.0.1,172.16.0.0/12", false, false},
159159
{"with spaces", " 10.0.0.1 , 10.0.0.2 ", false, false},
160-
{"invalid IP", "notanip", false, true},
160+
{"hostname", "notanip", false, false}, // treated as DNS hostname, not invalid
161161
{"invalid CIDR", "10.0.0.0/99", false, true},
162162
}
163163
for _, tt := range tests {
@@ -215,3 +215,105 @@ func TestClientFilter_NilAllowsAll(t *testing.T) {
215215
t.Error("nil ClientFilter should allow all IPs")
216216
}
217217
}
218+
219+
func TestNewClientFilter_Hostnames(t *testing.T) {
220+
cf, err := NewClientFilter("localhost")
221+
if err != nil {
222+
t.Fatalf("NewClientFilter(\"localhost\") error: %v", err)
223+
}
224+
if cf == nil {
225+
t.Fatal("expected non-nil ClientFilter")
226+
}
227+
if len(cf.hostnames) != 1 || cf.hostnames[0] != "localhost" {
228+
t.Errorf("expected hostnames=[localhost], got %v", cf.hostnames)
229+
}
230+
if len(cf.resolved["localhost"]) == 0 {
231+
t.Error("expected at least one resolved IP for localhost")
232+
}
233+
}
234+
235+
func TestNewClientFilter_MixedIPsHostnamesCIDRs(t *testing.T) {
236+
cf, err := NewClientFilter("10.0.0.1,localhost,172.16.0.0/12")
237+
if err != nil {
238+
t.Fatalf("unexpected error: %v", err)
239+
}
240+
if len(cf.ips) != 1 {
241+
t.Errorf("expected 1 static IP, got %d", len(cf.ips))
242+
}
243+
if len(cf.nets) != 1 {
244+
t.Errorf("expected 1 CIDR, got %d", len(cf.nets))
245+
}
246+
if len(cf.hostnames) != 1 || cf.hostnames[0] != "localhost" {
247+
t.Errorf("expected hostnames=[localhost], got %v", cf.hostnames)
248+
}
249+
}
250+
251+
func TestClientFilter_IsAllowed_WithResolvedIPs(t *testing.T) {
252+
cf := &ClientFilter{
253+
ips: []net.IP{net.ParseIP("10.0.0.1")},
254+
resolved: map[string][]net.IP{"myhost": {net.ParseIP("192.168.1.100")}},
255+
stopCh: make(chan struct{}),
256+
}
257+
258+
// Static IP should match
259+
if !cf.IsAllowed(net.ParseIP("10.0.0.1")) {
260+
t.Error("static IP 10.0.0.1 should be allowed")
261+
}
262+
// Resolved IP should match
263+
if !cf.IsAllowed(net.ParseIP("192.168.1.100")) {
264+
t.Error("resolved IP 192.168.1.100 should be allowed")
265+
}
266+
// Unknown IP should not match
267+
if cf.IsAllowed(net.ParseIP("10.0.0.2")) {
268+
t.Error("unknown IP 10.0.0.2 should not be allowed")
269+
}
270+
}
271+
272+
func TestClientFilter_IsAllowed_Localhost(t *testing.T) {
273+
cf, err := NewClientFilter("localhost")
274+
if err != nil {
275+
t.Fatalf("unexpected error: %v", err)
276+
}
277+
// localhost should resolve to 127.0.0.1 and/or ::1
278+
if !cf.IsAllowed(net.ParseIP("127.0.0.1")) && !cf.IsAllowed(net.ParseIP("::1")) {
279+
t.Error("expected localhost to resolve to 127.0.0.1 or ::1")
280+
}
281+
}
282+
283+
func TestClientFilter_StopNilSafe(t *testing.T) {
284+
var cf *ClientFilter
285+
cf.Stop() // nil — should not panic
286+
287+
cf2, _ := NewClientFilter("10.0.0.1")
288+
cf2.Stop() // no hostnames — should not panic
289+
}
290+
291+
func TestClientFilter_StopDoubleCall(t *testing.T) {
292+
cf, _ := NewClientFilter("localhost")
293+
cf.StartResolving()
294+
cf.Stop()
295+
cf.Stop() // second call — should not panic
296+
}
297+
298+
func TestClientFilter_String_WithHostnames(t *testing.T) {
299+
cf, err := NewClientFilter("10.0.0.1,localhost")
300+
if err != nil {
301+
t.Fatalf("unexpected error: %v", err)
302+
}
303+
s := cf.String()
304+
if !strings.Contains(s, "10.0.0.1") {
305+
t.Errorf("String() should contain static IP, got %q", s)
306+
}
307+
if !strings.Contains(s, "localhost") {
308+
t.Errorf("String() should contain hostname, got %q", s)
309+
}
310+
if !strings.Contains(s, "resolved:") {
311+
t.Errorf("String() should contain resolved IPs for localhost, got %q", s)
312+
}
313+
}
314+
315+
func TestClientFilter_StartResolving_NilSafe(t *testing.T) {
316+
// StartResolving on nil should not panic
317+
var cf *ClientFilter
318+
cf.StartResolving()
319+
}

0 commit comments

Comments
 (0)