Skip to content

Commit

Permalink
feat(netlink): detect IPv6 using query to address
Browse files Browse the repository at this point in the history
- If a default IPv6 route is found, query the ip:port defined by `IPV6_CHECK_ADDRESS` to check for internet access
  • Loading branch information
qdm12 committed Dec 14, 2024
1 parent 96a4a64 commit e3e6431
Show file tree
Hide file tree
Showing 13 changed files with 384 additions and 7 deletions.
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ ENV VPN_SERVICE_PROVIDER=pia \
FIREWALL_INPUT_PORTS= \
FIREWALL_OUTBOUND_SUBNETS= \
FIREWALL_DEBUG=off \
# IPv6
IPV6_CHECK_ADDRESS=[2606:4700::6810:84e5]:443 \
# Logging
LOG_LEVEL=info \
# Health
Expand Down
8 changes: 6 additions & 2 deletions cmd/gluetun/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"net/http"
"net/netip"
"os"
"os/exec"
"os/signal"
Expand Down Expand Up @@ -242,7 +243,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation,
return err
}

ipv6SupportLevel, err := netLinker.FindIPv6SupportLevel()
ipv6SupportLevel, err := netLinker.FindIPv6SupportLevel(ctx,
allSettings.IPv6.CheckAddress, firewallConf)
if err != nil {
return fmt.Errorf("checking for IPv6 support: %w", err)
}
Expand Down Expand Up @@ -551,7 +553,9 @@ type netLinker interface {
Ruler
Linker
IsWireguardSupported() (ok bool, err error)
FindIPv6SupportLevel() (level netlink.IPv6SupportLevel, err error)
FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall netlink.Firewall,
) (level netlink.IPv6SupportLevel, err error)
PatchLoggerLevel(level log.Level)
}

Expand Down
14 changes: 14 additions & 0 deletions internal/cli/noopfirewall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package cli

import (
"context"
"net/netip"
)

type noopFirewall struct{}

func (f *noopFirewall) AcceptOutput(_ context.Context, _, _ string, _ netip.Addr,
_ uint16, _ bool,
) (err error) {
return nil
}
7 changes: 5 additions & 2 deletions internal/cli/openvpnconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ type IPFetcher interface {
}

type IPv6Checker interface {
FindIPv6SupportLevel() (level netlink.IPv6SupportLevel, err error)
FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall netlink.Firewall,
) (level netlink.IPv6SupportLevel, err error)
}

func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
Expand All @@ -58,7 +60,8 @@ func (c *CLI) OpenvpnConfig(logger OpenvpnConfigLogger, reader *reader.Reader,
return err
}

ipv6SupportLevel, err := ipv6Checker.FindIPv6SupportLevel()
ipv6SupportLevel, err := ipv6Checker.FindIPv6SupportLevel(context.Background(),
allSettings.IPv6.CheckAddress, &noopFirewall{})
if err != nil {
return fmt.Errorf("checking for IPv6 support: %w", err)
}
Expand Down
51 changes: 51 additions & 0 deletions internal/configuration/settings/ipv6.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package settings

import (
"net/netip"

"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
)

// IPv6 contains settings regarding IPv6 configuration.
type IPv6 struct {
// CheckAddress is the TCP ip:port address to dial to check
// IPv6 is supported, in case a default IPv6 route is found.
// It defaults to cloudflare.com address [2606:4700::6810:84e5]:443
CheckAddress netip.AddrPort
}

func (i IPv6) validate() (err error) {
return nil
}

func (i *IPv6) copy() (copied IPv6) {
return IPv6{
CheckAddress: i.CheckAddress,
}
}

func (i *IPv6) overrideWith(other IPv6) {
i.CheckAddress = gosettings.OverrideWithValidator(i.CheckAddress, other.CheckAddress)
}

