-
-
Notifications
You must be signed in to change notification settings - Fork 600
chore(restrictednet): add internal/restrictednet package
#3361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
f10f1af
initial
qdm12 27d8026
Minor fixes
qdm12 295aabd
imporatnt fix 1
qdm12 d65bd65
imporatnt fix 2
qdm12 bf9c938
Fix test to use a random port and not 443
qdm12 7b07231
review feedback
qdm12 65ff541
pr review fixes
qdm12 6359e43
context aware connectSourceConnection
qdm12 956ad68
moare fixes
qdm12 5ba3fba
add tests
qdm12 b316d52
Change tests to be more integration oriented
qdm12 dc226ba
Fix ordering in cleanup function
qdm12 e1ab8f7
PR feedback fixes
qdm12 003258d
pr review changes
qdm12 feb5d25
PR feedback
qdm12 4a9b5b4
context aware connectFD
qdm12 197848a
lint fix
qdm12 5886a16
pr review feedback
qdm12 a1e731d
PR feedback
qdm12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package restrictednet | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "net/netip" | ||
| "strconv" | ||
|
|
||
| "github.com/qdm12/dns/v2/pkg/provider" | ||
| ) | ||
|
|
||
| // Client is a client for making restricted network requests, | ||
| // such as opening temporary firewall rules for HTTPS connections. | ||
| // It is not meant to be high performance, although it can be used for | ||
| // multiple requests and concurrently. | ||
| type Client struct { | ||
| outboundInterface string | ||
| ipv6Supported bool | ||
| firewall Firewall | ||
| dohServers []provider.DoHServer | ||
| } | ||
|
|
||
| func New(settings Settings) *Client { | ||
| if err := settings.validate(); err != nil { | ||
| panic(fmt.Sprintf("invalid settings: %v", err)) // programming error | ||
| } | ||
| dohServers := make([]provider.DoHServer, len(settings.UpstreamResolvers)) | ||
| for i, upstreamResolver := range settings.UpstreamResolvers { | ||
| dohServers[i] = upstreamResolver.DoH | ||
| } | ||
|
|
||
| return &Client{ | ||
| outboundInterface: settings.DefaultInterface, | ||
| ipv6Supported: *settings.IPv6Supported, | ||
| firewall: settings.Firewall, | ||
| dohServers: dohServers, | ||
| } | ||
| } | ||
|
|
||
| // OpenHTTPSByHostname opens an https connection through the firewall, | ||
| // to the hostname which in the format `host:port`. The returned cleanup | ||
| // function must be called to remove the temporary firewall rule and close connections. | ||
| // It first resolves the domain in hostname using DNS over HTTPS and then opens | ||
| // the restricted HTTPS connection to the resolved IP. | ||
| func (c *Client) OpenHTTPSByHostname(ctx context.Context, hostname string) ( | ||
| httpClient *http.Client, cleanup func() error, err error, | ||
| ) { | ||
| host, portStr, err := net.SplitHostPort(hostname) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("splitting host and port: %w", err) | ||
| } | ||
| resolvedIPs, err := c.ResolveName(ctx, host) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("resolving name: %w", err) | ||
| } else if len(resolvedIPs) == 0 { | ||
| return nil, nil, fmt.Errorf("no IP address found for name %q", host) | ||
| } | ||
|
|
||
| portUint, err := strconv.ParseUint(portStr, 10, 16) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("parsing port: %w", err) | ||
| } else if portUint == 0 { | ||
| return nil, nil, errors.New("destination port cannot be 0") | ||
| } | ||
| port := uint16(portUint) | ||
|
|
||
| errs := make([]error, 0, len(resolvedIPs)) | ||
| for _, ip := range resolvedIPs { | ||
| addrPort := netip.AddrPortFrom(ip, port) | ||
| httpClient, cleanup, err := c.OpenHTTPS(ctx, host, addrPort) | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("for %s: %w", ip, err)) | ||
| continue | ||
| } | ||
| return httpClient, cleanup, nil | ||
| } | ||
|
|
||
| return nil, nil, fmt.Errorf("opening HTTPS to %s: %w", hostname, errors.Join(errs...)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //go:build integration | ||
|
|
||
| package restrictednet | ||
|
|
||
| func ptrTo[T any](value T) *T { | ||
| return &value | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| package restrictednet | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/tls" | ||
| "errors" | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "net/netip" | ||
| "os" | ||
| "time" | ||
|
|
||
| "github.com/jsimonetti/rtnetlink" | ||
| "github.com/qdm12/gluetun/internal/pmtud/constants" | ||
| ) | ||
|
|
||
| // OpenHTTPS opens temporary restrictive firewall output for one HTTPS destination. | ||
| // The returned [*http.Client] must be used sequentially only, and each request must | ||
| // have its response body fully read/discarded and then closed. | ||
| // The returned cleanup function must be called to remove the temporary firewall rule and close connections. | ||
| func (c *Client) OpenHTTPS(ctx context.Context, destinationTLSName string, destinationAddrPort netip.AddrPort, | ||
| ) (httpClient *http.Client, cleanup func() error, err error) { | ||
| fd, sourceAddrPort, err := bindSourceConnection(destinationAddrPort.Addr()) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("binding source port: %w", err) | ||
| } | ||
|
|
||
| const remove = false | ||
| err = c.firewall.AcceptOutputFromIPPortToIPPort(ctx, "tcp", c.outboundInterface, | ||
| sourceAddrPort, destinationAddrPort, remove) | ||
| if err != nil { | ||
| closeFD(fd) | ||
| return nil, nil, fmt.Errorf("allowing output traffic through firewall: %w", err) | ||
| } | ||
|
|
||
| connection, err := connectSourceConnection(ctx, fd, destinationAddrPort) | ||
| if err != nil { | ||
| const remove = true | ||
| _ = c.firewall.AcceptOutputFromIPPortToIPPort(context.Background(), "tcp", c.outboundInterface, | ||
| sourceAddrPort, destinationAddrPort, remove) | ||
| return nil, nil, fmt.Errorf("connecting source socket: %w", err) | ||
| } | ||
|
|
||
| dial := makeDial(connection, destinationTLSName) | ||
| httpClient = newHTTPSClient(destinationTLSName, dial) | ||
| cleanup = func() error { | ||
| var errs []error | ||
| httpClient.CloseIdleConnections() | ||
| err := connection.Close() | ||
| if err != nil && !errors.Is(err, net.ErrClosed) { | ||
| errs = append(errs, fmt.Errorf("closing connection: %w", err)) | ||
| } | ||
| const remove = true | ||
| err = c.firewall.AcceptOutputFromIPPortToIPPort(context.Background(), "tcp", c.outboundInterface, | ||
| sourceAddrPort, destinationAddrPort, remove) | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("removing output traffic rule: %w", err)) | ||
| } | ||
| if len(errs) > 0 { | ||
| return errors.Join(errs...) | ||
| } | ||
| return nil | ||
| } | ||
| return httpClient, cleanup, nil | ||
| } | ||
|
|
||
| type dialFunc func(ctx context.Context, network, address string) (net.Conn, error) | ||
|
|
||
| func newHTTPSClient(destinationTLSName string, dial dialFunc) *http.Client { | ||
| const timeout = 5 * time.Second | ||
| transport := &http.Transport{ | ||
| MaxIdleConns: 1, | ||
| MaxIdleConnsPerHost: 1, | ||
| MaxConnsPerHost: 1, | ||
| TLSClientConfig: &tls.Config{ | ||
| MinVersion: tls.VersionTLS12, | ||
| ServerName: destinationTLSName, | ||
| }, | ||
| DialContext: dial, | ||
| } | ||
| return &http.Client{ | ||
| Timeout: timeout, | ||
| Transport: transport, | ||
| } | ||
| } | ||
|
|
||
| func makeDial(connection net.Conn, tlsName string) dialFunc { | ||
| _, destinationPort, err := net.SplitHostPort(connection.RemoteAddr().String()) | ||
| if err != nil { | ||
| panic(err) // connection remote address should always be in the form "host:port" | ||
| } | ||
| expectedAddress := net.JoinHostPort(tlsName, destinationPort) | ||
| used := false | ||
| return func(_ context.Context, network, address string) (net.Conn, error) { | ||
| if used { | ||
| return nil, errors.New("dial function called more than once") | ||
| } | ||
| used = true | ||
| switch network { | ||
| case "tcp", "tcp4", "tcp6": | ||
| default: | ||
| return nil, fmt.Errorf("unexpected dial network %q", network) | ||
| } | ||
| if address != expectedAddress { | ||
| return nil, fmt.Errorf("unexpected dial address %q (expected %q)", address, expectedAddress) | ||
| } | ||
| return connection, nil | ||
| } | ||
| } | ||
|
|
||
| func bindSourceConnection(destinationIP netip.Addr) (fd int, sourceAddr netip.AddrPort, err error) { | ||
| sourceIP, err := sourceIPForDestination(destinationIP) | ||
| if err != nil { | ||
| return 0, netip.AddrPort{}, fmt.Errorf("finding source IP: %w", err) | ||
| } | ||
|
|
||
| family := constants.AF_INET | ||
| if sourceIP.Is6() { | ||
| family = constants.AF_INET6 | ||
| } | ||
|
|
||
| fd, err = newTCPSockStream(family) | ||
| if err != nil { | ||
| return 0, netip.AddrPort{}, fmt.Errorf("creating socket: %w", err) | ||
| } | ||
|
|
||
| bindAddrPort := netip.AddrPortFrom(sourceIP, 0) | ||
| err = bindFD(fd, bindAddrPort) | ||
| if err != nil { | ||
| closeFD(fd) | ||
| return 0, netip.AddrPort{}, fmt.Errorf("binding socket: %w", err) | ||
| } | ||
|
|
||
| sourceAddr, err = fdToSourceAddr(fd) | ||
| if err != nil { | ||
| closeFD(fd) | ||
| return 0, netip.AddrPort{}, fmt.Errorf("getting source address: %w", err) | ||
| } | ||
|
|
||
| return fd, sourceAddr, nil | ||
| } | ||
|
|
||
| func connectSourceConnection(ctx context.Context, fd int, destinationAddrPort netip.AddrPort) ( | ||
| connection net.Conn, err error, | ||
| ) { | ||
| err = connectFD(ctx, fd, destinationAddrPort) | ||
| if err != nil { | ||
| closeFD(fd) | ||
| return nil, fmt.Errorf("connecting socket: %w", err) | ||
| } | ||
|
|
||
| file := os.NewFile(uintptr(fd), "") | ||
| if file == nil { | ||
| closeFD(fd) | ||
| return nil, fmt.Errorf("creating socket file for destination %s", destinationAddrPort) | ||
| } | ||
|
qdm12 marked this conversation as resolved.
|
||
| defer file.Close() | ||
|
|
||
| connection, err = net.FileConn(file) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("wrapping socket connection: %w", err) | ||
| } | ||
|
|
||
| return connection, nil | ||
| } | ||
|
|
||
| func sourceIPForDestination(destinationIP netip.Addr) (srcIP netip.Addr, err error) { | ||
| conn, err := rtnetlink.Dial(nil) | ||
| if err != nil { | ||
| return netip.Addr{}, err | ||
| } | ||
| defer conn.Close() | ||
|
|
||
| family := uint8(constants.AF_INET) | ||
| if destinationIP.Is6() { | ||
| family = constants.AF_INET6 | ||
| } | ||
|
|
||
| requestMessage := &rtnetlink.RouteMessage{ | ||
| Family: family, | ||
| Attributes: rtnetlink.RouteAttributes{ | ||
| Dst: destinationIP.AsSlice(), | ||
| }, | ||
| } | ||
| messages, err := conn.Route.Get(requestMessage) | ||
| if err != nil { | ||
| return netip.Addr{}, fmt.Errorf("getting routes to %s: %w", destinationIP, err) | ||
| } | ||
|
|
||
| for _, message := range messages { | ||
| if message.Attributes.Src == nil { | ||
| continue | ||
| } | ||
| if message.Attributes.Src.To4() == nil { | ||
| return netip.AddrFrom16([16]byte(message.Attributes.Src)), nil | ||
| } | ||
| return netip.AddrFrom4([4]byte(message.Attributes.Src)), nil | ||
|
qdm12 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return netip.Addr{}, fmt.Errorf("no route to %s", destinationIP) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.