1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
|
# Deploying an IPv6 Test Service on Kubernetes
## Introduction
Deploying a simple IPv6/IPv4 connectivity test application to the f3s Kubernetes cluster. It displays visitors' IP addresses and tells them whether they're connecting via IPv6 or IPv4—useful for testing dual-stack connectivity.
The interesting bit was preserving the original client IP address through multiple reverse proxies: from the OpenBSD relayd frontends, through Traefik ingress, to the Apache CGI backend.
[f3s series](./2024-11-17-f3s-kubernetes-with-freebsd-part-1.md)
## Architecture Overview
The request flow looks like this:
```
Client → relayd (OpenBSD) → Traefik (k3s) → Apache + CGI (Pod)
```
Each hop needs to preserve the client's real IP address via the `X-Forwarded-For` header.
## The Application
The application is a simple Perl CGI script that:
1. Detects whether the client is using IPv4 or IPv6
2. Performs DNS lookups on client and server addresses
3. Displays diagnostic information
```perl
#!/usr/bin/perl
use strict;
use warnings;
print "Content-type: text/html\n\n";
my $is_ipv4 = ($ENV{REMOTE_ADDR} =~ /(?:\d+\.){3}\d/);
print "You are using: " . ($is_ipv4 ? "IPv4" : "IPv6") . "\n";
print "Client address: $ENV{REMOTE_ADDR}\n";
```
## Docker Image
The Docker image uses Apache httpd with CGI and `mod_remoteip` enabled:
```dockerfile
FROM httpd:2.4-alpine
RUN apk add --no-cache perl bind-tools
# Enable CGI and remoteip modules
RUN sed -i 's/#LoadModule cgid_module/LoadModule cgid_module/' \
/usr/local/apache2/conf/httpd.conf && \
sed -i 's/#LoadModule remoteip_module/LoadModule remoteip_module/' \
/usr/local/apache2/conf/httpd.conf && \
echo 'RemoteIPHeader X-Forwarded-For' >> /usr/local/apache2/conf/httpd.conf && \
echo 'RemoteIPInternalProxy 10.0.0.0/8' >> /usr/local/apache2/conf/httpd.conf && \
echo 'RemoteIPInternalProxy 192.168.0.0/16' >> /usr/local/apache2/conf/httpd.conf
COPY index.pl /usr/local/apache2/cgi-bin/index.pl
```
The key is `mod_remoteip`: it reads the `X-Forwarded-For` header and sets `REMOTE_ADDR` to the original client IP. The `RemoteIPInternalProxy` directives tell Apache which upstream proxies to trust.
## Kubernetes Deployment
The Helm chart is straightforward:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipv6test
namespace: services
spec:
replicas: 1
selector:
matchLabels:
app: ipv6test
template:
spec:
containers:
- name: ipv6test
image: registry.lan.buetow.org:30001/ipv6test:1.1.0
ports:
- containerPort: 80
```
## Configuring Traefik to Trust Forwarded Headers
By default, Traefik overwrites `X-Forwarded-For` with its own view of the client IP (which is the upstream proxy, not the real client). To preserve the original header, Traefik needs to trust the upstream proxies.
In k3s, this is configured via a HelmChartConfig:
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
additionalArguments:
- "--entryPoints.web.forwardedHeaders.trustedIPs=192.168.0.0/16,10.0.0.0/8"
- "--entryPoints.websecure.forwardedHeaders.trustedIPs=192.168.0.0/16,10.0.0.0/8"
```
This tells Traefik to trust `X-Forwarded-For` headers from the WireGuard tunnel IPs (where relayd connects from) and internal pod networks.
## Relayd Configuration
The OpenBSD relayd proxy already sets the `X-Forwarded-For` header:
```
http protocol "https" {
match request header set "X-Forwarded-For" value "$REMOTE_ADDR"
match request header set "X-Forwarded-Proto" value "https"
}
```
## IPv4-Only and IPv6-Only Subdomains
To properly test IPv4 and IPv6 connectivity separately, three hostnames are configured:
* ipv6test.f3s.buetow.org - Dual stack (A + AAAA records)
* ipv4.ipv6test.f3s.buetow.org - IPv4 only (A record only)
* ipv6.ipv6test.f3s.buetow.org - IPv6 only (AAAA record only)
The NSD zone template dynamically generates the correct record types:
```perl
<% for my $host (@$f3s_hosts) {
my $is_ipv6_only = $host =~ /^ipv6\./;
my $is_ipv4_only = $host =~ /^ipv4\./;
-%>
<% unless ($is_ipv6_only) { -%>
<%= $host %>. 300 IN A <%= $ips->{current_master}{ipv4} %>
<% } -%>
<% unless ($is_ipv4_only) { -%>
<%= $host %>. 300 IN AAAA <%= $ips->{current_master}{ipv6} %>
<% } -%>
<% } -%>
```
This ensures:
* Hosts starting with `ipv6.` get only AAAA records
* Hosts starting with `ipv4.` get only A records
* All other hosts get both A and AAAA records
The Kubernetes ingress handles all three hostnames, routing to the same backend service.
## TLS Certificates with Subject Alternative Names
Since Let's Encrypt validates domains via HTTP, the IPv6-only subdomain (`ipv6.ipv6test.f3s.buetow.org`) cannot be validated directly—Let's Encrypt's validation servers use IPv4. The solution is to include all subdomains as Subject Alternative Names (SANs) in the parent certificate.
The ACME client configuration template dynamically builds the SAN list:
```perl
<% for my $host (@$acme_hosts) {
# Skip ipv4/ipv6 subdomains - they're included as SANs in parent cert
next if $host =~ /^(ipv4|ipv6)\./;
-%>
<% my @alt_names = ("www.$host");
for my $sub_host (@$acme_hosts) {
if ($sub_host =~ /^(ipv4|ipv6)\.\Q$host\E$/) {
push @alt_names, $sub_host;
}
}
-%>
domain <%= $host %> {
alternative names { <%= join(' ', @alt_names) %> }
...
}
<% } -%>
```
This generates a single certificate for `ipv6test.f3s.buetow.org` that includes:
* www.ipv6test.f3s.buetow.org
* ipv4.ipv6test.f3s.buetow.org
* ipv6.ipv6test.f3s.buetow.org
## DNS and TLS Deployment
The DNS records and ACME certificates are managed via Rex automation:
```perl
our @f3s_hosts = qw/
...
ipv6test.f3s.buetow.org
ipv4.ipv6test.f3s.buetow.org
ipv6.ipv6test.f3s.buetow.org
/;
our @acme_hosts = qw/
...
ipv6test.f3s.buetow.org
ipv4.ipv6test.f3s.buetow.org
ipv6.ipv6test.f3s.buetow.org
/;
```
Running `rex nsd httpd acme acme_invoke relayd` deploys the DNS zone, configures httpd for ACME challenges, obtains the certificates, and reloads relayd.
## Testing
Verify DNS records are correct:
```sh
$ dig ipv4.ipv6test.f3s.buetow.org A +short
46.23.94.99
$ dig ipv4.ipv6test.f3s.buetow.org AAAA +short
(no output - IPv4 only)
$ dig ipv6.ipv6test.f3s.buetow.org AAAA +short
2a03:6000:6f67:624::99
$ dig ipv6.ipv6test.f3s.buetow.org A +short
(no output - IPv6 only)
```
Verify the application shows the correct test type:
```sh
$ curl -s https://ipv4.ipv6test.f3s.buetow.org/cgi-bin/index.pl | grep "Test Results"
<h3>IPv4 Only Test Results:</h3>
```
The displayed IP should be the real client IP, not an internal cluster address.
## W3C Compliant HTML
The CGI script generates valid HTML5 that passes W3C validation. Key considerations:
* Proper DOCTYPE, charset, and lang attributes
* HTML-escaping command outputs (dig output contains `<<>>` characters)
```perl
sub html_escape {
my $str = shift;
$str =~ s/&/&/g;
$str =~ s/</</g;
$str =~ s/>/>/g;
return $str;
}
my $digremote = html_escape(`dig -x $ENV{REMOTE_ADDR}`);
```
You can verify the output passes validation:
[W3C Validator](https://validator.w3.org/nu/?doc=https%3A%2F%2Fipv6test.f3s.buetow.org%2Fcgi-bin%2Findex.pl)
## Summary
Preserving client IP addresses through multiple reverse proxies requires configuration at each layer:
1. **relayd**: Sets `X-Forwarded-For` header
2. **Traefik**: Trusts headers from known proxy IPs via `forwardedHeaders.trustedIPs`
3. **Apache**: Uses `mod_remoteip` to set `REMOTE_ADDR` from the header
Additional challenges solved:
* **TLS for IPv6-only hosts**: Use SANs to include all subdomains in a single certificate validated via the dual-stack parent domain
* **W3C compliance**: HTML-escape all command outputs to handle special characters
The configuration is managed via GitOps with ArgoCD, including the Traefik HelmChartConfig.
[Source code](https://codeberg.org/snonux/ipv6test)
[Kubernetes manifests](https://codeberg.org/snonux/conf/src/branch/master/f3s/ipv6test)
[Traefik configuration](https://codeberg.org/snonux/conf/src/branch/master/f3s/traefik-config)
E-Mail your comments to paul@paulbias.net :-)
[← Back to the index](./index.md)
|