func (i *IPv6) setDefaults() {
defaultCheckAddress := netip.MustParseAddrPort("[2606:4700::6810:84e5]:443")
i.CheckAddress = gosettings.DefaultComparable(i.CheckAddress, defaultCheckAddress)
}

func (i IPv6) String() string {
return i.toLinesNode().String()
}

func (i IPv6) toLinesNode() (node *gotree.Node) {
node = gotree.New("IPv6 settings:")
node.Appendf("Check address: %s", i.CheckAddress)
return node
}

func (i *IPv6) read(r *reader.Reader) (err error) {
i.CheckAddress, err = r.NetipAddrPort("IPV6_CHECK_ADDRESS")
return err
}
7 changes: 7 additions & 0 deletions internal/configuration/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Settings struct {
Updater Updater
Version Version
VPN VPN
IPv6 IPv6
Pprof pprof.Settings
}

Expand All @@ -53,6 +54,7 @@ func (s *Settings) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Support
"system": s.System.validate,
"updater": s.Updater.Validate,
"version": s.Version.validate,
"ipv6": s.IPv6.validate,
// Pprof validation done in pprof constructor
"VPN": func() error {
return s.VPN.Validate(filterChoicesGetter, ipv6Supported, warner)
Expand Down Expand Up @@ -85,6 +87,7 @@ func (s *Settings) copy() (copied Settings) {
Version: s.Version.copy(),
VPN: s.VPN.Copy(),
Pprof: s.Pprof.Copy(),
IPv6: s.IPv6.copy(),
}
}

