Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dnscrypt-proxy/config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -95,6 +96,11 @@ func configureXTransport(proxy *Proxy, config *Config) error {
proxy.xTransport.useIPv4 = config.SourceIPv4
proxy.xTransport.useIPv6 = config.SourceIPv6
proxy.xTransport.keepAlive = time.Duration(config.KeepAlive) * time.Second
// SO_MARK-based outgoing policy routing is Linux-only; elsewhere the
// resolver is never consulted, so it is not worth building one.
if runtime.GOOS == "linux" {
proxy.xTransport.markResolver = newFWMarkResolver()
}

// Configure HTTP proxy URL if specified
if len(config.HTTPProxyURL) > 0 {
Expand Down Expand Up @@ -129,6 +135,14 @@ func configureXTransport(proxy *Proxy, config *Config) error {
}
proxy.xTransport.proxyDialer = &proxyDialer
proxy.xTransport.mainProto = "tcp"
if proxy.xTransport.markResolver != nil {
// A SOCKS proxy replaces the dialer entirely, so no socket of ours
// is left to mark. Clear it so that every dial path is covered by
// this one check rather than each having to repeat it.
dlog.Warn("fwmark is ignored while a SOCKS proxy is configured")
_ = proxy.xTransport.markResolver.Close()
proxy.xTransport.markResolver = nil
}
}

proxy.xTransport.rebuildTransport()
Expand Down
16 changes: 16 additions & 0 deletions dnscrypt-proxy/example-dnscrypt-proxy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ http3_probe = false
# http_proxy = 'http://127.0.0.1:8888'


## Outgoing fwmark (Linux only, no setting)
## The sockets used for DoH queries (including HTTP/3) carry a mark (SO_MARK)
## read from Redis on 127.0.0.1:6379: the key `dnscrypt:markkey` holds the
## name of the key holding the numeric mark. Policy routing rules can then
## steer those queries - e.g. send DoH through a VPN client instead of the
## default route - and a mark that changes while the proxy runs is picked up
## within 5 seconds, without a restart and without touching this file.
## The mark has to fit in 31 bits. Setting SO_MARK requires the
## CAP_NET_ADMIN capability. When no mark can be
## read the sockets are left unmarked and the queries follow the default
## route. Ignored when `proxy` is set.
## Only DoH queries are marked: DNSCrypt-protocol servers, anonymized DNS
## relays, forwarding rules and bootstrap/netprobe lookups keep using the
## default route.


## How long a DNS query will wait for a response, in milliseconds.
## If you have a network with *a lot* of latency, you may need to
## increase this. Startup may be slower if you do so.
Expand Down
41 changes: 41 additions & 0 deletions dnscrypt-proxy/mark_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"syscall"

"github.com/jedisct1/dlog"
"golang.org/x/sys/unix"
)

// dialerControlForMark returns a net.Dialer/net.ListenConfig Control
// callback that sets SO_MARK on the underlying socket, so that outgoing
// packets can be steered by policy routing (e.g. to send DoH queries out via
// a specific VPN client). Setting SO_MARK requires CAP_NET_ADMIN.
//
// The mark is resolved per dial rather than captured here, so that a mark
// which changes while the proxy runs takes effect on the next connection.
//
// A mark that cannot be resolved, or cannot be set, never fails the dial: the
// socket is left unmarked and the query follows the default route. This is
// what the unbound build on the same device does, so a VPN client selection
// steers both services the same way and fails over the same way.
func dialerControlForMark(resolver *fwmarkResolver) func(network, address string, c syscall.RawConn) error {
return func(_, _ string, c syscall.RawConn) error {
mark := resolver.fwmark()
if mark == 0 {
return nil
}
var sockErr error
if err := c.Control(func(fd uintptr) {
// The mark is bounded to 31 bits when it is read, so it is
// representable as an int on every platform this builds for.
sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, int(mark))
}); err != nil {
return err
}
if sockErr != nil {
dlog.Warnf("Unable to set fwmark %d on socket, CAP_NET_ADMIN is required: %v", mark, sockErr)
}
return nil
}
}
80 changes: 80 additions & 0 deletions dnscrypt-proxy/mark_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"net"
"os"
"testing"

"golang.org/x/sys/unix"
)

func TestDialerControlForMarkSetsSockopt(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("setting SO_MARK requires CAP_NET_ADMIN")
}
const mark = 2048

conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("unable to open a socket: %v", err)
}
defer conn.Close()
rawConn, err := conn.(*net.UDPConn).SyscallConn()
if err != nil {
t.Fatalf("unable to get the raw connection: %v", err)
}

if err := dialerControlForMark(resolverFor(mark))("udp4", "127.0.0.1:0", rawConn); err != nil {
t.Fatalf("the Control callback failed: %v", err)
}

var (
readMark int
readErr error
)
if err := rawConn.Control(func(fd uintptr) {
readMark, readErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK)
}); err != nil {
t.Fatalf("unable to read back the socket option: %v", err)
}
if readErr != nil {
t.Fatalf("unable to read back SO_MARK: %v", readErr)
}
if readMark != mark {
t.Fatalf("expected SO_MARK to be %d, got %d", mark, readMark)
}
}

func TestDialerControlForMarkLeavesTheSocketUnmarkedWhenUnresolvable(t *testing.T) {
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("unable to open a socket: %v", err)
}
defer conn.Close()
rawConn, err := conn.(*net.UDPConn).SyscallConn()
if err != nil {
t.Fatalf("unable to get the raw connection: %v", err)
}

// Matching unbound, an unresolvable mark leaves the socket unmarked and
// lets the query follow the default route instead of failing the dial.
if err := dialerControlForMark(unresolvableFWMark())("udp4", "127.0.0.1:0", rawConn); err != nil {
t.Fatalf("expected the dial to proceed unmarked, got: %v", err)
}

var (
readMark int
readErr error
)
if err := rawConn.Control(func(fd uintptr) {
readMark, readErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK)
}); err != nil {
t.Fatalf("unable to read back the socket option: %v", err)
}
if readErr != nil {
t.Fatalf("unable to read back SO_MARK: %v", readErr)
}
if readMark != 0 {
t.Fatalf("expected the socket to be left unmarked, got %d", readMark)
}
}
12 changes: 12 additions & 0 deletions dnscrypt-proxy/mark_others.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !linux

package main

import "syscall"

// dialerControlForMark: SO_MARK-based outgoing policy routing is Linux-only.
// A nil Control callback means the dialer/listener behaves exactly as it did
// before fwmark was configured.
func dialerControlForMark(*fwmarkResolver) func(network, address string, c syscall.RawConn) error {
return nil
}
Loading
Loading