mirror of
https://github.com/qdm12/gluetun.git
synced 2026-07-31 17:33:22 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7892a2ffe | |||
| 52a41cb891 | |||
| 6c76273ef6 | |||
| 366062dc12 | |||
| 8abb05567c | |||
| a53a0267e4 | |||
| 4e986c8af7 | |||
| 6d84462f00 | |||
| acab89b91a | |||
| 48c1f2bf6a | |||
| c599e7fd2c | |||
| ff6e45fae0 | |||
| 17f24343d6 |
+15
-11
@@ -28,6 +28,10 @@ on:
|
|||||||
- go.mod
|
- go.mod
|
||||||
- go.sum
|
- go.sum
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -37,7 +41,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: "1"
|
DOCKER_BUILDKIT: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: reviewdog/action-misspell@v1
|
- uses: reviewdog/action-misspell@v1
|
||||||
with:
|
with:
|
||||||
@@ -75,7 +79,7 @@ jobs:
|
|||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
@@ -101,7 +105,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: secrets
|
environment: secrets
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- run: docker build -t qmcgaw/gluetun .
|
- run: docker build -t qmcgaw/gluetun .
|
||||||
|
|
||||||
@@ -129,12 +133,12 @@ jobs:
|
|||||||
secrets.PROTONVPN_OPENVPN_PASSWORD }}" | ./ci/runner
|
secrets.PROTONVPN_OPENVPN_PASSWORD }}" | ./ci/runner
|
||||||
protonvpn-openvpn-port-forwarding
|
protonvpn-openvpn-port-forwarding
|
||||||
|
|
||||||
- name:
|
# - name:
|
||||||
Run Gluetun container with Private Internet Access OpenVPN and port
|
# Run Gluetun container with Private Internet Access OpenVPN and port
|
||||||
forwarding configuration
|
# forwarding configuration
|
||||||
run: echo -e "${{ secrets.PRIVATEINTERNETACCESS_OPENVPN_USER }}\n${{
|
# run: echo -e "${{ secrets.PRIVATEINTERNETACCESS_OPENVPN_USER }}\n${{
|
||||||
secrets.PRIVATEINTERNETACCESS_OPENVPN_PASSWORD }}" | ./ci/runner
|
# secrets.PRIVATEINTERNETACCESS_OPENVPN_PASSWORD }}" | ./ci/runner
|
||||||
private-internet-access-openvpn-port-forwarding
|
# private-internet-access-openvpn-port-forwarding
|
||||||
|
|
||||||
- name: Run Gluetun container with AirVPN Wireguard configuration
|
- name: Run Gluetun container with AirVPN Wireguard configuration
|
||||||
run: echo -e "${{ secrets.AIRVPN_WIREGUARD_PRIVATE_KEY }}\n${{
|
run: echo -e "${{ secrets.AIRVPN_WIREGUARD_PRIVATE_KEY }}\n${{
|
||||||
@@ -153,7 +157,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
@@ -179,7 +183,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: secrets
|
environment: secrets
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# extract metadata (tags, labels) for Docker
|
# extract metadata (tags, labels) for Docker
|
||||||
# https://github.com/docker/metadata-action
|
# https://github.com/docker/metadata-action
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: crazy-max/ghaction-github-labeler@v6
|
- uses: crazy-max/ghaction-github-labeler@v6
|
||||||
with:
|
with:
|
||||||
yaml-file: .github/labels.yml
|
yaml-file: .github/labels.yml
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ on:
|
|||||||
- "**.md"
|
- "**.md"
|
||||||
- .github/workflows/markdown.yml
|
- .github/workflows/markdown.yml
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
markdown:
|
markdown:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -19,7 +23,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
environment: secrets
|
environment: secrets
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: DavidAnson/markdownlint-cli2-action@v22
|
- uses: DavidAnson/markdownlint-cli2-action@v22
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ formatters:
|
|||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
|
|
||||||
|
run:
|
||||||
|
build-tags:
|
||||||
|
- integration
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
settings:
|
settings:
|
||||||
misspell:
|
misspell:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
// to develop this project.
|
// to develop this project.
|
||||||
"files.eol": "\n",
|
"files.eol": "\n",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"go.buildTags": "linux",
|
"go.buildTags": "linux,integration",
|
||||||
"go.toolsEnvVars": {
|
"go.toolsEnvVars": {
|
||||||
"CGO_ENABLED": "0"
|
"CGO_ENABLED": "0"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -276,7 +276,7 @@ ENV VPN_SERVICE_PROVIDER=pia \
|
|||||||
PUID=1000 \
|
PUID=1000 \
|
||||||
PGID=1000
|
PGID=1000
|
||||||
ENTRYPOINT ["/gluetun-entrypoint"]
|
ENTRYPOINT ["/gluetun-entrypoint"]
|
||||||
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp 1080/tcp
|
EXPOSE 8000/tcp 8888/tcp 8388/tcp 8388/udp 1080/tcp 1080/udp
|
||||||
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck
|
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheck
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
RUN apk add --no-cache --update -l wget && \
|
RUN apk add --no-cache --update -l wget && \
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Lightweight swiss-army-knife-like VPN client to multiple VPN service providers
|
|||||||
- Choose the vpn network protocol, `udp` or `tcp`
|
- Choose the vpn network protocol, `udp` or `tcp`
|
||||||
- Built in firewall kill switch to allow traffic only with needed the VPN servers and LAN devices
|
- Built in firewall kill switch to allow traffic only with needed the VPN servers and LAN devices
|
||||||
- Built in Shadowsocks proxy server (protocol based on SOCKS5 with an encryption layer, tunnels TCP+UDP)
|
- Built in Shadowsocks proxy server (protocol based on SOCKS5 with an encryption layer, tunnels TCP+UDP)
|
||||||
- Built in Socks5 proxy server (tunnels TCP) - partial credits to @angelakis and @adjscent
|
- Built in Socks5 proxy server (tunnels TCP+UDP) - partial credits to @angelakis and @adjscent
|
||||||
- Built in HTTP proxy (tunnels HTTP and HTTPS through TCP)
|
- Built in HTTP proxy (tunnels HTTP and HTTPS through TCP)
|
||||||
- [Connect other containers to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-container-to-gluetun.md)
|
- [Connect other containers to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-container-to-gluetun.md)
|
||||||
- [Connect LAN devices to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-lan-device-to-gluetun.md)
|
- [Connect LAN devices to it](https://github.com/qdm12/gluetun-wiki/blob/main/setup/connect-a-lan-device-to-gluetun.md)
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Start launches a command and streams stdout and stderr to channels.
|
// Start launches a command and streams stdout and stderr to channels.
|
||||||
// All the channels returned are ready only and won't be closed
|
// stdoutLines and stderrLines channels will be closed when there is no more
|
||||||
// if the command fails later.
|
// output to read, in order for the caller to catch all lines even after the
|
||||||
|
// command has finished. The waitError channel returned will never be closed.
|
||||||
func (c *Cmder) Start(cmd *exec.Cmd) (
|
func (c *Cmder) Start(cmd *exec.Cmd) (
|
||||||
stdoutLines, stderrLines <-chan string,
|
stdoutLines, stderrLines <-chan string,
|
||||||
waitError <-chan error, startErr error,
|
waitError <-chan error, startErr error,
|
||||||
@@ -38,6 +39,7 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
_ = stdout.Close()
|
_ = stdout.Close()
|
||||||
<-stdoutDone
|
<-stdoutDone
|
||||||
|
close(stdoutLinesCh)
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
go streamToChannel(stderrReady, stderrDone, stderr, stderrLinesCh)
|
go streamToChannel(stderrReady, stderrDone, stderr, stderrLinesCh)
|
||||||
@@ -45,9 +47,11 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
|
|||||||
err = cmd.Start()
|
err = cmd.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = stdout.Close()
|
_ = stdout.Close()
|
||||||
_ = stderr.Close()
|
|
||||||
<-stdoutDone
|
<-stdoutDone
|
||||||
|
close(stdoutLinesCh)
|
||||||
|
_ = stderr.Close()
|
||||||
<-stderrDone
|
<-stderrDone
|
||||||
|
close(stderrLinesCh)
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +59,10 @@ func start(cmd execCmd) (stdoutLines, stderrLines <-chan string,
|
|||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
<-stdoutDone
|
<-stdoutDone
|
||||||
<-stderrDone
|
close(stdoutLinesCh)
|
||||||
_ = stdout.Close()
|
_ = stdout.Close()
|
||||||
|
<-stderrDone
|
||||||
|
close(stderrLinesCh)
|
||||||
_ = stderr.Close()
|
_ = stderr.Close()
|
||||||
waitErrorCh <- err
|
waitErrorCh <- err
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -89,30 +89,48 @@ func Test_start(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var stdoutIndex, stderrIndex int
|
collectAndCheckChannels(t, stdoutLines, stderrLines, waitError,
|
||||||
|
testCase.stdout, testCase.stderr, testCase.waitErr)
|
||||||
done := false
|
|
||||||
for !done {
|
|
||||||
select {
|
|
||||||
case line := <-stdoutLines:
|
|
||||||
assert.Equal(t, testCase.stdout[stdoutIndex], line)
|
|
||||||
stdoutIndex++
|
|
||||||
case line := <-stderrLines:
|
|
||||||
assert.Equal(t, testCase.stderr[stderrIndex], line)
|
|
||||||
stderrIndex++
|
|
||||||
case err := <-waitError:
|
|
||||||
if testCase.waitErr != nil {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Equal(t, testCase.waitErr.Error(), err.Error())
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
done = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, len(testCase.stdout), stdoutIndex)
|
|
||||||
assert.Equal(t, len(testCase.stderr), stderrIndex)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectAndCheckChannels(t *testing.T, stdoutLines, stderrLines <-chan string,
|
||||||
|
waitError <-chan error, expectedStdout, expectedStderr []string, expectedWaitErr error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stdoutIndex := 0
|
||||||
|
stderrIndex := 0
|
||||||
|
|
||||||
|
done := false
|
||||||
|
for !done {
|
||||||
|
select {
|
||||||
|
case line, ok := <-stdoutLines:
|
||||||
|
if !ok {
|
||||||
|
stdoutLines = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, expectedStdout[stdoutIndex], line)
|
||||||
|
stdoutIndex++
|
||||||
|
case line, ok := <-stderrLines:
|
||||||
|
if !ok {
|
||||||
|
stderrLines = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, expectedStderr[stderrIndex], line)
|
||||||
|
stderrIndex++
|
||||||
|
case err := <-waitError:
|
||||||
|
if expectedWaitErr != nil {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, expectedWaitErr.Error(), err.Error())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, len(expectedStdout), stdoutIndex)
|
||||||
|
assert.Equal(t, len(expectedStderr), stderrIndex)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,31 +18,39 @@ func (c *Cmder) RunAndLog(ctx context.Context, command string, logger Logger) (e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
|
||||||
streamDone := make(chan struct{})
|
streamDone := make(chan struct{})
|
||||||
go streamLines(streamCtx, streamDone, logger, stdout, stderr)
|
go streamLines(streamDone, logger, stdout, stderr)
|
||||||
|
|
||||||
err = <-waitError
|
err = <-waitError
|
||||||
streamCancel()
|
|
||||||
<-streamDone
|
<-streamDone
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func streamLines(ctx context.Context, done chan<- struct{},
|
func streamLines(done chan<- struct{}, logger Logger,
|
||||||
logger Logger, stdout, stderr <-chan string,
|
stdout, stderr <-chan string,
|
||||||
) {
|
) {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
var line string
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case line, ok := <-stdout:
|
||||||
return
|
if ok {
|
||||||
case line = <-stdout:
|
logger.Info(line)
|
||||||
logger.Info(line)
|
break
|
||||||
case line = <-stderr:
|
}
|
||||||
logger.Error(line)
|
if stderr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdout = nil
|
||||||
|
case line, ok := <-stderr:
|
||||||
|
if ok {
|
||||||
|
logger.Error(line)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if stdout == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderr = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,19 +29,16 @@ func (r *Runner) Run(ctx context.Context, errCh chan<- error, ready chan<- struc
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
streamCtx, streamCancel := context.WithCancel(context.Background())
|
|
||||||
streamDone := make(chan struct{})
|
streamDone := make(chan struct{})
|
||||||
go streamLines(streamCtx, streamDone, r.logger,
|
go streamLines(streamDone, r.logger,
|
||||||
stdoutLines, stderrLines, ready)
|
stdoutLines, stderrLines, ready)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
<-waitError
|
<-waitError
|
||||||
streamCancel()
|
|
||||||
<-streamDone
|
<-streamDone
|
||||||
errCh <- ctx.Err()
|
errCh <- ctx.Err()
|
||||||
case err := <-waitError:
|
case err := <-waitError:
|
||||||
streamCancel()
|
|
||||||
<-streamDone
|
<-streamDone
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
package openvpn
|
package openvpn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func streamLines(ctx context.Context, done chan<- struct{},
|
func streamLines(done chan<- struct{},
|
||||||
logger Logger, stdout, stderr <-chan string,
|
logger Logger, stdout, stderr <-chan string,
|
||||||
tunnelReady chan<- struct{},
|
tunnelReady chan<- struct{},
|
||||||
) {
|
) {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
var line string
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
var line string
|
||||||
|
var ok bool
|
||||||
errLine := false
|
errLine := false
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case line, ok = <-stdout:
|
||||||
return
|
if ok {
|
||||||
case line = <-stdout:
|
break
|
||||||
case line = <-stderr:
|
}
|
||||||
errLine = true
|
if stderr == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdout = nil
|
||||||
|
case line, ok = <-stderr:
|
||||||
|
if ok {
|
||||||
|
errLine = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if stdout == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderr = nil
|
||||||
}
|
}
|
||||||
line, level := processLogLine(line)
|
line, level := processLogLine(line)
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/qdm12/gluetun/internal/configuration/settings"
|
"github.com/qdm12/gluetun/internal/configuration/settings"
|
||||||
|
"github.com/qdm12/gluetun/internal/constants"
|
||||||
"github.com/qdm12/gluetun/internal/constants/openvpn"
|
"github.com/qdm12/gluetun/internal/constants/openvpn"
|
||||||
"github.com/qdm12/gluetun/internal/models"
|
"github.com/qdm12/gluetun/internal/models"
|
||||||
"github.com/qdm12/gluetun/internal/provider/utils"
|
"github.com/qdm12/gluetun/internal/provider/utils"
|
||||||
@@ -65,7 +66,11 @@ func modifyConfig(lines []string, connection models.Connection,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add values
|
// Add values
|
||||||
modified = append(modified, "proto "+connection.Protocol)
|
protocol := connection.Protocol
|
||||||
|
if protocol == constants.TCP {
|
||||||
|
protocol = "tcp-client"
|
||||||
|
}
|
||||||
|
modified = append(modified, "proto "+protocol)
|
||||||
modified = append(modified, fmt.Sprintf("remote %s %d", connection.IP, connection.Port))
|
modified = append(modified, fmt.Sprintf("remote %s %d", connection.IP, connection.Port))
|
||||||
modified = append(modified, "dev "+settings.Interface)
|
modified = append(modified, "dev "+settings.Interface)
|
||||||
modified = append(modified, "mute-replay-warnings")
|
modified = append(modified, "mute-replay-warnings")
|
||||||
|
|||||||
+74
-65
@@ -21,8 +21,8 @@ type server struct {
|
|||||||
listening atomic.Bool
|
listening atomic.Bool
|
||||||
socksConnCtx context.Context //nolint:containedctx
|
socksConnCtx context.Context //nolint:containedctx
|
||||||
socksConnCancel context.CancelFunc
|
socksConnCancel context.CancelFunc
|
||||||
done <-chan struct{}
|
done <-chan error
|
||||||
stopping atomic.Bool
|
stopCh chan<- struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(settings Settings) *server {
|
func newServer(settings Settings) *server {
|
||||||
@@ -58,9 +58,11 @@ func (s *server) Start(ctx context.Context) (runErr <-chan error, err error) {
|
|||||||
ready := make(chan struct{})
|
ready := make(chan struct{})
|
||||||
runErrCh := make(chan error)
|
runErrCh := make(chan error)
|
||||||
runErr = runErrCh
|
runErr = runErrCh
|
||||||
done := make(chan struct{})
|
done := make(chan error)
|
||||||
s.done = done
|
s.done = done
|
||||||
go s.runServer(ready, runErrCh, done)
|
stop := make(chan struct{})
|
||||||
|
s.stopCh = stop
|
||||||
|
go s.runServer(ready, runErrCh, stop, done)
|
||||||
select {
|
select {
|
||||||
case <-ready:
|
case <-ready:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -71,78 +73,85 @@ func (s *server) Start(ctx context.Context) (runErr <-chan error, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) runServer(ready chan<- struct{},
|
func (s *server) runServer(ready chan<- struct{},
|
||||||
runErrCh chan<- error, done chan<- struct{},
|
runErrCh chan<- error, stop <-chan struct{}, done chan<- error,
|
||||||
) {
|
) {
|
||||||
close(ready)
|
close(ready)
|
||||||
defer close(done)
|
defer close(done)
|
||||||
wg := new(sync.WaitGroup)
|
|
||||||
defer wg.Wait()
|
|
||||||
|
|
||||||
wg.Go(func() {
|
udpErrCh := make(chan error)
|
||||||
err := s.udpRouter.run(s.socksConnCtx)
|
go func() {
|
||||||
if err != nil {
|
udpErrCh <- s.udpRouter.run(s.socksConnCtx)
|
||||||
if !s.stopping.Load() {
|
}()
|
||||||
_ = s.stop()
|
|
||||||
runErrCh <- fmt.Errorf("running UDP router: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
dialer := &net.Dialer{}
|
tcpErrCh := make(chan error)
|
||||||
for {
|
go func() {
|
||||||
connection, err := s.tcpListener.Accept()
|
var wg sync.WaitGroup
|
||||||
if err != nil {
|
defer wg.Wait()
|
||||||
if !s.stopping.Load() {
|
|
||||||
_ = s.stop()
|
dialer := &net.Dialer{}
|
||||||
runErrCh <- fmt.Errorf("accepting connection: %w", err)
|
for {
|
||||||
}
|
connection, err := s.tcpListener.Accept()
|
||||||
return
|
|
||||||
}
|
|
||||||
wg.Add(1)
|
|
||||||
go func(ctx context.Context, connection net.Conn,
|
|
||||||
dialer *net.Dialer, wg *sync.WaitGroup,
|
|
||||||
) {
|
|
||||||
defer wg.Done()
|
|
||||||
socksConn := &socksConn{
|
|
||||||
dialer: dialer,
|
|
||||||
username: s.username,
|
|
||||||
password: s.password,
|
|
||||||
clientConn: connection,
|
|
||||||
udpRouter: s.udpRouter,
|
|
||||||
logger: s.logger,
|
|
||||||
}
|
|
||||||
err := socksConn.run(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Infof("running socks connection: %s", err)
|
s.socksConnCancel() // stop ongoing TCP socks connections - no impact on UDP
|
||||||
|
tcpErrCh <- fmt.Errorf("accepting connection: %w", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}(s.socksConnCtx, connection, dialer, wg)
|
wg.Go(func() {
|
||||||
|
connection := connection // capture loop variable
|
||||||
|
socksConn := &socksConn{
|
||||||
|
dialer: dialer,
|
||||||
|
username: s.username,
|
||||||
|
password: s.password,
|
||||||
|
clientConn: connection,
|
||||||
|
udpRouter: s.udpRouter,
|
||||||
|
logger: s.logger,
|
||||||
|
}
|
||||||
|
err := socksConn.run(s.socksConnCtx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Infof("running socks connection: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
s.listening.Store(false)
|
||||||
|
var errs []error
|
||||||
|
err := s.tcpListener.Close()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("closing TCP listener: %w", err))
|
||||||
|
}
|
||||||
|
// stop ongoing TCP socks connections. This impacts the udpRouter run error when it is being closed.
|
||||||
|
s.socksConnCancel()
|
||||||
|
<-tcpErrCh // wait for TCP server to stop
|
||||||
|
err = s.udpRouter.close()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("closing UDP router: %w", err))
|
||||||
|
}
|
||||||
|
<-udpErrCh // wait for UDP router to stop
|
||||||
|
if len(errs) > 0 {
|
||||||
|
// Only write to the done channel if the [server.Stop] method is waiting to read from it
|
||||||
|
done <- errors.Join(errs...)
|
||||||
|
}
|
||||||
|
// If no error, the done channel is closed so the error is effectively `nil`
|
||||||
|
// Note: do NOT write an error the runError channel, since we are stopping the server gracefully.
|
||||||
|
case err := <-udpErrCh:
|
||||||
|
_ = s.tcpListener.Close() // stop accepting new TCP connections
|
||||||
|
s.socksConnCancel() // stop ongoing TCP socks connections
|
||||||
|
<-tcpErrCh // wait for TCP server to stop
|
||||||
|
runErrCh <- fmt.Errorf("running UDP router: %w", err)
|
||||||
|
case err := <-tcpErrCh:
|
||||||
|
s.socksConnCancel()
|
||||||
|
_ = s.udpRouter.close() // stop UDP router
|
||||||
|
<-udpErrCh // wait for UDP router to stop
|
||||||
|
runErrCh <- fmt.Errorf("running TCP server: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) Stop() (err error) {
|
func (s *server) Stop() (err error) {
|
||||||
s.stopping.Store(true)
|
close(s.stopCh)
|
||||||
err = s.stop()
|
return <-s.done
|
||||||
<-s.done // wait for run goroutine to finish
|
|
||||||
s.stopping.Store(false)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) stop() error {
|
|
||||||
s.listening.Store(false)
|
|
||||||
var errs []error
|
|
||||||
err := s.tcpListener.Close()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("closing TCP listener: %w", err))
|
|
||||||
}
|
|
||||||
err = s.udpRouter.close()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("closing UDP router: %w", err))
|
|
||||||
}
|
|
||||||
s.socksConnCancel() // stop ongoing socks connections
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) listeningAddress() net.Addr {
|
func (s *server) listeningAddress() net.Addr {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package socks5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_Server_UDPResolution(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
server := newServer(Settings{
|
||||||
|
Address: "127.0.0.1:0",
|
||||||
|
Logger: noopLogger{},
|
||||||
|
})
|
||||||
|
runErr, err := server.Start(ctx)
|
||||||
|
require.NoError(t, err, "starting SOCKS5 server")
|
||||||
|
|
||||||
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
|
// Connect to the SOCKS5 server via TCP to negotiate UDP associate
|
||||||
|
dialer := &net.Dialer{Timeout: timeout}
|
||||||
|
tcpConn, err := dialer.DialContext(ctx, "tcp", server.listeningAddress().String())
|
||||||
|
require.NoError(t, err, "tcp connecting to SOCKS5 server")
|
||||||
|
t.Cleanup(func() { tcpConn.Close() })
|
||||||
|
|
||||||
|
negotiateSOCKS5(t, tcpConn, "", "")
|
||||||
|
|
||||||
|
// UDP Associate Command: [VERSION (5), CMD (3 = UDP ASSOC), RSV (0), ATYP (1 = IPv4), ADDR (0.0.0.0), PORT (0)]
|
||||||
|
_, err = tcpConn.Write([]byte{5, 3, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||||
|
require.NoError(t, err, "sending UDP ASSOC request")
|
||||||
|
|
||||||
|
relayAddressString, err := readSOCKS5ResponseAddress(t, tcpConn)
|
||||||
|
require.NoError(t, err, "reading UDP ASSOC reply")
|
||||||
|
relayAddress, err := net.ResolveUDPAddr("udp", relayAddressString)
|
||||||
|
require.NoError(t, err, "resolving udp relay address")
|
||||||
|
|
||||||
|
// Dial the relay using IPv4 so source IP family matches the control connection.
|
||||||
|
udpConn, err := net.DialUDP("udp4", nil, relayAddress)
|
||||||
|
require.NoError(t, err, "dialing UDP relay")
|
||||||
|
t.Cleanup(func() { _ = udpConn.Close() })
|
||||||
|
|
||||||
|
queryID := uint16(rand.Uint32()) //nolint:gosec
|
||||||
|
dnsRequest := &dns.Msg{
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: queryID,
|
||||||
|
RecursionDesired: true,
|
||||||
|
},
|
||||||
|
Question: []dns.Question{{
|
||||||
|
Name: dns.Fqdn("github.com"),
|
||||||
|
Qtype: dns.TypeA,
|
||||||
|
Qclass: dns.ClassINET,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
dnsQuery, err := dnsRequest.Pack()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Encapsulate DNS payload into SOCKS5 UDP Request Header
|
||||||
|
// [RSV (0,0), FRAG (0), ATYP (1 = IPv4), DST.ADDR (1.1.1.1), DST.PORT (53)]
|
||||||
|
packet := append([]byte{0, 0, 0, 1, 1, 1, 1, 1, 0, 53}, dnsQuery...)
|
||||||
|
|
||||||
|
// Send encapsulated packet to the proxy's UDP relay address
|
||||||
|
_, err = udpConn.Write(packet)
|
||||||
|
require.NoError(t, err, "sending UDP packet to relay")
|
||||||
|
|
||||||
|
// Read response from the proxy relay
|
||||||
|
err = udpConn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
require.NoError(t, err, "setting read deadline on UDP connection")
|
||||||
|
buffer := make([]byte, 2048)
|
||||||
|
n, err := udpConn.Read(buffer)
|
||||||
|
require.NoError(t, err, "receiving UDP response from relay")
|
||||||
|
const minimumHeaderSize = 10
|
||||||
|
require.GreaterOrEqual(t, n, minimumHeaderSize, "received UDP packet too short to contain valid SOCKS5 header")
|
||||||
|
|
||||||
|
// Verify header layout and slice out the raw DNS response
|
||||||
|
// Header format: RSV(2) FRAG(1) ATYP(1) DST.ADDR(variable) DST.PORT(2)
|
||||||
|
atyp := buffer[3]
|
||||||
|
var headerSize int
|
||||||
|
switch atyp {
|
||||||
|
case 1: // IPv4
|
||||||
|
headerSize = 10
|
||||||
|
case 3: // Domain name
|
||||||
|
headerSize = 4 + 1 + int(buffer[4]) + 2
|
||||||
|
case 4: // IPv6
|
||||||
|
headerSize = 22
|
||||||
|
default:
|
||||||
|
t.Fatalf("Unknown ATYP in SOCKS5 UDP header: %d", atyp)
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsResponse := new(dns.Msg)
|
||||||
|
err = dnsResponse.Unpack(buffer[headerSize:n])
|
||||||
|
require.NoError(t, err, "unpacking DNS response from SOCKS5 UDP packet")
|
||||||
|
|
||||||
|
assert.Equal(t, queryID, dnsResponse.Id, "DNS response ID should match query ID")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-runErr:
|
||||||
|
require.NoError(t, err, "SOCKS5 server run error")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
err = server.Stop()
|
||||||
|
require.NoError(t, err, "stopping SOCKS5 server")
|
||||||
|
}
|
||||||
+64
-17
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -119,7 +120,7 @@ func (c *socksConn) handleRequest(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case udpAssociate:
|
case udpAssociate:
|
||||||
err = c.handleUDPAssociateRequest(ctx, socksVersion)
|
err = c.handleUDPAssociateRequest(ctx, socksVersion, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("handling %s request: %w", request.command, err)
|
return fmt.Errorf("handling %s request: %w", request.command, err)
|
||||||
}
|
}
|
||||||
@@ -196,14 +197,27 @@ func (c *socksConn) handleConnectRequest(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *socksConn) handleUDPAssociateRequest(ctx context.Context,
|
func (c *socksConn) handleUDPAssociateRequest(ctx context.Context,
|
||||||
socksVersion byte,
|
socksVersion byte, request request,
|
||||||
) error {
|
) error {
|
||||||
|
expectedAddrPort, err := udpAssociateExpectedClientEndpoint(request)
|
||||||
|
if err != nil {
|
||||||
|
c.encodeFailedResponse(c.clientConn, socksVersion, addressTypeNotSupported)
|
||||||
|
return fmt.Errorf("deriving expected client address and port from request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
bindAddress, bindPort, bindAddrType, err := c.udpAssociationAddresses()
|
bindAddress, bindPort, bindAddrType, err := c.udpAssociationAddresses()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
|
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
|
||||||
return fmt.Errorf("getting udp association addresses: %w", err)
|
return fmt.Errorf("getting udp association addresses: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
association, err := c.udpRouter.registerAssociation(c.clientConn, expectedAddrPort)
|
||||||
|
if err != nil {
|
||||||
|
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
|
||||||
|
return fmt.Errorf("registering udp association: %w", err)
|
||||||
|
}
|
||||||
|
defer c.udpRouter.unregisterAssociation(association)
|
||||||
|
|
||||||
err = c.encodeSuccessResponse(c.clientConn, socksVersion, succeeded,
|
err = c.encodeSuccessResponse(c.clientConn, socksVersion, succeeded,
|
||||||
bindAddrType, bindAddress, bindPort)
|
bindAddrType, bindAddress, bindPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,31 +225,46 @@ func (c *socksConn) handleUDPAssociateRequest(ctx context.Context,
|
|||||||
return fmt.Errorf("writing successful %s response: %w", udpAssociate, err)
|
return fmt.Errorf("writing successful %s response: %w", udpAssociate, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
association, err := c.udpRouter.registerAssociation(c.clientConn)
|
|
||||||
if err != nil {
|
|
||||||
c.encodeFailedResponse(c.clientConn, socksVersion, generalServerFailure)
|
|
||||||
return fmt.Errorf("registering udp association: %w", err)
|
|
||||||
}
|
|
||||||
defer c.udpRouter.unregisterAssociation(association)
|
|
||||||
|
|
||||||
associationCtx, associationCancel := context.WithCancel(ctx)
|
associationCtx, associationCancel := context.WithCancel(ctx)
|
||||||
defer associationCancel()
|
defer associationCancel()
|
||||||
|
|
||||||
handlerDone := make(chan struct{})
|
var wg sync.WaitGroup
|
||||||
go func() {
|
|
||||||
defer close(handlerDone)
|
|
||||||
c.udpRouter.runAssociationHandler(associationCtx, association)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
wg.Go(func() {
|
||||||
|
c.udpRouter.runAssociationHandler(associationCtx, association)
|
||||||
|
})
|
||||||
|
|
||||||
|
wg.Go(func() {
|
||||||
_, _ = io.Copy(io.Discard, c.clientConn)
|
_, _ = io.Copy(io.Discard, c.clientConn)
|
||||||
associationCancel()
|
associationCancel()
|
||||||
}()
|
})
|
||||||
<-associationCtx.Done()
|
<-associationCtx.Done()
|
||||||
<-handlerDone
|
wg.Wait()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func udpAssociateExpectedClientEndpoint(request request) (expectedAddrPort netip.AddrPort, err error) {
|
||||||
|
switch request.addressType {
|
||||||
|
case ipv4, ipv6:
|
||||||
|
expectedClientAddress, parseErr := netip.ParseAddr(request.destination)
|
||||||
|
if parseErr != nil {
|
||||||
|
return netip.AddrPort{}, fmt.Errorf("parsing destination address: %w", parseErr)
|
||||||
|
}
|
||||||
|
expectedClientAddress = expectedClientAddress.Unmap()
|
||||||
|
if !expectedClientAddress.IsUnspecified() {
|
||||||
|
return netip.AddrPortFrom(expectedClientAddress, request.port), nil
|
||||||
|
}
|
||||||
|
return netip.AddrPortFrom(netip.Addr{}, request.port), nil
|
||||||
|
case domainName:
|
||||||
|
// For UDP associate, client endpoint matching is based on observed UDP source
|
||||||
|
// address/port. A hostname is not directly matchable at this stage, so we
|
||||||
|
// ignore the domain name request destination entirely.
|
||||||
|
return netip.AddrPortFrom(netip.Addr{}, request.port), nil
|
||||||
|
default:
|
||||||
|
return netip.AddrPort{}, fmt.Errorf("address type %d is not supported", request.addressType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *socksConn) udpAssociationAddresses() (bindAddress string,
|
func (c *socksConn) udpAssociationAddresses() (bindAddress string,
|
||||||
bindPort uint16, bindAddrType addrType, err error,
|
bindPort uint16, bindAddrType addrType, err error,
|
||||||
) {
|
) {
|
||||||
@@ -250,6 +279,14 @@ func (c *socksConn) udpAssociationAddresses() (bindAddress string,
|
|||||||
}
|
}
|
||||||
bindAddress = host
|
bindAddress = host
|
||||||
bindPort = uint16(port)
|
bindPort = uint16(port)
|
||||||
|
if isUnspecifiedIPAddress(bindAddress) {
|
||||||
|
controlLocalAddress := c.clientConn.LocalAddr().String()
|
||||||
|
controlLocalHost, _, splitErr := net.SplitHostPort(controlLocalAddress)
|
||||||
|
if splitErr != nil {
|
||||||
|
return "", 0, 0, fmt.Errorf("splitting control connection local address: %w", splitErr)
|
||||||
|
}
|
||||||
|
bindAddress = controlLocalHost
|
||||||
|
}
|
||||||
|
|
||||||
ipAddress := net.ParseIP(bindAddress)
|
ipAddress := net.ParseIP(bindAddress)
|
||||||
if ipAddress == nil {
|
if ipAddress == nil {
|
||||||
@@ -266,6 +303,14 @@ func (c *socksConn) udpAssociationAddresses() (bindAddress string,
|
|||||||
return bindAddress, bindPort, bindAddrType, nil
|
return bindAddress, bindPort, bindAddrType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUnspecifiedIPAddress(address string) bool {
|
||||||
|
ipAddress, err := netip.ParseAddr(address)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ipAddress.IsUnspecified()
|
||||||
|
}
|
||||||
|
|
||||||
func decodeUDPDatagram(packet []byte) (destination string, payload []byte, err error) {
|
func decodeUDPDatagram(packet []byte) (destination string, payload []byte, err error) {
|
||||||
const minimumPacketLength = 4
|
const minimumPacketLength = 4
|
||||||
if len(packet) < minimumPacketLength {
|
if len(packet) < minimumPacketLength {
|
||||||
@@ -364,6 +409,8 @@ func writeUDPDatagramSourceAddress(writer io.Writer, address netip.Addr) error {
|
|||||||
addrType = ipv6
|
addrType = ipv6
|
||||||
array := address.As16()
|
array := address.As16()
|
||||||
addressBytes = array[:]
|
addressBytes = array[:]
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("address type is not supported: %v", address)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := writer.Write([]byte{0, 0, 0, byte(addrType)})
|
_, err := writer.Write([]byte{0, 0, 0, byte(addrType)})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -703,6 +704,70 @@ func Test_decodeRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_udpAssociateExpectedClientEndpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := map[string]struct {
|
||||||
|
request request
|
||||||
|
expected netip.AddrPort
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
"ipv4_endpoint": {
|
||||||
|
request: request{
|
||||||
|
addressType: ipv4,
|
||||||
|
destination: "192.0.2.10",
|
||||||
|
port: 5555,
|
||||||
|
},
|
||||||
|
expected: netip.MustParseAddrPort("192.0.2.10:5555"),
|
||||||
|
},
|
||||||
|
"ipv4_unspecified_address": {
|
||||||
|
request: request{
|
||||||
|
addressType: ipv4,
|
||||||
|
destination: "0.0.0.0",
|
||||||
|
port: 6000,
|
||||||
|
},
|
||||||
|
expected: netip.AddrPortFrom(netip.Addr{}, 6000),
|
||||||
|
},
|
||||||
|
"domain_name_with_port": {
|
||||||
|
request: request{
|
||||||
|
addressType: domainName,
|
||||||
|
destination: "client.example",
|
||||||
|
port: 7000,
|
||||||
|
},
|
||||||
|
expected: netip.AddrPortFrom(netip.Addr{}, 7000),
|
||||||
|
},
|
||||||
|
"domain_name_without_port": {
|
||||||
|
request: request{
|
||||||
|
addressType: domainName,
|
||||||
|
destination: "client.example",
|
||||||
|
},
|
||||||
|
expected: netip.AddrPort{},
|
||||||
|
},
|
||||||
|
"unsupported_address_type": {
|
||||||
|
request: request{
|
||||||
|
addressType: 255,
|
||||||
|
},
|
||||||
|
expectedErr: "address type 255 is not supported",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, testCase := range testCases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result, err := udpAssociateExpectedClientEndpoint(testCase.request)
|
||||||
|
|
||||||
|
if testCase.expectedErr != "" {
|
||||||
|
assert.ErrorContains(t, err, testCase.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, testCase.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Test_verifyFirstNegotiation(t *testing.T) {
|
func Test_verifyFirstNegotiation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
testCases := map[string]struct {
|
testCases := map[string]struct {
|
||||||
@@ -942,3 +1007,80 @@ func Test_cmdType_String(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_socksConn_udpAssociationAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := map[string]struct {
|
||||||
|
routerAddress string
|
||||||
|
expectAddressFromConn bool
|
||||||
|
expectedAddress string
|
||||||
|
}{
|
||||||
|
"wildcard_router_address_uses_control_connection_local_ip": {
|
||||||
|
routerAddress: ":0",
|
||||||
|
expectAddressFromConn: true,
|
||||||
|
},
|
||||||
|
"concrete_router_address_is_kept": {
|
||||||
|
routerAddress: "127.0.0.1:0",
|
||||||
|
expectedAddress: "127.0.0.1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, testCase := range testCases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router, err := newUDPRouter(t.Context(), testCase.routerAddress, noopLogger{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := router.close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
controlListener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := controlListener.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
acceptedConnCh := make(chan net.Conn, 1)
|
||||||
|
go func() {
|
||||||
|
acceptedConn, acceptErr := controlListener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acceptedConnCh <- acceptedConn
|
||||||
|
}()
|
||||||
|
|
||||||
|
clientControlConn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", controlListener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientControlConn.Close()
|
||||||
|
|
||||||
|
serverControlConn := <-acceptedConnCh
|
||||||
|
defer serverControlConn.Close()
|
||||||
|
|
||||||
|
socksConnection := &socksConn{
|
||||||
|
clientConn: clientControlConn,
|
||||||
|
udpRouter: router,
|
||||||
|
}
|
||||||
|
bindAddress, bindPort, bindAddrType, err := socksConnection.udpAssociationAddresses()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if testCase.expectAddressFromConn {
|
||||||
|
clientLocalHost, _, err := net.SplitHostPort(clientControlConn.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, clientLocalHost, bindAddress)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, testCase.expectedAddress, bindAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, routerPortString, err := net.SplitHostPort(router.localAddress().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
routerPort, err := strconv.ParseUint(routerPortString, 10, 16)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint16(routerPort), bindPort)
|
||||||
|
assert.Equal(t, ipv4, bindAddrType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type udpAssociation struct {
|
type udpAssociation struct {
|
||||||
id uint64
|
id uint64
|
||||||
clientAddrPort netip.AddrPort
|
clientAddrPort netip.AddrPort
|
||||||
controlConnAddr netip.Addr
|
expectedAddrPort netip.AddrPort
|
||||||
packetCh chan *bytes.Buffer
|
controlConnAddr netip.Addr
|
||||||
|
packetCh chan *bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
type udpRouter struct {
|
type udpRouter struct {
|
||||||
@@ -47,7 +48,7 @@ func newUDPRouter(ctx context.Context, address string, logger Logger) (router *u
|
|||||||
listener: listener,
|
listener: listener,
|
||||||
bufferPool: sync.Pool{
|
bufferPool: sync.Pool{
|
||||||
New: func() any {
|
New: func() any {
|
||||||
return bytes.NewBuffer(make([]byte, pooledUDPPacketBufferCapacity))
|
return bytes.NewBuffer(make([]byte, 0, pooledUDPPacketBufferCapacity))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
nextAssociationID: 1,
|
nextAssociationID: 1,
|
||||||
@@ -65,7 +66,7 @@ func (r *udpRouter) close() error {
|
|||||||
return r.listener.Close()
|
return r.listener.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *udpRouter) registerAssociation(controlConn net.Conn) (udpAssociation, error) {
|
func (r *udpRouter) registerAssociation(controlConn net.Conn, expectedAddrPort netip.AddrPort) (udpAssociation, error) {
|
||||||
controlConnAddrPort, err := netip.ParseAddrPort(controlConn.RemoteAddr().String())
|
controlConnAddrPort, err := netip.ParseAddrPort(controlConn.RemoteAddr().String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return udpAssociation{}, fmt.Errorf("parsing control connection address: %w", err)
|
return udpAssociation{}, fmt.Errorf("parsing control connection address: %w", err)
|
||||||
@@ -75,14 +76,22 @@ func (r *udpRouter) registerAssociation(controlConn net.Conn) (udpAssociation, e
|
|||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
defer r.mutex.Unlock()
|
||||||
|
|
||||||
const udpPacketChannelBuffer = 2
|
const udpPacketChannelBuffer = 64
|
||||||
associationID := r.nextAssociationID
|
associationID := r.nextAssociationID
|
||||||
r.nextAssociationID++
|
r.nextAssociationID++
|
||||||
|
|
||||||
association := udpAssociation{
|
association := udpAssociation{
|
||||||
id: associationID,
|
id: associationID,
|
||||||
controlConnAddr: controlConnAddr,
|
expectedAddrPort: expectedAddrPort,
|
||||||
packetCh: make(chan *bytes.Buffer, udpPacketChannelBuffer),
|
controlConnAddr: controlConnAddr,
|
||||||
|
packetCh: make(chan *bytes.Buffer, udpPacketChannelBuffer),
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedAddrPort.Addr().IsValid() && expectedAddrPort.Port() != 0 {
|
||||||
|
association.clientAddrPort = expectedAddrPort
|
||||||
|
r.clientAddrPortToAssociation[association.clientAddrPort] = association
|
||||||
|
r.associationIDToClientAddrPort[association.id] = association.clientAddrPort
|
||||||
|
return association, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingAssociations := r.clientIPToPendingAssociations[controlConnAddr]
|
pendingAssociations := r.clientIPToPendingAssociations[controlConnAddr]
|
||||||
@@ -102,10 +111,6 @@ func (r *udpRouter) unregisterAssociation(association udpAssociation) {
|
|||||||
delete(r.clientAddrPortToAssociation, clientAddrPort)
|
delete(r.clientAddrPortToAssociation, clientAddrPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if association.clientAddrPort.IsValid() {
|
|
||||||
delete(r.clientAddrPortToAssociation, association.clientAddrPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingAssociations := r.clientIPToPendingAssociations[association.controlConnAddr]
|
pendingAssociations := r.clientIPToPendingAssociations[association.controlConnAddr]
|
||||||
for i, pendingAssociation := range pendingAssociations {
|
for i, pendingAssociation := range pendingAssociations {
|
||||||
if pendingAssociation.id == association.id {
|
if pendingAssociation.id == association.id {
|
||||||
@@ -154,8 +159,9 @@ func (r *udpRouter) run(ctx context.Context) error {
|
|||||||
|
|
||||||
func (r *udpRouter) routePacket(sourceAddrPort netip.AddrPort, packet *bytes.Buffer) error {
|
func (r *udpRouter) routePacket(sourceAddrPort netip.AddrPort, packet *bytes.Buffer) error {
|
||||||
r.mutex.Lock()
|
r.mutex.Lock()
|
||||||
defer r.mutex.Unlock()
|
|
||||||
association, packetFromClient := r.findClientAssociation(sourceAddrPort)
|
association, packetFromClient := r.findClientAssociation(sourceAddrPort)
|
||||||
|
r.mutex.Unlock()
|
||||||
|
|
||||||
if !packetFromClient {
|
if !packetFromClient {
|
||||||
r.bufferPool.Put(packet)
|
r.bufferPool.Put(packet)
|
||||||
return nil
|
return nil
|
||||||
@@ -184,8 +190,19 @@ func (r *udpRouter) findClientAssociation(sourceAddrPort netip.AddrPort) (
|
|||||||
return udpAssociation{}, false
|
return udpAssociation{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
association = pendingAssociations[0]
|
index := -1
|
||||||
r.clientIPToPendingAssociations[sourceAddr] = pendingAssociations[1:]
|
for i, pendingAssociation := range pendingAssociations {
|
||||||
|
if matchesExpectedClientEndpoint(pendingAssociation, sourceAddrPort) {
|
||||||
|
association = pendingAssociation
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return udpAssociation{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
r.clientIPToPendingAssociations[sourceAddr] = append(pendingAssociations[:index], pendingAssociations[index+1:]...)
|
||||||
if len(r.clientIPToPendingAssociations[sourceAddr]) == 0 {
|
if len(r.clientIPToPendingAssociations[sourceAddr]) == 0 {
|
||||||
delete(r.clientIPToPendingAssociations, sourceAddr)
|
delete(r.clientIPToPendingAssociations, sourceAddr)
|
||||||
}
|
}
|
||||||
@@ -197,6 +214,16 @@ func (r *udpRouter) findClientAssociation(sourceAddrPort netip.AddrPort) (
|
|||||||
return association, true
|
return association, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchesExpectedClientEndpoint(association udpAssociation, sourceAddrPort netip.AddrPort) bool {
|
||||||
|
switch {
|
||||||
|
case association.expectedAddrPort.Addr().IsValid() && sourceAddrPort.Addr() != association.expectedAddrPort.Addr():
|
||||||
|
return false
|
||||||
|
case association.expectedAddrPort.Port() != 0 && sourceAddrPort.Port() != association.expectedAddrPort.Port():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (r *udpRouter) clientAddrPortForAssociation(associationID uint64) (
|
func (r *udpRouter) clientAddrPortForAssociation(associationID uint64) (
|
||||||
clientAddrPort netip.AddrPort, ok bool,
|
clientAddrPort netip.AddrPort, ok bool,
|
||||||
) {
|
) {
|
||||||
@@ -209,7 +236,7 @@ func (r *udpRouter) clientAddrPortForAssociation(associationID uint64) (
|
|||||||
|
|
||||||
func (r *udpRouter) runAssociationHandler(ctx context.Context, association udpAssociation) {
|
func (r *udpRouter) runAssociationHandler(ctx context.Context, association udpAssociation) {
|
||||||
config := &net.ListenConfig{}
|
config := &net.ListenConfig{}
|
||||||
socket, err := config.ListenPacket(ctx, "udp", "127.0.0.1:0")
|
socket, err := config.ListenPacket(ctx, "udp", ":0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Warnf("creating per-association UDP socket: %s", err)
|
r.logger.Warnf("creating per-association UDP socket: %s", err)
|
||||||
return
|
return
|
||||||
@@ -304,6 +331,23 @@ func (r *udpRouter) writeClientPacketToDestination(ctx context.Context,
|
|||||||
return fmt.Errorf("decoding UDP datagram: %w", err)
|
return fmt.Errorf("decoding UDP datagram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host, portStr, err := net.SplitHostPort(destination)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("splitting destination host and port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := netip.ParseAddr(host); err != nil { // domain name
|
||||||
|
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving destination host: %w", err)
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return fmt.Errorf("resolving destination host: no addresses found for %q", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination = net.JoinHostPort(addrs[0], portStr)
|
||||||
|
}
|
||||||
|
|
||||||
resolvedDestinationUDPAddress, err := net.ResolveUDPAddr("udp", destination)
|
resolvedDestinationUDPAddress, err := net.ResolveUDPAddr("udp", destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolving destination UDP address: %w", err)
|
return fmt.Errorf("resolving destination UDP address: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package socks5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang/mock/gomock"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_udpRouter_ResolveGithubFromCloudflareDNS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := t.Context()
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
deadline, hasDeadline := ctx.Deadline()
|
||||||
|
if hasDeadline {
|
||||||
|
const deadlineBuffer = 500 * time.Millisecond
|
||||||
|
deadline = deadline.Add(-deadlineBuffer)
|
||||||
|
} else {
|
||||||
|
const defaultTimeout = 10 * time.Second
|
||||||
|
deadline = time.Now().Add(defaultTimeout)
|
||||||
|
}
|
||||||
|
ctx, cancel = context.WithDeadline(ctx, deadline)
|
||||||
|
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
logger := NewMockLogger(ctrl)
|
||||||
|
|
||||||
|
router, err := newUDPRouter(ctx, "127.0.0.1:0", logger)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
routerRunErrCh := make(chan error)
|
||||||
|
go func() {
|
||||||
|
routerRunErrCh <- router.run(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
err := router.close()
|
||||||
|
assert.NoError(t, err, "closing router")
|
||||||
|
runErr := <-routerRunErrCh
|
||||||
|
assert.NoError(t, runErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
controlListener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := controlListener.Close()
|
||||||
|
assert.NoError(t, err, "closing control listener")
|
||||||
|
})
|
||||||
|
|
||||||
|
acceptedConnCh := make(chan net.Conn)
|
||||||
|
go func() {
|
||||||
|
acceptedConn, acceptErr := controlListener.Accept()
|
||||||
|
assert.NoError(t, acceptErr, "accepting control connection")
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acceptedConnCh <- acceptedConn
|
||||||
|
}()
|
||||||
|
|
||||||
|
clientControlConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", controlListener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err = clientControlConn.Close()
|
||||||
|
assert.NoError(t, err, "closing client control connection")
|
||||||
|
})
|
||||||
|
|
||||||
|
serverControlConn := <-acceptedConnCh
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := serverControlConn.Close()
|
||||||
|
assert.NoError(t, err, "closing server control connection")
|
||||||
|
})
|
||||||
|
|
||||||
|
association, err := router.registerAssociation(serverControlConn, netip.AddrPort{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
router.unregisterAssociation(association)
|
||||||
|
})
|
||||||
|
|
||||||
|
associationCtx, associationCancel := context.WithCancel(ctx)
|
||||||
|
handlerDoneCh := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
router.runAssociationHandler(associationCtx, association)
|
||||||
|
close(handlerDoneCh)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
associationCancel()
|
||||||
|
<-handlerDoneCh
|
||||||
|
})
|
||||||
|
|
||||||
|
udpRouterAddress, err := net.ResolveUDPAddr("udp", router.localAddress().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
clientUDPConn, err := net.DialUDP("udp", nil, udpRouterAddress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := clientUDPConn.Close()
|
||||||
|
assert.NoError(t, err, "closing client UDP connection")
|
||||||
|
})
|
||||||
|
|
||||||
|
queryID := uint16(rand.Uint32()) //nolint:gosec
|
||||||
|
dnsRequest := &dns.Msg{
|
||||||
|
MsgHdr: dns.MsgHdr{
|
||||||
|
Id: queryID,
|
||||||
|
RecursionDesired: true,
|
||||||
|
},
|
||||||
|
Question: []dns.Question{{
|
||||||
|
Name: dns.Fqdn("github.com"),
|
||||||
|
Qtype: dns.TypeA,
|
||||||
|
Qclass: dns.ClassINET,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
dnsQuery, err := dnsRequest.Pack()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
targetAddrPort := netip.MustParseAddrPort("1.1.1.1:53")
|
||||||
|
socksDatagramBuffer := bytes.NewBuffer(nil)
|
||||||
|
err = encodeUDPDatagramToBuffer(socksDatagramBuffer, targetAddrPort, dnsQuery)
|
||||||
|
require.NoError(t, err)
|
||||||
|
socksDatagram := socksDatagramBuffer.Bytes()
|
||||||
|
|
||||||
|
err = clientUDPConn.SetDeadline(deadline)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = clientUDPConn.Write(socksDatagram)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
responseBuffer := make([]byte, maxUDPPacketLength)
|
||||||
|
responseLength, err := clientUDPConn.Read(responseBuffer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
responseDestination, responsePayload, err := decodeUDPDatagram(responseBuffer[:responseLength])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
responseHost, responsePortString, err := net.SplitHostPort(responseDestination)
|
||||||
|
require.NoError(t, err)
|
||||||
|
responsePort, err := strconv.ParseUint(responsePortString, 10, 16)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(53), responsePort)
|
||||||
|
assert.NotEmpty(t, responseHost)
|
||||||
|
|
||||||
|
dnsResponse := new(dns.Msg)
|
||||||
|
err = dnsResponse.Unpack(responsePayload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, queryID, dnsResponse.Id)
|
||||||
|
assert.True(t, dnsResponse.Response)
|
||||||
|
assert.Equal(t, dns.RcodeSuccess, dnsResponse.Rcode)
|
||||||
|
require.NotEmpty(t, dnsResponse.Question)
|
||||||
|
assert.Equal(t, dns.Fqdn("github.com"), dnsResponse.Question[0].Name)
|
||||||
|
assert.Equal(t, dns.TypeA, dnsResponse.Question[0].Qtype)
|
||||||
|
assert.NotEmpty(t, dnsResponse.Answer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user