Skip to content

Commit 70a9bb7

Browse files
committed
feat(netlink): detect IPv6 using query to address
- If a default IPv6 route is found, query the ip:port defined by `IPV6_CHECK_ADDRESS` to check for internet access
1 parent 96a4a64 commit 70a9bb7

File tree

13 files changed

+384
-7
lines changed

13 files changed

+384
-7
lines changed

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
159159
FIREWALL_INPUT_PORTS= \
160160
FIREWALL_OUTBOUND_SUBNETS= \
161161
FIREWALL_DEBUG=off \
162+
# IPv6
163+
IPV6_CHECK_ADDRESS=[2606:4700::6810:84e5]:443 \
162164
# Logging
163165
LOG_LEVEL=info \
164166
# Health

cmd/gluetun/main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io/fs"
88
"net/http"
9+
"net/netip"
910
"os"
1011
"os/exec"
1112
"os/signal"
@@ -242,7 +243,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
242243
return err
243244
}
244245

245-
ipv6SupportLevel, err := netLinker.FindIPv6SupportLevel()
246+
ipv6SupportLevel, err := netLinker.FindIPv6SupportLevel(ctx,
247+
allSettings.IPv6.CheckAddress, firewallConf)
246248
if err != nil {
247249
return fmt.Errorf("checking for IPv6 support: %w", err)
248250
}
@@ -551,7 +553,9 @@ type netLinker interface {
551553
Ruler
552554
Linker
553555
IsWireguardSupported() (ok bool, err error)
554-
FindIPv6SupportLevel() (level netlink.IPv6SupportLevel, err error)
556+
FindIPv6SupportLevel(ctx context.Context,
557+
checkAddress netip.AddrPort, firewall netlink.Firewall,
558+
) (level netlink.IPv6SupportLevel, err error)
555559
PatchLoggerLevel(level log.Level)
556560
}
557561

internal/cli/noopfirewall.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"net/netip"
6+
)
7+
8+
type noopFirewall struct{}
9+
10+
func (f *noopFirewall) AcceptOutput(_ context.Context, _, _ string, _ netip.Addr,
11+
_ uint16, _ bool,
12+
) (err error) {
13+
return nil
14+
}

internal/cli/openvpnconfig.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ type IPFetcher interface {
4141
}
4242

4343
type IPv6Checker interface {
44-
FindIPv6SupportLevel() (level netlink.IPv6SupportLevel, err error)
44+
FindIPv6SupportLevel(ctx context.Context,
45+
checkAddress netip.AddrPort, firewall netlink.Firewall,
46+
) (level netlink.IPv6SupportLevel, err error)
4547
}
4648

4749
func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
@@ -58,7 +60,8 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
5860
return err
5961
}
6062

