mirror of
https://github.com/fosrl/gerbil.git
synced 2026-02-08 14:06:41 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16aef10cca | ||
|
|
19031ebdfd | ||
|
|
0eebbc51d5 | ||
|
|
d321a8ba7e | ||
|
|
3ea86222ca | ||
|
|
c3ebe930d9 | ||
|
|
f2b96f2a38 | ||
|
|
9038239bbe | ||
|
|
3e64eb9c4f | ||
|
|
92992b8c14 | ||
|
|
4ee9d77532 | ||
|
|
bd7a5bd4b0 | ||
|
|
1cd49f8ee3 | ||
|
|
7a919d867b | ||
|
|
ce50c627a7 | ||
|
|
691d5f0271 | ||
|
|
56151089e3 | ||
|
|
af7c1caf98 | ||
|
|
dd208ab67c | ||
|
|
8189d41a45 | ||
|
|
ea3477c8ce | ||
|
|
a8a0f92c9b | ||
|
|
7040a9436e | ||
|
|
04361242fe | ||
|
|
554b1d55dc | ||
|
|
b03f8911a5 | ||
|
|
47589570c9 | ||
|
|
9f5b8dea26 | ||
|
|
f6a1e1e27c | ||
|
|
f983a8f141 | ||
|
|
efce3cb0b2 | ||
|
|
6eeebd81b2 | ||
|
|
c970fd5a18 | ||
|
|
09bd02456d | ||
|
|
c24537af36 | ||
|
|
9de3f14799 | ||
|
|
0908f75f5f | ||
|
|
10958f8c55 | ||
|
|
b1840fd5c3 | ||
|
|
1df5eb19ff | ||
|
|
f71f183886 | ||
|
|
8922ca9736 |
47
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
47
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Summary
|
||||
description: A clear and concise summary of the requested feature.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: |
|
||||
Why is this feature important?
|
||||
Explain the problem this feature would solve or what use case it would enable.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: |
|
||||
How would you like to see this feature implemented?
|
||||
Provide as much detail as possible about the desired behavior, configuration, or changes.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you've thought about.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, please:
|
||||
- Check if there is an existing issue for this feature.
|
||||
- Clearly explain the benefit and use case.
|
||||
- Be as specific as possible to help contributors evaluate and implement.
|
||||
51
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
51
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Bug Report
|
||||
description: Create a bug report
|
||||
labels: []
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- Pangolin Version:
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Olm Version: (if applicable)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: |
|
||||
Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below.
|
||||
|
||||
If using code blocks, make sure syntax highlighting is correct and double-check that the rendered preview is not broken.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Contributors should be able to follow the steps provided in order to reproduce the bug.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Need help or have questions?
|
||||
url: https://github.com/orgs/fosrl/discussions
|
||||
about: Ask questions, get help, and discuss with other community members
|
||||
- name: Request a Feature
|
||||
url: https://github.com/orgs/fosrl/discussions/new?category=feature-requests
|
||||
about: Feature requests should be opened as discussions so others can upvote and comment
|
||||
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -33,3 +33,8 @@ updates:
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
10
.github/workflows/cicd.yml
vendored
10
.github/workflows/cicd.yml
vendored
@@ -12,16 +12,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.23.1
|
||||
go-version: 1.25
|
||||
|
||||
- name: Build and push Docker images
|
||||
run: |
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -11,12 +11,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Build go
|
||||
run: go build
|
||||
|
||||
1
.go-version
Normal file
1
.go-version
Normal file
@@ -0,0 +1 @@
|
||||
1.25
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24.3-alpine AS builder
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
@@ -16,18 +16,13 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /gerbil
|
||||
|
||||
# Start a new stage from scratch
|
||||
FROM ubuntu:24.04 AS runner
|
||||
FROM alpine:3.22 AS runner
|
||||
|
||||
RUN apt-get update && apt-get install -y iptables iproute2 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache iptables iproute2
|
||||
|
||||
# Copy the pre-built binary file from the previous stage and the entrypoint script
|
||||
COPY --from=builder /gerbil /usr/local/bin/
|
||||
COPY entrypoint.sh /
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Copy the entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Command to run the executable
|
||||
CMD ["gerbil"]
|
||||
39
README.md
39
README.md
@@ -20,11 +20,25 @@ Gerbil will create the peers defined in the config on the WireGuard interface. T
|
||||
|
||||
### Report Bandwidth
|
||||
|
||||
Bytes transmitted in and out of each peer are collected every 10 seconds, and incremental usage is reported via the "reportBandwidthTo" endpoint. This can be used to track data usage of each peer on the remote server.
|
||||
Bytes transmitted in and out of each peer are collected every 10 seconds, and incremental usage is reported via the api endpoint. This can be used to track data usage of each peer on the remote server.
|
||||
|
||||
### Handle client relaying
|
||||
|
||||
Gerbil listens on port 21820 for incoming UDP hole punch packets to orchestrate NAT hole punching between olm and newt clients. Additionally, it handles relaying data through the gerbil server down to the newt. This is accomplished by scanning each packet for headers and handling them appropriately.
|
||||
Gerbil listens on port 21820 for incoming UDP hole punch packets to orchestrate NAT hole punching between olm and newt clients. Additionally, it handles relaying data through the gerbil server down to the newt. This is accomplished by scanning each packet for headers and handling them appropriately.
|
||||
|
||||
### SNI Proxy
|
||||
|
||||
Gerbil includes an SNI (Server Name Indication) proxy that enables intelligent routing of HTTPS traffic between Pangolin nodes. When a TLS connection comes in, the proxy extracts the hostname from the SNI extension and queries Pangolin to determine the correct routing destination. This allows seamless routing of web traffic through the WireGuard mesh network:
|
||||
|
||||
- If the hostname is configured for local handling (via local overrides or local SNIs), traffic is routed to the local proxy
|
||||
- Otherwise, the proxy queries Pangolin's routing API to determine which node should handle the traffic
|
||||
- Supports caching of routing decisions to improve performance
|
||||
- Handles connection pooling and graceful shutdown
|
||||
- Optional PROXY protocol v1 support to preserve original client IP addresses when forwarding to downstream proxies (HAProxy, Nginx, etc.)
|
||||
|
||||
The PROXY protocol allows downstream proxies to know the real client IP address instead of seeing the SNI proxy's IP. When enabled with `--proxy-protocol`, the SNI proxy will prepend a PROXY protocol header to each connection containing the original client's IP and port information.
|
||||
|
||||
In single node (self hosted) Pangolin deployments this can be bypassed by using port 443:443 to route to Traefik instead of the SNI proxy at 8443.
|
||||
|
||||
## CLI Args
|
||||
|
||||
@@ -37,10 +51,15 @@ Note: You must use either `config` or `remoteConfig` to configure WireGuard.
|
||||
|
||||
- `reportBandwidthTo` (optional): **DEPRECATED** - Use `remoteConfig` instead. Remote HTTP endpoint to send peer bandwidth data
|
||||
- `interface` (optional): Name of the WireGuard interface created by Gerbil. Default: `wg0`
|
||||
- `listen` (optional): Port to listen on for HTTP server. Default: `:3003`
|
||||
- `listen` (optional): Port to listen on for HTTP server. Default: `:3004`
|
||||
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: `INFO`
|
||||
- `mtu` (optional): MTU of the WireGuard interface. Default: `1280`
|
||||
- `notify` (optional): URL to notify on peer changes
|
||||
- `sni-port` (optional): Port for the SNI proxy to listen on. Default: `8443`
|
||||
- `local-proxy` (optional): Address for local proxy when routing local traffic. Default: `localhost`
|
||||
- `local-proxy-port` (optional): Port for local proxy when routing local traffic. Default: `443`
|
||||
- `local-overrides` (optional): Comma-separated list of domain names that should always be routed to the local proxy
|
||||
- `proxy-protocol` (optional): Enable PROXY protocol v1 for preserving client IP addresses when forwarding to downstream proxies. Default: `false`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -55,15 +74,19 @@ All CLI arguments can also be provided via environment variables:
|
||||
- `LOG_LEVEL`: Log level (DEBUG, INFO, WARN, ERROR, FATAL)
|
||||
- `MTU`: MTU of the WireGuard interface
|
||||
- `NOTIFY_URL`: URL to notify on peer changes
|
||||
- `SNI_PORT`: Port for the SNI proxy to listen on
|
||||
- `LOCAL_PROXY`: Address for local proxy when routing local traffic
|
||||
- `LOCAL_PROXY_PORT`: Port for local proxy when routing local traffic
|
||||
- `LOCAL_OVERRIDES`: Comma-separated list of domain names that should always be routed to the local proxy
|
||||
- `PROXY_PROTOCOL`: Enable PROXY protocol v1 for preserving client IP addresses (true/false)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./gerbil \
|
||||
--reachableAt=http://gerbil:3003 \
|
||||
--reachableAt=http://gerbil:3004 \
|
||||
--generateAndSaveKeyTo=/var/config/key \
|
||||
--remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config \
|
||||
--reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
|
||||
--remoteConfig=http://pangolin:3001/api/v1/
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -75,8 +98,7 @@ services:
|
||||
command:
|
||||
- --reachableAt=http://gerbil:3003
|
||||
- --generateAndSaveKeyTo=/var/config/key
|
||||
- --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config
|
||||
- --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth
|
||||
- --remoteConfig=http://pangolin:3001/api/v1/
|
||||
volumes:
|
||||
- ./config/:/var/config
|
||||
cap_add:
|
||||
@@ -85,6 +107,7 @@ services:
|
||||
ports:
|
||||
- 51820:51820/udp
|
||||
- 21820:21820/udp
|
||||
- 443:8443/tcp # SNI proxy port
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
11
go.mod
11
go.mod
@@ -1,11 +1,11 @@
|
||||
module github.com/fosrl/gerbil
|
||||
|
||||
go 1.23.1
|
||||
|
||||
toolchain go1.23.2
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
)
|
||||
|
||||
@@ -16,9 +16,8 @@ require (
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
|
||||
)
|
||||
|
||||
14
go.sum
14
go.sum
@@ -10,20 +10,22 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U
|
||||
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
|
||||
|
||||
154
main.go
154
main.go
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/gerbil/logger"
|
||||
"github.com/fosrl/gerbil/proxy"
|
||||
"github.com/fosrl/gerbil/relay"
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
@@ -32,7 +33,8 @@ var (
|
||||
mu sync.Mutex
|
||||
wgMu sync.Mutex // Protects WireGuard operations
|
||||
notifyURL string
|
||||
proxyServer *relay.UDPProxyServer
|
||||
proxyRelay *relay.UDPProxyServer
|
||||
proxySNI *proxy.SNIProxy
|
||||
)
|
||||
|
||||
type WgConfig struct {
|
||||
@@ -115,6 +117,12 @@ func main() {
|
||||
reachableAt string
|
||||
logLevel string
|
||||
mtu string
|
||||
sniProxyPort int
|
||||
localProxyAddr string
|
||||
localProxyPort int
|
||||
localOverridesStr string
|
||||
trustedUpstreamsStr string
|
||||
proxyProtocol bool
|
||||
)
|
||||
|
||||
interfaceName = os.Getenv("INTERFACE")
|
||||
@@ -127,6 +135,13 @@ func main() {
|
||||
mtu = os.Getenv("MTU")
|
||||
notifyURL = os.Getenv("NOTIFY_URL")
|
||||
|
||||
sniProxyPortStr := os.Getenv("SNI_PORT")
|
||||
localProxyAddr = os.Getenv("LOCAL_PROXY")
|
||||
localProxyPortStr := os.Getenv("LOCAL_PROXY_PORT")
|
||||
localOverridesStr = os.Getenv("LOCAL_OVERRIDES")
|
||||
trustedUpstreamsStr = os.Getenv("TRUSTED_UPSTREAMS")
|
||||
proxyProtocolStr := os.Getenv("PROXY_PROTOCOL")
|
||||
|
||||
if interfaceName == "" {
|
||||
flag.StringVar(&interfaceName, "interface", "wg0", "Name of the WireGuard interface")
|
||||
}
|
||||
@@ -137,7 +152,7 @@ func main() {
|
||||
flag.StringVar(&remoteConfigURL, "remoteConfig", "", "URL of the Pangolin server")
|
||||
}
|
||||
if listenAddr == "" {
|
||||
flag.StringVar(&listenAddr, "listen", ":3003", "Address to listen on")
|
||||
flag.StringVar(&listenAddr, "listen", "", "DEPRECATED (overridden by reachableAt): Address to listen on")
|
||||
}
|
||||
// DEPRECATED AND UNSED: reportBandwidthTo
|
||||
// allow reportBandwidthTo to be passed but dont do anything with it just thow it away
|
||||
@@ -147,9 +162,11 @@ func main() {
|
||||
if generateAndSaveKeyTo == "" {
|
||||
flag.StringVar(&generateAndSaveKeyTo, "generateAndSaveKeyTo", "", "Path to save generated private key")
|
||||
}
|
||||
|
||||
if reachableAt == "" {
|
||||
flag.StringVar(&reachableAt, "reachableAt", "", "Endpoint of the http server to tell remote config about")
|
||||
}
|
||||
|
||||
if logLevel == "" {
|
||||
flag.StringVar(&logLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR, FATAL)")
|
||||
}
|
||||
@@ -159,11 +176,63 @@ func main() {
|
||||
if notifyURL == "" {
|
||||
flag.StringVar(¬ifyURL, "notify", "", "URL to notify on peer changes")
|
||||
}
|
||||
|
||||
if sniProxyPortStr != "" {
|
||||
if port, err := strconv.Atoi(sniProxyPortStr); err == nil {
|
||||
sniProxyPort = port
|
||||
}
|
||||
}
|
||||
if sniProxyPortStr == "" {
|
||||
flag.IntVar(&sniProxyPort, "sni-port", 8443, "Port to listen on")
|
||||
}
|
||||
|
||||
if localProxyAddr == "" {
|
||||
flag.StringVar(&localProxyAddr, "local-proxy", "localhost", "Local proxy address")
|
||||
}
|
||||
|
||||
if localProxyPortStr != "" {
|
||||
if port, err := strconv.Atoi(localProxyPortStr); err == nil {
|
||||
localProxyPort = port
|
||||
}
|
||||
}
|
||||
if localProxyPortStr == "" {
|
||||
flag.IntVar(&localProxyPort, "local-proxy-port", 443, "Local proxy port")
|
||||
}
|
||||
if localOverridesStr != "" {
|
||||
flag.StringVar(&localOverridesStr, "local-overrides", "", "Comma-separated list of local overrides for SNI proxy")
|
||||
}
|
||||
if trustedUpstreamsStr == "" {
|
||||
flag.StringVar(&trustedUpstreamsStr, "trusted-upstreams", "", "Comma-separated list of trusted upstream proxy domain names/IPs that can send PROXY protocol")
|
||||
}
|
||||
|
||||
if proxyProtocolStr != "" {
|
||||
proxyProtocol = strings.ToLower(proxyProtocolStr) == "true"
|
||||
}
|
||||
if proxyProtocolStr == "" {
|
||||
flag.BoolVar(&proxyProtocol, "proxy-protocol", true, "Enable PROXY protocol v1 for preserving client IP")
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
logger.Init()
|
||||
logger.GetLogger().SetLevel(parseLogLevel(logLevel))
|
||||
|
||||
// try to parse as http://host:port and set the listenAddr to the :port from this reachableAt.
|
||||
if reachableAt != "" && listenAddr == "" {
|
||||
if strings.HasPrefix(reachableAt, "http://") || strings.HasPrefix(reachableAt, "https://") {
|
||||
parts := strings.Split(reachableAt, ":")
|
||||
if len(parts) == 3 {
|
||||
port := parts[2]
|
||||
if strings.Contains(port, "/") {
|
||||
port = strings.Split(port, "/")[0]
|
||||
}
|
||||
listenAddr = ":" + port
|
||||
}
|
||||
}
|
||||
} else if listenAddr == "" {
|
||||
listenAddr = ":3003"
|
||||
}
|
||||
|
||||
mtuInt, err = strconv.Atoi(mtu)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to parse MTU: %v", err)
|
||||
@@ -258,17 +327,48 @@ func main() {
|
||||
go periodicBandwidthCheck(remoteConfigURL + "/gerbil/receive-bandwidth")
|
||||
|
||||
// Start the UDP proxy server
|
||||
proxyServer = relay.NewUDPProxyServer(":21820", remoteConfigURL, key, reachableAt)
|
||||
err = proxyServer.Start()
|
||||
proxyRelay = relay.NewUDPProxyServer(":21820", remoteConfigURL, key, reachableAt)
|
||||
err = proxyRelay.Start()
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to start UDP proxy server: %v", err)
|
||||
}
|
||||
defer proxyServer.Stop()
|
||||
defer proxyRelay.Stop()
|
||||
|
||||
// TODO: WE SHOULD PULL THIS OUT OF THE CONFIG OR SOMETHING
|
||||
// SO YOU DON'T NEED TO SET THIS SEPARATELY
|
||||
// Parse local overrides
|
||||
var localOverrides []string
|
||||
if localOverridesStr != "" {
|
||||
localOverrides = strings.Split(localOverridesStr, ",")
|
||||
for i, domain := range localOverrides {
|
||||
localOverrides[i] = strings.TrimSpace(domain)
|
||||
}
|
||||
logger.Info("Local overrides configured: %v", localOverrides)
|
||||
}
|
||||
|
||||
var trustedUpstreams []string
|
||||
if trustedUpstreamsStr != "" {
|
||||
trustedUpstreams = strings.Split(trustedUpstreamsStr, ",")
|
||||
for i, upstream := range trustedUpstreams {
|
||||
trustedUpstreams[i] = strings.TrimSpace(upstream)
|
||||
}
|
||||
logger.Info("Trusted upstreams configured: %v", trustedUpstreams)
|
||||
}
|
||||
|
||||
proxySNI, err = proxy.NewSNIProxy(sniProxyPort, remoteConfigURL, key.PublicKey().String(), localProxyAddr, localProxyPort, localOverrides, proxyProtocol, trustedUpstreams)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to create proxy: %v", err)
|
||||
}
|
||||
|
||||
if err := proxySNI.Start(); err != nil {
|
||||
logger.Fatal("Failed to start proxy: %v", err)
|
||||
}
|
||||
|
||||
// Set up HTTP server
|
||||
http.HandleFunc("/peer", handlePeer)
|
||||
http.HandleFunc("/update-proxy-mapping", handleUpdateProxyMapping)
|
||||
http.HandleFunc("/update-destinations", handleUpdateDestinations)
|
||||
http.HandleFunc("/update-local-snis", handleUpdateLocalSNIs)
|
||||
logger.Info("Starting HTTP server on %s", listenAddr)
|
||||
|
||||
// Run HTTP server in a goroutine
|
||||
@@ -647,9 +747,9 @@ func addPeerInternal(peer Peer) error {
|
||||
}
|
||||
|
||||
// Clear relay connections for the peer's WireGuard IPs
|
||||
if proxyServer != nil {
|
||||
if proxyRelay != nil {
|
||||
for _, wgIP := range wgIPs {
|
||||
proxyServer.OnPeerAdded(wgIP)
|
||||
proxyRelay.OnPeerAdded(wgIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,7 +792,7 @@ func removePeerInternal(publicKey string) error {
|
||||
|
||||
// Get current peer info before removing to clear relay connections
|
||||
var wgIPs []string
|
||||
if proxyServer != nil {
|
||||
if proxyRelay != nil {
|
||||
device, err := wgClient.Device(interfaceName)
|
||||
if err == nil {
|
||||
for _, peer := range device.Peers {
|
||||
@@ -721,9 +821,9 @@ func removePeerInternal(publicKey string) error {
|
||||
}
|
||||
|
||||
// Clear relay connections for the peer's WireGuard IPs
|
||||
if proxyServer != nil {
|
||||
if proxyRelay != nil {
|
||||
for _, wgIP := range wgIPs {
|
||||
proxyServer.OnPeerRemoved(wgIP)
|
||||
proxyRelay.OnPeerRemoved(wgIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,13 +860,13 @@ func handleUpdateProxyMapping(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Update the proxy mappings in the relay server
|
||||
if proxyServer == nil {
|
||||
if proxyRelay == nil {
|
||||
logger.Error("Proxy server is not available")
|
||||
http.Error(w, "Proxy server is not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updatedCount := proxyServer.UpdateDestinationInMappings(update.OldDestination, update.NewDestination)
|
||||
updatedCount := proxyRelay.UpdateDestinationInMappings(update.OldDestination, update.NewDestination)
|
||||
|
||||
logger.Info("Updated %d proxy mappings: %s:%d -> %s:%d",
|
||||
updatedCount,
|
||||
@@ -830,13 +930,13 @@ func handleUpdateDestinations(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Update the proxy mappings in the relay server
|
||||
if proxyServer == nil {
|
||||
if proxyRelay == nil {
|
||||
logger.Error("Proxy server is not available")
|
||||
http.Error(w, "Proxy server is not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
proxyServer.UpdateProxyMapping(request.SourceIP, request.SourcePort, request.Destinations)
|
||||
proxyRelay.UpdateProxyMapping(request.SourceIP, request.SourcePort, request.Destinations)
|
||||
|
||||
logger.Info("Updated proxy mapping for %s:%d with %d destinations",
|
||||
request.SourceIP, request.SourcePort, len(request.Destinations))
|
||||
@@ -851,6 +951,32 @@ func handleUpdateDestinations(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLocalSNIsRequest represents the JSON payload for updating local SNIs
|
||||
type UpdateLocalSNIsRequest struct {
|
||||
FullDomains []string `json:"fullDomains"`
|
||||
}
|
||||
|
||||
func handleUpdateLocalSNIs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
logger.Error("Invalid method: %s", r.Method)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateLocalSNIsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
proxySNI.UpdateLocalSNIs(req.FullDomains)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "Local SNIs updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func periodicBandwidthCheck(endpoint string) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
845
proxy/proxy.go
Normal file
845
proxy/proxy.go
Normal file
@@ -0,0 +1,845 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fosrl/gerbil/logger"
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
// RouteRecord represents a routing configuration
|
||||
type RouteRecord struct {
|
||||
Hostname string
|
||||
TargetHost string
|
||||
TargetPort int
|
||||
}
|
||||
|
||||
// RouteAPIResponse represents the response from the route API
|
||||
type RouteAPIResponse struct {
|
||||
Endpoints []string `json:"endpoints"`
|
||||
}
|
||||
|
||||
// ProxyProtocolInfo holds information parsed from incoming PROXY protocol header
|
||||
type ProxyProtocolInfo struct {
|
||||
Protocol string // TCP4 or TCP6
|
||||
SrcIP string
|
||||
DestIP string
|
||||
SrcPort int
|
||||
DestPort int
|
||||
OriginalConn net.Conn // The original connection after PROXY protocol parsing
|
||||
}
|
||||
|
||||
// SNIProxy represents the main proxy server
|
||||
type SNIProxy struct {
|
||||
port int
|
||||
cache *cache.Cache
|
||||
listener net.Listener
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
localProxyAddr string
|
||||
localProxyPort int
|
||||
remoteConfigURL string
|
||||
publicKey string
|
||||
proxyProtocol bool // Enable PROXY protocol v1
|
||||
|
||||
// New fields for fast local SNI lookup
|
||||
localSNIs map[string]struct{}
|
||||
localSNIsLock sync.RWMutex
|
||||
|
||||
// Local overrides for domains that should always use local proxy
|
||||
localOverrides map[string]struct{}
|
||||
|
||||
// Track active tunnels by SNI
|
||||
activeTunnels map[string]*activeTunnel
|
||||
activeTunnelsLock sync.Mutex
|
||||
|
||||
// Trusted upstream proxies that can send PROXY protocol
|
||||
trustedUpstreams map[string]struct{}
|
||||
}
|
||||
|
||||
type activeTunnel struct {
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
// readOnlyConn is a wrapper for io.Reader that implements net.Conn
|
||||
type readOnlyConn struct {
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (conn readOnlyConn) Read(p []byte) (int, error) { return conn.reader.Read(p) }
|
||||
func (conn readOnlyConn) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
func (conn readOnlyConn) Close() error { return nil }
|
||||
func (conn readOnlyConn) LocalAddr() net.Addr { return nil }
|
||||
func (conn readOnlyConn) RemoteAddr() net.Addr { return nil }
|
||||
func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// parseProxyProtocolHeader parses a PROXY protocol v1 header from the connection
|
||||
func (p *SNIProxy) parseProxyProtocolHeader(conn net.Conn) (*ProxyProtocolInfo, net.Conn, error) {
|
||||
// Check if the connection comes from a trusted upstream
|
||||
remoteHost, _, err := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if err != nil {
|
||||
return nil, conn, fmt.Errorf("failed to parse remote address: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the remote IP to hostname to check if it's trusted
|
||||
// For simplicity, we'll check the IP directly in trusted upstreams
|
||||
// In production, you might want to do reverse DNS lookup
|
||||
if _, isTrusted := p.trustedUpstreams[remoteHost]; !isTrusted {
|
||||
// Not from trusted upstream, return original connection
|
||||
return nil, conn, nil
|
||||
}
|
||||
|
||||
// Set read timeout for PROXY protocol parsing
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return nil, conn, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
// Read the first line (PROXY protocol header)
|
||||
buffer := make([]byte, 512) // PROXY protocol header should be much smaller
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil {
|
||||
// If we can't read from trusted upstream, treat as regular connection
|
||||
logger.Debug("Could not read from trusted upstream %s, treating as regular connection: %v", remoteHost, err)
|
||||
// Clear read timeout before returning
|
||||
if clearErr := conn.SetReadDeadline(time.Time{}); clearErr != nil {
|
||||
logger.Debug("Failed to clear read deadline: %v", clearErr)
|
||||
}
|
||||
return nil, conn, nil
|
||||
}
|
||||
|
||||
// Find the end of the first line (CRLF)
|
||||
headerEnd := bytes.Index(buffer[:n], []byte("\r\n"))
|
||||
if headerEnd == -1 {
|
||||
// No PROXY protocol header found, treat as regular TLS connection
|
||||
// Return the connection with the buffered data prepended
|
||||
logger.Debug("No PROXY protocol header from trusted upstream %s, treating as regular TLS connection", remoteHost)
|
||||
|
||||
// Clear read timeout
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
logger.Debug("Failed to clear read deadline: %v", err)
|
||||
}
|
||||
|
||||
// Create a reader that includes the buffered data + original connection
|
||||
newReader := io.MultiReader(bytes.NewReader(buffer[:n]), conn)
|
||||
wrappedConn := &proxyProtocolConn{
|
||||
Conn: conn,
|
||||
reader: newReader,
|
||||
}
|
||||
return nil, wrappedConn, nil
|
||||
}
|
||||
|
||||
headerLine := string(buffer[:headerEnd])
|
||||
remainingData := buffer[headerEnd+2 : n]
|
||||
|
||||
// Parse PROXY protocol line: "PROXY TCP4/TCP6 srcIP destIP srcPort destPort"
|
||||
parts := strings.Fields(headerLine)
|
||||
if len(parts) != 6 || parts[0] != "PROXY" {
|
||||
// Check for PROXY UNKNOWN
|
||||
if len(parts) == 2 && parts[0] == "PROXY" && parts[1] == "UNKNOWN" {
|
||||
// PROXY UNKNOWN - use original connection info
|
||||
return nil, conn, nil
|
||||
}
|
||||
// Invalid PROXY protocol, but might be regular TLS - treat as such
|
||||
logger.Debug("Invalid PROXY protocol from trusted upstream %s, treating as regular TLS connection: %s", remoteHost, headerLine)
|
||||
|
||||
// Clear read timeout
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
logger.Debug("Failed to clear read deadline: %v", err)
|
||||
}
|
||||
|
||||
// Return the connection with all buffered data prepended
|
||||
newReader := io.MultiReader(bytes.NewReader(buffer[:n]), conn)
|
||||
wrappedConn := &proxyProtocolConn{
|
||||
Conn: conn,
|
||||
reader: newReader,
|
||||
}
|
||||
return nil, wrappedConn, nil
|
||||
}
|
||||
|
||||
protocol := parts[1]
|
||||
srcIP := parts[2]
|
||||
destIP := parts[3]
|
||||
srcPort, err := strconv.Atoi(parts[4])
|
||||
if err != nil {
|
||||
return nil, conn, fmt.Errorf("invalid source port in PROXY header: %s", parts[4])
|
||||
}
|
||||
destPort, err := strconv.Atoi(parts[5])
|
||||
if err != nil {
|
||||
return nil, conn, fmt.Errorf("invalid destination port in PROXY header: %s", parts[5])
|
||||
}
|
||||
|
||||
// Create a new reader that includes remaining data + original connection
|
||||
var newReader io.Reader
|
||||
if len(remainingData) > 0 {
|
||||
newReader = io.MultiReader(bytes.NewReader(remainingData), conn)
|
||||
} else {
|
||||
newReader = conn
|
||||
}
|
||||
|
||||
// Create a wrapper connection that reads from the combined reader
|
||||
wrappedConn := &proxyProtocolConn{
|
||||
Conn: conn,
|
||||
reader: newReader,
|
||||
}
|
||||
|
||||
proxyInfo := &ProxyProtocolInfo{
|
||||
Protocol: protocol,
|
||||
SrcIP: srcIP,
|
||||
DestIP: destIP,
|
||||
SrcPort: srcPort,
|
||||
DestPort: destPort,
|
||||
OriginalConn: wrappedConn,
|
||||
}
|
||||
|
||||
// Clear read timeout
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
return nil, conn, fmt.Errorf("failed to clear read deadline: %w", err)
|
||||
}
|
||||
|
||||
return proxyInfo, wrappedConn, nil
|
||||
}
|
||||
|
||||
// proxyProtocolConn wraps a connection to read from a custom reader
|
||||
type proxyProtocolConn struct {
|
||||
net.Conn
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (c *proxyProtocolConn) Read(b []byte) (int, error) {
|
||||
return c.reader.Read(b)
|
||||
}
|
||||
|
||||
// buildProxyProtocolHeaderFromInfo creates a PROXY protocol v1 header using ProxyProtocolInfo
|
||||
func (p *SNIProxy) buildProxyProtocolHeaderFromInfo(proxyInfo *ProxyProtocolInfo, targetAddr net.Addr) string {
|
||||
targetTCP, ok := targetAddr.(*net.TCPAddr)
|
||||
if !ok {
|
||||
// Fallback for unknown address types
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
|
||||
// Use the original client information from the PROXY protocol
|
||||
var targetIP string
|
||||
var protocol string
|
||||
|
||||
// Parse source IP to determine protocol family
|
||||
srcIP := net.ParseIP(proxyInfo.SrcIP)
|
||||
if srcIP == nil {
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
|
||||
if srcIP.To4() != nil {
|
||||
// Source is IPv4, use TCP4 protocol
|
||||
protocol = "TCP4"
|
||||
if targetTCP.IP.To4() != nil {
|
||||
// Target is also IPv4, use as-is
|
||||
targetIP = targetTCP.IP.String()
|
||||
} else {
|
||||
// Target is IPv6, but we need IPv4 for consistent protocol family
|
||||
if targetTCP.IP.IsLoopback() {
|
||||
targetIP = "127.0.0.1"
|
||||
} else {
|
||||
targetIP = "127.0.0.1" // Safe fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Source is IPv6, use TCP6 protocol
|
||||
protocol = "TCP6"
|
||||
if targetTCP.IP.To4() != nil {
|
||||
// Target is IPv4, convert to IPv6 representation
|
||||
targetIP = "::ffff:" + targetTCP.IP.String()
|
||||
} else {
|
||||
// Target is also IPv6, use as-is
|
||||
targetIP = targetTCP.IP.String()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n",
|
||||
protocol,
|
||||
proxyInfo.SrcIP,
|
||||
targetIP,
|
||||
proxyInfo.SrcPort,
|
||||
targetTCP.Port)
|
||||
}
|
||||
|
||||
// buildProxyProtocolHeader creates a PROXY protocol v1 header
|
||||
func buildProxyProtocolHeader(clientAddr, targetAddr net.Addr) string {
|
||||
clientTCP, ok := clientAddr.(*net.TCPAddr)
|
||||
if !ok {
|
||||
// Fallback for unknown address types
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
|
||||
targetTCP, ok := targetAddr.(*net.TCPAddr)
|
||||
if !ok {
|
||||
// Fallback for unknown address types
|
||||
return "PROXY UNKNOWN\r\n"
|
||||
}
|
||||
|
||||
// Determine protocol family based on client IP and normalize target IP accordingly
|
||||
var protocol string
|
||||
var targetIP string
|
||||
|
||||
if clientTCP.IP.To4() != nil {
|
||||
// Client is IPv4, use TCP4 protocol
|
||||
protocol = "TCP4"
|
||||
if targetTCP.IP.To4() != nil {
|
||||
// Target is also IPv4, use as-is
|
||||
targetIP = targetTCP.IP.String()
|
||||
} else {
|
||||
// Target is IPv6, but we need IPv4 for consistent protocol family
|
||||
// Use the IPv4 loopback if target is IPv6 loopback, otherwise use 127.0.0.1
|
||||
if targetTCP.IP.IsLoopback() {
|
||||
targetIP = "127.0.0.1"
|
||||
} else {
|
||||
// For non-loopback IPv6 targets, we could try to extract embedded IPv4
|
||||
// or fall back to a sensible IPv4 address based on the target
|
||||
targetIP = "127.0.0.1" // Safe fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Client is IPv6, use TCP6 protocol
|
||||
protocol = "TCP6"
|
||||
if targetTCP.IP.To4() != nil {
|
||||
// Target is IPv4, convert to IPv6 representation
|
||||
targetIP = "::ffff:" + targetTCP.IP.String()
|
||||
} else {
|
||||
// Target is also IPv6, use as-is
|
||||
targetIP = targetTCP.IP.String()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n",
|
||||
protocol,
|
||||
clientTCP.IP.String(),
|
||||
targetIP,
|
||||
clientTCP.Port,
|
||||
targetTCP.Port)
|
||||
}
|
||||
|
||||
// NewSNIProxy creates a new SNI proxy instance
|
||||
func NewSNIProxy(port int, remoteConfigURL, publicKey, localProxyAddr string, localProxyPort int, localOverrides []string, proxyProtocol bool, trustedUpstreams []string) (*SNIProxy, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create local overrides map
|
||||
overridesMap := make(map[string]struct{})
|
||||
for _, domain := range localOverrides {
|
||||
if domain != "" {
|
||||
overridesMap[domain] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Create trusted upstreams map
|
||||
trustedMap := make(map[string]struct{})
|
||||
for _, upstream := range trustedUpstreams {
|
||||
if upstream != "" {
|
||||
// Add both the domain and potentially resolved IPs
|
||||
trustedMap[upstream] = struct{}{}
|
||||
|
||||
// Try to resolve the domain to IPs and add them too
|
||||
if ips, err := net.LookupIP(upstream); err == nil {
|
||||
for _, ip := range ips {
|
||||
trustedMap[ip.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proxy := &SNIProxy{
|
||||
port: port,
|
||||
cache: cache.New(3*time.Second, 10*time.Minute),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
localProxyAddr: localProxyAddr,
|
||||
localProxyPort: localProxyPort,
|
||||
remoteConfigURL: remoteConfigURL,
|
||||
publicKey: publicKey,
|
||||
proxyProtocol: proxyProtocol,
|
||||
localSNIs: make(map[string]struct{}),
|
||||
localOverrides: overridesMap,
|
||||
activeTunnels: make(map[string]*activeTunnel),
|
||||
trustedUpstreams: trustedMap,
|
||||
}
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// Start begins listening for connections
|
||||
func (p *SNIProxy) Start() error {
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", p.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", p.port, err)
|
||||
}
|
||||
|
||||
p.listener = listener
|
||||
logger.Debug("SNI Proxy listening on port %d", p.port)
|
||||
|
||||
// Accept connections in a goroutine
|
||||
go p.acceptConnections()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the proxy
|
||||
func (p *SNIProxy) Stop() error {
|
||||
log.Println("Stopping SNI Proxy...")
|
||||
|
||||
p.cancel()
|
||||
|
||||
if p.listener != nil {
|
||||
p.listener.Close()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
p.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.Println("All connections closed gracefully")
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Println("Timeout waiting for connections to close")
|
||||
}
|
||||
|
||||
log.Println("SNI Proxy stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptConnections handles incoming connections
|
||||
func (p *SNIProxy) acceptConnections() {
|
||||
for {
|
||||
conn, err := p.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
logger.Debug("Accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
p.wg.Add(1)
|
||||
go p.handleConnection(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// readClientHello reads and parses the TLS ClientHello message
|
||||
func (p *SNIProxy) readClientHello(reader io.Reader) (*tls.ClientHelloInfo, error) {
|
||||
var hello *tls.ClientHelloInfo
|
||||
err := tls.Server(readOnlyConn{reader: reader}, &tls.Config{
|
||||
GetConfigForClient: func(argHello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
hello = new(tls.ClientHelloInfo)
|
||||
*hello = *argHello
|
||||
return nil, nil
|
||||
},
|
||||
}).Handshake()
|
||||
if hello == nil {
|
||||
return nil, err
|
||||
}
|
||||
return hello, nil
|
||||
}
|
||||
|
||||
// peekClientHello reads the ClientHello while preserving the data for forwarding
|
||||
func (p *SNIProxy) peekClientHello(reader io.Reader) (*tls.ClientHelloInfo, io.Reader, error) {
|
||||
peekedBytes := new(bytes.Buffer)
|
||||
hello, err := p.readClientHello(io.TeeReader(reader, peekedBytes))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return hello, io.MultiReader(peekedBytes, reader), nil
|
||||
}
|
||||
|
||||
// extractSNI extracts the SNI hostname from the TLS ClientHello
|
||||
func (p *SNIProxy) extractSNI(conn net.Conn) (string, io.Reader, error) {
|
||||
clientHello, clientReader, err := p.peekClientHello(conn)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to peek ClientHello: %w", err)
|
||||
}
|
||||
|
||||
if clientHello.ServerName == "" {
|
||||
return "", clientReader, fmt.Errorf("no SNI hostname found in ClientHello")
|
||||
}
|
||||
|
||||
return clientHello.ServerName, clientReader, nil
|
||||
}
|
||||
|
||||
// handleConnection processes a single client connection
|
||||
func (p *SNIProxy) handleConnection(clientConn net.Conn) {
|
||||
defer p.wg.Done()
|
||||
defer clientConn.Close()
|
||||
|
||||
logger.Debug("Accepted connection from %s", clientConn.RemoteAddr())
|
||||
|
||||
// Check for PROXY protocol from trusted upstream
|
||||
var proxyInfo *ProxyProtocolInfo
|
||||
var actualClientConn net.Conn = clientConn
|
||||
|
||||
if len(p.trustedUpstreams) > 0 {
|
||||
var err error
|
||||
proxyInfo, actualClientConn, err = p.parseProxyProtocolHeader(clientConn)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to parse PROXY protocol: %v", err)
|
||||
return
|
||||
}
|
||||
if proxyInfo != nil {
|
||||
logger.Debug("Received PROXY protocol from trusted upstream: %s:%d -> %s:%d",
|
||||
proxyInfo.SrcIP, proxyInfo.SrcPort, proxyInfo.DestIP, proxyInfo.DestPort)
|
||||
} else {
|
||||
// No PROXY protocol detected, but connection is from trusted upstream
|
||||
// This is fine - treat as regular connection
|
||||
logger.Debug("No PROXY protocol detected from trusted upstream, treating as regular connection")
|
||||
}
|
||||
}
|
||||
|
||||
// Set read timeout for SNI extraction
|
||||
if err := actualClientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
logger.Debug("Failed to set read deadline: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract SNI hostname
|
||||
hostname, clientReader, err := p.extractSNI(actualClientConn)
|
||||
if err != nil {
|
||||
logger.Debug("SNI extraction failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
log.Println("No SNI hostname found")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("SNI hostname detected: %s", hostname)
|
||||
|
||||
// Remove read timeout for normal operation
|
||||
if err := actualClientConn.SetReadDeadline(time.Time{}); err != nil {
|
||||
logger.Debug("Failed to clear read deadline: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get routing information - use original client address if available from PROXY protocol
|
||||
var clientAddrStr string
|
||||
if proxyInfo != nil {
|
||||
clientAddrStr = fmt.Sprintf("%s:%d", proxyInfo.SrcIP, proxyInfo.SrcPort)
|
||||
} else {
|
||||
clientAddrStr = clientConn.RemoteAddr().String()
|
||||
}
|
||||
|
||||
route, err := p.getRoute(hostname, clientAddrStr)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to get route for %s: %v", hostname, err)
|
||||
return
|
||||
}
|
||||
|
||||
if route == nil {
|
||||
logger.Debug("No route found for hostname: %s", hostname)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Routing %s to %s:%d", hostname, route.TargetHost, route.TargetPort)
|
||||
|
||||
// Connect to target server
|
||||
targetConn, err := net.DialTimeout("tcp",
|
||||
fmt.Sprintf("%s:%d", route.TargetHost, route.TargetPort),
|
||||
10*time.Second)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to connect to target %s:%d: %v",
|
||||
route.TargetHost, route.TargetPort, err)
|
||||
return
|
||||
}
|
||||
defer targetConn.Close()
|
||||
|
||||
logger.Debug("Connected to target: %s:%d", route.TargetHost, route.TargetPort)
|
||||
|
||||
// Send PROXY protocol header if enabled
|
||||
if p.proxyProtocol {
|
||||
var proxyHeader string
|
||||
if proxyInfo != nil {
|
||||
// Use original client info from PROXY protocol
|
||||
proxyHeader = p.buildProxyProtocolHeaderFromInfo(proxyInfo, targetConn.LocalAddr())
|
||||
} else {
|
||||
// Use direct client connection info
|
||||
proxyHeader = buildProxyProtocolHeader(clientConn.RemoteAddr(), targetConn.LocalAddr())
|
||||
}
|
||||
logger.Debug("Sending PROXY protocol header: %s", strings.TrimSpace(proxyHeader))
|
||||
|
||||
if _, err := targetConn.Write([]byte(proxyHeader)); err != nil {
|
||||
logger.Debug("Failed to send PROXY protocol header: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Track this tunnel by SNI
|
||||
p.activeTunnelsLock.Lock()
|
||||
tunnel, ok := p.activeTunnels[hostname]
|
||||
if !ok {
|
||||
tunnel = &activeTunnel{}
|
||||
p.activeTunnels[hostname] = tunnel
|
||||
}
|
||||
tunnel.conns = append(tunnel.conns, actualClientConn)
|
||||
p.activeTunnelsLock.Unlock()
|
||||
|
||||
defer func() {
|
||||
// Remove this conn from active tunnels
|
||||
p.activeTunnelsLock.Lock()
|
||||
if tunnel, ok := p.activeTunnels[hostname]; ok {
|
||||
newConns := make([]net.Conn, 0, len(tunnel.conns))
|
||||
for _, c := range tunnel.conns {
|
||||
if c != actualClientConn {
|
||||
newConns = append(newConns, c)
|
||||
}
|
||||
}
|
||||
if len(newConns) == 0 {
|
||||
delete(p.activeTunnels, hostname)
|
||||
} else {
|
||||
tunnel.conns = newConns
|
||||
}
|
||||
}
|
||||
p.activeTunnelsLock.Unlock()
|
||||
}()
|
||||
|
||||
// Start bidirectional data transfer
|
||||
p.pipe(actualClientConn, targetConn, clientReader)
|
||||
}
|
||||
|
||||
// getRoute retrieves routing information for a hostname
|
||||
func (p *SNIProxy) getRoute(hostname, clientAddr string) (*RouteRecord, error) {
|
||||
// Check local overrides first
|
||||
if _, isOverride := p.localOverrides[hostname]; isOverride {
|
||||
logger.Debug("Local override matched for hostname: %s", hostname)
|
||||
return &RouteRecord{
|
||||
Hostname: hostname,
|
||||
TargetHost: p.localProxyAddr,
|
||||
TargetPort: p.localProxyPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fast path: check if hostname is in localSNIs
|
||||
p.localSNIsLock.RLock()
|
||||
_, isLocal := p.localSNIs[hostname]
|
||||
p.localSNIsLock.RUnlock()
|
||||
if isLocal {
|
||||
return &RouteRecord{
|
||||
Hostname: hostname,
|
||||
TargetHost: p.localProxyAddr,
|
||||
TargetPort: p.localProxyPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if cached, found := p.cache.Get(hostname); found {
|
||||
if cached == nil {
|
||||
return nil, nil // Cached negative result
|
||||
}
|
||||
logger.Debug("Cache hit for hostname: %s", hostname)
|
||||
return cached.(*RouteRecord), nil
|
||||
}
|
||||
|
||||
logger.Debug("Cache miss for hostname: %s, querying API", hostname)
|
||||
|
||||
// Query API with timeout
|
||||
ctx, cancel := context.WithTimeout(p.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Construct API URL (without hostname in path)
|
||||
apiURL := fmt.Sprintf("%s/gerbil/get-resolved-hostname", p.remoteConfigURL)
|
||||
|
||||
// Create request body with hostname and public key
|
||||
requestBody := map[string]string{
|
||||
"hostname": hostname,
|
||||
"publicKey": p.publicKey,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Make HTTP request
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// Cache negative result for shorter time (1 minute)
|
||||
p.cache.Set(hostname, nil, 1*time.Minute)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var apiResponse RouteAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode API response: %w", err)
|
||||
}
|
||||
|
||||
endpoints := apiResponse.Endpoints
|
||||
|
||||
// Default target configuration
|
||||
targetHost := p.localProxyAddr
|
||||
targetPort := p.localProxyPort
|
||||
|
||||
// If no endpoints returned, use local node
|
||||
if len(endpoints) == 0 {
|
||||
logger.Debug("No endpoints returned for hostname: %s, using local node", hostname)
|
||||
} else {
|
||||
// Select endpoint using consistent hashing for stickiness
|
||||
selectedEndpoint := p.selectStickyEndpoint(clientAddr, endpoints)
|
||||
targetHost = selectedEndpoint
|
||||
targetPort = 443 // Default HTTPS port
|
||||
logger.Debug("Selected endpoint %s for hostname %s from client %s", selectedEndpoint, hostname, clientAddr)
|
||||
}
|
||||
|
||||
route := &RouteRecord{
|
||||
Hostname: hostname,
|
||||
TargetHost: targetHost,
|
||||
TargetPort: targetPort,
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
p.cache.Set(hostname, route, cache.DefaultExpiration)
|
||||
logger.Debug("Cached route for hostname: %s", hostname)
|
||||
|
||||
return route, nil
|
||||
}
|
||||
|
||||
// selectStickyEndpoint selects an endpoint using consistent hashing to ensure
|
||||
// the same client always routes to the same endpoint for load balancing
|
||||
func (p *SNIProxy) selectStickyEndpoint(clientAddr string, endpoints []string) string {
|
||||
if len(endpoints) == 0 {
|
||||
return p.localProxyAddr
|
||||
}
|
||||
if len(endpoints) == 1 {
|
||||
return endpoints[0]
|
||||
}
|
||||
|
||||
// Use FNV hash for consistent selection based on client address
|
||||
hash := fnv.New32a()
|
||||
hash.Write([]byte(clientAddr))
|
||||
index := hash.Sum32() % uint32(len(endpoints))
|
||||
|
||||
return endpoints[index]
|
||||
}
|
||||
|
||||
// pipe handles bidirectional data transfer between connections
|
||||
func (p *SNIProxy) pipe(clientConn, targetConn net.Conn, clientReader io.Reader) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// Copy data from client to target (using the buffered reader)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if tcpConn, ok := targetConn.(*net.TCPConn); ok {
|
||||
tcpConn.CloseWrite()
|
||||
}
|
||||
}()
|
||||
|
||||
// Use a large buffer for better performance
|
||||
buf := make([]byte, 32*1024)
|
||||
_, err := io.CopyBuffer(targetConn, clientReader, buf)
|
||||
if err != nil && err != io.EOF {
|
||||
logger.Debug("Copy client->target error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy data from target to client
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if tcpConn, ok := clientConn.(*net.TCPConn); ok {
|
||||
tcpConn.CloseWrite()
|
||||
}
|
||||
}()
|
||||
|
||||
// Use a large buffer for better performance
|
||||
buf := make([]byte, 32*1024)
|
||||
_, err := io.CopyBuffer(clientConn, targetConn, buf)
|
||||
if err != nil && err != io.EOF {
|
||||
logger.Debug("Copy target->client error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// GetCacheStats returns cache statistics
|
||||
func (p *SNIProxy) GetCacheStats() (int, int) {
|
||||
return p.cache.ItemCount(), len(p.cache.Items())
|
||||
}
|
||||
|
||||
// ClearCache clears all cached entries
|
||||
func (p *SNIProxy) ClearCache() {
|
||||
p.cache.Flush()
|
||||
log.Println("Cache cleared")
|
||||
}
|
||||
|
||||
// UpdateLocalSNIs updates the local SNIs and invalidates cache for changed domains
|
||||
func (p *SNIProxy) UpdateLocalSNIs(fullDomains []string) {
|
||||
newSNIs := make(map[string]struct{})
|
||||
for _, domain := range fullDomains {
|
||||
newSNIs[domain] = struct{}{}
|
||||
// Invalidate any cached route for this domain
|
||||
p.cache.Delete(domain)
|
||||
}
|
||||
|
||||
// Update localSNIs
|
||||
p.localSNIsLock.Lock()
|
||||
removed := make([]string, 0)
|
||||
for sni := range p.localSNIs {
|
||||
if _, stillLocal := newSNIs[sni]; !stillLocal {
|
||||
removed = append(removed, sni)
|
||||
}
|
||||
}
|
||||
p.localSNIs = newSNIs
|
||||
p.localSNIsLock.Unlock()
|
||||
|
||||
logger.Debug("Updated local SNIs, added %d, removed %d", len(newSNIs), len(removed))
|
||||
|
||||
// Terminate tunnels for removed SNIs
|
||||
if len(removed) > 0 {
|
||||
p.activeTunnelsLock.Lock()
|
||||
for _, sni := range removed {
|
||||
if tunnels, ok := p.activeTunnels[sni]; ok {
|
||||
for _, conn := range tunnels.conns {
|
||||
conn.Close()
|
||||
}
|
||||
delete(p.activeTunnels, sni)
|
||||
logger.Debug("Closed tunnels for SNI target change: %s", sni)
|
||||
}
|
||||
}
|
||||
p.activeTunnelsLock.Unlock()
|
||||
}
|
||||
}
|
||||
119
proxy/proxy_test.go
Normal file
119
proxy/proxy_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildProxyProtocolHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
clientAddr string
|
||||
targetAddr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "IPv4 client and target",
|
||||
clientAddr: "192.168.1.100:12345",
|
||||
targetAddr: "10.0.0.1:443",
|
||||
expected: "PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n",
|
||||
},
|
||||
{
|
||||
name: "IPv6 client and target",
|
||||
clientAddr: "[2001:db8::1]:12345",
|
||||
targetAddr: "[2001:db8::2]:443",
|
||||
expected: "PROXY TCP6 2001:db8::1 2001:db8::2 12345 443\r\n",
|
||||
},
|
||||
{
|
||||
name: "IPv4 client with IPv6 loopback target",
|
||||
clientAddr: "192.168.1.100:12345",
|
||||
targetAddr: "[::1]:443",
|
||||
expected: "PROXY TCP4 192.168.1.100 127.0.0.1 12345 443\r\n",
|
||||
},
|
||||
{
|
||||
name: "IPv4 client with IPv6 target",
|
||||
clientAddr: "192.168.1.100:12345",
|
||||
targetAddr: "[2001:db8::2]:443",
|
||||
expected: "PROXY TCP4 192.168.1.100 127.0.0.1 12345 443\r\n",
|
||||
},
|
||||
{
|
||||
name: "IPv6 client with IPv4 target",
|
||||
clientAddr: "[2001:db8::1]:12345",
|
||||
targetAddr: "10.0.0.1:443",
|
||||
expected: "PROXY TCP6 2001:db8::1 ::ffff:10.0.0.1 12345 443\r\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clientTCP, err := net.ResolveTCPAddr("tcp", tt.clientAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve client address: %v", err)
|
||||
}
|
||||
|
||||
targetTCP, err := net.ResolveTCPAddr("tcp", tt.targetAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve target address: %v", err)
|
||||
}
|
||||
|
||||
result := buildProxyProtocolHeader(clientTCP, targetTCP)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyProtocolHeaderUnknownType(t *testing.T) {
|
||||
// Test with non-TCP address type
|
||||
clientAddr := &net.UDPAddr{IP: net.ParseIP("192.168.1.100"), Port: 12345}
|
||||
targetAddr := &net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 443}
|
||||
|
||||
result := buildProxyProtocolHeader(clientAddr, targetAddr)
|
||||
expected := "PROXY UNKNOWN\r\n"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyProtocolHeaderFromInfo(t *testing.T) {
|
||||
proxy, err := NewSNIProxy(8443, "", "", "127.0.0.1", 443, nil, true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SNI proxy: %v", err)
|
||||
}
|
||||
|
||||
// Test IPv4 case
|
||||
proxyInfo := &ProxyProtocolInfo{
|
||||
Protocol: "TCP4",
|
||||
SrcIP: "10.0.0.1",
|
||||
DestIP: "192.168.1.100",
|
||||
SrcPort: 12345,
|
||||
DestPort: 443,
|
||||
}
|
||||
|
||||
targetAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
|
||||
header := proxy.buildProxyProtocolHeaderFromInfo(proxyInfo, targetAddr)
|
||||
|
||||
expected := "PROXY TCP4 10.0.0.1 127.0.0.1 12345 8080\r\n"
|
||||
if header != expected {
|
||||
t.Errorf("Expected header '%s', got '%s'", expected, header)
|
||||
}
|
||||
|
||||
// Test IPv6 case
|
||||
proxyInfo = &ProxyProtocolInfo{
|
||||
Protocol: "TCP6",
|
||||
SrcIP: "2001:db8::1",
|
||||
DestIP: "2001:db8::2",
|
||||
SrcPort: 12345,
|
||||
DestPort: 443,
|
||||
}
|
||||
|
||||
targetAddr, _ = net.ResolveTCPAddr("tcp6", "[::1]:8080")
|
||||
header = proxy.buildProxyProtocolHeaderFromInfo(proxyInfo, targetAddr)
|
||||
|
||||
expected = "PROXY TCP6 2001:db8::1 ::1 12345 8080\r\n"
|
||||
if header != expected {
|
||||
t.Errorf("Expected header '%s', got '%s'", expected, header)
|
||||
}
|
||||
}
|
||||
159
relay/relay.go
159
relay/relay.go
@@ -37,6 +37,7 @@ type ClientEndpoint struct {
|
||||
Port int `json:"port"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ReachableAt string `json:"reachableAt"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
|
||||
// Updated to support multiple destination peers
|
||||
@@ -63,6 +64,17 @@ type WireGuardSession struct {
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// Type for tracking bidirectional communication patterns to rebuild sessions
|
||||
type CommunicationPattern struct {
|
||||
FromClient *net.UDPAddr // The client address
|
||||
ToDestination *net.UDPAddr // The destination address
|
||||
ClientIndex uint32 // The receiver index seen from client
|
||||
DestIndex uint32 // The receiver index seen from destination
|
||||
LastFromClient time.Time // Last packet from client to destination
|
||||
LastFromDest time.Time // Last packet from destination to client
|
||||
PacketCount int // Number of packets observed
|
||||
}
|
||||
|
||||
type InitialMappings struct {
|
||||
Mappings map[string]ProxyMapping `json:"mappings"` // key is "ip:port"
|
||||
}
|
||||
@@ -104,6 +116,9 @@ type UDPProxyServer struct {
|
||||
// Session tracking for WireGuard peers
|
||||
// Key format: "senderIndex:receiverIndex"
|
||||
wgSessions sync.Map
|
||||
// Communication pattern tracking for rebuilding sessions
|
||||
// Key format: "clientIP:clientPort-destIP:destPort"
|
||||
commPatterns sync.Map
|
||||
// ReachableAt is the URL where this server can be reached
|
||||
ReachableAt string
|
||||
}
|
||||
@@ -155,6 +170,9 @@ func (s *UDPProxyServer) Start() error {
|
||||
// Start the proxy mapping cleanup routine
|
||||
go s.cleanupIdleProxyMappings()
|
||||
|
||||
// Start the communication pattern cleanup routine
|
||||
go s.cleanupIdleCommunicationPatterns()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,9 +243,11 @@ func (s *UDPProxyServer) packetWorker() {
|
||||
Port: packet.remoteAddr.Port,
|
||||
Timestamp: time.Now().Unix(),
|
||||
ReachableAt: s.ReachableAt,
|
||||
PublicKey: s.privateKey.PublicKey().String(),
|
||||
}
|
||||
logger.Debug("Created endpoint from packet remoteAddr %s: IP=%s, Port=%d", packet.remoteAddr.String(), endpoint.IP, endpoint.Port)
|
||||
s.notifyServer(endpoint)
|
||||
s.clearSessionsForIP(endpoint.IP) // Clear sessions for this IP to allow re-establishment
|
||||
}
|
||||
// Return the buffer to the pool for reuse.
|
||||
bufferPool.Put(packet.data[:1500])
|
||||
@@ -355,7 +375,7 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD
|
||||
switch messageType {
|
||||
case WireGuardMessageTypeHandshakeInitiation:
|
||||
// Initial handshake: forward to all peers
|
||||
logger.Debug("Forwarding handshake initiation from %s (sender index: %d)", remoteAddr, senderIndex)
|
||||
logger.Debug("Forwarding handshake initiation from %s (sender index: %d) to peers %v", remoteAddr, senderIndex, proxyMapping.Destinations)
|
||||
|
||||
for _, dest := range proxyMapping.Destinations {
|
||||
destAddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", dest.DestinationIP, dest.DestinationPort))
|
||||
@@ -414,7 +434,7 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD
|
||||
|
||||
case WireGuardMessageTypeTransportData:
|
||||
// Data packet: forward only to the established session peer
|
||||
logger.Debug("Received transport data with receiver index %d from %s", receiverIndex, remoteAddr)
|
||||
// logger.Debug("Received transport data with receiver index %d from %s", receiverIndex, remoteAddr)
|
||||
|
||||
// Look up the session based on the receiver index
|
||||
var destAddr *net.UDPAddr
|
||||
@@ -442,6 +462,9 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD
|
||||
return
|
||||
}
|
||||
|
||||
// Track communication pattern for session rebuilding
|
||||
s.trackCommunicationPattern(remoteAddr, destAddr, receiverIndex, true)
|
||||
|
||||
_, err = conn.Write(packet)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to forward transport data: %v", err)
|
||||
@@ -462,6 +485,9 @@ func (s *UDPProxyServer) handleWireGuardPacket(packet []byte, remoteAddr *net.UD
|
||||
continue
|
||||
}
|
||||
|
||||
// Track communication pattern for session rebuilding
|
||||
s.trackCommunicationPattern(remoteAddr, destAddr, receiverIndex, true)
|
||||
|
||||
_, err = conn.Write(packet)
|
||||
if err != nil {
|
||||
logger.Debug("Failed to forward transport data: %v", err)
|
||||
@@ -545,6 +571,9 @@ func (s *UDPProxyServer) handleResponses(conn *net.UDPConn, destAddr *net.UDPAdd
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
logger.Debug("Stored session mapping: %s -> %s", sessionKey, destAddr.String())
|
||||
} else if ok && buffer[0] == WireGuardMessageTypeTransportData {
|
||||
// Track communication pattern for session rebuilding (reverse direction)
|
||||
s.trackCommunicationPattern(destAddr, remoteAddr, receiverIndex, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +689,7 @@ func (s *UDPProxyServer) UpdateProxyMapping(sourceIP string, sourcePort int, des
|
||||
func (s *UDPProxyServer) OnPeerAdded(wgIP string) {
|
||||
logger.Info("Clearing connections for added peer with WG IP: %s", wgIP)
|
||||
s.clearConnectionsForWGIP(wgIP)
|
||||
s.clearSessionsForWGIP(wgIP)
|
||||
// s.clearSessionsForWGIP(wgIP) THE DEST ADDR IS NOT THE WG IP, SO THIS IS NOT NEEDED
|
||||
// s.clearProxyMappingsForWGIP(wgIP)
|
||||
}
|
||||
|
||||
@@ -668,7 +697,7 @@ func (s *UDPProxyServer) OnPeerAdded(wgIP string) {
|
||||
func (s *UDPProxyServer) OnPeerRemoved(wgIP string) {
|
||||
logger.Info("Clearing connections for removed peer with WG IP: %s", wgIP)
|
||||
s.clearConnectionsForWGIP(wgIP)
|
||||
s.clearSessionsForWGIP(wgIP)
|
||||
// s.clearSessionsForWGIP(wgIP) THE DEST ADDR IS NOT THE WG IP, SO THIS IS NOT NEEDED
|
||||
// s.clearProxyMappingsForWGIP(wgIP)
|
||||
}
|
||||
|
||||
@@ -699,7 +728,7 @@ func (s *UDPProxyServer) clearConnectionsForWGIP(wgIP string) {
|
||||
}
|
||||
|
||||
// clearSessionsForWGIP removes all WireGuard sessions associated with a specific WireGuard IP
|
||||
func (s *UDPProxyServer) clearSessionsForWGIP(wgIP string) {
|
||||
func (s *UDPProxyServer) clearSessionsForIP(ip string) {
|
||||
var keysToDelete []string
|
||||
|
||||
s.wgSessions.Range(func(key, value interface{}) bool {
|
||||
@@ -707,9 +736,9 @@ func (s *UDPProxyServer) clearSessionsForWGIP(wgIP string) {
|
||||
session := value.(*WireGuardSession)
|
||||
|
||||
// Check if the session's destination address contains the WG IP
|
||||
if session.DestAddr != nil && session.DestAddr.IP.String() == wgIP {
|
||||
if session.DestAddr != nil && session.DestAddr.IP.String() == ip {
|
||||
keysToDelete = append(keysToDelete, keyStr)
|
||||
logger.Debug("Marking session for deletion for WG IP %s: %s", wgIP, keyStr)
|
||||
logger.Debug("Marking session for deletion for WG IP %s: %s", ip, keyStr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -719,7 +748,7 @@ func (s *UDPProxyServer) clearSessionsForWGIP(wgIP string) {
|
||||
s.wgSessions.Delete(key)
|
||||
}
|
||||
|
||||
logger.Info("Cleared %d sessions for WG IP: %s", len(keysToDelete), wgIP)
|
||||
logger.Info("Cleared %d sessions for WG IP: %s", len(keysToDelete), ip)
|
||||
}
|
||||
|
||||
// // clearProxyMappingsForWGIP removes all proxy mappings that have destinations pointing to a specific WireGuard IP
|
||||
@@ -820,3 +849,117 @@ func (s *UDPProxyServer) UpdateDestinationInMappings(oldDest, newDest PeerDestin
|
||||
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
// trackCommunicationPattern tracks bidirectional communication patterns to rebuild sessions
|
||||
func (s *UDPProxyServer) trackCommunicationPattern(fromAddr, toAddr *net.UDPAddr, receiverIndex uint32, fromClient bool) {
|
||||
var clientAddr, destAddr *net.UDPAddr
|
||||
var clientIndex, destIndex uint32
|
||||
|
||||
if fromClient {
|
||||
clientAddr = fromAddr
|
||||
destAddr = toAddr
|
||||
clientIndex = receiverIndex
|
||||
destIndex = 0 // We don't know the destination index yet
|
||||
} else {
|
||||
clientAddr = toAddr
|
||||
destAddr = fromAddr
|
||||
clientIndex = 0 // We don't know the client index yet
|
||||
destIndex = receiverIndex
|
||||
}
|
||||
|
||||
patternKey := fmt.Sprintf("%s-%s", clientAddr.String(), destAddr.String())
|
||||
now := time.Now()
|
||||
|
||||
if existingPattern, ok := s.commPatterns.Load(patternKey); ok {
|
||||
pattern := existingPattern.(*CommunicationPattern)
|
||||
|
||||
// Update the pattern
|
||||
if fromClient {
|
||||
pattern.LastFromClient = now
|
||||
if pattern.ClientIndex == 0 {
|
||||
pattern.ClientIndex = clientIndex
|
||||
}
|
||||
} else {
|
||||
pattern.LastFromDest = now
|
||||
if pattern.DestIndex == 0 {
|
||||
pattern.DestIndex = destIndex
|
||||
}
|
||||
}
|
||||
|
||||
pattern.PacketCount++
|
||||
s.commPatterns.Store(patternKey, pattern)
|
||||
|
||||
// Check if we have bidirectional communication and can rebuild a session
|
||||
s.tryRebuildSession(pattern)
|
||||
} else {
|
||||
// Create new pattern
|
||||
pattern := &CommunicationPattern{
|
||||
FromClient: clientAddr,
|
||||
ToDestination: destAddr,
|
||||
ClientIndex: clientIndex,
|
||||
DestIndex: destIndex,
|
||||
PacketCount: 1,
|
||||
}
|
||||
|
||||
if fromClient {
|
||||
pattern.LastFromClient = now
|
||||
} else {
|
||||
pattern.LastFromDest = now
|
||||
}
|
||||
|
||||
s.commPatterns.Store(patternKey, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// tryRebuildSession attempts to rebuild a WireGuard session from communication patterns
|
||||
func (s *UDPProxyServer) tryRebuildSession(pattern *CommunicationPattern) {
|
||||
// Check if we have bidirectional communication within a reasonable time window
|
||||
timeDiff := pattern.LastFromClient.Sub(pattern.LastFromDest)
|
||||
if timeDiff < 0 {
|
||||
timeDiff = -timeDiff
|
||||
}
|
||||
|
||||
// Only rebuild if we have recent bidirectional communication and both indices
|
||||
if timeDiff < 30*time.Second && pattern.ClientIndex != 0 && pattern.DestIndex != 0 && pattern.PacketCount >= 4 {
|
||||
// Create session mapping: client's index maps to destination
|
||||
sessionKey := fmt.Sprintf("%d:%d", pattern.DestIndex, pattern.ClientIndex)
|
||||
|
||||
// Check if we already have this session
|
||||
if _, exists := s.wgSessions.Load(sessionKey); !exists {
|
||||
session := &WireGuardSession{
|
||||
ReceiverIndex: pattern.DestIndex,
|
||||
SenderIndex: pattern.ClientIndex,
|
||||
DestAddr: pattern.ToDestination,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
s.wgSessions.Store(sessionKey, session)
|
||||
logger.Info("Rebuilt WireGuard session from communication pattern: %s -> %s (packets: %d)",
|
||||
sessionKey, pattern.ToDestination.String(), pattern.PacketCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupIdleCommunicationPatterns periodically removes idle communication patterns
|
||||
func (s *UDPProxyServer) cleanupIdleCommunicationPatterns() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.commPatterns.Range(func(key, value interface{}) bool {
|
||||
pattern := value.(*CommunicationPattern)
|
||||
|
||||
// Get the most recent activity
|
||||
lastActivity := pattern.LastFromClient
|
||||
if pattern.LastFromDest.After(lastActivity) {
|
||||
lastActivity = pattern.LastFromDest
|
||||
}
|
||||
|
||||
// Remove patterns that haven't had activity in 20 minutes
|
||||
if now.Sub(lastActivity) > 20*time.Minute {
|
||||
s.commPatterns.Delete(key)
|
||||
logger.Debug("Removed idle communication pattern: %s", key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user