Expand All @@ -106,6 +109,7 @@ func (s *Settings) OverrideWith(other Settings,
patchedSettings.Version.overrideWith(other.Version)
patchedSettings.VPN.OverrideWith(other.VPN)
patchedSettings.Pprof.OverrideWith(other.Pprof)
patchedSettings.IPv6.overrideWith(other.IPv6)
err = patchedSettings.Validate(filterChoicesGetter, ipv6Supported, warner)
if err != nil {
return err
Expand All @@ -121,6 +125,7 @@ func (s *Settings) SetDefaults() {
s.Health.SetDefaults()
s.HTTPProxy.setDefaults()
s.Log.setDefaults()
s.IPv6.setDefaults()
s.PublicIP.setDefaults()
s.Shadowsocks.setDefaults()
s.Storage.setDefaults()
Expand All @@ -142,6 +147,7 @@ func (s Settings) toLinesNode() (node *gotree.Node) {
node.AppendNode(s.DNS.toLinesNode())
node.AppendNode(s.Firewall.toLinesNode())
node.AppendNode(s.Log.toLinesNode())
node.AppendNode(s.IPv6.toLinesNode())
node.AppendNode(s.Health.toLinesNode())
node.AppendNode(s.Shadowsocks.toLinesNode())
node.AppendNode(s.HTTPProxy.toLinesNode())
Expand Down Expand Up @@ -208,6 +214,7 @@ func (s *Settings) Read(r *reader.Reader, warner Warner) (err error) {
"updater": s.Updater.read,
"version": s.Version.read,
"VPN": s.VPN.read,
"IPv6": s.IPv6.read,
"profiling": s.Pprof.Read,
}

Expand Down
2 changes: 2 additions & 0 deletions internal/configuration/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func Test_Settings_String(t *testing.T) {
| └── Enabled: yes
├── Log settings:
| └── Log level: INFO
├── IPv6 settings:
| └── Check address: [2606:4700::6810:84e5]:443
├── Health settings:
| ├── Server listening address: 127.0.0.1:9999
| ├── Target address: cloudflare.com:443
Expand Down
18 changes: 18 additions & 0 deletions internal/firewall/iptables.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ func (c *Config) acceptOutputTrafficToVPN(ctx context.Context,
return c.runIP6tablesInstruction(ctx, instruction)
}

func (c *Config) AcceptOutput(ctx context.Context,
protocol, intf string, ip netip.Addr, port uint16, remove bool,
) error {
interfaceFlag := "-o " + intf
if intf == "*" { // all interfaces
interfaceFlag = ""
}

instruction := fmt.Sprintf("%s OUTPUT -d %s %s -p %s -m %s --dport %d -j ACCEPT",
appendOrDelete(remove), ip, interfaceFlag, protocol, protocol, port)
if ip.Is4() {
return c.runIptablesInstruction(ctx, instruction)
} else if c.ip6Tables == "" {
return fmt.Errorf("accept output to VPN server: %w", ErrNeedIP6Tables)
}
return c.runIP6tablesInstruction(ctx, instruction)
}

// Thanks to @npawelek.
func (c *Config) acceptOutputFromIPToSubnet(ctx context.Context,
intf string, sourceIP netip.Addr, destinationSubnet netip.Prefix, remove bool,
Expand Down
12 changes: 11 additions & 1 deletion internal/netlink/interfaces.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package netlink

import "github.com/qdm12/log"
import (
"context"
"net/netip"

"github.com/qdm12/log"
)

type DebugLogger interface {
Debug(message string)
Debugf(format string, args ...any)
Patch(options ...log.Option)
}

type Firewall interface {
AcceptOutput(ctx context.Context, protocol, intf string, ip netip.Addr,
port uint16, remove bool) (err error)
}
51 changes: 49 additions & 2 deletions internal/netlink/ipv6.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package netlink

import (
"context"
"fmt"
"net"
"net/netip"
"time"
)

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

func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
func (n *NetLink) FindIPv6SupportLevel(ctx context.Context,
checkAddress netip.AddrPort, firewall Firewall,
) (level IPv6SupportLevel, err error) {
routes, err := n.RouteList(FamilyV6)
if err != nil {
return IPv6Unsupported, fmt.Errorf("listing IPv6 routes: %w", err)
Expand All @@ -44,7 +50,14 @@ func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
case sourceIsIPv4 && destinationIsIPv4,
destinationIsIPv6 && route.Dst.Addr().IsLoopback():
case route.Dst.Addr().IsUnspecified(): // default ipv6 route
n.debugLogger.Debugf("IPv6 internet access is enabled on link %s", link.Name)
n.debugLogger.Debugf("IPv6 default route found on link %s", link.Name)
err = dialAddrThroughFirewall(ctx, link.Name, checkAddress, firewall)
if err != nil {
n.debugLogger.Debugf("IPv6 query failed on %s: %w", link.Name, err)
level = IPv6Supported
continue
}
n.debugLogger.Debugf("IPv6 internet is accessible through link %s", link.Name)
return IPv6Internet, nil
default: // non-default ipv6 route found
n.debugLogger.Debugf("IPv6 is supported by link %s", link.Name)
Expand All @@ -57,3 +70,37 @@ func (n *NetLink) FindIPv6SupportLevel() (level IPv6SupportLevel, err error) {
}
return level, nil
}

func dialAddrThroughFirewall(ctx context.Context, intf string,
checkAddress netip.AddrPort, firewall Firewall,
) (err error) {
const protocol = "tcp"
remove := false
err = firewall.AcceptOutput(ctx, protocol, intf,
checkAddress.Addr(), checkAddress.Port(), remove)
if err != nil {
return fmt.Errorf("accepting output traffic: %w", err)
}
defer func() {
remove = true
firewallErr := firewall.AcceptOutput(ctx, protocol, intf,
checkAddress.Addr(), checkAddress.Port(), remove)
if err == nil && firewallErr != nil {
err = fmt.Errorf("removing output traffic rule: %w", firewallErr)
}
}()

dialer := &net.Dialer{
Timeout: time.Second,
}
conn, err := dialer.DialContext(ctx, protocol, checkAddress.String())
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
err = conn.Close()
if err != nil {
return fmt.Errorf("closing connection: %w", err)
}

return nil
}
Loading

0 comments on commit e3e6431

Please sign in to comment.