61-
ipv6SupportLevel, err := ipv6Checker.FindIPv6SupportLevel()
63+
ipv6SupportLevel, err := ipv6Checker.FindIPv6SupportLevel(context.Background(),
64+
allSettings.IPv6.CheckAddress, &noopFirewall{})
6265
if err != nil {
6366
return fmt.Errorf("checking for IPv6 support: %w", err)
6467
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package settings
2+
3+
import (
4+
"net/netip"
5+
6+
"github.com/qdm12/gosettings"
7+
"github.com/qdm12/gosettings/reader"
8+
"github.com/qdm12/gotree"
9+
)
10+
11+
// IPv6 contains settings regarding IPv6 configuration.
12+
type IPv6 struct {
13+
// CheckAddress is the TCP ip:port address to dial to check
14+
// IPv6 is supported, in case a default IPv6 route is found.
15+
// It defaults to cloudflare.com address [2606:4700::6810:84e5]:443
16+
CheckAddress netip.AddrPort
17+
}
18+
19+
func (i IPv6) validate() (err error) {
20+
return nil
21+
}
22+
23+
func (i *IPv6) copy() (copied IPv6) {
24+
return IPv6{
25+
CheckAddress: i.CheckAddress,
26+
}
27+
}
28+
29+
func (i *IPv6) overrideWith(other IPv6) {
30+
i.CheckAddress = gosettings.OverrideWithValidator(i.CheckAddress, other.CheckAddress)
31+
}
32+
33+
func (i *IPv6) setDefaults() {
34+
defaultCheckAddress := netip.MustParseAddrPort("[2606:4700::6810:84e5]:443")
35+
i.CheckAddress = gosettings.DefaultComparable(i.CheckAddress, defaultCheckAddress)
36+
}
37+
38+
func (i IPv6) String() string {
39+
return i.toLinesNode().String()
40+
}
41+
42+
func (i IPv6) toLinesNode() (node *gotree.Node) {
43+
node = gotree.New("IPv6 settings:")
44+
node.Appendf("Check address: %s", i.CheckAddress)
45+
return node
46+
}
47+
48+
func (i *IPv6) read(r *reader.Reader) (err error) {
49+
i.CheckAddress, err = r.NetipAddrPort("IPV6_CHECK_ADDRESS")
50+
return err
51+
}

internal/configuration/settings/settings.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type Settings struct {
2727
Updater Updater
2828
Version Version
2929
VPN VPN
30+
IPv6 IPv6
3031
Pprof pprof.Settings
3132
}
3233

@@ -53,6 +54,7 @@ func (s *Settings) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Support
5354
"system": s.System.validate,
5455
"updater": s.Updater.Validate,
5556
"version": s.Version.validate,
57+
"ipv6": s.IPv6.validate,
5658
// Pprof validation done in pprof constructor
5759
"VPN": func() error {
5860
return s.VPN.Validate(filterChoicesGetter, ipv6Supported, warner)
@@ -85,6 +87,7 @@ func (s *Settings) copy() (copied Settings) {
8587
Version: s.Version.copy(),
8688
VPN: s.VPN.Copy(),
8789
Pprof: s.Pprof.Copy(),
90+
IPv6: s.IPv6.copy(),
8891
}
8992
}
9093

@@ -106,6 +109,7 @@ func (s *Settings) OverrideWith(other Settings,
106109
patchedSettings.Version.overrideWith(other.Version)
107110
patchedSettings.VPN.OverrideWith(other.VPN)
108111
patchedSettings.Pprof.OverrideWith(other.Pprof)
112+
patchedSettings.IPv6.overrideWith(other.IPv6)
109113
err = patchedSettings.Validate(filterChoicesGetter, ipv6Supported, warner)
110114
if err != nil {
111115
return err
@@ -121,6 +125,7 @@ func (s *Settings) SetDefaults() {
121125
s.Health.SetDefaults()
122126
s.HTTPProxy.setDefaults()
123127
s.Log.setDefaults()
128+
s.IPv6.setDefaults()
124129
s.PublicIP.setDefaults()
125130
s.Shadowsocks.setDefaults()
126131
s.Storage.setDefaults()
@@ -142,6 +147,7 @@ func (s Settings) toLinesNode() (node *gotree.Node) {
142147
node.AppendNode(s.DNS.toLinesNode())
143148
node.AppendNode(s.Firewall.toLinesNode())
144149
node.AppendNode(s.Log.toLinesNode())
150+
node.AppendNode(s.IPv6.toLinesNode())
145151
node.AppendNode(s.Health.toLinesNode())
146152
node.AppendNode(s.Shadowsocks.toLinesNode())
147153
node.AppendNode(s.HTTPProxy.toLinesNode())
@@ -208,6 +214,7 @@ func (s *Settings) Read(r *reader.Reader, warner Warner) (err error) {
208214
"updater": s.Updater.read,
209215
"version": s.Version.read,
210216
"VPN": s.VPN.read,
217+
"IPv6": s.IPv6.read,
211218
"profiling": s.Pprof.Read,
212219
}
213220

internal/configuration/settings/settings_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ func Test_Settings_String(t *testing.T) {
5555
| └── Enabled: yes
5656
├── Log settings:
5757
| └── Log level: INFO
58+
├── IPv6 settings:
59+
| └── Check address: [2606:4700::6810:84e5]:443
5860
├── Health settings:
5961
| ├── Server listening address: 127.0.0.1:9999
6062
| ├── Target address: cloudflare.com:443

internal/firewall/iptables.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,24 @@ func (c *Config) acceptOutputTrafficToVPN(ctx context.Context,
162162
return c.runIP6tablesInstruction(ctx, instruction)
163163
}
164164

165+
func (c *Config) AcceptOutput(ctx context.Context,
166+
protocol, intf string, ip netip.Addr, port uint16, remove bool,
167+
) error {
168+
interfaceFlag := "-o " + intf
169+
if intf == "*" { // all interfaces
170+
interfaceFlag = ""
171+
}
172+
173+
instruction := fmt.Sprintf("%s OUTPUT -d %s %s -p %s -m %s --dport %d -j ACCEPT",
174+
appendOrDelete(remove), ip, interfaceFlag, protocol, protocol, port)
175+
if ip.Is4() {
176+
return c.runIptablesInstruction(ctx, instruction)
177+
} else if c.ip6Tables == "" {
178+
return fmt.Errorf("accept output to VPN server: %w", ErrNeedIP6Tables)
179+
}
180+
return c.runIP6tablesInstruction(ctx, instruction)
181+
}
182+
165183
// Thanks to @npawelek.
166184
func (c *Config) acceptOutputFromIPToSubnet(ctx context.Context,
167185
intf string, sourceIP netip.Addr, destinationSubnet netip.Prefix, remove bool,

internal/netlink/interfaces.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
package netlink
22

3-
import "github.com/qdm12/log"
3+
import (
4+
"context"
5+
"net/netip"
6+
7+
"github.com/qdm12/log"
8+
)
49

510
type DebugLogger interface {
611
Debug(message string)
712
Debugf(format string, args ...any)
813
Patch(options ...log.Option)
914
}
15+
16+
type Firewall interface {
17+
AcceptOutput(ctx context.Context, protocol, intf string, ip netip.Addr,
18+
port uint16, remove bool) (err error)
19+
}

internal/netlink/ipv6.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package netlink
22

33
import (
4+
"context"
45
"fmt"
6+
"net"
7+
"net/netip"
8+
"time"
59
)
610

711
type IPv6SupportLevel uint8
@@ -21,7 +25,9 @@ func (i IPv6SupportLevel) IsSupported() bool {
2125
return i == IPv6Supported || i == IPv6Internet
2226
}
2327

24-
func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
28+
func (n *NetLink) FindIPv6SupportLevel(ctx context.Context,
29+
checkAddress netip.AddrPort, firewall Firewall,
30+
) (level IPv6SupportLevel, err error) {
2531
routes, err := n.RouteList(FamilyV6)
2632
if err != nil {
2733
return IPv6Unsupported, fmt.Errorf("listing IPv6 routes: %w", err)
@@ -44,7 +50,14 @@ func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
4450
case sourceIsIPv4 && destinationIsIPv4,
4551
destinationIsIPv6 && route.Dst.Addr().IsLoopback():
4652
case route.Dst.Addr().IsUnspecified(): // default ipv6 route
47-
n.debugLogger.Debugf("IPv6 internet access is enabled on link %s", link.Name)
53+
n.debugLogger.Debugf("IPv6 default route found on link %s", link.Name)
54+
err = dialAddrThroughFirewall(ctx, link.Name, checkAddress, firewall)
55+
if err != nil {
56+
n.debugLogger.Debugf("IPv6 query failed on %s: %w", link.Name, err)
57+
level = IPv6Supported
58+
continue
59+
}
60+
n.debugLogger.Debugf("IPv6 internet is accessible through link %s", link.Name)
4861
return IPv6Internet, nil
4962
default: // non-default ipv6 route found
5063
n.debugLogger.Debugf("IPv6 is supported by link %s", link.Name)
@@ -57,3 +70,37 @@ func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
5770
}
5871
return level, nil
5972
}
73+
74+
func dialAddrThroughFirewall(ctx context.Context, intf string,
75+
checkAddress netip.AddrPort, firewall Firewall,
76+
) (err error) {
77+
const protocol = "tcp"
78+
remove := false
79+
err = firewall.AcceptOutput(ctx, protocol, intf,
80+
checkAddress.Addr(), checkAddress.Port(), remove)
81+
if err != nil {
82+
return fmt.Errorf("accepting output traffic: %w", err)
83+
}
84+
defer func() {
85+
remove = true
86+
firewallErr := firewall.AcceptOutput(ctx, protocol, intf,
87+
checkAddress.Addr(), checkAddress.Port(), remove)
88+
if err == nil && firewallErr != nil {
89+
err = fmt.Errorf("removing output traffic rule: %w", firewallErr)
90+
}
91+
}()
92+
93+
dialer := &net.Dialer{
94+
Timeout: time.Second,
95+
}
96+
conn, err := dialer.DialContext(ctx, protocol, checkAddress.String())
97+
if err != nil {
98+
return fmt.Errorf("dialing: %w", err)
99+
}
100+
err = conn.Close()
101+
if err != nil {
102+
return fmt.Errorf("closing connection: %w", err)
103+
}
104+
105+
return nil
106+
}

0 commit comments

Comments
 (0)