From e9be43cd8d9804213d206ef36d0f5a499783c7c7 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Sat, 27 Sep 2025 14:03:20 +0200 Subject: [PATCH 1/7] feat: Extended tunnel and added a cli subcommand for it - Added `aesgcm_conn.go` to provide AES-GCM encryption for net.Conn. - Introduced `NewAESGCMConn` function for creating encrypted connections. - Implemented Read and Write methods for encrypted data transmission. - Added tests in `aesgcm_conn_test.go` to validate encryption and decryption functionality. - Created a command-line tool in `cmd/netx` for establishing secure tunnels with chainable transforms. - Implemented UDP and TCP echo servers and clients for end-to-end testing in `internal/tools/e2e`. - Enhanced logging and error handling throughout the codebase. - Updated `.gitignore` to exclude build artifacts and temporary files. --- .github/workflows/lint_and_test.yml | 1 + .gitignore | 5 + README.md | 41 +- Taskfile.yml | 178 +++++++- aesgcm_conn.go | 177 ++++++++ aesgcm_conn_test.go | 207 ++++++++++ buffered_conn.go | 15 +- cmd/netx/main.go | 41 ++ cmd/netx/tun/run.go | 559 ++++++++++++++++++++++++++ framed_conn.go | 10 +- go.mod | 16 + go.sum | 83 ++++ internal/tools/e2e/tcp_client/main.go | 38 ++ internal/tools/e2e/tcp_echo/main.go | 47 +++ internal/tools/e2e/udp_client/main.go | 42 ++ internal/tools/e2e/udp_echo/main.go | 36 ++ 16 files changed, 1482 insertions(+), 14 deletions(-) create mode 100644 .gitignore create mode 100644 aesgcm_conn.go create mode 100644 aesgcm_conn_test.go create mode 100644 cmd/netx/main.go create mode 100644 cmd/netx/tun/run.go create mode 100644 internal/tools/e2e/tcp_client/main.go create mode 100644 internal/tools/e2e/tcp_echo/main.go create mode 100644 internal/tools/e2e/udp_client/main.go create mode 100644 internal/tools/e2e/udp_echo/main.go diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 5341ca3..db52d40 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -25,3 +25,4 @@ jobs: - run: task deps - run: task lint - run: task test + - run: task e2e:tun diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03756b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.task/ +/build/ + +# e2e working directory created by Task e2e:tun +/.e2e/ diff --git a/README.md b/README.md index b08d2ec..5906ff6 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Notes: rawClient, rawServer := net.Pipe() defer rawClient.Close(); defer rawServer.Close() -client := netx.NewFramedConn(rawClient) // default max frame size 16KiB +client := netx.NewFramedConn(rawClient) // default max frame size 32KiB server := netx.NewFramedConn(rawServer, netx.WithMaxFrameSize(64<<10)) msg := []byte("hello frame") @@ -153,10 +153,43 @@ If `Logger` is nil, the server/tunnel use `slog.Default()`. - Unhandled connections are dropped immediately after all routes decline. - `Shutdown(ctx)` will close listeners, then wait for tracked connections until `ctx` is done, after which remaining connections are force‑closed. -## Testing +## CLI -The repository includes unit and end‑to‑end tests (UDP over TCP, TLS routing, graceful shutdown). Run: +An extendable CLI is available at `cmd/netx` with an initial `tun` subcommand to relay between chainable endpoints. + +Build: + +```bash +task build +``` + +Install and use: ```bash -go test ./... +go install github.com/pedramktb/go-netx/cmd/netx@latest + +# Show help +netx tun --help + +# Example: TCP TLS server to TCP TLS+framed+aesgcm client +netx tun --from tcp+tls[cert=server.crt,key=server.key] \ + --to tcp+tls[serverName=example.com,insecure=true]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff] \ + tcp://:9000 tcp://example.com:9443 ``` + +Chain syntax: + +- Base: `tcp` or `udp` +- Wrappers: + - `tls[cert=...,key=...]` (server) or `tls[serverName=...,ca=...,insecure=true]` (client) + - `dtls[cert=...,key=...]` (server) or `dtls[serverName=...,ca=...,insecure=true]` (client) with UDP + - `tlspsk[key=...]` (With a deprecated library and TLS1.2, use at your own risk!) + - `dtlspsk[key=...]` + - `aesgcm[key=,maxPacket=32768]` + - `buffered[buf=4096]` + - `framed[maxFrame=32768]` + +Notes: + +- Endpoints use URI form: `://host:port` +- You can chain multiple wrappers on either side; the tool uses `TunMaster` under the hood. diff --git a/Taskfile.yml b/Taskfile.yml index ee195f0..5143f56 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,7 +2,7 @@ version: '3' tasks: default: - cmds: + cmds: - task --list deps: @@ -20,3 +20,179 @@ tasks: desc: Run tests cmds: - go test ./... + + build: + desc: Build binaries and libraries + cmds: + # Linux binaries and shared libraries + - env GOOS=linux GOARCH=amd64 go build -o build/netx_linux_x64 cmd/netx/main.go + - env GOOS=linux GOARCH=arm64 go build -o build/netx_linux_arm64 cmd/netx/main.go + # - env GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc go build -buildmode=c-shared -o build/libnetx_linux_x64.so cmd/netx/lib/main.go + # - env GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -buildmode=c-shared -o build/libnetx_linux_arm64.so cmd/netx/lib/main.go + # # Windows binaries and shared libraries + - env GOOS=windows GOARCH=amd64 go build -o build/netx_windows_x64.exe cmd/netx/main.go + - env GOOS=windows GOARCH=arm64 go build -o build/netx_windows_arm64.exe cmd/netx/main.go + # - env GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libnetx_windows_x64.dll cmd/netx/lib/main.go + # # aarch64-w64-mingw32-gcc is experimental and not available + # # - env GOOS=windows GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libenetx_windows_arm64.dll cmd/lib/main.go + # # macOS binaries + - env GOOS=darwin GOARCH=amd64 go build -o build/netx_macos_x64 cmd/netx/main.go + - env GOOS=darwin GOARCH=arm64 go build -o build/netx_macos_arm64 cmd/netx/main.go + # # Android shared libraries + # - env GOOS=android GOARCH=amd64 CGO_ENABLED=1 CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android26-clang go build -buildmode=c-shared -o build/libnetx_android_x64.so cmd/netx/lib/main.go + # - env GOOS=android GOARCH=arm64 CGO_ENABLED=1 CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang go build -buildmode=c-shared -o build/libnetx_android_arm64.so cmd/netx/lib/main.go + sources: + - '**/*.go' + - go.mod + - go.sum + generates: + - build/netx_linux_x64 + - build/netx_linux_arm64 + - build/libnetx_linux_x64.so + - build/libnetx_linux_arm64.so + - build/netx_windows_x64.exe + - build/netx_windows_arm64.exe + - build/libnetx_windows_x64.dll + - build/netx_macos_x64 + - build/netx_macos_arm64 + - build/libnetx_android_x64.so + - build/libnetx_android_arm64.so + + build-apple-libs: + desc: Build Apple libraries (optional, requires mac toolchains) + cmds: + - env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=c-shared -o build/libnetx_macos_x64.dylib cmd/netx/lib/main.go + - env GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -buildmode=c-shared -o build/libnetx_macos_arm64.dylib cmd/netx/lib/main.go + - | + mkdir -p build + export CC=$(xcrun -find -sdk iphonesimulator clang) + export CXX=$(xcrun -find -sdk iphonesimulator clang++) + export SDKROOT=$(xcrun --sdk iphonesimulator --show-sdk-path) + export CFLAGS="-arch x86_64 -isysroot $SDKROOT -mios-simulator-version-min=10.0" + export LDFLAGS="-arch x86_64 -isysroot $SDKROOT -mios-simulator-version-min=10.0" + env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=c-archive -o build/libnetx_ios_x64.a cmd/netx/lib/main.go + - | + mkdir -p build + export CC=$(xcrun -find -sdk iphoneos clang) + export CXX=$(xcrun -find -sdk iphoneos clang++) + export SDKROOT=$(xcrun --sdk iphoneos --show-sdk-path) + export CFLAGS="-arch arm64 -isysroot $SDKROOT -mios-version-min=10.0" + export LDFLAGS="-arch arm64 -isysroot $SDKROOT -mios-version-min=10.0" + env GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -buildmode=c-archive -o build/libnetx_ios_arm64.a cmd/netx/lib/main.go + sources: + - '**/*.go' + - go.mod + - go.sum + generates: + - build/libnetx_macos_x64.dylib + - build/libnetx_macos_arm64.dylib + - build/libnetx_ios_x64.a + - build/libnetx_ios_arm64.a + + e2e:clean: + desc: Clean up e2e artifacts and kill any .e2e pid-based processes + cmds: + - | + set -euo pipefail + if [ -d .e2e ]; then + cd .e2e || exit 0 + for f in *.pid; do + [ -f "$f" ] || continue + kill $(cat "$f") 2>/dev/null || true + rm -f "$f" + done + fi + cd - >/dev/null 2>&1 || true + rm -rf .e2e + + e2e:tun: + desc: Run CLI end-to-end tun tests locally (uses .e2e working dir). Set E2E_INCLUDE_TLSPSK=1 to include tlspsk. + cmds: + - | + set -euo pipefail + ROOT=$(pwd) + WORK=.e2e + mkdir -p "$WORK" + # build local CLI binary for current platform + go build -o "$WORK/netx" ./cmd/netx + chmod +x "$WORK/netx" + cd "$WORK" + # generate certs/keys + openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 1 -nodes -subj "/CN=localhost" >/dev/null 2>&1 + openssl rand -hex 32 > psk.hex + cp psk.hex aes.hex + # build tiny echo servers and clients from tracked sources (internal, e2e-tagged) + go build -tags e2e -o tcp_echo "$ROOT/internal/tools/e2e/tcp_echo" + go build -tags e2e -o udp_echo "$ROOT/internal/tools/e2e/udp_echo" + go build -tags e2e -o tcp_client "$ROOT/internal/tools/e2e/tcp_client" + go build -tags e2e -o udp_client "$ROOT/internal/tools/e2e/udp_client" + # cleanup function + cleanup(){ + for p in tcp_echo udp_echo \ + tls_server dtls_server tlspsk_server dtlspsk_server aesgcm_tcp_server aesgcm_udp_server framed_tcp_server \ + tls_client tlspsk_client dtls_client dtlspsk_client aesgcm_tcp_client aesgcm_udp_client framed_tcp_client; do + [ -f ${p}.pid ] && kill $(cat ${p}.pid) 2>/dev/null || true + rm -f ${p}.pid || true + done + } + # kill any stale from previous runs, even if EXIT trap didn't fire + cleanup || true + trap cleanup EXIT + # Use isolated ports to avoid conflicts with external demos + TE=48080; UE=48081 + STLS=49000; SDTLS=49100; SDTLSP=49300; SAESCT=49400; SAESCU=49500; SFR=49600; STLSPSK=49200 + CTLS=50000; CDTLS=50010; CDTLSP=50011; CAESCT=50002; CAESCU=50012; CFR=50003; CTLSPSK=50001 + # preflight: free ports if occupied by other processes + free_port(){ + port=$1; proto=$2 + if command -v lsof >/dev/null 2>&1; then + if [ "$proto" = tcp ]; then + pids=$(lsof -t -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) + else + pids=$(lsof -t -iUDP:"$port" 2>/dev/null || true) + fi + if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; fi + elif command -v fuser >/dev/null 2>&1; then + if [ "$proto" = tcp ]; then fuser -k -n tcp "$port" 2>/dev/null || true; else fuser -k -n udp "$port" 2>/dev/null || true; fi + fi + } + for p in $TE $STLS $SAESCT $SFR $CTLS $CAESCT; do free_port "$p" tcp; done + for p in $UE $SDTLS $SDTLSP $SAESCU $CDTLS $CDTLSP $CAESCU; do free_port "$p" udp; done + + # start raw peers + (nohup ./tcp_echo 127.0.0.1:${TE} > tcp_echo.log 2>&1 & echo $! > tcp_echo.pid) + (nohup ./udp_echo 127.0.0.1:${UE} > udp_echo.log 2>&1 & echo $! > udp_echo.pid) + # start server tunnels (accept secure on left, forward to raw peers on right) + (nohup ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${STLS} --to tcp://127.0.0.1:${TE} --log info > tls_server.log 2>&1 & echo $! > tls_server.pid) + (nohup ./netx tun --from udp+dtls[cert=server.crt,key=server.key]://127.0.0.1:${SDTLS} --to udp://127.0.0.1:${UE} --log info > dtls_server.log 2>&1 & echo $! > dtls_server.pid) + (nohup ./netx tun --from udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --to udp://127.0.0.1:${UE} --log info > dtlspsk_server.log 2>&1 & echo $! > dtlspsk_server.pid) + (nohup ./netx tun --from tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --to tcp://127.0.0.1:${TE} --log info > aesgcm_tcp_server.log 2>&1 & echo $! > aesgcm_tcp_server.pid) + (nohup ./netx tun --from udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --to udp://127.0.0.1:${UE} --log info > aesgcm_udp_server.log 2>&1 & echo $! > aesgcm_udp_server.pid) + (nohup ./netx tun --from tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --to udp://127.0.0.1:${UE} --log info > framed_tcp_server.log 2>&1 & echo $! > framed_tcp_server.pid) + (nohup ./netx tun --from tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --to tcp://127.0.0.1:${TE} --log info > tlspsk_server.log 2>&1 & echo $! > tlspsk_server.pid) + + # start client tunnels (accept local on left, connect to server with secure chain on right) + (nohup ./netx tun --from tcp://127.0.0.1:${CTLS} --to tcp+tls[cert=server.crt]://127.0.0.1:${STLS} --log info > tls_client.log 2>&1 & echo $! > tls_client.pid) + (nohup ./netx tun --from udp://127.0.0.1:${CDTLS} --to udp+dtls[cert=server.crt]://127.0.0.1:${SDTLS} --log info > dtls_client.log 2>&1 & echo $! > dtls_client.pid) + (nohup ./netx tun --from udp://127.0.0.1:${CDTLSP} --to udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --log info > dtlspsk_client.log 2>&1 & echo $! > dtlspsk_client.pid) + (nohup ./netx tun --from tcp://127.0.0.1:${CAESCT} --to tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --log info > aesgcm_tcp_client.log 2>&1 & echo $! > aesgcm_tcp_client.pid) + (nohup ./netx tun --from udp://127.0.0.1:${CAESCU} --to udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --log info > aesgcm_udp_client.log 2>&1 & echo $! > aesgcm_udp_client.pid) + (nohup ./netx tun --from udp://127.0.0.1:${CFR} --to tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --log info > framed_tcp_client.log 2>&1 & echo $! > framed_tcp_client.pid) + (nohup ./netx tun --from tcp://127.0.0.1:${CTLSPSK} --to tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --log info > tlspsk_client.log 2>&1 & echo $! > tlspsk_client.pid) + + # allow listeners to start + sleep 2 + pass=0; fail=0 + run_tcp(){ name=$1; addr=$2; msg=$3; out=$(./tcp_client "$addr" "$msg" || true); if [ "$out" = "$msg" ]; then echo "PASS $name"; pass=$((pass+1)); else echo "FAIL $name -> got: $out"; fail=$((fail+1)); fi } + run_udp(){ name=$1; addr=$2; msg=$3; out=$(./udp_client "$addr" "$msg" || true); if [ "$out" = "$msg" ]; then echo "PASS $name"; pass=$((pass+1)); else echo "FAIL $name -> got: $out"; fail=$((fail+1)); fi } + run_tcp TLS 127.0.0.1:${CTLS} hello_tls + run_udp DTLS 127.0.0.1:${CDTLS} hello_dtls + run_udp DTLSPSK 127.0.0.1:${CDTLSP} hello_dtlspsk + run_tcp AESGCM_TCP 127.0.0.1:${CAESCT} hello_aesgcm_tcp + run_udp AESGCM_UDP 127.0.0.1:${CAESCU} hello_aesgcm_udp + run_udp FRAMED_TCP_BR 127.0.0.1:${CFR} hello_udp_over_tcp + if [ "${E2E_INCLUDE_TLSPSK:-}" != "" ]; then + run_tcp TLSPSK 127.0.0.1:${CTLSPSK} hello_tlspsk || true + fi + echo "RESULTS: pass=$pass fail=$fail" + [ "$fail" -eq 0 ] diff --git a/aesgcm_conn.go b/aesgcm_conn.go new file mode 100644 index 0000000..05a5e6c --- /dev/null +++ b/aesgcm_conn.go @@ -0,0 +1,177 @@ +package netx + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "errors" + "io" + "net" + "sync/atomic" + "time" +) + +type aesgcmConn struct { + bc net.Conn + aead cipher.AEAD + wiv [12]byte + riv [12]byte + + // sequence number for nonce derivation, incremented atomically + seq atomic.Uint64 + + // Maximum size of a single ciphertext packet we accept on Read. + // This should be >= 8 (seq) + plaintext + aead.Overhead(). + maxPacketSize int +} + +type AESGCMOption func(*aesgcmConn) + +// WithMaxPacket sets the maximum ciphertext packet size accepted on Read. +// Default is 32KB. +func WithMaxPacket(size int) AESGCMOption { return func(c *aesgcmConn) { c.maxPacketSize = size } } + +// NewAESGCMConn constructs a new AES-GCM wrapper around a packet-based net.Conn. +// Key must be 16, 24, or 32 bytes (AES-128/192/256). +// It encrypts each packet using AES-GCM. It assumes the underlying conn +// preserves packet boundaries; it does not perform additional framing. +// +// Packet layout (single datagram): +// +// [8-byte seq big-endian][GCM(ciphertext||tag)] +// +// Nonce derivation: 12-byte IV is required. For a packet with sequence S, +// nonce = IV with its last 8 bytes XORed with S (big-endian). This ensures +// per-packet unique nonces without transmitting the full nonce. +// Write IV is randomly generated on creation and sent to the peer in the +// passive handshake that is performed on creation to exchange random IVs. +func NewAESGCMConn(c net.Conn, key []byte, opts ...AESGCMOption) (net.Conn, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + a, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + agc := &aesgcmConn{ + bc: c, + aead: a, + maxPacketSize: 32 * 1024} + for _, opt := range opts { + opt(agc) + } + if agc.maxPacketSize < 8+a.Overhead() { + return nil, errors.New("aesgcmConn: maxPacketSize too small") + } + if _, err := io.ReadFull(rand.Reader, agc.wiv[:]); err != nil { + return nil, err + } + + // Passive handshake (duplex): concurrently read peer IV while writing ours + handshakeDeadline := time.Now().Add(5 * time.Second) + _ = c.SetDeadline(handshakeDeadline) + defer c.SetDeadline(time.Time{}) // clear deadline after handshake + + // Start read of peer's 12-byte IV + readErrCh := make(chan error, 1) + go func() { + // io.ReadFull returns err if not enough bytes + _, err := io.ReadFull(c, agc.riv[:]) + readErrCh <- err + }() + + // Write our 12-byte IV + o := 0 + for o < len(agc.wiv) { + n, err := c.Write(agc.wiv[o:]) + if err != nil { + return nil, err + } + o += n + } + if o != len(agc.wiv) { + return nil, io.ErrShortWrite + } + + // Wait for read to complete + if err := <-readErrCh; err != nil { + return nil, err + } + + return agc, nil +} + +// Read reads and decrypts a single datagram from the underlying conn. +// If p is too small for the decrypted payload, io.ErrShortBuffer is returned. +func (c *aesgcmConn) Read(p []byte) (int, error) { + buf := make([]byte, c.maxPacketSize) + n, err := c.bc.Read(buf) + if err != nil { + return 0, err + } + if n == c.maxPacketSize { + return 0, errors.New("aesgcmConn: packet may be truncated; increase maxPacketSize") + } + if n < 8+c.aead.Overhead() { + return 0, errors.New("aesgcmConn: packet too small") + } + + nonce := [12]byte{} + copy(nonce[:], c.riv[:]) + for i := range 8 { + nonce[4+i] ^= buf[i] + } + + buf, err = c.aead.Open(buf[8:8], nonce[:], buf[8:n], buf[:8]) + if err != nil { + return 0, err + } + + if len(buf) > len(p) { + return 0, io.ErrShortBuffer + } + + copy(p, buf) + return len(buf), nil +} + +// Write encrypts p as a single datagram and writes it to the underlying conn. +// It prepends an 8-byte sequence number used for nonce derivation. +func (c *aesgcmConn) Write(p []byte) (int, error) { + if len(p)+8+c.aead.Overhead() > c.maxPacketSize { + return 0, errors.New("aesgcmConn: packet may be too large; increase maxPacketSize") + } + buf := make([]byte, c.maxPacketSize) + + seq := c.seq.Add(1) - 1 + binary.BigEndian.PutUint64(buf[:8], seq) + + nonce := [12]byte{} + copy(nonce[:], c.wiv[:]) + for i := range 8 { + nonce[4+i] ^= buf[i] + } + + ct := c.aead.Seal(buf[8:8], nonce[:], p, buf[:8]) + buf = buf[:8+len(ct)] + + n, err := c.bc.Write(buf) + if err != nil { + return 0, err + } + if n != len(buf) { + return 0, io.ErrShortWrite + } + + // Satisfy io.Writer contract: on success, return len(p) bytes written. + return len(p), nil +} + +func (c *aesgcmConn) Close() error { return c.bc.Close() } +func (c *aesgcmConn) LocalAddr() net.Addr { return c.bc.LocalAddr() } +func (c *aesgcmConn) RemoteAddr() net.Addr { return c.bc.RemoteAddr() } +func (c *aesgcmConn) SetDeadline(t time.Time) error { return c.bc.SetDeadline(t) } +func (c *aesgcmConn) SetReadDeadline(t time.Time) error { return c.bc.SetReadDeadline(t) } +func (c *aesgcmConn) SetWriteDeadline(t time.Time) error { return c.bc.SetWriteDeadline(t) } diff --git a/aesgcm_conn_test.go b/aesgcm_conn_test.go new file mode 100644 index 0000000..27ada68 --- /dev/null +++ b/aesgcm_conn_test.go @@ -0,0 +1,207 @@ +package netx_test + +import ( + "bytes" + "io" + "net" + "testing" + "time" + + netx "github.com/pedramktb/go-netx" +) + +// helper to create an AES-GCM protected pair over a framed connection +func newAESPair(t *testing.T) (client net.Conn, server net.Conn) { + t.Helper() + cr, sr := net.Pipe() + t.Cleanup(func() { _ = cr.Close(); _ = sr.Close() }) + + fc := netx.NewFramedConn(cr) + fs := netx.NewFramedConn(sr) + + key := bytes.Repeat([]byte{0x42}, 32) + + var ( + c net.Conn + s net.Conn + ec error + es error + done = make(chan struct{}, 2) + ) + go func() { c, ec = netx.NewAESGCMConn(fc, key); done <- struct{}{} }() + go func() { s, es = netx.NewAESGCMConn(fs, key); done <- struct{}{} }() + <-done + <-done + if ec != nil { + t.Fatalf("client aesgcm: %v", ec) + } + if es != nil { + t.Fatalf("server aesgcm: %v", es) + } + return c, s +} + +func TestAESGCM_Roundtrip(t *testing.T) { + c, s := newAESPair(t) + + msg := []byte("hello secret world") + + got := make([]byte, len(msg)) + done := make(chan error, 1) + go func() { + _, err := io.ReadFull(s, got) + done <- err + }() + time.Sleep(10 * time.Millisecond) + if _, err := c.Write(msg); err != nil { + t.Fatalf("write: %v", err) + } + select { + case err := <-done: + if err != nil { + t.Fatalf("readfull: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("timeout") + } + if !bytes.Equal(got, msg) { + t.Fatalf("mismatch") + } + + // multiple sequential messages should also work + data2 := bytes.Repeat([]byte("x"), 1024) + go func() { _, _ = c.Write(data2) }() + buf := make([]byte, len(data2)) + if _, err := io.ReadFull(s, buf); err != nil { + t.Fatalf("readfull2: %v", err) + } + if !bytes.Equal(buf, data2) { + t.Fatalf("mismatch2") + } +} + +func TestAESGCM_EmptyPayload(t *testing.T) { + c, s := newAESPair(t) + // write an empty datagram concurrently to avoid net.Pipe blocking + doneW := make(chan error, 1) + go func() { + _, err := c.Write(nil) + doneW <- err + }() + // should deliver a zero-length read (keep-alive style) + buf := make([]byte, 8) + n, err := s.Read(buf) + if err != nil { + t.Fatalf("read empty: %v", err) + } + if n != 0 { + t.Fatalf("expected zero-length read, got %d", n) + } + select { + case err := <-doneW: + if err != nil { + t.Fatalf("write empty err: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("write timeout") + } +} + +func TestAESGCM_ShortBufferDropsPacket(t *testing.T) { + c, s := newAESPair(t) + + first := bytes.Repeat([]byte("a"), 128) + second := []byte("ok") + + // send two packets back-to-back + go func() { _, _ = c.Write(first); _, _ = c.Write(second) }() + + // too-small buffer for the first packet should return io.ErrShortBuffer + small := make([]byte, 10) + if n, err := s.Read(small); err != io.ErrShortBuffer || n != 0 { + t.Fatalf("want io.ErrShortBuffer, got n=%d err=%v", n, err) + } + + // the next read should yield the second packet + buf := make([]byte, 16) + n, err := s.Read(buf) + if err != nil { + t.Fatalf("read second: %v", err) + } + if n != len(second) || !bytes.Equal(buf[:n], second) { + t.Fatalf("unexpected second packet: %q", buf[:n]) + } +} + +func TestAESGCM_MaxPacketWrite(t *testing.T) { + // choose a small max packet size so writes exceed it + // Overhead is 8 (seq) + 16 (GCM) + len(plaintext) + // set max to 48; max plaintext allowed ~= 24 + cr, sr := net.Pipe() + t.Cleanup(func() { _ = cr.Close(); _ = sr.Close() }) + fc := netx.NewFramedConn(cr) + fs := netx.NewFramedConn(sr) + key := bytes.Repeat([]byte{0x42}, 32) + var ( + c net.Conn + ec error + es error + done = make(chan struct{}, 2) + ) + go func() { c, ec = netx.NewAESGCMConn(fc, key, netx.WithMaxPacket(48)); done <- struct{}{} }() + go func() { _, es = netx.NewAESGCMConn(fs, key, netx.WithMaxPacket(48)); done <- struct{}{} }() + <-done + <-done + if ec != nil { + t.Fatalf("client: %v", ec) + } + if es != nil { + t.Fatalf("server: %v", es) + } + + big := bytes.Repeat([]byte("b"), 64) + if _, err := c.Write(big); err == nil { + t.Fatalf("expected write error due to max packet size") + } +} + +func TestAESGCM_DecryptErrorWrongKey(t *testing.T) { + cr, sr := net.Pipe() + t.Cleanup(func() { _ = cr.Close(); _ = sr.Close() }) + fc := netx.NewFramedConn(cr) + fs := netx.NewFramedConn(sr) + + keyA := bytes.Repeat([]byte{0x11}, 32) + keyB := bytes.Repeat([]byte{0x22}, 32) + + var ( + c net.Conn + s net.Conn + ec error + es error + done = make(chan struct{}, 2) + ) + go func() { c, ec = netx.NewAESGCMConn(fc, keyA); done <- struct{}{} }() + go func() { s, es = netx.NewAESGCMConn(fs, keyB); done <- struct{}{} }() + <-done + <-done + if ec != nil { + t.Fatalf("client: %v", ec) + } + if es != nil { + t.Fatalf("server: %v", es) + } + + // write a packet and expect read to fail + writeDone := make(chan error, 1) + go func() { + _, err := c.Write([]byte("test")) + writeDone <- err + }() + + buf := make([]byte, 16) + if _, err := s.Read(buf); err == nil { + t.Fatalf("expected decrypt error") + } + <-writeDone +} diff --git a/buffered_conn.go b/buffered_conn.go index 413a0ae..55eeefa 100644 --- a/buffered_conn.go +++ b/buffered_conn.go @@ -18,15 +18,22 @@ type bufConn struct { bw *bufio.Writer } -type bufConnOption func(*bufConn) +type BufConnOption func(*bufConn) -func WithBufWriterSize(size int) bufConnOption { +func WithBufSize(size int) BufConnOption { + return func(bc *bufConn) { + bc.br = bufio.NewReaderSize(bc.bc, size) + bc.bw = bufio.NewWriterSize(bc.bc, size) + } +} + +func WithBufWriterSize(size int) BufConnOption { return func(bc *bufConn) { bc.bw = bufio.NewWriterSize(bc.bc, size) } } -func WithBufReaderSize(size int) bufConnOption { +func WithBufReaderSize(size int) BufConnOption { return func(bc *bufConn) { bc.br = bufio.NewReaderSize(bc.bc, size) } @@ -34,7 +41,7 @@ func WithBufReaderSize(size int) bufConnOption { // NewBufConn wraps a net.Conn with buffered reader and writer. // By default, the buffer size is 4KB. Use WithBufWriterSize and WithBufReaderSize to customize the sizes. -func NewBufConn(c net.Conn, opts ...bufConnOption) BufConn { +func NewBufConn(c net.Conn, opts ...BufConnOption) BufConn { bc := &bufConn{ bc: c, br: bufio.NewReader(c), diff --git a/cmd/netx/main.go b/cmd/netx/main.go new file mode 100644 index 0000000..4c4e0ec --- /dev/null +++ b/cmd/netx/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + tuncmd "github.com/pedramktb/go-netx/cmd/netx/tun" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "tun": + tuncmd.Run(ctx, cancel, os.Args[2:]) + case "-h", "--help", "help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `netx - small networking toolbox + +Subcommands: + tun Relay between two endpoints with chainable transforms. + +Run 'netx --help' for details. +`) +} diff --git a/cmd/netx/tun/run.go b/cmd/netx/tun/run.go new file mode 100644 index 0000000..ea0c2b2 --- /dev/null +++ b/cmd/netx/tun/run.go @@ -0,0 +1,559 @@ +package tun + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "os" + "strconv" + "strings" + "time" + + netx "github.com/pedramktb/go-netx" + dtls "github.com/pion/dtls/v3" + dtlsnet "github.com/pion/dtls/v3/pkg/net" + pudp "github.com/pion/udp/v2" + tlswithpks "github.com/raff/tls-ext" + tlspks "github.com/raff/tls-psk" +) + +// chainStep represents a single segment in a connection chain (e.g. tls[key=...]). +type chainStep struct { + name string + params map[string]string +} + +// Run executes the tun subcommand. +// Usage: netx tun --from ://host:port --to ://host:port +func Run(ctx context.Context, cancel context.CancelFunc, args []string) { + fs := flag.NewFlagSet("tun", flag.ExitOnError) + from := fs.String("from", "", "chain URI for incoming side, e.g. tcp+tls[cert=...,key=...]://:9000 or udp+dtls[cert=...,key=...]://:4444") + to := fs.String("to", "", "chain URI for peer side, e.g. tcp+tls[cert=...]://example.com:9443 or udp+aesgcm[key=...]://1.2.3.4:5555") + logLevel := fs.String("log", "info", "log level: debug|info|warn|error") + help := fs.Bool("h", false, "show help") + _ = fs.Parse(args) + + if *help { + fmt.Fprintln(os.Stderr, tunUsage()) + return + } + + rest := fs.Args() + if len(rest) != 0 || *from == "" || *to == "" { + fmt.Fprintln(os.Stderr, tunUsage()) + os.Exit(2) + } + + // Configure logging level + lvl := slog.LevelInfo + switch strings.ToLower(*logLevel) { + case "debug": + lvl = slog.LevelDebug + case "info": + lvl = slog.LevelInfo + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) + + // Parse endpoints (chain + address) + fromBase, fromSteps, fromAddr, err := parseChainURI(*from) + if err != nil { + slog.ErrorContext(ctx, "parse --from", "error", err) + os.Exit(2) + } + toBase, toSteps, toAddr, err := parseChainURI(*to) + if err != nil { + slog.ErrorContext(ctx, "parse --to", "error", err) + os.Exit(2) + } + + // Build base listener only (wrappers are applied in the handler in order) + ln, err := buildListener(ctx, fromAddr, fromBase) + if err != nil { + slog.ErrorContext(ctx, "error listening", "error", err, "addr", fromAddr) + os.Exit(2) + } + defer ln.Close() + + // Build base dialer for outgoing side + dialBase, err := buildDialer(toAddr, toBase) + if err != nil { + slog.ErrorContext(ctx, "error building dialer", "error", err, "addr", toAddr) + os.Exit(2) + } + + // Create TunMaster and route everything + tm := netx.TunMaster[struct{}]{} + + tm.SetRoute(struct{}{}, func(ctx context.Context, conn net.Conn) (bool, context.Context, netx.Tun) { + // Apply incoming wrappers in order (skip the base step) + inSteps := fromSteps[1:] + wc, err := applyWrappers(conn, inSteps, true) + if err != nil { + slog.Error("wrap incoming", "err", err) + _ = conn.Close() + return false, ctx, netx.Tun{} + } + + // Dial and wrap peer side + pcRaw, err := dialBase(ctx) + if err != nil { + slog.Error("dial peer", "err", err) + _ = wc.Close() + return false, ctx, netx.Tun{} + } + outSteps := toSteps[1:] + pc, err := applyWrappers(pcRaw, outSteps, false) + if err != nil { + slog.Error("wrap outgoing", "err", err) + _ = wc.Close() + _ = pcRaw.Close() + return false, ctx, netx.Tun{} + } + + return true, ctx, netx.Tun{Conn: wc, Peer: pc} + }) + + go func() { + if err := tm.Serve(ctx, ln); err != nil && !errors.Is(err, netx.ErrServerClosed) { + slog.Error("serve error", "err", err) + cancel() + } + }() + + slog.Info("netx tun started", "listen", ln.Addr().String(), "from", *from, "to", *to) + + <-ctx.Done() + shutdownCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + _ = tm.Shutdown(shutdownCtx) +} + +func tunUsage() string { + return `netx tun - relay between two endpoints with chainable transforms + +Usage: + netx tun --from ://listenAddr --to ://connectAddr + +Where is a '+'-separated list starting with 'tcp' or 'udp', e.g.: + tcp+tls[cert=server.crt,key=server.key] + udp+dtls[cert=server.crt,key=server.key] + tcp+tls[cert=server.crt,key=server.key]+framed[maxFrame=4096]+aesgcm[key=001122...] + +Examples: + netx tun \ + --from tcp+tls[cert=server.crt,key=server.key]://:9000 \ + --to tcp+tls[cert=client.crt]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 + + netx tun \ + --from udp+dtls[cert=server.crt,key=server.key]://:4444 \ + --to udp+aesgcm[key=...]://10.0.0.10:5555 + +Supported base transports: + - tcp: TCP listener or dialer + - udp: UDP listener or dialer + +Supported wrappers: + - tls: Transport Layer Security + options: key (required for server), cert (required for server; optional on client to enable SPKI pinning), serverName (optional on client) + - dtls: Datagram Transport Layer Security + options: key (required for server), cert (required for server; optional on client to enable SPKI pinning) + - tlspsk: TLS with pre-shared key. Cipher is TLS_DHE_PSK_WITH_AES_256_CBC_SHA. WARNING: This is not provided by the standard library, USE WITH CAUTION. + options: key (hex-encoded) + - dtlspsk: DTLS with pre-shared key. Cipher is TLS_PSK_WITH_AES_128_GCM_SHA256. + options: key (hex-encoded, required) + - aesgcm: AES-GCM encryption. A passive 12-byte handshake exchanges IVs. + options: key (hex-encoded, required), maxPacket (default 32768) + - buffered: buffered read/write for better performance when using framing. + options: bufSize (default 4096) + - framed: length-prefixed frames for transporting packet protocols or wrappers that need packet semantics over streams. + options: maxFrame (default 32768) + +Notes: + - If 'cert' is provided on the client for tls/dtls, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed + against the provided certificate. This is certificate pinning and will fail if the server presents a different key. +` +} + +// parseChainURI parses strings like "tcp+tls[...]+framed://host:port". +// Returns base (tcp|udp), full steps (including the base), and addr. +func parseChainURI(s string) (string, []chainStep, string, error) { + parts := strings.SplitN(s, "://", 2) + if len(parts) != 2 { + return "", nil, "", fmt.Errorf("invalid chain URI (missing ://): %q", s) + } + chainSpec, addr := parts[0], parts[1] + if strings.TrimSpace(addr) == "" { + return "", nil, "", fmt.Errorf("missing host:port in %q", s) + } + steps, err := parseChain(chainSpec) + if err != nil { + return "", nil, "", err + } + if len(steps) == 0 || (steps[0].name != "tcp" && steps[0].name != "udp") { + return "", nil, "", fmt.Errorf("chain must start with tcp or udp: %q", chainSpec) + } + return steps[0].name, steps, addr, nil +} + +// parseChain parses strings like "tcp+tls[cert=x,key=y]+framed[maxFrame=4096]". +func parseChain(s string) ([]chainStep, error) { + var steps []chainStep + i := 0 + for i < len(s) { + // read name until '[' or '+' or end + j := i + for j < len(s) && s[j] != '[' && s[j] != '+' { + j++ + } + if j == i { + return nil, fmt.Errorf("unexpected token at %d", i) + } + name := strings.ToLower(s[i:j]) + params := map[string]string{} + if j < len(s) && s[j] == '[' { + // find closing ']' + k := j + 1 + depth := 1 + for k < len(s) && depth > 0 { + if s[k] == '[' { + depth++ + } else if s[k] == ']' { + depth-- + if depth == 0 { + break + } + } + k++ + } + if depth != 0 { + return nil, fmt.Errorf("unclosed '[' for %s", name) + } + content := s[j+1 : k] + // parse k=v pairs separated by ',' + if strings.TrimSpace(content) != "" { + for _, kv := range splitComma(content) { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid param %q in %s", kv, name) + } + params[strings.ToLower(strings.TrimSpace(parts[0]))] = strings.TrimSpace(parts[1]) + } + } + j = k + 1 + } + steps = append(steps, chainStep{name: name, params: params}) + if j < len(s) { + if s[j] != '+' { + return nil, fmt.Errorf("expected '+' after %s", name) + } + j++ + } + i = j + } + if len(steps) == 0 { + return nil, fmt.Errorf("empty chain") + } + return steps, nil +} + +func splitComma(s string) []string { + // simple split, no escaping supported + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// buildListener returns a listener possibly pre-wrapped with tls/dtls. +// It also returns remaining steps that should be applied per-connection on the incoming side. +func buildListener(ctx context.Context, addr string, base string) (net.Listener, error) { + switch base { + case "tcp": + return (&net.ListenConfig{}).Listen(ctx, "tcp", addr) + case "udp": + uaddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return nil, err + } + return (&pudp.ListenConfig{}).Listen("udp", uaddr) + default: + return nil, fmt.Errorf("unknown base %q (want tcp|udp)", base) + } +} + +// buildDialer creates a function that dials and applies wrappers according to the chain. +func buildDialer(addr string, base string) (func(ctx context.Context) (net.Conn, error), error) { + switch base { + case "tcp": + return func(ctx context.Context) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", addr) + }, nil + case "udp": + return func(ctx context.Context) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "udp", addr) + }, nil + default: + return nil, fmt.Errorf("unknown base %q (want tcp|udp)", base) + } +} + +// applyWrappers applies the given steps in order on the provided connection. +// The 'from' parameter indicates if this is the incoming side (true) or outgoing side (false). +func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error) { + var c net.Conn = conn + for _, st := range steps { + switch st.name { + case "buffered": + opts := []netx.BufConnOption{} + if v, ok := st.params["buf"]; ok && strings.TrimSpace(v) != "" { + size, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("invalid buf size %q: %w", v, err) + } + opts = append(opts, netx.WithBufSize(size)) + } + c = netx.NewBufConn(c, opts...) + case "framed": + opts := []netx.FramedConnOption{} + if v, ok := st.params["maxFrame"]; ok && strings.TrimSpace(v) != "" { + max, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("invalid maxFrame size %q: %w", v, err) + } + opts = append(opts, netx.WithMaxFrameSize(max)) + } + c = netx.NewFramedConn(c, opts...) + case "aesgcm": + keyHex := st.params["key"] + if keyHex == "" { + return nil, fmt.Errorf("aesgcm requires key") + } + key, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid aesgcm key: %w", err) + } + opts := []netx.AESGCMOption{} + if v, ok := st.params["maxpacket"]; ok && strings.TrimSpace(v) != "" { + maxPkt, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("invalid maxpacket size %q: %w", v, err) + } + opts = append(opts, netx.WithMaxPacket(maxPkt)) + } + c, err = netx.NewAESGCMConn(c, key, opts...) + if err != nil { + return nil, err + } + case "tls": + cfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + } + if from { + // Server side requires cert+key + certs, err := loadServerCertificates(st.params) + if err != nil { + return nil, fmt.Errorf("tls server config: %w", err) + } + cfg.Certificates = certs + c = tls.Server(c, cfg) + } else { + // Client: if cert is provided, enable SPKI pinning with InsecureSkipVerify + if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { + verify, err := makeSPKIPinVerifierFromCertPath(cp) + if err != nil { + return nil, fmt.Errorf("tls client pin setup: %w", err) + } + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate = verify + + } + c = tls.Client(c, cfg) + } + case "dtls": + var err error + cfg := &dtls.Config{} + if from { + certs, cerr := loadServerCertificates(st.params) + if cerr != nil { + return nil, fmt.Errorf("dtls server config: %w", cerr) + } + cfg.Certificates = certs + c, err = dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } else { + if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { + verify, verr := makeSPKIPinVerifierFromCertPath(cp) + if verr != nil { + return nil, fmt.Errorf("dtls client pin setup: %w", verr) + } + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate = verify + } + c, err = dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } + if err != nil { + return nil, err + } + case "tlspsk": + keyHex := strings.TrimSpace(st.params["key"]) + if keyHex == "" { + return nil, fmt.Errorf("tlspsk requires key") + } + psk, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid tlspsk key: %w", err) + } + identity := strings.TrimSpace(st.params["identity"]) + if identity == "" { + return nil, fmt.Errorf("dtlspsk requires identity") + } + cfg := &tlswithpks.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + Extra: tlspks.PSKConfig{ + GetKey: func(identity string) ([]byte, error) { return psk, nil }, + GetIdentity: func() string { return identity }, + }, + CipherSuites: []uint16{tlspks.TLS_PSK_WITH_AES_256_CBC_SHA}, + InsecureSkipVerify: true, + } + if from { + // Provide dummy Certificates to make tlspsk happy on server side + cfg.Certificates = dummyCert() + c = tlswithpks.Server(c, cfg) + } else { + c = tlswithpks.Client(c, cfg) + } + case "dtlspsk": + keyHex := strings.TrimSpace(st.params["key"]) + if keyHex == "" { + return nil, fmt.Errorf("dtlspsk requires key") + } + psk, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid dtlspsk key: %w", err) + } + identity := strings.TrimSpace(st.params["identity"]) + if identity == "" { + return nil, fmt.Errorf("dtlspsk requires identity") + } + cfg := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { return psk, nil }, + PSKIdentityHint: []byte(identity), + CipherSuites: []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_GCM_SHA256}, + InsecureSkipVerify: true, + } + if from { + c, err = dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + if err != nil { + return nil, err + } + } else { + c, err = dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + if err != nil { + return nil, err + } + } + default: + return nil, fmt.Errorf("unknown wrapper %q on incoming side", st.name) + } + } + return c, nil +} + +// loadServerCertificates loads the key pair specified by params["cert"], params["key"]. +// Returns an error if missing or invalid. +func loadServerCertificates(params map[string]string) ([]tls.Certificate, error) { + certPath := strings.TrimSpace(params["cert"]) + keyPath := strings.TrimSpace(params["key"]) + if certPath == "" || keyPath == "" { + return nil, fmt.Errorf("both cert and key are required") + } + pair, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("load key pair: %w", err) + } + return []tls.Certificate{pair}, nil +} + +// makeSPKIPinVerifierFromCertPath creates a VerifyPeerCertificate callback that pins the +// peer's SPKI hash (SHA-256 over RawSubjectPublicKeyInfo) to the certificate at certPath. +func makeSPKIPinVerifierFromCertPath(certPath string) (func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error, error) { + spkiHash, err := spkiHashFromCertFile(certPath) + if err != nil { + return nil, err + } + return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + for _, rawCert := range rawCerts { + c, err := x509.ParseCertificate(rawCert) + if err != nil { + return fmt.Errorf("parse peer cert: %w", err) + } + if bytes.Equal(sha256.New().Sum(c.RawSubjectPublicKeyInfo), spkiHash) { + return nil + } + } + return fmt.Errorf("no matching SPKI found") + }, nil +} + +// spkiHashFromCertFile reads a PEM certificate file and returns SHA-256(SPKI) bytes. +func spkiHashFromCertFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read cert: %w", err) + } + block, _ := pem.Decode(data) + if block == nil || (block.Type != "CERTIFICATE" && !strings.HasSuffix(block.Type, "CERTIFICATE")) { + return nil, fmt.Errorf("no PEM certificate found in %s", path) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse cert: %w", err) + } + sum := sha256.New().Sum(cert.RawSubjectPublicKeyInfo) + return sum, nil +} + +// dummyCert returns a self-signed certificate for use in tls-psk server mode. (ed25519) +func dummyCert() []tlswithpks.Certificate { + // Generated with: + // openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem -days 100000 -nodes -subj "/CN=dummy" + certPEM := `-----BEGIN CERTIFICATE----- +MIIBNjCB6aADAgECAhRX020iAjrT4wTjwRdAJ+PPjpe33DAFBgMrZXAwEDEOMAwG +A1UEAwwFZHVtbXkwIBcNMjUwOTIxMTUxNzMwWhgPMjI5OTA3MDcxNTE3MzBaMBAx +DjAMBgNVBAMMBWR1bW15MCowBQYDK2VwAyEA/8RGhnpLT8uPAm8Ah0vEYWCskGrk +R3lqdOjspIidVmKjUzBRMB0GA1UdDgQWBBRMUX8P7I1KV1UxMjcJlIT42a72ozAf +BgNVHSMEGDAWgBRMUX8P7I1KV1UxMjcJlIT42a72ozAPBgNVHRMBAf8EBTADAQH/ +MAUGAytlcANBAEFf17f1XhfLek4D203mGz8BihBfXfeL6kADMMV+G2qpkqZPcnTI +NXPuT9B/6+hM7nD/vh7JKXTfSAEFo22rzwA= +-----END CERTIFICATE----- +` + keyPEM := `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIEsb9X3HHGBFSe5jKvqNmua6ZFplNaiBROtJ7ZZAJlRz +-----END PRIVATE KEY----- +` + cert, err := tlswithpks.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err != nil { + panic("dummyCert: " + err.Error()) + } + return []tlswithpks.Certificate{cert} +} diff --git a/framed_conn.go b/framed_conn.go index d60d3ad..f208b19 100644 --- a/framed_conn.go +++ b/framed_conn.go @@ -18,9 +18,9 @@ type framedConn struct { rmu, wmu sync.Mutex } -type framedConnOption func(*framedConn) +type FramedConnOption func(*framedConn) -func WithMaxFrameSize(size int) framedConnOption { +func WithMaxFrameSize(size int) FramedConnOption { return func(c *framedConn) { c.maxFrameSize = size } @@ -29,11 +29,11 @@ func WithMaxFrameSize(size int) framedConnOption { // NewFramedConn wraps a net.Conn with a simple length-prefixed framing protocol. // Each frame is prefixed with a 4-byte big-endian unsigned integer indicating the length of the frame. // If the frame size exceeds maxFrameSize, Read will return ErrFrameTooLarge. -// The default maxFrameSize is 16KB. -func NewFramedConn(c net.Conn, opts ...framedConnOption) net.Conn { +// The default maxFrameSize is 32KB. +func NewFramedConn(c net.Conn, opts ...FramedConnOption) net.Conn { fc := &framedConn{ bc: c, - maxFrameSize: 1 << 14, // 16KB default max frame size + maxFrameSize: 32 * 1024, // 32KB default max frame size } for _, opt := range opts { opt(fc) diff --git a/go.mod b/go.mod index 3482f60..9a4f994 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,19 @@ module github.com/pedramktb/go-netx go 1.25.1 + +require ( + github.com/pion/dtls/v3 v3.0.7 + github.com/pion/udp/v2 v2.0.1 + github.com/raff/tls-ext v1.0.0 + github.com/raff/tls-psk v1.0.0 +) + +require ( + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v2 v2.2.4 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum index e69de29..3824c4d 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,83 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= +github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0= +github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo= +github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54= +github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/raff/tls-ext v1.0.0 h1:72EP1QYiXxTpTt3zWLi6YefLDnXFHTvnxog/H6COwj4= +github.com/raff/tls-ext v1.0.0/go.mod h1:HEICLTE9Cp+MmIiJ9iZnNj4VYxkUKjdpEml9ersDBbs= +github.com/raff/tls-psk v1.0.0 h1:cLGFfZCxtkBpsie1TzACuYHJHEj0VYRN1dCv+lPRPxo= +github.com/raff/tls-psk v1.0.0/go.mod h1:SUNKszL9dnQq9lkqg7P34Qrg9FuCiHcTKRVqdIyHbF0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tools/e2e/tcp_client/main.go b/internal/tools/e2e/tcp_client/main.go new file mode 100644 index 0000000..9ee9179 --- /dev/null +++ b/internal/tools/e2e/tcp_client/main.go @@ -0,0 +1,38 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "bufio" + "fmt" + "net" + "os" + "time" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("usage: tcp_client host:port message") + os.Exit(2) + } + addr, msg := os.Args[1], os.Args[2] + c, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + fmt.Println("dial error:", err) + os.Exit(1) + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := c.Write([]byte(msg)); err != nil { + fmt.Println("write error:", err) + os.Exit(1) + } + r := bufio.NewReader(c) + buf := make([]byte, len(msg)) + if _, err := r.Read(buf); err != nil { + fmt.Println("read error:", err) + os.Exit(1) + } + fmt.Print(string(buf)) +} diff --git a/internal/tools/e2e/tcp_echo/main.go b/internal/tools/e2e/tcp_echo/main.go new file mode 100644 index 0000000..824c5f2 --- /dev/null +++ b/internal/tools/e2e/tcp_echo/main.go @@ -0,0 +1,47 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "bufio" + "io" + "log" + "net" + "os" +) + +func main() { + addr := "127.0.0.1:28080" + if len(os.Args) > 1 { + addr = os.Args[1] + } + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal(err) + } + log.Printf("tcp echo listening on %s", ln.Addr()) + for { + c, err := ln.Accept() + if err != nil { + log.Fatal(err) + } + go func(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + _, _ = conn.Write(buf[:n]) + } + if err != nil { + if err != io.EOF { + log.Println("read err:", err) + } + return + } + } + }(c) + } +} diff --git a/internal/tools/e2e/udp_client/main.go b/internal/tools/e2e/udp_client/main.go new file mode 100644 index 0000000..126a56f --- /dev/null +++ b/internal/tools/e2e/udp_client/main.go @@ -0,0 +1,42 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "fmt" + "net" + "os" + "time" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("usage: udp_client host:port message") + os.Exit(2) + } + addr, msg := os.Args[1], os.Args[2] + raddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + fmt.Println("resolve error:", err) + os.Exit(1) + } + c, err := net.DialUDP("udp", nil, raddr) + if err != nil { + fmt.Println("dial error:", err) + os.Exit(1) + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := c.Write([]byte(msg)); err != nil { + fmt.Println("write error:", err) + os.Exit(1) + } + buf := make([]byte, 65535) + n, _, err := c.ReadFrom(buf) + if err != nil { + fmt.Println("read error:", err) + os.Exit(1) + } + fmt.Print(string(buf[:n])) +} diff --git a/internal/tools/e2e/udp_echo/main.go b/internal/tools/e2e/udp_echo/main.go new file mode 100644 index 0000000..0a5a61e --- /dev/null +++ b/internal/tools/e2e/udp_echo/main.go @@ -0,0 +1,36 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "log" + "net" + "os" + "time" +) + +func main() { + addr := "127.0.0.1:28081" + if len(os.Args) > 1 { + addr = os.Args[1] + } + a, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + log.Fatal(err) + } + conn, err := net.ListenUDP("udp", a) + if err != nil { + log.Fatal(err) + } + log.Printf("udp echo listening on %s", conn.LocalAddr()) + buf := make([]byte, 65535) + for { + n, ra, err := conn.ReadFromUDP(buf) + if err != nil { + log.Fatal(err) + } + conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _, _ = conn.WriteToUDP(buf[:n], ra) + } +} From 0ac5bb15ad4c3a01c29fc9494113001dc210fad6 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:07:26 +0200 Subject: [PATCH 2/7] feat: add SSH and uTLS tun support in cli feat: Implemented SSH connection management in ssh_conn.go, allowing for direct channel handling over SSH. --- ...t_and_test.yml => lint_test_and_build.yml} | 0 README.md | 3 +- Taskfile.yml | 139 ++++++------- cmd/netx/tun/run.go | 189 +++++++++++++++++- go.mod | 5 +- go.sum | 8 + ssh_conn.go | 72 +++++++ ...udp_tcp_test.go => tun_udp_tcp_int_test.go | 4 +- 8 files changed, 324 insertions(+), 96 deletions(-) rename .github/workflows/{lint_and_test.yml => lint_test_and_build.yml} (100%) create mode 100644 ssh_conn.go rename tun_e2e_udp_tcp_test.go => tun_udp_tcp_int_test.go (99%) diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_test_and_build.yml similarity index 100% rename from .github/workflows/lint_and_test.yml rename to .github/workflows/lint_test_and_build.yml diff --git a/README.md b/README.md index 5906ff6..1e12435 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,8 @@ Chain syntax: - Base: `tcp` or `udp` - Wrappers: - - `tls[cert=...,key=...]` (server) or `tls[serverName=...,ca=...,insecure=true]` (client) + - `tls[cert=...,key=...]` (server) or `tls[serverName=...,ca=...,insecure=true]` (client) + - `utls[cert=...,key=...]` (server behaves like `tls`; client: `utls[serverName=...,hello=chrome|firefox|ios|android|safari|edge|randomized|randomizedNoALPN,cert=...]`) — client side uses uTLS to reduce fingerprinting; if `cert` provided, SPKI pinning is used - `dtls[cert=...,key=...]` (server) or `dtls[serverName=...,ca=...,insecure=true]` (client) with UDP - `tlspsk[key=...]` (With a deprecated library and TLS1.2, use at your own risk!) - `dtlspsk[key=...]` diff --git a/Taskfile.yml b/Taskfile.yml index 5143f56..327e39d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -89,110 +89,85 @@ tasks: - build/libnetx_ios_x64.a - build/libnetx_ios_arm64.a - e2e:clean: - desc: Clean up e2e artifacts and kill any .e2e pid-based processes - cmds: - - | - set -euo pipefail - if [ -d .e2e ]; then - cd .e2e || exit 0 - for f in *.pid; do - [ -f "$f" ] || continue - kill $(cat "$f") 2>/dev/null || true - rm -f "$f" - done - fi - cd - >/dev/null 2>&1 || true - rm -rf .e2e - e2e:tun: - desc: Run CLI end-to-end tun tests locally (uses .e2e working dir). Set E2E_INCLUDE_TLSPSK=1 to include tlspsk. + desc: Run CLI end-to-end tun tests locally (uses .e2e working dir). cmds: - | set -euo pipefail ROOT=$(pwd) WORK=.e2e mkdir -p "$WORK" - # build local CLI binary for current platform + + echo "Building netx binary..." go build -o "$WORK/netx" ./cmd/netx chmod +x "$WORK/netx" cd "$WORK" - # generate certs/keys + + echo "Generating certs and keys..." openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt -days 1 -nodes -subj "/CN=localhost" >/dev/null 2>&1 openssl rand -hex 32 > psk.hex cp psk.hex aes.hex - # build tiny echo servers and clients from tracked sources (internal, e2e-tagged) + echo y | ssh-keygen -t ed25519 -f ssh_server_key -N "" -C "e2e-server" >/dev/null 2>&1 + echo y | ssh-keygen -t ed25519 -f ssh_client_key -N "" -C "e2e-client" >/dev/null 2>&1 + + echo "Building echo servers and clients..." go build -tags e2e -o tcp_echo "$ROOT/internal/tools/e2e/tcp_echo" go build -tags e2e -o udp_echo "$ROOT/internal/tools/e2e/udp_echo" go build -tags e2e -o tcp_client "$ROOT/internal/tools/e2e/tcp_client" go build -tags e2e -o udp_client "$ROOT/internal/tools/e2e/udp_client" - # cleanup function - cleanup(){ - for p in tcp_echo udp_echo \ - tls_server dtls_server tlspsk_server dtlspsk_server aesgcm_tcp_server aesgcm_udp_server framed_tcp_server \ - tls_client tlspsk_client dtls_client dtlspsk_client aesgcm_tcp_client aesgcm_udp_client framed_tcp_client; do - [ -f ${p}.pid ] && kill $(cat ${p}.pid) 2>/dev/null || true - rm -f ${p}.pid || true - done - } - # kill any stale from previous runs, even if EXIT trap didn't fire - cleanup || true - trap cleanup EXIT + # Use isolated ports to avoid conflicts with external demos TE=48080; UE=48081 - STLS=49000; SDTLS=49100; SDTLSP=49300; SAESCT=49400; SAESCU=49500; SFR=49600; STLSPSK=49200 - CTLS=50000; CDTLS=50010; CDTLSP=50011; CAESCT=50002; CAESCU=50012; CFR=50003; CTLSPSK=50001 - # preflight: free ports if occupied by other processes - free_port(){ - port=$1; proto=$2 - if command -v lsof >/dev/null 2>&1; then - if [ "$proto" = tcp ]; then - pids=$(lsof -t -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) - else - pids=$(lsof -t -iUDP:"$port" 2>/dev/null || true) - fi - if [ -n "$pids" ]; then kill $pids 2>/dev/null || true; fi - elif command -v fuser >/dev/null 2>&1; then - if [ "$proto" = tcp ]; then fuser -k -n tcp "$port" 2>/dev/null || true; else fuser -k -n udp "$port" 2>/dev/null || true; fi - fi - } - for p in $TE $STLS $SAESCT $SFR $CTLS $CAESCT; do free_port "$p" tcp; done - for p in $UE $SDTLS $SDTLSP $SAESCU $CDTLS $CDTLSP $CAESCU; do free_port "$p" udp; done - - # start raw peers - (nohup ./tcp_echo 127.0.0.1:${TE} > tcp_echo.log 2>&1 & echo $! > tcp_echo.pid) - (nohup ./udp_echo 127.0.0.1:${UE} > udp_echo.log 2>&1 & echo $! > udp_echo.pid) - # start server tunnels (accept secure on left, forward to raw peers on right) - (nohup ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${STLS} --to tcp://127.0.0.1:${TE} --log info > tls_server.log 2>&1 & echo $! > tls_server.pid) - (nohup ./netx tun --from udp+dtls[cert=server.crt,key=server.key]://127.0.0.1:${SDTLS} --to udp://127.0.0.1:${UE} --log info > dtls_server.log 2>&1 & echo $! > dtls_server.pid) - (nohup ./netx tun --from udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --to udp://127.0.0.1:${UE} --log info > dtlspsk_server.log 2>&1 & echo $! > dtlspsk_server.pid) - (nohup ./netx tun --from tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --to tcp://127.0.0.1:${TE} --log info > aesgcm_tcp_server.log 2>&1 & echo $! > aesgcm_tcp_server.pid) - (nohup ./netx tun --from udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --to udp://127.0.0.1:${UE} --log info > aesgcm_udp_server.log 2>&1 & echo $! > aesgcm_udp_server.pid) - (nohup ./netx tun --from tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --to udp://127.0.0.1:${UE} --log info > framed_tcp_server.log 2>&1 & echo $! > framed_tcp_server.pid) - (nohup ./netx tun --from tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --to tcp://127.0.0.1:${TE} --log info > tlspsk_server.log 2>&1 & echo $! > tlspsk_server.pid) - - # start client tunnels (accept local on left, connect to server with secure chain on right) - (nohup ./netx tun --from tcp://127.0.0.1:${CTLS} --to tcp+tls[cert=server.crt]://127.0.0.1:${STLS} --log info > tls_client.log 2>&1 & echo $! > tls_client.pid) - (nohup ./netx tun --from udp://127.0.0.1:${CDTLS} --to udp+dtls[cert=server.crt]://127.0.0.1:${SDTLS} --log info > dtls_client.log 2>&1 & echo $! > dtls_client.pid) - (nohup ./netx tun --from udp://127.0.0.1:${CDTLSP} --to udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --log info > dtlspsk_client.log 2>&1 & echo $! > dtlspsk_client.pid) - (nohup ./netx tun --from tcp://127.0.0.1:${CAESCT} --to tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --log info > aesgcm_tcp_client.log 2>&1 & echo $! > aesgcm_tcp_client.pid) - (nohup ./netx tun --from udp://127.0.0.1:${CAESCU} --to udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --log info > aesgcm_udp_client.log 2>&1 & echo $! > aesgcm_udp_client.pid) - (nohup ./netx tun --from udp://127.0.0.1:${CFR} --to tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --log info > framed_tcp_client.log 2>&1 & echo $! > framed_tcp_client.pid) - (nohup ./netx tun --from tcp://127.0.0.1:${CTLSPSK} --to tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --log info > tlspsk_client.log 2>&1 & echo $! > tlspsk_client.pid) - - # allow listeners to start + STLS=49000; SDTLS=49100; SDTLSP=49300; SAESCT=49400; SAESCU=49500; SFR=49600; STLSPSK=49200; SSSH=49700; SUTLS=49800 + CTLS=50000; CDTLS=50010; CDTLSP=50011; CAESCT=50002; CAESCU=50012; CFR=50003; CTLSPSK=50001; CSSH=50004; CUTLS=50005 + + echo "Starting echo servers..." + ./tcp_echo 127.0.0.1:${TE} > tcp_echo.log 2>&1 & + ./udp_echo 127.0.0.1:${UE} > udp_echo.log 2>&1 & + + echo "Starting server tunnels..." + ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${STLS} --to tcp://127.0.0.1:${TE} --log info > tls_server.log 2>&1 & + ./netx tun --from udp+dtls[cert=server.crt,key=server.key]://127.0.0.1:${SDTLS} --to udp://127.0.0.1:${UE} --log info > dtls_server.log 2>&1 & + ./netx tun --from udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --to udp://127.0.0.1:${UE} --log info > dtlspsk_server.log 2>&1 & + ./netx tun --from tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --to tcp://127.0.0.1:${TE} --log info > aesgcm_tcp_server.log 2>&1 & + ./netx tun --from udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --to udp://127.0.0.1:${UE} --log info > aesgcm_udp_server.log 2>&1 & + ./netx tun --from tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --to udp://127.0.0.1:${UE} --log info > framed_tcp_server.log 2>&1 & + ./netx tun --from tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --to tcp://127.0.0.1:${TE} --log info > tlspsk_server.log 2>&1 & + ./netx tun --from tcp+ssh[hostKey=ssh_server_key,authKey=ssh_client_key.pub]://127.0.0.1:${SSSH} --to tcp://127.0.0.1:${TE} --log info > ssh_server.log 2>&1 & + ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${SUTLS} --to tcp://127.0.0.1:${TE} --log info > utls_server.log 2>&1 & + + echo "Starting client tunnels..." + ./netx tun --from tcp://127.0.0.1:${CTLS} --to tcp+tls[cert=server.crt]://127.0.0.1:${STLS} --log info > tls_client.log 2>&1 & + ./netx tun --from udp://127.0.0.1:${CDTLS} --to udp+dtls[cert=server.crt]://127.0.0.1:${SDTLS} --log info > dtls_client.log 2>&1 & + ./netx tun --from udp://127.0.0.1:${CDTLSP} --to udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --log info > dtlspsk_client.log 2>&1 & + ./netx tun --from tcp://127.0.0.1:${CAESCT} --to tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --log info > aesgcm_tcp_client.log 2>&1 & + ./netx tun --from udp://127.0.0.1:${CAESCU} --to udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --log info > aesgcm_udp_client.log 2>&1 & + ./netx tun --from udp://127.0.0.1:${CFR} --to tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --log info > framed_tcp_client.log 2>&1 & + ./netx tun --from tcp://127.0.0.1:${CTLSPSK} --to tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --log info > tlspsk_client.log 2>&1 & + ./netx tun --from tcp://127.0.0.1:${CSSH} --to tcp+ssh[hostKey=ssh_server_key.pub,user=testuser,key=ssh_client_key]://127.0.0.1:${SSSH} --log info > ssh_client.log 2>&1 & + ./netx tun --from tcp://127.0.0.1:${CUTLS} --to tcp+utls[cert=server.crt,hello=chrome]://127.0.0.1:${SUTLS} --log info > utls_client.log 2>&1 & + sleep 2 + + echo "Running tests..." pass=0; fail=0 run_tcp(){ name=$1; addr=$2; msg=$3; out=$(./tcp_client "$addr" "$msg" || true); if [ "$out" = "$msg" ]; then echo "PASS $name"; pass=$((pass+1)); else echo "FAIL $name -> got: $out"; fail=$((fail+1)); fi } run_udp(){ name=$1; addr=$2; msg=$3; out=$(./udp_client "$addr" "$msg" || true); if [ "$out" = "$msg" ]; then echo "PASS $name"; pass=$((pass+1)); else echo "FAIL $name -> got: $out"; fail=$((fail+1)); fi } - run_tcp TLS 127.0.0.1:${CTLS} hello_tls - run_udp DTLS 127.0.0.1:${CDTLS} hello_dtls - run_udp DTLSPSK 127.0.0.1:${CDTLSP} hello_dtlspsk - run_tcp AESGCM_TCP 127.0.0.1:${CAESCT} hello_aesgcm_tcp - run_udp AESGCM_UDP 127.0.0.1:${CAESCU} hello_aesgcm_udp - run_udp FRAMED_TCP_BR 127.0.0.1:${CFR} hello_udp_over_tcp - if [ "${E2E_INCLUDE_TLSPSK:-}" != "" ]; then - run_tcp TLSPSK 127.0.0.1:${CTLSPSK} hello_tlspsk || true - fi + run_tcp TLS 127.0.0.1:${CTLS} hello_tls + run_udp DTLS 127.0.0.1:${CDTLS} hello_dtls + run_udp DTLSPSK 127.0.0.1:${CDTLSP} hello_dtlspsk + run_tcp AESGCM_TCP 127.0.0.1:${CAESCT} hello_aesgcm_tcp + run_udp AESGCM_UDP 127.0.0.1:${CAESCU} hello_aesgcm_udp + run_udp FRAMED_TCP_BR 127.0.0.1:${CFR} hello_udp_over_tcp + run_tcp SSH 127.0.0.1:${CSSH} hello_ssh + run_tcp UTLS 127.0.0.1:${CUTLS} hello_utls + run_tcp TLSPSK 127.0.0.1:${CTLSPSK} hello_tlspsk echo "RESULTS: pass=$pass fail=$fail" + + echo "Cleaning up..." + pkill netx || true + pkill tcp_echo || true + pkill udp_echo || true + + echo "Done." [ "$fail" -eq 0 ] diff --git a/cmd/netx/tun/run.go b/cmd/netx/tun/run.go index ea0c2b2..ed66b00 100644 --- a/cmd/netx/tun/run.go +++ b/cmd/netx/tun/run.go @@ -24,6 +24,8 @@ import ( pudp "github.com/pion/udp/v2" tlswithpks "github.com/raff/tls-ext" tlspks "github.com/raff/tls-psk" + utls "github.com/refraction-networking/utls" + "golang.org/x/crypto/ssh" ) // chainStep represents a single segment in a connection chain (e.g. tls[key=...]). @@ -167,19 +169,26 @@ Supported base transports: Supported wrappers: - tls: Transport Layer Security - options: key (required for server), cert (required for server; optional on client to enable SPKI pinning), serverName (optional on client) + server params: key, cert + client params: cert (optional, for SPKI pinning), serverName (required if cert not provided) + - utls: TLS with client fingerprint camouflage via uTLS (github.com/refraction-networking/utls) + client params: cert (optional, for SPKI pinning), serverName (required if cert not provided), hello (optional, e.g. chrome, firefox, ios, android, safari, edge, randomized) - dtls: Datagram Transport Layer Security - options: key (required for server), cert (required for server; optional on client to enable SPKI pinning) + server params: key, cert + client params: cert (optional, for SPKI pinning), serverName (required if cert not provided) - tlspsk: TLS with pre-shared key. Cipher is TLS_DHE_PSK_WITH_AES_256_CBC_SHA. WARNING: This is not provided by the standard library, USE WITH CAUTION. - options: key (hex-encoded) + params: key (hex-encoded) - dtlspsk: DTLS with pre-shared key. Cipher is TLS_PSK_WITH_AES_128_GCM_SHA256. - options: key (hex-encoded, required) + params: key (hex-encoded) - aesgcm: AES-GCM encryption. A passive 12-byte handshake exchanges IVs. - options: key (hex-encoded, required), maxPacket (default 32768) + params: key (hex-encoded), maxPacket (optional, defaults to 32768) - buffered: buffered read/write for better performance when using framing. - options: bufSize (default 4096) + params: bufSize (optional, defaults to 4096) - framed: length-prefixed frames for transporting packet protocols or wrappers that need packet semantics over streams. - options: maxFrame (default 32768) + params: maxFrame (optional, defaults to 32768) + - ssh: SSH tunneling via "direct-tcpip" channels. + server params: hostKey, user (optional, required with pass), pass (optional), authKey (optional, required if no pass) + client options: hostKey, user, pass (optional), key (optional, required if no pass) Notes: - If 'cert' is provided on the client for tls/dtls, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed @@ -333,7 +342,7 @@ func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error c = netx.NewBufConn(c, opts...) case "framed": opts := []netx.FramedConnOption{} - if v, ok := st.params["maxFrame"]; ok && strings.TrimSpace(v) != "" { + if v, ok := st.params["maxframe"]; ok && strings.TrimSpace(v) != "" { max, err := strconv.Atoi(v) if err != nil { return nil, fmt.Errorf("invalid maxFrame size %q: %w", v, err) @@ -384,10 +393,66 @@ func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error } cfg.InsecureSkipVerify = true cfg.VerifyPeerCertificate = verify - + } else { + // Otherwise, require serverName + if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { + cfg.ServerName = strings.TrimSpace(sn) + } else { + return nil, fmt.Errorf("tls client requires serverName or cert") + } } c = tls.Client(c, cfg) } + case "utls": + if from { + return nil, fmt.Errorf("utls is client-side only") + } + cfg := &utls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + } + if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { + verify, err := makeSPKIPinVerifierFromCertPath(cp) + if err != nil { + return nil, fmt.Errorf("utls client pin setup: %w", err) + } + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate = verify + } else { + if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { + cfg.ServerName = strings.TrimSpace(sn) + } else { + return nil, fmt.Errorf("utls client requires serverName or cert") + } + } + // Map hello profile. + hello := strings.ToLower(strings.TrimSpace(st.params["hello"])) + var id utls.ClientHelloID + switch hello { + case "", "chrome": + id = utls.HelloChrome_Auto + case "firefox": + id = utls.HelloFirefox_Auto + case "ios": + id = utls.HelloIOS_Auto + case "android": + id = utls.HelloAndroid_11_OkHttp + case "safari": + id = utls.HelloSafari_Auto + case "edge": + id = utls.HelloEdge_Auto + case "randomized": + id = utls.HelloRandomizedALPN + case "randomizednoalpn": + id = utls.HelloRandomized + default: + return nil, fmt.Errorf("unknown utls hello profile %q", hello) + } + uconn := utls.UClient(c, cfg, id) + if err := uconn.Handshake(); err != nil { + return nil, fmt.Errorf("utls handshake: %w", err) + } + c = uconn case "dtls": var err error cfg := &dtls.Config{} @@ -406,6 +471,13 @@ func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error } cfg.InsecureSkipVerify = true cfg.VerifyPeerCertificate = verify + } else { + // Otherwise, require serverName + if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { + cfg.ServerName = strings.TrimSpace(sn) + } else { + return nil, fmt.Errorf("dtls client requires serverName or cert") + } } c, err = dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) } @@ -423,7 +495,7 @@ func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error } identity := strings.TrimSpace(st.params["identity"]) if identity == "" { - return nil, fmt.Errorf("dtlspsk requires identity") + return nil, fmt.Errorf("tlspsk requires identity") } cfg := &tlswithpks.Config{ MinVersion: tls.VersionTLS12, @@ -472,6 +544,103 @@ func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error return nil, err } } + case "ssh": + if from { + cfg := &ssh.ServerConfig{} + if hp := strings.TrimSpace(st.params["hostkey"]); hp != "" { + keyData, err := os.ReadFile(hp) + if err != nil { + return nil, fmt.Errorf("read hostkey: %w", err) + } + private, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse hostkey: %w", err) + } + cfg.AddHostKey(private) + } + if p := strings.TrimSpace(st.params["pass"]); p != "" { + cfg.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if c.User() == strings.TrimSpace(st.params["user"]) && string(pass) == p { + return nil, nil + } + return nil, fmt.Errorf("invalid user or password") + } + } + if ak := strings.TrimSpace(st.params["authkey"]); ak != "" { + // Load authorized key file + data, err := os.ReadFile(ak) + if err != nil { + return nil, fmt.Errorf("read authkey: %w", err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(data) + if err != nil { + return nil, fmt.Errorf("parse authkey: %w", err) + } + cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if bytes.Equal(key.Marshal(), pubKey.Marshal()) { + return nil, nil + } + return nil, fmt.Errorf("unauthorized public key") + } + } + if cfg.PasswordCallback == nil && cfg.PublicKeyCallback == nil { + return nil, fmt.Errorf("ssh server requires pass or authKey") + } + var err error + c, err = netx.NewSSHServerConn(c, cfg) + if err != nil { + return nil, err + } + } else { + cfg := &ssh.ClientConfig{ + User: strings.TrimSpace(st.params["user"]), + } + if p := strings.TrimSpace(st.params["pass"]); p != "" { + cfg.Auth = append(cfg.Auth, ssh.Password(p)) + } + if k := strings.TrimSpace(st.params["key"]); k != "" { + keyData, err := os.ReadFile(k) + if err != nil { + return nil, fmt.Errorf("read private key: %w", err) + } + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + cfg.Auth = append(cfg.Auth, ssh.PublicKeys(signer)) + } + if hk := strings.TrimSpace(st.params["hostkey"]); hk != "" { + keyData, err := os.ReadFile(hk) + if err != nil { + return nil, fmt.Errorf("read hostkey: %w", err) + } + hostKey, _, _, _, err := ssh.ParseAuthorizedKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse hostkey: %w", err) + } + cfg.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if bytes.Equal(key.Marshal(), hostKey.Marshal()) { + return nil + } + return fmt.Errorf("host key mismatch") + } + } + if cfg.User == "" || len(cfg.Auth) == 0 { + return nil, fmt.Errorf("ssh client requires user and (pass or key)") + } + if cfg.HostKeyCallback == nil { + if strings.ToLower(strings.TrimSpace(st.params["insecure"])) == "true" { + cfg.HostKeyCallback = ssh.InsecureIgnoreHostKey() + } else { + return nil, fmt.Errorf("ssh client requires hostKey or insecure=true") + } + } + var err error + c, err = netx.NewSSHClientConn(c, cfg) + if err != nil { + return nil, err + } + } default: return nil, fmt.Errorf("unknown wrapper %q on incoming side", st.name) } diff --git a/go.mod b/go.mod index 9a4f994..86f9b8b 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,16 @@ require ( github.com/pion/udp/v2 v2.0.1 github.com/raff/tls-ext v1.0.0 github.com/raff/tls-psk v1.0.0 + github.com/refraction-networking/utls v1.8.0 + golang.org/x/crypto v0.42.0 ) require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/transport/v2 v2.2.4 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 3824c4d..c6e185b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= @@ -19,6 +23,8 @@ github.com/raff/tls-ext v1.0.0 h1:72EP1QYiXxTpTt3zWLi6YefLDnXFHTvnxog/H6COwj4= github.com/raff/tls-ext v1.0.0/go.mod h1:HEICLTE9Cp+MmIiJ9iZnNj4VYxkUKjdpEml9ersDBbs= github.com/raff/tls-psk v1.0.0 h1:cLGFfZCxtkBpsie1TzACuYHJHEj0VYRN1dCv+lPRPxo= github.com/raff/tls-psk v1.0.0/go.mod h1:SUNKszL9dnQq9lkqg7P34Qrg9FuCiHcTKRVqdIyHbF0= +github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= +github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -66,6 +72,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/ssh_conn.go b/ssh_conn.go new file mode 100644 index 0000000..d8e2c8f --- /dev/null +++ b/ssh_conn.go @@ -0,0 +1,72 @@ +package netx + +import ( + "errors" + "net" + "time" + + ssh "golang.org/x/crypto/ssh" +) + +type sshConn struct { + ssh.Channel + sshConn ssh.Conn + bc net.Conn +} + +func (s *sshConn) LocalAddr() net.Addr { return s.sshConn.LocalAddr() } +func (s *sshConn) RemoteAddr() net.Addr { return s.sshConn.RemoteAddr() } +func (s *sshConn) SetDeadline(t time.Time) error { return s.bc.SetDeadline(t) } +func (s *sshConn) SetReadDeadline(t time.Time) error { return s.bc.SetReadDeadline(t) } +func (s *sshConn) SetWriteDeadline(t time.Time) error { return s.bc.SetWriteDeadline(t) } +func (s *sshConn) Close() error { + return errors.Join(s.Channel.Close(), s.sshConn.Close()) +} +func (s *sshConn) CloseWrite() error { + err := s.Channel.CloseWrite() + if bcCloseWrite, ok := s.bc.(interface{ CloseWrite() error }); ok { + err = errors.Join(err, bcCloseWrite.CloseWrite()) + } + return err +} + +func NewSSHServerConn(bc net.Conn, cfg *ssh.ServerConfig) (net.Conn, error) { + svConn, sshChans, sshReqs, err := ssh.NewServerConn(bc, cfg) + if err != nil { + return nil, err + } + go ssh.DiscardRequests(sshReqs) + for newCh := range sshChans { + switch newCh.ChannelType() { + case "direct-tcpip": + ch, reqs, err := newCh.Accept() + if err != nil { + _ = svConn.Close() + return nil, err + } + go ssh.DiscardRequests(reqs) + return &sshConn{Channel: ch, sshConn: svConn, bc: bc}, nil + default: + _ = newCh.Reject(ssh.UnknownChannelType, "unsupported channel type") + return nil, errors.New("no supported ssh channel opened by client") + } + + } + _ = svConn.Close() + return nil, errors.New("no ssh channel opened by client") +} + +func NewSSHClientConn(bc net.Conn, cfg *ssh.ClientConfig) (net.Conn, error) { + clConn, _, sshReqs, err := ssh.NewClientConn(bc, "", cfg) + if err != nil { + return nil, err + } + go ssh.DiscardRequests(sshReqs) + ch, reqs, err := clConn.OpenChannel("direct-tcpip", nil) + if err != nil { + _ = clConn.Close() + return nil, err + } + go ssh.DiscardRequests(reqs) + return &sshConn{Channel: ch, sshConn: clConn, bc: bc}, nil +} diff --git a/tun_e2e_udp_tcp_test.go b/tun_udp_tcp_int_test.go similarity index 99% rename from tun_e2e_udp_tcp_test.go rename to tun_udp_tcp_int_test.go index 423df52..0fe1195 100644 --- a/tun_e2e_udp_tcp_test.go +++ b/tun_udp_tcp_int_test.go @@ -77,7 +77,7 @@ func newUDPPair(t *testing.T) (*net.UDPConn, *net.UDPConn) { return a, b } -func TestE2E_UDP_over_TCP_TunMasters(t *testing.T) { +func TestInt_UDP_over_TCP_TunMasters(t *testing.T) { t.Parallel() ctx := context.Background() logger := &memLogger{} @@ -182,7 +182,7 @@ func TestE2E_UDP_over_TCP_TunMasters(t *testing.T) { // - TLS route: expects a tls.Conn and forwards to server UDPTLS peer // - Plain route: handles non-TLS and forwards to server UDPPlain peer // Two client tunnels are created: one plain over framed stream, one framed over TLS. -func TestE2E_TunMasterRouting_PlainAndTLS(t *testing.T) { +func TestInt_TunMasterRouting_PlainAndTLS(t *testing.T) { t.Parallel() ctx := context.Background() logger := &memLogger{} From 9e43840710a36785346260cf3960c891f294cb84 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:49:21 +0200 Subject: [PATCH 3/7] feat: Implement URI handling with layered transport options - Added `listener` struct to manage connections with URI layers. - Introduced `Layers` and `Layer` types to support multiple connection layers. - Implemented `Wrap` method for `Layers` to wrap connections with specified layers. - Created `Scheme` type to encapsulate transport and layers for URIs. - Defined `Transport` type with TCP and UDP options. - Developed `URI` type to represent a URI with scheme and address. - Implemented marshaling and unmarshaling for `Layers`, `Scheme`, `Transport`, and `URI`. - Added support for various connection layers including SSH, TLS, DTLS, and PSK. - Included error handling for invalid parameters and missing keys in layer configurations. --- README.md | 79 +++-- Taskfile.yml | 48 +-- aesgcm_conn.go | 25 +- aesgcm_conn_test.go | 4 +- buffered_conn.go | 32 +- cmd/netx/main.go | 71 +++-- cmd/netx/tun.go | 102 +++++++ cmd/netx/tun/run.go | 728 -------------------------------------------- cmd/netx/uri.go | 42 +++ dial.go | 77 +++++ framed_conn.go | 28 +- go.mod | 21 +- go.sum | 95 ++---- uri/dial.go | 44 +++ uri/layer.go | 564 ++++++++++++++++++++++++++++++++++ uri/scheme.go | 43 +++ uri/transport.go | 32 ++ uri/uri.go | 39 +++ 18 files changed, 1153 insertions(+), 921 deletions(-) create mode 100644 cmd/netx/tun.go delete mode 100644 cmd/netx/tun/run.go create mode 100644 cmd/netx/uri.go create mode 100644 dial.go create mode 100644 uri/dial.go create mode 100644 uri/layer.go create mode 100644 uri/scheme.go create mode 100644 uri/transport.go create mode 100644 uri/uri.go diff --git a/README.md b/README.md index 1e12435..6a6b55d 100644 --- a/README.md +++ b/README.md @@ -169,28 +169,71 @@ Install and use: go install github.com/pedramktb/go-netx/cmd/netx@latest # Show help -netx tun --help +netx tun -h -# Example: TCP TLS server to TCP TLS+framed+aesgcm client -netx tun --from tcp+tls[cert=server.crt,key=server.key] \ - --to tcp+tls[serverName=example.com,insecure=true]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff] \ - tcp://:9000 tcp://example.com:9443 +# Example: TCP TLS server to TCP TLS+buffered+framed+aesgcm client +netx tun \ + --from tcp+tls[cert=server.crt,key=server.key]://:9000 \ + --to tcp+tls[cert=client.crt]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 + +# Example: UDP DTLS server to UDP aesgcm client +netx tun \ + --from udp+dtls[cert=server.crt,key=server.key]://:4444 \ + --to udp+aesgcm[key=00112233445566778899aabbccddeeff]://10.0.0.10:5555 ``` +Options: + +- `--from ://listenAddr` - Incoming side chain URI (required) +- `--to ://connectAddr` - Peer side chain URI (required) +- `--log ` - Log level: debug|info|warn|error (default: info) +- `-h` - Show help + Chain syntax: -- Base: `tcp` or `udp` -- Wrappers: - - `tls[cert=...,key=...]` (server) or `tls[serverName=...,ca=...,insecure=true]` (client) - - `utls[cert=...,key=...]` (server behaves like `tls`; client: `utls[serverName=...,hello=chrome|firefox|ios|android|safari|edge|randomized|randomizedNoALPN,cert=...]`) — client side uses uTLS to reduce fingerprinting; if `cert` provided, SPKI pinning is used - - `dtls[cert=...,key=...]` (server) or `dtls[serverName=...,ca=...,insecure=true]` (client) with UDP - - `tlspsk[key=...]` (With a deprecated library and TLS1.2, use at your own risk!) - - `dtlspsk[key=...]` - - `aesgcm[key=,maxPacket=32768]` - - `buffered[buf=4096]` - - `framed[maxFrame=32768]` +Chains use the form `://host:port` where `` is a `+`-separated list starting with a base transport (`tcp` or `udp`), optionally followed by wrappers with parameters in brackets. -Notes: +**Supported base transports:** + +- `tcp` - TCP listener or dialer +- `udp` - UDP listener or dialer + +**Supported wrappers:** + +- `tls` - Transport Layer Security + - Server params: `cert`, `key` + - Client params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided) + +- `utls` - TLS with client fingerprint camouflage via uTLS + - Client-side only + - Params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided), `hello` (optional: chrome, firefox, ios, android, safari, edge, randomized, randomizednoalpn; default: chrome) + +- `dtls` - Datagram Transport Layer Security + - Server params: `cert`, `key` + - Client params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided) + +- `tlspsk` - TLS with pre-shared key (TLS 1.2, cipher: TLS_PSK_WITH_AES_256_CBC_SHA) + - ⚠️ WARNING: Uses deprecated library, use at your own risk! + - Params: `key` (hex-encoded), `identity` + +- `dtlspsk` - DTLS with pre-shared key (cipher: TLS_PSK_WITH_AES_128_GCM_SHA256) + - Params: `key` (hex-encoded), `identity` + +- `aesgcm` - AES-GCM encryption with passive IV exchange + - Params: `key` (hex-encoded), `maxPacket` (optional, default: 32768) + +- `buffered` - Buffered read/write for better performance + - Params: `buf` (optional, default: 4096) + +- `framed` - Length-prefixed frames for packet semantics over streams + - Params: `maxFrame` (optional, default: 32768) + +- `ssh` - SSH tunneling via "direct-tcpip" channels + - Server params: `hostKey`, `user` (optional, required with pass), `pass` (optional), `authKey` (optional, required if no pass) + - Client params: `hostKey` (or `insecure=true`), `user`, `pass` (optional), `key` (optional, required if no pass) + +**Notes:** -- Endpoints use URI form: `://host:port` -- You can chain multiple wrappers on either side; the tool uses `TunMaster` under the hood. +- If `cert` is provided on the client for `tls`/`dtls`/`utls`, default validation is disabled and SPKI (SubjectPublicKeyInfo) pinning is performed instead +- Multiple wrappers can be chained on either side +- The tool uses `TunMaster` under the hood for efficient bidirectional relay diff --git a/Taskfile.yml b/Taskfile.yml index 327e39d..f137a47 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -25,19 +25,19 @@ tasks: desc: Build binaries and libraries cmds: # Linux binaries and shared libraries - - env GOOS=linux GOARCH=amd64 go build -o build/netx_linux_x64 cmd/netx/main.go - - env GOOS=linux GOARCH=arm64 go build -o build/netx_linux_arm64 cmd/netx/main.go + - env GOOS=linux GOARCH=amd64 go build -o build/netx_linux_x64 cmd/netx/*.go + - env GOOS=linux GOARCH=arm64 go build -o build/netx_linux_arm64 cmd/netx/*.go # - env GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc go build -buildmode=c-shared -o build/libnetx_linux_x64.so cmd/netx/lib/main.go # - env GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -buildmode=c-shared -o build/libnetx_linux_arm64.so cmd/netx/lib/main.go # # Windows binaries and shared libraries - - env GOOS=windows GOARCH=amd64 go build -o build/netx_windows_x64.exe cmd/netx/main.go - - env GOOS=windows GOARCH=arm64 go build -o build/netx_windows_arm64.exe cmd/netx/main.go + - env GOOS=windows GOARCH=amd64 go build -o build/netx_windows_x64.exe cmd/netx/*.go + - env GOOS=windows GOARCH=arm64 go build -o build/netx_windows_arm64.exe cmd/netx/*.go # - env GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libnetx_windows_x64.dll cmd/netx/lib/main.go # # aarch64-w64-mingw32-gcc is experimental and not available # # - env GOOS=windows GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libenetx_windows_arm64.dll cmd/lib/main.go # # macOS binaries - - env GOOS=darwin GOARCH=amd64 go build -o build/netx_macos_x64 cmd/netx/main.go - - env GOOS=darwin GOARCH=arm64 go build -o build/netx_macos_arm64 cmd/netx/main.go + - env GOOS=darwin GOARCH=amd64 go build -o build/netx_macos_x64 cmd/netx/*.go + - env GOOS=darwin GOARCH=arm64 go build -o build/netx_macos_arm64 cmd/netx/*.go # # Android shared libraries # - env GOOS=android GOARCH=amd64 CGO_ENABLED=1 CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android26-clang go build -buildmode=c-shared -o build/libnetx_android_x64.so cmd/netx/lib/main.go # - env GOOS=android GOARCH=arm64 CGO_ENABLED=1 CC=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang go build -buildmode=c-shared -o build/libnetx_android_arm64.so cmd/netx/lib/main.go @@ -126,26 +126,26 @@ tasks: ./udp_echo 127.0.0.1:${UE} > udp_echo.log 2>&1 & echo "Starting server tunnels..." - ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${STLS} --to tcp://127.0.0.1:${TE} --log info > tls_server.log 2>&1 & - ./netx tun --from udp+dtls[cert=server.crt,key=server.key]://127.0.0.1:${SDTLS} --to udp://127.0.0.1:${UE} --log info > dtls_server.log 2>&1 & - ./netx tun --from udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --to udp://127.0.0.1:${UE} --log info > dtlspsk_server.log 2>&1 & - ./netx tun --from tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --to tcp://127.0.0.1:${TE} --log info > aesgcm_tcp_server.log 2>&1 & - ./netx tun --from udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --to udp://127.0.0.1:${UE} --log info > aesgcm_udp_server.log 2>&1 & - ./netx tun --from tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --to udp://127.0.0.1:${UE} --log info > framed_tcp_server.log 2>&1 & - ./netx tun --from tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --to tcp://127.0.0.1:${TE} --log info > tlspsk_server.log 2>&1 & - ./netx tun --from tcp+ssh[hostKey=ssh_server_key,authKey=ssh_client_key.pub]://127.0.0.1:${SSSH} --to tcp://127.0.0.1:${TE} --log info > ssh_server.log 2>&1 & - ./netx tun --from tcp+tls[cert=server.crt,key=server.key]://127.0.0.1:${SUTLS} --to tcp://127.0.0.1:${TE} --log info > utls_server.log 2>&1 & + ./netx tun --from "tcp+tls[cert=$(xxd -p server.crt | tr -d '\n'),key=$(xxd -p server.key | tr -d '\n')]://127.0.0.1:${STLS}" --to "tcp://127.0.0.1:${TE}" --log info > tls_server.log 2>&1 & + ./netx tun --from "udp+dtls[cert=$(xxd -p server.crt | tr -d '\n'),key=$(xxd -p server.key | tr -d '\n')]://127.0.0.1:${SDTLS}" --to "udp://127.0.0.1:${UE}" --log info > dtls_server.log 2>&1 & + ./netx tun --from "udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP}" --to "udp://127.0.0.1:${UE}" --log info > dtlspsk_server.log 2>&1 & + ./netx tun --from "tcp+buffered[size=8192]+framed[maxsize=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT}" --to "tcp://127.0.0.1:${TE}" --log info > aesgcm_tcp_server.log 2>&1 & + ./netx tun --from "udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU}" --to "udp://127.0.0.1:${UE}" --log info > aesgcm_udp_server.log 2>&1 & + ./netx tun --from "tcp+framed[maxsize=4096]://127.0.0.1:${SFR}" --to "udp://127.0.0.1:${UE}" --log info > framed_tcp_server.log 2>&1 & + ./netx tun --from "tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK}" --to "tcp://127.0.0.1:${TE}" --log info > tlspsk_server.log 2>&1 & + ./netx tun --from "tcp+ssh[key=$(xxd -p ssh_server_key | tr -d '\n'),pubkey=$(xxd -p ssh_client_key.pub | tr -d '\n')]://127.0.0.1:${SSSH}" --to "tcp://127.0.0.1:${TE}" --log info > ssh_server.log 2>&1 & + ./netx tun --from "tcp+tls[cert=$(xxd -p server.crt | tr -d '\n'),key=$(xxd -p server.key | tr -d '\n')]://127.0.0.1:${SUTLS}" --to "tcp://127.0.0.1:${TE}" --log info > utls_server.log 2>&1 & echo "Starting client tunnels..." - ./netx tun --from tcp://127.0.0.1:${CTLS} --to tcp+tls[cert=server.crt]://127.0.0.1:${STLS} --log info > tls_client.log 2>&1 & - ./netx tun --from udp://127.0.0.1:${CDTLS} --to udp+dtls[cert=server.crt]://127.0.0.1:${SDTLS} --log info > dtls_client.log 2>&1 & - ./netx tun --from udp://127.0.0.1:${CDTLSP} --to udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP} --log info > dtlspsk_client.log 2>&1 & - ./netx tun --from tcp://127.0.0.1:${CAESCT} --to tcp+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT} --log info > aesgcm_tcp_client.log 2>&1 & - ./netx tun --from udp://127.0.0.1:${CAESCU} --to udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU} --log info > aesgcm_udp_client.log 2>&1 & - ./netx tun --from udp://127.0.0.1:${CFR} --to tcp+framed[maxFrame=4096]://127.0.0.1:${SFR} --log info > framed_tcp_client.log 2>&1 & - ./netx tun --from tcp://127.0.0.1:${CTLSPSK} --to tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK} --log info > tlspsk_client.log 2>&1 & - ./netx tun --from tcp://127.0.0.1:${CSSH} --to tcp+ssh[hostKey=ssh_server_key.pub,user=testuser,key=ssh_client_key]://127.0.0.1:${SSSH} --log info > ssh_client.log 2>&1 & - ./netx tun --from tcp://127.0.0.1:${CUTLS} --to tcp+utls[cert=server.crt,hello=chrome]://127.0.0.1:${SUTLS} --log info > utls_client.log 2>&1 & + ./netx tun --from "tcp://127.0.0.1:${CTLS}" --to "tcp+tls[cert=$(xxd -p server.crt | tr -d '\n')]://127.0.0.1:${STLS}" --log info > tls_client.log 2>&1 & + ./netx tun --from "udp://127.0.0.1:${CDTLS}" --to "udp+dtls[cert=$(xxd -p server.crt | tr -d '\n')]://127.0.0.1:${SDTLS}" --log info > dtls_client.log 2>&1 & + ./netx tun --from "udp://127.0.0.1:${CDTLSP}" --to "udp+dtlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${SDTLSP}" --log info > dtlspsk_client.log 2>&1 & + ./netx tun --from "tcp://127.0.0.1:${CAESCT}" --to "tcp+buffered[size=8192]+framed[maxsize=4096]+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCT}" --log info > aesgcm_tcp_client.log 2>&1 & + ./netx tun --from "udp://127.0.0.1:${CAESCU}" --to "udp+aesgcm[key=$(cat aes.hex)]://127.0.0.1:${SAESCU}" --log info > aesgcm_udp_client.log 2>&1 & + ./netx tun --from "udp://127.0.0.1:${CFR}" --to "tcp+framed[maxsize=4096]://127.0.0.1:${SFR}" --log info > framed_tcp_client.log 2>&1 & + ./netx tun --from "tcp://127.0.0.1:${CTLSPSK}" --to "tcp+tlspsk[identity=i,key=$(cat psk.hex)]://127.0.0.1:${STLSPSK}" --log info > tlspsk_client.log 2>&1 & + ./netx tun --from "tcp://127.0.0.1:${CSSH}" --to "tcp+ssh[pubkey=$(xxd -p ssh_server_key.pub | tr -d '\n'),key=$(xxd -p ssh_client_key | tr -d '\n')]://127.0.0.1:${SSSH}" --log info > ssh_client.log 2>&1 & + ./netx tun --from "tcp://127.0.0.1:${CUTLS}" --to "tcp+utls[cert=$(xxd -p server.crt | tr -d '\n'),hello=chrome]://127.0.0.1:${SUTLS}" --log info > utls_client.log 2>&1 & sleep 2 diff --git a/aesgcm_conn.go b/aesgcm_conn.go index 05a5e6c..851472f 100644 --- a/aesgcm_conn.go +++ b/aesgcm_conn.go @@ -13,7 +13,7 @@ import ( ) type aesgcmConn struct { - bc net.Conn + net.Conn aead cipher.AEAD wiv [12]byte riv [12]byte @@ -21,16 +21,18 @@ type aesgcmConn struct { // sequence number for nonce derivation, incremented atomically seq atomic.Uint64 - // Maximum size of a single ciphertext packet we accept on Read. - // This should be >= 8 (seq) + plaintext + aead.Overhead(). maxPacketSize int } type AESGCMOption func(*aesgcmConn) // WithMaxPacket sets the maximum ciphertext packet size accepted on Read. -// Default is 32KB. -func WithMaxPacket(size int) AESGCMOption { return func(c *aesgcmConn) { c.maxPacketSize = size } } +// Default is 32KB. This should be >= 8 (seq) + plaintext + aead.Overhead(). +func WithAESGCMMaxPacket(size uint32) AESGCMOption { + return func(c *aesgcmConn) { + c.maxPacketSize = int(size) + } +} // NewAESGCMConn constructs a new AES-GCM wrapper around a packet-based net.Conn. // Key must be 16, 24, or 32 bytes (AES-128/192/256). @@ -56,7 +58,7 @@ func NewAESGCMConn(c net.Conn, key []byte, opts ...AESGCMOption) (net.Conn, erro return nil, err } agc := &aesgcmConn{ - bc: c, + Conn: c, aead: a, maxPacketSize: 32 * 1024} for _, opt := range opts { @@ -107,7 +109,7 @@ func NewAESGCMConn(c net.Conn, key []byte, opts ...AESGCMOption) (net.Conn, erro // If p is too small for the decrypted payload, io.ErrShortBuffer is returned. func (c *aesgcmConn) Read(p []byte) (int, error) { buf := make([]byte, c.maxPacketSize) - n, err := c.bc.Read(buf) + n, err := c.Conn.Read(buf) if err != nil { return 0, err } @@ -157,7 +159,7 @@ func (c *aesgcmConn) Write(p []byte) (int, error) { ct := c.aead.Seal(buf[8:8], nonce[:], p, buf[:8]) buf = buf[:8+len(ct)] - n, err := c.bc.Write(buf) + n, err := c.Conn.Write(buf) if err != nil { return 0, err } @@ -168,10 +170,3 @@ func (c *aesgcmConn) Write(p []byte) (int, error) { // Satisfy io.Writer contract: on success, return len(p) bytes written. return len(p), nil } - -func (c *aesgcmConn) Close() error { return c.bc.Close() } -func (c *aesgcmConn) LocalAddr() net.Addr { return c.bc.LocalAddr() } -func (c *aesgcmConn) RemoteAddr() net.Addr { return c.bc.RemoteAddr() } -func (c *aesgcmConn) SetDeadline(t time.Time) error { return c.bc.SetDeadline(t) } -func (c *aesgcmConn) SetReadDeadline(t time.Time) error { return c.bc.SetReadDeadline(t) } -func (c *aesgcmConn) SetWriteDeadline(t time.Time) error { return c.bc.SetWriteDeadline(t) } diff --git a/aesgcm_conn_test.go b/aesgcm_conn_test.go index 27ada68..2d8b610 100644 --- a/aesgcm_conn_test.go +++ b/aesgcm_conn_test.go @@ -148,8 +148,8 @@ func TestAESGCM_MaxPacketWrite(t *testing.T) { es error done = make(chan struct{}, 2) ) - go func() { c, ec = netx.NewAESGCMConn(fc, key, netx.WithMaxPacket(48)); done <- struct{}{} }() - go func() { _, es = netx.NewAESGCMConn(fs, key, netx.WithMaxPacket(48)); done <- struct{}{} }() + go func() { c, ec = netx.NewAESGCMConn(fc, key, netx.WithAESGCMMaxPacket(48)); done <- struct{}{} }() + go func() { _, es = netx.NewAESGCMConn(fs, key, netx.WithAESGCMMaxPacket(48)); done <- struct{}{} }() <-done <-done if ec != nil { diff --git a/buffered_conn.go b/buffered_conn.go index 55eeefa..75bfdde 100644 --- a/buffered_conn.go +++ b/buffered_conn.go @@ -4,7 +4,6 @@ import ( "bufio" "errors" "net" - "time" ) type BufConn interface { @@ -13,29 +12,29 @@ type BufConn interface { } type bufConn struct { - bc net.Conn + net.Conn br *bufio.Reader bw *bufio.Writer } type BufConnOption func(*bufConn) -func WithBufSize(size int) BufConnOption { +func WithBufSize(size uint32) BufConnOption { return func(bc *bufConn) { - bc.br = bufio.NewReaderSize(bc.bc, size) - bc.bw = bufio.NewWriterSize(bc.bc, size) + bc.br = bufio.NewReaderSize(bc.Conn, int(size)) + bc.bw = bufio.NewWriterSize(bc.Conn, int(size)) } } -func WithBufWriterSize(size int) BufConnOption { +func WithBufWriterSize(size uint32) BufConnOption { return func(bc *bufConn) { - bc.bw = bufio.NewWriterSize(bc.bc, size) + bc.bw = bufio.NewWriterSize(bc.Conn, int(size)) } } -func WithBufReaderSize(size int) BufConnOption { +func WithBufReaderSize(size uint32) BufConnOption { return func(bc *bufConn) { - bc.br = bufio.NewReaderSize(bc.bc, size) + bc.br = bufio.NewReaderSize(bc.Conn, int(size)) } } @@ -43,9 +42,9 @@ func WithBufReaderSize(size int) BufConnOption { // By default, the buffer size is 4KB. Use WithBufWriterSize and WithBufReaderSize to customize the sizes. func NewBufConn(c net.Conn, opts ...BufConnOption) BufConn { bc := &bufConn{ - bc: c, - br: bufio.NewReader(c), - bw: bufio.NewWriter(c), + Conn: c, + br: bufio.NewReader(c), + bw: bufio.NewWriter(c), } for _, opt := range opts { opt(bc) @@ -64,17 +63,12 @@ func (c *bufConn) Close() error { err = errors.Join(err, fErr) } } - if c.bc != nil { - if cErr := c.bc.Close(); cErr != nil { + if c.Conn != nil { + if cErr := c.Conn.Close(); cErr != nil { err = errors.Join(err, cErr) } } return err } -func (c *bufConn) LocalAddr() net.Addr { return c.bc.LocalAddr() } -func (c *bufConn) RemoteAddr() net.Addr { return c.bc.RemoteAddr() } -func (c *bufConn) SetDeadline(t time.Time) error { return c.bc.SetDeadline(t) } -func (c *bufConn) SetReadDeadline(t time.Time) error { return c.bc.SetReadDeadline(t) } -func (c *bufConn) SetWriteDeadline(t time.Time) error { return c.bc.SetWriteDeadline(t) } func (c *bufConn) Flush() error { return c.bw.Flush() } diff --git a/cmd/netx/main.go b/cmd/netx/main.go index 4c4e0ec..8e6eddb 100644 --- a/cmd/netx/main.go +++ b/cmd/netx/main.go @@ -3,39 +3,68 @@ package main import ( "context" "fmt" + "log/slog" "os" "os/signal" + "strings" "syscall" - tuncmd "github.com/pedramktb/go-netx/cmd/netx/tun" + "github.com/spf13/cobra" ) func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - switch os.Args[1] { - case "tun": - tuncmd.Run(ctx, cancel, os.Args[2:]) - case "-h", "--help", "help": - usage() - default: - fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) - usage() - os.Exit(2) + var logLevel string + + cmd := &cobra.Command{ + Use: "netx [command]", + Short: "Small networking toolbox", + Long: "netx is a small networking toolbox.", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + lvl, err := parseLogLevel(logLevel) + if err != nil { + return err + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) + return nil + }, } -} -func usage() { - fmt.Fprintf(os.Stderr, `netx - small networking toolbox + defaultHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + defaultHelp(cmd, args) + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprint(cmd.OutOrStdout(), uriFormat) + }) + + cmd.PersistentFlags().StringVar(&logLevel, "log", "info", "log level: debug|info|warn|error") -Subcommands: - tun Relay between two endpoints with chainable transforms. + cmd.AddCommand(tun(cancel)) -Run 'netx --help' for details. -`) + if err := cmd.ExecuteContext(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func parseLogLevel(level string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(level)) { + case "", "info": + return slog.LevelInfo, nil + case "debug": + return slog.LevelDebug, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("invalid log level %q", level) + } } diff --git a/cmd/netx/tun.go b/cmd/netx/tun.go new file mode 100644 index 0000000..7fe1663 --- /dev/null +++ b/cmd/netx/tun.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "time" + + netx "github.com/pedramktb/go-netx" + "github.com/pedramktb/go-netx/uri" + "github.com/spf13/cobra" +) + +const tunExample = ` netx tun \ + --from "tcp+tls[cert=$(cat server.crt | xxd -p),key=$(cat server.key | xxd -p)]://:9000" \ + --to "udp+aesgcm[key=00112233445566778899aabbccddeeff]://127.0.0.1:5555" +` + +func tun(cancel context.CancelFunc) *cobra.Command { + var from string + var to string + + if cancel == nil { + cancel = func() {} + } + + cmd := &cobra.Command{ + Use: "tun", + Short: "Relay between two endpoints with chainable transforms.", + Long: "tun relays between two endpoints with chainable transforms, this can be used for obfuscation tunnels, proxies, reverse proxies, etc.", + Example: tunExample, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + err := runTun(ctx, cancel, from, to) + if err != nil { + return errors.Join(err, cmd.Help()) + } + return nil + }, + } + + cmd.Flags().StringVar(&from, "from", "", "") + cmd.Flags().StringVar(&to, "to", "", "") + + _ = cmd.MarkFlagRequired("from") + _ = cmd.MarkFlagRequired("to") + + return cmd +} + +func runTun(ctx context.Context, cancel context.CancelFunc, from, to string) error { + var fromURI, toURI uri.URI + fromURI.Listener = true + if err := fromURI.UnmarshalText([]byte(from)); err != nil { + return fmt.Errorf("parse --from: %w", err) + } + if err := toURI.UnmarshalText([]byte(to)); err != nil { + return fmt.Errorf("parse --to: %w", err) + } + + ln, err := fromURI.Listen(ctx) + if err != nil { + return err + } + defer ln.Close() + + tm := netx.TunMaster[struct{}]{} + + tm.SetRoute(struct{}{}, func(ctx context.Context, conn net.Conn) (bool, context.Context, netx.Tun) { + pconn, err := toURI.Dial(ctx) + if err != nil { + slog.Error("dial peer", "err", err) + _ = conn.Close() + return false, ctx, netx.Tun{} + } + + return true, ctx, netx.Tun{Conn: conn, Peer: pconn} + }) + + go func() { + if err := tm.Serve(ctx, ln); err != nil && !errors.Is(err, netx.ErrServerClosed) { + slog.Error("serve error", "err", err) + cancel() + } + }() + + slog.Info("netx tun started", "listen", ln.Addr().String(), "from", from, "to", to) + + <-ctx.Done() + shutdownCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) + defer stop() + _ = tm.Shutdown(shutdownCtx) + + return nil +} diff --git a/cmd/netx/tun/run.go b/cmd/netx/tun/run.go deleted file mode 100644 index ed66b00..0000000 --- a/cmd/netx/tun/run.go +++ /dev/null @@ -1,728 +0,0 @@ -package tun - -import ( - "bytes" - "context" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "encoding/pem" - "errors" - "flag" - "fmt" - "log/slog" - "net" - "os" - "strconv" - "strings" - "time" - - netx "github.com/pedramktb/go-netx" - dtls "github.com/pion/dtls/v3" - dtlsnet "github.com/pion/dtls/v3/pkg/net" - pudp "github.com/pion/udp/v2" - tlswithpks "github.com/raff/tls-ext" - tlspks "github.com/raff/tls-psk" - utls "github.com/refraction-networking/utls" - "golang.org/x/crypto/ssh" -) - -// chainStep represents a single segment in a connection chain (e.g. tls[key=...]). -type chainStep struct { - name string - params map[string]string -} - -// Run executes the tun subcommand. -// Usage: netx tun --from ://host:port --to ://host:port -func Run(ctx context.Context, cancel context.CancelFunc, args []string) { - fs := flag.NewFlagSet("tun", flag.ExitOnError) - from := fs.String("from", "", "chain URI for incoming side, e.g. tcp+tls[cert=...,key=...]://:9000 or udp+dtls[cert=...,key=...]://:4444") - to := fs.String("to", "", "chain URI for peer side, e.g. tcp+tls[cert=...]://example.com:9443 or udp+aesgcm[key=...]://1.2.3.4:5555") - logLevel := fs.String("log", "info", "log level: debug|info|warn|error") - help := fs.Bool("h", false, "show help") - _ = fs.Parse(args) - - if *help { - fmt.Fprintln(os.Stderr, tunUsage()) - return - } - - rest := fs.Args() - if len(rest) != 0 || *from == "" || *to == "" { - fmt.Fprintln(os.Stderr, tunUsage()) - os.Exit(2) - } - - // Configure logging level - lvl := slog.LevelInfo - switch strings.ToLower(*logLevel) { - case "debug": - lvl = slog.LevelDebug - case "info": - lvl = slog.LevelInfo - case "warn": - lvl = slog.LevelWarn - case "error": - lvl = slog.LevelError - } - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))) - - // Parse endpoints (chain + address) - fromBase, fromSteps, fromAddr, err := parseChainURI(*from) - if err != nil { - slog.ErrorContext(ctx, "parse --from", "error", err) - os.Exit(2) - } - toBase, toSteps, toAddr, err := parseChainURI(*to) - if err != nil { - slog.ErrorContext(ctx, "parse --to", "error", err) - os.Exit(2) - } - - // Build base listener only (wrappers are applied in the handler in order) - ln, err := buildListener(ctx, fromAddr, fromBase) - if err != nil { - slog.ErrorContext(ctx, "error listening", "error", err, "addr", fromAddr) - os.Exit(2) - } - defer ln.Close() - - // Build base dialer for outgoing side - dialBase, err := buildDialer(toAddr, toBase) - if err != nil { - slog.ErrorContext(ctx, "error building dialer", "error", err, "addr", toAddr) - os.Exit(2) - } - - // Create TunMaster and route everything - tm := netx.TunMaster[struct{}]{} - - tm.SetRoute(struct{}{}, func(ctx context.Context, conn net.Conn) (bool, context.Context, netx.Tun) { - // Apply incoming wrappers in order (skip the base step) - inSteps := fromSteps[1:] - wc, err := applyWrappers(conn, inSteps, true) - if err != nil { - slog.Error("wrap incoming", "err", err) - _ = conn.Close() - return false, ctx, netx.Tun{} - } - - // Dial and wrap peer side - pcRaw, err := dialBase(ctx) - if err != nil { - slog.Error("dial peer", "err", err) - _ = wc.Close() - return false, ctx, netx.Tun{} - } - outSteps := toSteps[1:] - pc, err := applyWrappers(pcRaw, outSteps, false) - if err != nil { - slog.Error("wrap outgoing", "err", err) - _ = wc.Close() - _ = pcRaw.Close() - return false, ctx, netx.Tun{} - } - - return true, ctx, netx.Tun{Conn: wc, Peer: pc} - }) - - go func() { - if err := tm.Serve(ctx, ln); err != nil && !errors.Is(err, netx.ErrServerClosed) { - slog.Error("serve error", "err", err) - cancel() - } - }() - - slog.Info("netx tun started", "listen", ln.Addr().String(), "from", *from, "to", *to) - - <-ctx.Done() - shutdownCtx, stop := context.WithTimeout(context.Background(), 3*time.Second) - defer stop() - _ = tm.Shutdown(shutdownCtx) -} - -func tunUsage() string { - return `netx tun - relay between two endpoints with chainable transforms - -Usage: - netx tun --from ://listenAddr --to ://connectAddr - -Where is a '+'-separated list starting with 'tcp' or 'udp', e.g.: - tcp+tls[cert=server.crt,key=server.key] - udp+dtls[cert=server.crt,key=server.key] - tcp+tls[cert=server.crt,key=server.key]+framed[maxFrame=4096]+aesgcm[key=001122...] - -Examples: - netx tun \ - --from tcp+tls[cert=server.crt,key=server.key]://:9000 \ - --to tcp+tls[cert=client.crt]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 - - netx tun \ - --from udp+dtls[cert=server.crt,key=server.key]://:4444 \ - --to udp+aesgcm[key=...]://10.0.0.10:5555 - -Supported base transports: - - tcp: TCP listener or dialer - - udp: UDP listener or dialer - -Supported wrappers: - - tls: Transport Layer Security - server params: key, cert - client params: cert (optional, for SPKI pinning), serverName (required if cert not provided) - - utls: TLS with client fingerprint camouflage via uTLS (github.com/refraction-networking/utls) - client params: cert (optional, for SPKI pinning), serverName (required if cert not provided), hello (optional, e.g. chrome, firefox, ios, android, safari, edge, randomized) - - dtls: Datagram Transport Layer Security - server params: key, cert - client params: cert (optional, for SPKI pinning), serverName (required if cert not provided) - - tlspsk: TLS with pre-shared key. Cipher is TLS_DHE_PSK_WITH_AES_256_CBC_SHA. WARNING: This is not provided by the standard library, USE WITH CAUTION. - params: key (hex-encoded) - - dtlspsk: DTLS with pre-shared key. Cipher is TLS_PSK_WITH_AES_128_GCM_SHA256. - params: key (hex-encoded) - - aesgcm: AES-GCM encryption. A passive 12-byte handshake exchanges IVs. - params: key (hex-encoded), maxPacket (optional, defaults to 32768) - - buffered: buffered read/write for better performance when using framing. - params: bufSize (optional, defaults to 4096) - - framed: length-prefixed frames for transporting packet protocols or wrappers that need packet semantics over streams. - params: maxFrame (optional, defaults to 32768) - - ssh: SSH tunneling via "direct-tcpip" channels. - server params: hostKey, user (optional, required with pass), pass (optional), authKey (optional, required if no pass) - client options: hostKey, user, pass (optional), key (optional, required if no pass) - -Notes: - - If 'cert' is provided on the client for tls/dtls, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed - against the provided certificate. This is certificate pinning and will fail if the server presents a different key. -` -} - -// parseChainURI parses strings like "tcp+tls[...]+framed://host:port". -// Returns base (tcp|udp), full steps (including the base), and addr. -func parseChainURI(s string) (string, []chainStep, string, error) { - parts := strings.SplitN(s, "://", 2) - if len(parts) != 2 { - return "", nil, "", fmt.Errorf("invalid chain URI (missing ://): %q", s) - } - chainSpec, addr := parts[0], parts[1] - if strings.TrimSpace(addr) == "" { - return "", nil, "", fmt.Errorf("missing host:port in %q", s) - } - steps, err := parseChain(chainSpec) - if err != nil { - return "", nil, "", err - } - if len(steps) == 0 || (steps[0].name != "tcp" && steps[0].name != "udp") { - return "", nil, "", fmt.Errorf("chain must start with tcp or udp: %q", chainSpec) - } - return steps[0].name, steps, addr, nil -} - -// parseChain parses strings like "tcp+tls[cert=x,key=y]+framed[maxFrame=4096]". -func parseChain(s string) ([]chainStep, error) { - var steps []chainStep - i := 0 - for i < len(s) { - // read name until '[' or '+' or end - j := i - for j < len(s) && s[j] != '[' && s[j] != '+' { - j++ - } - if j == i { - return nil, fmt.Errorf("unexpected token at %d", i) - } - name := strings.ToLower(s[i:j]) - params := map[string]string{} - if j < len(s) && s[j] == '[' { - // find closing ']' - k := j + 1 - depth := 1 - for k < len(s) && depth > 0 { - if s[k] == '[' { - depth++ - } else if s[k] == ']' { - depth-- - if depth == 0 { - break - } - } - k++ - } - if depth != 0 { - return nil, fmt.Errorf("unclosed '[' for %s", name) - } - content := s[j+1 : k] - // parse k=v pairs separated by ',' - if strings.TrimSpace(content) != "" { - for _, kv := range splitComma(content) { - parts := strings.SplitN(kv, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid param %q in %s", kv, name) - } - params[strings.ToLower(strings.TrimSpace(parts[0]))] = strings.TrimSpace(parts[1]) - } - } - j = k + 1 - } - steps = append(steps, chainStep{name: name, params: params}) - if j < len(s) { - if s[j] != '+' { - return nil, fmt.Errorf("expected '+' after %s", name) - } - j++ - } - i = j - } - if len(steps) == 0 { - return nil, fmt.Errorf("empty chain") - } - return steps, nil -} - -func splitComma(s string) []string { - // simple split, no escaping supported - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} - -// buildListener returns a listener possibly pre-wrapped with tls/dtls. -// It also returns remaining steps that should be applied per-connection on the incoming side. -func buildListener(ctx context.Context, addr string, base string) (net.Listener, error) { - switch base { - case "tcp": - return (&net.ListenConfig{}).Listen(ctx, "tcp", addr) - case "udp": - uaddr, err := net.ResolveUDPAddr("udp", addr) - if err != nil { - return nil, err - } - return (&pudp.ListenConfig{}).Listen("udp", uaddr) - default: - return nil, fmt.Errorf("unknown base %q (want tcp|udp)", base) - } -} - -// buildDialer creates a function that dials and applies wrappers according to the chain. -func buildDialer(addr string, base string) (func(ctx context.Context) (net.Conn, error), error) { - switch base { - case "tcp": - return func(ctx context.Context) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "tcp", addr) - }, nil - case "udp": - return func(ctx context.Context) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "udp", addr) - }, nil - default: - return nil, fmt.Errorf("unknown base %q (want tcp|udp)", base) - } -} - -// applyWrappers applies the given steps in order on the provided connection. -// The 'from' parameter indicates if this is the incoming side (true) or outgoing side (false). -func applyWrappers(conn net.Conn, steps []chainStep, from bool) (net.Conn, error) { - var c net.Conn = conn - for _, st := range steps { - switch st.name { - case "buffered": - opts := []netx.BufConnOption{} - if v, ok := st.params["buf"]; ok && strings.TrimSpace(v) != "" { - size, err := strconv.Atoi(v) - if err != nil { - return nil, fmt.Errorf("invalid buf size %q: %w", v, err) - } - opts = append(opts, netx.WithBufSize(size)) - } - c = netx.NewBufConn(c, opts...) - case "framed": - opts := []netx.FramedConnOption{} - if v, ok := st.params["maxframe"]; ok && strings.TrimSpace(v) != "" { - max, err := strconv.Atoi(v) - if err != nil { - return nil, fmt.Errorf("invalid maxFrame size %q: %w", v, err) - } - opts = append(opts, netx.WithMaxFrameSize(max)) - } - c = netx.NewFramedConn(c, opts...) - case "aesgcm": - keyHex := st.params["key"] - if keyHex == "" { - return nil, fmt.Errorf("aesgcm requires key") - } - key, err := hex.DecodeString(keyHex) - if err != nil { - return nil, fmt.Errorf("invalid aesgcm key: %w", err) - } - opts := []netx.AESGCMOption{} - if v, ok := st.params["maxpacket"]; ok && strings.TrimSpace(v) != "" { - maxPkt, err := strconv.Atoi(v) - if err != nil { - return nil, fmt.Errorf("invalid maxpacket size %q: %w", v, err) - } - opts = append(opts, netx.WithMaxPacket(maxPkt)) - } - c, err = netx.NewAESGCMConn(c, key, opts...) - if err != nil { - return nil, err - } - case "tls": - cfg := &tls.Config{ - MinVersion: tls.VersionTLS13, - MaxVersion: tls.VersionTLS13, - } - if from { - // Server side requires cert+key - certs, err := loadServerCertificates(st.params) - if err != nil { - return nil, fmt.Errorf("tls server config: %w", err) - } - cfg.Certificates = certs - c = tls.Server(c, cfg) - } else { - // Client: if cert is provided, enable SPKI pinning with InsecureSkipVerify - if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { - verify, err := makeSPKIPinVerifierFromCertPath(cp) - if err != nil { - return nil, fmt.Errorf("tls client pin setup: %w", err) - } - cfg.InsecureSkipVerify = true - cfg.VerifyPeerCertificate = verify - } else { - // Otherwise, require serverName - if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { - cfg.ServerName = strings.TrimSpace(sn) - } else { - return nil, fmt.Errorf("tls client requires serverName or cert") - } - } - c = tls.Client(c, cfg) - } - case "utls": - if from { - return nil, fmt.Errorf("utls is client-side only") - } - cfg := &utls.Config{ - MinVersion: tls.VersionTLS13, - MaxVersion: tls.VersionTLS13, - } - if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { - verify, err := makeSPKIPinVerifierFromCertPath(cp) - if err != nil { - return nil, fmt.Errorf("utls client pin setup: %w", err) - } - cfg.InsecureSkipVerify = true - cfg.VerifyPeerCertificate = verify - } else { - if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { - cfg.ServerName = strings.TrimSpace(sn) - } else { - return nil, fmt.Errorf("utls client requires serverName or cert") - } - } - // Map hello profile. - hello := strings.ToLower(strings.TrimSpace(st.params["hello"])) - var id utls.ClientHelloID - switch hello { - case "", "chrome": - id = utls.HelloChrome_Auto - case "firefox": - id = utls.HelloFirefox_Auto - case "ios": - id = utls.HelloIOS_Auto - case "android": - id = utls.HelloAndroid_11_OkHttp - case "safari": - id = utls.HelloSafari_Auto - case "edge": - id = utls.HelloEdge_Auto - case "randomized": - id = utls.HelloRandomizedALPN - case "randomizednoalpn": - id = utls.HelloRandomized - default: - return nil, fmt.Errorf("unknown utls hello profile %q", hello) - } - uconn := utls.UClient(c, cfg, id) - if err := uconn.Handshake(); err != nil { - return nil, fmt.Errorf("utls handshake: %w", err) - } - c = uconn - case "dtls": - var err error - cfg := &dtls.Config{} - if from { - certs, cerr := loadServerCertificates(st.params) - if cerr != nil { - return nil, fmt.Errorf("dtls server config: %w", cerr) - } - cfg.Certificates = certs - c, err = dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) - } else { - if cp, ok := st.params["cert"]; ok && strings.TrimSpace(cp) != "" { - verify, verr := makeSPKIPinVerifierFromCertPath(cp) - if verr != nil { - return nil, fmt.Errorf("dtls client pin setup: %w", verr) - } - cfg.InsecureSkipVerify = true - cfg.VerifyPeerCertificate = verify - } else { - // Otherwise, require serverName - if sn, ok := st.params["servername"]; ok && strings.TrimSpace(sn) != "" { - cfg.ServerName = strings.TrimSpace(sn) - } else { - return nil, fmt.Errorf("dtls client requires serverName or cert") - } - } - c, err = dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) - } - if err != nil { - return nil, err - } - case "tlspsk": - keyHex := strings.TrimSpace(st.params["key"]) - if keyHex == "" { - return nil, fmt.Errorf("tlspsk requires key") - } - psk, err := hex.DecodeString(keyHex) - if err != nil { - return nil, fmt.Errorf("invalid tlspsk key: %w", err) - } - identity := strings.TrimSpace(st.params["identity"]) - if identity == "" { - return nil, fmt.Errorf("tlspsk requires identity") - } - cfg := &tlswithpks.Config{ - MinVersion: tls.VersionTLS12, - MaxVersion: tls.VersionTLS12, - Extra: tlspks.PSKConfig{ - GetKey: func(identity string) ([]byte, error) { return psk, nil }, - GetIdentity: func() string { return identity }, - }, - CipherSuites: []uint16{tlspks.TLS_PSK_WITH_AES_256_CBC_SHA}, - InsecureSkipVerify: true, - } - if from { - // Provide dummy Certificates to make tlspsk happy on server side - cfg.Certificates = dummyCert() - c = tlswithpks.Server(c, cfg) - } else { - c = tlswithpks.Client(c, cfg) - } - case "dtlspsk": - keyHex := strings.TrimSpace(st.params["key"]) - if keyHex == "" { - return nil, fmt.Errorf("dtlspsk requires key") - } - psk, err := hex.DecodeString(keyHex) - if err != nil { - return nil, fmt.Errorf("invalid dtlspsk key: %w", err) - } - identity := strings.TrimSpace(st.params["identity"]) - if identity == "" { - return nil, fmt.Errorf("dtlspsk requires identity") - } - cfg := &dtls.Config{ - PSK: func(hint []byte) ([]byte, error) { return psk, nil }, - PSKIdentityHint: []byte(identity), - CipherSuites: []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_GCM_SHA256}, - InsecureSkipVerify: true, - } - if from { - c, err = dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) - if err != nil { - return nil, err - } - } else { - c, err = dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) - if err != nil { - return nil, err - } - } - case "ssh": - if from { - cfg := &ssh.ServerConfig{} - if hp := strings.TrimSpace(st.params["hostkey"]); hp != "" { - keyData, err := os.ReadFile(hp) - if err != nil { - return nil, fmt.Errorf("read hostkey: %w", err) - } - private, err := ssh.ParsePrivateKey(keyData) - if err != nil { - return nil, fmt.Errorf("parse hostkey: %w", err) - } - cfg.AddHostKey(private) - } - if p := strings.TrimSpace(st.params["pass"]); p != "" { - cfg.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { - if c.User() == strings.TrimSpace(st.params["user"]) && string(pass) == p { - return nil, nil - } - return nil, fmt.Errorf("invalid user or password") - } - } - if ak := strings.TrimSpace(st.params["authkey"]); ak != "" { - // Load authorized key file - data, err := os.ReadFile(ak) - if err != nil { - return nil, fmt.Errorf("read authkey: %w", err) - } - pubKey, _, _, _, err := ssh.ParseAuthorizedKey(data) - if err != nil { - return nil, fmt.Errorf("parse authkey: %w", err) - } - cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - if bytes.Equal(key.Marshal(), pubKey.Marshal()) { - return nil, nil - } - return nil, fmt.Errorf("unauthorized public key") - } - } - if cfg.PasswordCallback == nil && cfg.PublicKeyCallback == nil { - return nil, fmt.Errorf("ssh server requires pass or authKey") - } - var err error - c, err = netx.NewSSHServerConn(c, cfg) - if err != nil { - return nil, err - } - } else { - cfg := &ssh.ClientConfig{ - User: strings.TrimSpace(st.params["user"]), - } - if p := strings.TrimSpace(st.params["pass"]); p != "" { - cfg.Auth = append(cfg.Auth, ssh.Password(p)) - } - if k := strings.TrimSpace(st.params["key"]); k != "" { - keyData, err := os.ReadFile(k) - if err != nil { - return nil, fmt.Errorf("read private key: %w", err) - } - signer, err := ssh.ParsePrivateKey(keyData) - if err != nil { - return nil, fmt.Errorf("parse private key: %w", err) - } - cfg.Auth = append(cfg.Auth, ssh.PublicKeys(signer)) - } - if hk := strings.TrimSpace(st.params["hostkey"]); hk != "" { - keyData, err := os.ReadFile(hk) - if err != nil { - return nil, fmt.Errorf("read hostkey: %w", err) - } - hostKey, _, _, _, err := ssh.ParseAuthorizedKey(keyData) - if err != nil { - return nil, fmt.Errorf("parse hostkey: %w", err) - } - cfg.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { - if bytes.Equal(key.Marshal(), hostKey.Marshal()) { - return nil - } - return fmt.Errorf("host key mismatch") - } - } - if cfg.User == "" || len(cfg.Auth) == 0 { - return nil, fmt.Errorf("ssh client requires user and (pass or key)") - } - if cfg.HostKeyCallback == nil { - if strings.ToLower(strings.TrimSpace(st.params["insecure"])) == "true" { - cfg.HostKeyCallback = ssh.InsecureIgnoreHostKey() - } else { - return nil, fmt.Errorf("ssh client requires hostKey or insecure=true") - } - } - var err error - c, err = netx.NewSSHClientConn(c, cfg) - if err != nil { - return nil, err - } - } - default: - return nil, fmt.Errorf("unknown wrapper %q on incoming side", st.name) - } - } - return c, nil -} - -// loadServerCertificates loads the key pair specified by params["cert"], params["key"]. -// Returns an error if missing or invalid. -func loadServerCertificates(params map[string]string) ([]tls.Certificate, error) { - certPath := strings.TrimSpace(params["cert"]) - keyPath := strings.TrimSpace(params["key"]) - if certPath == "" || keyPath == "" { - return nil, fmt.Errorf("both cert and key are required") - } - pair, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - return nil, fmt.Errorf("load key pair: %w", err) - } - return []tls.Certificate{pair}, nil -} - -// makeSPKIPinVerifierFromCertPath creates a VerifyPeerCertificate callback that pins the -// peer's SPKI hash (SHA-256 over RawSubjectPublicKeyInfo) to the certificate at certPath. -func makeSPKIPinVerifierFromCertPath(certPath string) (func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error, error) { - spkiHash, err := spkiHashFromCertFile(certPath) - if err != nil { - return nil, err - } - return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { - for _, rawCert := range rawCerts { - c, err := x509.ParseCertificate(rawCert) - if err != nil { - return fmt.Errorf("parse peer cert: %w", err) - } - if bytes.Equal(sha256.New().Sum(c.RawSubjectPublicKeyInfo), spkiHash) { - return nil - } - } - return fmt.Errorf("no matching SPKI found") - }, nil -} - -// spkiHashFromCertFile reads a PEM certificate file and returns SHA-256(SPKI) bytes. -func spkiHashFromCertFile(path string) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read cert: %w", err) - } - block, _ := pem.Decode(data) - if block == nil || (block.Type != "CERTIFICATE" && !strings.HasSuffix(block.Type, "CERTIFICATE")) { - return nil, fmt.Errorf("no PEM certificate found in %s", path) - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("parse cert: %w", err) - } - sum := sha256.New().Sum(cert.RawSubjectPublicKeyInfo) - return sum, nil -} - -// dummyCert returns a self-signed certificate for use in tls-psk server mode. (ed25519) -func dummyCert() []tlswithpks.Certificate { - // Generated with: - // openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem -days 100000 -nodes -subj "/CN=dummy" - certPEM := `-----BEGIN CERTIFICATE----- -MIIBNjCB6aADAgECAhRX020iAjrT4wTjwRdAJ+PPjpe33DAFBgMrZXAwEDEOMAwG -A1UEAwwFZHVtbXkwIBcNMjUwOTIxMTUxNzMwWhgPMjI5OTA3MDcxNTE3MzBaMBAx -DjAMBgNVBAMMBWR1bW15MCowBQYDK2VwAyEA/8RGhnpLT8uPAm8Ah0vEYWCskGrk -R3lqdOjspIidVmKjUzBRMB0GA1UdDgQWBBRMUX8P7I1KV1UxMjcJlIT42a72ozAf -BgNVHSMEGDAWgBRMUX8P7I1KV1UxMjcJlIT42a72ozAPBgNVHRMBAf8EBTADAQH/ -MAUGAytlcANBAEFf17f1XhfLek4D203mGz8BihBfXfeL6kADMMV+G2qpkqZPcnTI -NXPuT9B/6+hM7nD/vh7JKXTfSAEFo22rzwA= ------END CERTIFICATE----- -` - keyPEM := `-----BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIEsb9X3HHGBFSe5jKvqNmua6ZFplNaiBROtJ7ZZAJlRz ------END PRIVATE KEY----- -` - cert, err := tlswithpks.X509KeyPair([]byte(certPEM), []byte(keyPEM)) - if err != nil { - panic("dummyCert: " + err.Error()) - } - return []tlswithpks.Certificate{cert} -} diff --git a/cmd/netx/uri.go b/cmd/netx/uri.go new file mode 100644 index 0000000..e9cb4fb --- /dev/null +++ b/cmd/netx/uri.go @@ -0,0 +1,42 @@ +package main + +const uriFormat = `URI Format: + +[layer1param1key=layer1param1value,layer1param2key=layer1param2key,...]++...://
+ + Examples: + tcp+tls[cert=$(cat server.crt | xxd -p),key=$(cat server.key | xxd -p)]://:9000 + tcp+tls[cert=$(cat client.crt | xxd -p)]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 + + Supported transports: + - tcp: TCP listener or dialer + - udp: UDP listener or dialer + + Supported layers: + - framed: length-prefixed frames for transports or layers that need packet semantics over streams. + params: maxsize (optional, defaults to 32768) + - buffered: buffered read/write for better performance when using framing. + params: size (optional, defaults to 4096) + - aesgcm: AES-GCM encryption. A passive 12-byte handshake exchanges IVs. + params: key, maxpacket (optional, defaults to 32768) + - ssh: SSH tunneling via "direct-tcpip" channels. + server params: key, pass (optional), pubkey (optional, required if no pass) + client options: pubkey, pass (optional), key (optional, required if no pass) + - tls: Transport Layer Security + server params: key, cert + client params: cert (optional, for SPKI pinning), servername (required if cert not provided) + - utls: TLS with client fingerprint camouflage via uTLS (github.com/refraction-networking/utls) + client params: cert (optional, for SPKI pinning), servername (required if cert not provided), hello (optional, e.g. chrome, firefox, ios, android, safari, edge, randomized) + - dtls: Datagram Transport Layer Security + server params: key, cert + client params: cert (optional, for SPKI pinning), servername (required if cert not provided) + - tlspsk: TLS with pre-shared key. Cipher is TLS_DHE_PSK_WITH_AES_256_CBC_SHA. + params: key + - dtlspsk: DTLS with pre-shared key. Cipher is TLS_PSK_WITH_AES_128_GCM_SHA256. + params: key + + Notes: + - All passwords, keys and certificates must be provided as hex-encoded strings. + - When using 'cert' for client-side TLS/DTLS, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed + against the provided certificate. This is certificate pinning and will fail if the server presents a different key. + - SSH server must accept "direct-tcpip" channels (most do by default). +` diff --git a/dial.go b/dial.go new file mode 100644 index 0000000..5f0c7d5 --- /dev/null +++ b/dial.go @@ -0,0 +1,77 @@ +package netx + +import ( + "context" + "net" + "strings" + + pudp "github.com/pion/transport/v3/udp" +) + +type listenCfg struct { + net.ListenConfig + packet pudp.ListenConfig +} + +type ListenOption func(*listenCfg) + +func WithListenConfig(cfg net.ListenConfig) ListenOption { + return func(lc *listenCfg) { + lc.ListenConfig = cfg + } +} + +func WithPacketListenConfig(cfg pudp.ListenConfig) ListenOption { + return func(lc *listenCfg) { + lc.packet = cfg + } +} + +func Listen(ctx context.Context, network, addr string, opts ...ListenOption) (net.Listener, error) { + cfg := &listenCfg{} + for _, o := range opts { + o(cfg) + } + switch strings.Split(network, ":")[0] { + case "udp", "udp4", "udp6": + uaddr, err := net.ResolveUDPAddr(network, addr) + if err != nil { + return nil, err + } + return cfg.packet.Listen(network, uaddr) + // case "ip", "ip4", "ip6": + // iaddr, err := net.ResolveIPAddr(network, addr) + // if err != nil { + // return nil, err + // } + // return (&ip.ListenConfig{ + // Backlog: cfg.packet.Backlog, + // AcceptFilter: cfg.packet.AcceptFilter, + // ReadBufferSize: cfg.packet.ReadBufferSize, + // WriteBufferSize: cfg.packet.WriteBufferSize, + // Batch: cfg.packet.Batch, + // }).Listen(network, iaddr) + default: + return cfg.Listen(ctx, network, addr) + } +} + +type dialCfg struct { + net.Dialer +} + +type DialOption func(*dialCfg) + +func WithDialConfig(cfg net.Dialer) DialOption { + return func(dc *dialCfg) { + dc.Dialer = cfg + } +} + +func Dial(ctx context.Context, network, addr string, opts ...DialOption) (net.Conn, error) { + cfg := &dialCfg{} + for _, o := range opts { + o(cfg) + } + return cfg.DialContext(ctx, network, addr) +} diff --git a/framed_conn.go b/framed_conn.go index f208b19..b0181a2 100644 --- a/framed_conn.go +++ b/framed_conn.go @@ -6,13 +6,12 @@ import ( "io" "net" "sync" - "time" ) var ErrFrameTooLarge = errors.New("framedConn: frame too large") type framedConn struct { - bc net.Conn + net.Conn maxFrameSize int pending []byte rmu, wmu sync.Mutex @@ -20,9 +19,9 @@ type framedConn struct { type FramedConnOption func(*framedConn) -func WithMaxFrameSize(size int) FramedConnOption { +func WithMaxFrameSize(size uint32) FramedConnOption { return func(c *framedConn) { - c.maxFrameSize = size + c.maxFrameSize = int(size) } } @@ -32,7 +31,7 @@ func WithMaxFrameSize(size int) FramedConnOption { // The default maxFrameSize is 32KB. func NewFramedConn(c net.Conn, opts ...FramedConnOption) net.Conn { fc := &framedConn{ - bc: c, + Conn: c, maxFrameSize: 32 * 1024, // 32KB default max frame size } for _, opt := range opts { @@ -53,7 +52,7 @@ func (c *framedConn) Read(p []byte) (int, error) { } var hdr [4]byte - if _, err := io.ReadFull(c.bc, hdr[:]); err != nil { + if _, err := io.ReadFull(c.Conn, hdr[:]); err != nil { return 0, err } n := int(binary.BigEndian.Uint32(hdr[:])) @@ -66,12 +65,12 @@ func (c *framedConn) Read(p []byte) (int, error) { } if len(p) >= n { - _, err := io.ReadFull(c.bc, p[:n]) + _, err := io.ReadFull(c.Conn, p[:n]) return n, err } buf := make([]byte, n) - if _, err := io.ReadFull(c.bc, buf); err != nil { + if _, err := io.ReadFull(c.Conn, buf); err != nil { return 0, err } w := copy(p, buf) @@ -86,27 +85,20 @@ func (c *framedConn) Write(p []byte) (int, error) { var hdr [4]byte binary.BigEndian.PutUint32(hdr[:], uint32(len(p))) - if _, err := c.bc.Write(hdr[:]); err != nil { + if _, err := c.Conn.Write(hdr[:]); err != nil { return 0, err } if len(p) == 0 { return 0, nil } - if _, err := c.bc.Write(p); err != nil { + if _, err := c.Conn.Write(p); err != nil { return 0, err } // If the underlying layer is buffered and implements Flush, flush now to coalesce header+payload. - if fw, ok := c.bc.(BufConn); ok { + if fw, ok := c.Conn.(BufConn); ok { if err := fw.Flush(); err != nil { return 0, err } } return len(p), nil } - -func (c *framedConn) Close() error { return c.bc.Close() } -func (c *framedConn) LocalAddr() net.Addr { return c.bc.LocalAddr() } -func (c *framedConn) RemoteAddr() net.Addr { return c.bc.RemoteAddr() } -func (c *framedConn) SetDeadline(t time.Time) error { return c.bc.SetDeadline(t) } -func (c *framedConn) SetReadDeadline(t time.Time) error { return c.bc.SetReadDeadline(t) } -func (c *framedConn) SetWriteDeadline(t time.Time) error { return c.bc.SetWriteDeadline(t) } diff --git a/go.mod b/go.mod index 86f9b8b..299e482 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,24 @@ go 1.25.1 require ( github.com/pion/dtls/v3 v3.0.7 - github.com/pion/udp/v2 v2.0.1 + github.com/pion/transport/v3 v3.0.8 github.com/raff/tls-ext v1.0.0 github.com/raff/tls-psk v1.0.0 github.com/refraction-networking/utls v1.8.0 - golang.org/x/crypto v0.42.0 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.46.0 ) require ( - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/klauspost/compress v1.17.4 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/pion/logging v0.2.4 // indirect - github.com/pion/transport/v2 v2.2.4 // indirect - github.com/pion/transport/v3 v3.0.7 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sys v0.36.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c6e185b..8917f5c 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,18 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0= -github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo= -github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54= -github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8= +github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc= +github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/raff/tls-ext v1.0.0 h1:72EP1QYiXxTpTt3zWLi6YefLDnXFHTvnxog/H6COwj4= @@ -25,67 +21,30 @@ github.com/raff/tls-psk v1.0.0 h1:cLGFfZCxtkBpsie1TzACuYHJHEj0VYRN1dCv+lPRPxo= github.com/raff/tls-psk v1.0.0/go.mod h1:SUNKszL9dnQq9lkqg7P34Qrg9FuCiHcTKRVqdIyHbF0= github.com/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE= github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/uri/dial.go b/uri/dial.go new file mode 100644 index 0000000..d3c5111 --- /dev/null +++ b/uri/dial.go @@ -0,0 +1,44 @@ +package uri + +import ( + "context" + "fmt" + "net" + + "github.com/pedramktb/go-netx" +) + +type listener struct { + net.Listener + uri *URI +} + +func (l *listener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return l.uri.Layers.Wrap(c) +} + +func (u URI) Listen(ctx context.Context, opts ...netx.ListenOption) (net.Listener, error) { + if !u.Listener { + return nil, fmt.Errorf("uri: cannot listen on a non-listener URI") + } + l, err := netx.Listen(ctx, u.Scheme.Transport.String(), u.Addr, opts...) + if err != nil { + return nil, fmt.Errorf("error listening on %s://%s: %w", u.Scheme.Transport.String(), u.Addr, err) + } + return &listener{l, &u}, nil +} + +func (u URI) Dial(ctx context.Context, opts ...netx.DialOption) (net.Conn, error) { + if u.Listener { + return nil, fmt.Errorf("uri: cannot dial on a listener URI") + } + c, err := netx.Dial(ctx, u.Scheme.Transport.String(), u.Addr, opts...) + if err != nil { + return nil, fmt.Errorf("error dialing %s://%s: %w", u.Scheme.Transport.String(), u.Addr, err) + } + return u.Layers.Wrap(c) +} diff --git a/uri/layer.go b/uri/layer.go new file mode 100644 index 0000000..9e639e1 --- /dev/null +++ b/uri/layer.go @@ -0,0 +1,564 @@ +package uri + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/pedramktb/go-netx" + "github.com/pion/dtls/v3" + dtlsnet "github.com/pion/dtls/v3/pkg/net" + tlswithpks "github.com/raff/tls-ext" + tlspks "github.com/raff/tls-psk" + utls "github.com/refraction-networking/utls" + "golang.org/x/crypto/ssh" +) + +type Layers struct { + Listener bool + Layers []Layer +} + +func (ls Layers) Wrap(conn net.Conn) (net.Conn, error) { + var err error + for _, l := range ls.Layers { + conn, err = l.Wrap(conn) + if err != nil { + return nil, fmt.Errorf("wrap %q: %w", l.String(), err) + } + } + return conn, nil +} + +func (ls Layers) String() string { + strs := make([]string, len(ls.Layers)) + for i, l := range ls.Layers { + strs[i] = l.String() + } + return strings.Join(strs, "+") +} + +func (ls Layers) MarshalText() ([]byte, error) { + return []byte(ls.String()), nil +} + +func (ls *Layers) UnmarshalText(text []byte) error { + parts := strings.Split(string(text), "+") + ls.Layers = make([]Layer, len(parts)) + for i := range parts { + ls.Layers[i].Listener = ls.Listener + if err := ls.Layers[i].UnmarshalText([]byte(parts[i])); err != nil { + return err + } + } + + return nil +} + +type Layer struct { + Listener bool + string + wrap func(net.Conn) (net.Conn, error) +} + +func (l Layer) Wrap(conn net.Conn) (net.Conn, error) { + if l.wrap == nil { + return conn, nil + } + return l.wrap(conn) +} + +func (l Layer) String() string { + return l.string +} + +func (l Layer) MarshalText() ([]byte, error) { + return []byte(l.string), nil +} + +func (l *Layer) UnmarshalText(text []byte) error { + l.string = string(text) + + prot := strings.ToLower(strings.TrimSpace(l.string)) + params := map[string]string{} + if idx := strings.Index(l.string, "["); idx != -1 { + if !strings.HasSuffix(l.string, "]") { + return fmt.Errorf("uri: missing ']' in layer %q", l.string) + } + prot = strings.ToLower(strings.TrimSpace(l.string[:idx])) + for pair := range strings.SplitSeq(l.string[idx+1:len(l.string)-1], ",") { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("uri: invalid parameter %q", pair) + } + key := strings.ToLower(strings.TrimSpace(kv[0])) + value := strings.TrimSpace(kv[1]) + if key == "" { + return fmt.Errorf("uri: empty parameter key") + } + params[key] = value + } + } + + switch prot { + case "framed": + opts := []netx.FramedConnOption{} + for key, value := range params { + switch key { + case "maxsize": + maxSize, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return fmt.Errorf("uri: invalid framed maxsize parameter %q: %w", value, err) + } + opts = append(opts, netx.WithMaxFrameSize(uint32(maxSize))) + default: + return fmt.Errorf("uri: unknown framed parameter %q", key) + } + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return netx.NewFramedConn(c, opts...), nil + } + case "buffered": + opts := []netx.BufConnOption{} + for key, value := range params { + switch key { + case "size": + size, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return fmt.Errorf("uri: invalid buffered size parameter %q: %w", value, err) + } + opts = append(opts, netx.WithBufSize(uint32(size))) + default: + return fmt.Errorf("uri: unknown buffered parameter %q", key) + } + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return netx.NewBufConn(c, opts...), nil + } + case "aesgcm": + aeskey := []byte{} + opts := []netx.AESGCMOption{} + for key, value := range params { + switch key { + case "key": + var err error + aeskey, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid aesgcm key parameter: %w", err) + } + if len(aeskey) != 16 && len(aeskey) != 24 && len(aeskey) != 32 { + return fmt.Errorf("uri: invalid aesgcm key size %d", len(aeskey)) + } + case "maxpacket": + maxPacket, err := strconv.ParseUint(value, 10, 31) + if err != nil { + return fmt.Errorf("uri: invalid aesgcm maxpacket parameter %q: %w", value, err) + } + opts = append(opts, netx.WithAESGCMMaxPacket(uint32(maxPacket))) + default: + return fmt.Errorf("uri: unknown aesgcm parameter %q", key) + } + } + if len(aeskey) == 0 { + return fmt.Errorf("uri: missing aesgcm key parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return netx.NewAESGCMConn(c, aeskey, opts...) + } + case "ssh": + var pass string + var sshkey ssh.Signer // Host key for server, private key for client + var pubkey ssh.PublicKey + for key, value := range params { + switch key { + case "pass": + pass = value + case "key": + pemkey, err := hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid ssh key parameter: %w", err) + } + sshkey, err = ssh.ParsePrivateKey(pemkey) + if err != nil { + return fmt.Errorf("uri: invalid ssh private key: %w", err) + } + case "pubkey": + azkey, err := hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid ssh pubkey parameter: %w", err) + } + pubkey, _, _, _, err = ssh.ParseAuthorizedKey(azkey) + if err != nil { + return fmt.Errorf("uri: invalid ssh public key: %w", err) + } + default: + return fmt.Errorf("uri: unknown ssh parameter %q", key) + } + } + if l.Listener { + cfg := &ssh.ServerConfig{} + if sshkey == nil { + return fmt.Errorf("uri: ssh server requires key parameter") + } + cfg.AddHostKey(sshkey) + if pubkey != nil { + cfg.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if bytes.Equal(key.Marshal(), pubkey.Marshal()) { + return nil, nil + } + return nil, fmt.Errorf("uri: ssh public key mismatch") + } + } + if pass != "" { + cfg.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if pass == string(password) { + return nil, nil + } + return nil, fmt.Errorf("uri: ssh password mismatch") + } + } + if cfg.PublicKeyCallback == nil && cfg.PasswordCallback == nil { + return fmt.Errorf("uri: ssh server requires pubkey or pass parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return netx.NewSSHServerConn(c, cfg) + } + } else { + cfg := &ssh.ClientConfig{} + if pubkey == nil { + return fmt.Errorf("uri: ssh client requires pubkey parameter") + } + cfg.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if bytes.Equal(key.Marshal(), pubkey.Marshal()) { + return nil + } + return fmt.Errorf("uri: ssh host key mismatch") + } + if sshkey != nil { + cfg.Auth = append(cfg.Auth, ssh.PublicKeys(sshkey)) + } + if pass != "" { + cfg.Auth = append(cfg.Auth, ssh.Password(pass)) + } + if len(cfg.Auth) == 0 { + return fmt.Errorf("uri: ssh client requires key or pass parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return netx.NewSSHClientConn(c, cfg) + } + } + case "tls": + var certKey, cert []byte + cfg := &tls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + } + for key, value := range params { + switch key { + case "key": + var err error + certKey, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid tls key parameter: %w", err) + } + case "cert": + var err error + cert, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid tls cert parameter: %w", err) + } + case "servername": + cfg.ServerName = value + default: + return fmt.Errorf("uri: unknown tls parameter %q", key) + } + } + if l.Listener { + if cert == nil || certKey == nil { + return fmt.Errorf("uri: tls server requires cert and key parameters") + } + certificate, err := tls.X509KeyPair(cert, certKey) + if err != nil { + return fmt.Errorf("uri: invalid tls certificate: %w", err) + } + cfg.Certificates = []tls.Certificate{certificate} + l.wrap = func(c net.Conn) (net.Conn, error) { + return tls.Server(c, cfg), nil + } + } else { + if certKey != nil { + return fmt.Errorf("uri: tls client does not support key parameter") + } + if cert != nil { + var err error + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate, err = spkiVerifier(cert) + if err != nil { + return fmt.Errorf("uri: invalid tls cert parameter: %w", err) + } + } + if cfg.ServerName == "" && cert == nil { + return fmt.Errorf("uri: tls client requires servername or cert parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return tls.Client(c, cfg), nil + } + } + case "utls": + if l.Listener { + return errors.New("uri: utls is exclusive to clients, use tls for servers instead") + } + var cert []byte + cfg := &utls.Config{ + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + } + id := utls.HelloChrome_Auto + for key, value := range params { + switch key { + case "cert": + var err error + cert, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid utls cert parameter: %w", err) + } + case "servername": + cfg.ServerName = value + case "hello": + switch strings.ToLower(value) { + case "chrome": + id = utls.HelloChrome_Auto + case "firefox": + id = utls.HelloFirefox_Auto + case "ios": + id = utls.HelloIOS_Auto + case "android": + id = utls.HelloAndroid_11_OkHttp + case "safari": + id = utls.HelloSafari_Auto + case "edge": + id = utls.HelloEdge_Auto + case "randomized": + id = utls.HelloRandomizedALPN + case "randomizednoalpn": + id = utls.HelloRandomized + default: + return fmt.Errorf("unknown utls hello profile %q", value) + } + default: + return fmt.Errorf("uri: unknown utls parameter %q", key) + } + } + if cert != nil { + var err error + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate, err = spkiVerifier(cert) + if err != nil { + return fmt.Errorf("uri: invalid utls cert parameter: %w", err) + } + } + if cfg.ServerName == "" && cert == nil { + return fmt.Errorf("uri: utls client requires servername or cert parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + uc := utls.UClient(c, cfg, id) + return uc, uc.Handshake() + } + case "dtls": + var certKey, cert []byte + cfg := &dtls.Config{} + for key, value := range params { + switch key { + case "key": + var err error + certKey, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid dtls key parameter: %w", err) + } + case "cert": + var err error + cert, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid dtls cert parameter: %w", err) + } + case "servername": + cfg.ServerName = value + default: + return fmt.Errorf("uri: unknown dtls parameter %q", key) + } + } + if l.Listener { + if cert == nil || certKey == nil { + return fmt.Errorf("uri: dtls server requires cert and key parameters") + } + certificate, err := tls.X509KeyPair(cert, certKey) + if err != nil { + return fmt.Errorf("uri: invalid dtls certificate: %w", err) + } + cfg.Certificates = []tls.Certificate{certificate} + l.wrap = func(c net.Conn) (net.Conn, error) { + return dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } + } else { + if certKey != nil { + return fmt.Errorf("uri: dtls client does not support key parameter") + } + if cert != nil { + var err error + cfg.InsecureSkipVerify = true + cfg.VerifyPeerCertificate, err = spkiVerifier(cert) + if err != nil { + return fmt.Errorf("uri: invalid dtls cert parameter: %w", err) + } + } + if cfg.ServerName == "" && cert == nil { + return fmt.Errorf("uri: dtls client requires servername or cert parameter") + } + l.wrap = func(c net.Conn) (net.Conn, error) { + return dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } + } + case "tlspsk": + var identity string + var psk []byte + for key, value := range params { + switch key { + case "key": + var err error + psk, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid tlspsk key parameter: %w", err) + } + case "identity": + identity = value + default: + return fmt.Errorf("uri: unknown tlspsk parameter %q", key) + } + } + if len(psk) == 0 { + return fmt.Errorf("uri: missing tlspsk key parameter") + } + if !l.Listener && identity == "" { + return fmt.Errorf("uri: tlspsk client requires identity parameter") + } + cfg := &tlswithpks.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + Extra: tlspks.PSKConfig{ + GetIdentity: func() string { return identity }, + GetKey: func(identity string) ([]byte, error) { return psk, nil }, + }, + CipherSuites: []uint16{tlspks.TLS_PSK_WITH_AES_256_CBC_SHA}, + InsecureSkipVerify: true, + } + if l.Listener { + // Provide dummy Certificates to make tlspsk happy on server side + cfg.Certificates = dummyCert() + l.wrap = func(c net.Conn) (net.Conn, error) { + return tlswithpks.Server(c, cfg), nil + } + } else { + l.wrap = func(c net.Conn) (net.Conn, error) { + return tlswithpks.Client(c, cfg), nil + } + } + case "dtlspsk": + var identity string + var psk []byte + for key, value := range params { + switch key { + case "key": + var err error + psk, err = hex.DecodeString(value) + if err != nil { + return fmt.Errorf("uri: invalid dtlspsk key parameter: %w", err) + } + case "identity": + identity = value + default: + return fmt.Errorf("uri: unknown dtlspsk parameter %q", key) + } + } + if len(psk) == 0 { + return fmt.Errorf("uri: missing dtlspsk key parameter") + } + if !l.Listener && identity == "" { + return fmt.Errorf("uri: dtlspsk client requires identity parameter") + } + cfg := &dtls.Config{ + PSK: func(hint []byte) ([]byte, error) { + return psk, nil + }, + PSKIdentityHint: []byte(identity), + CipherSuites: []dtls.CipherSuiteID{dtls.TLS_PSK_WITH_AES_128_GCM_SHA256}, + InsecureSkipVerify: true, + } + if l.Listener { + l.wrap = func(c net.Conn) (net.Conn, error) { + return dtls.Server(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } + } else { + l.wrap = func(c net.Conn) (net.Conn, error) { + return dtls.Client(dtlsnet.PacketConnFromConn(c), c.RemoteAddr(), cfg) + } + } + } + return nil +} + +func spkiVerifier(certPEM []byte) (func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error, error) { + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("uri: invalid PEM certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("uri: parse x509 certificate: %w", err) + } + spkiHash := sha256.New().Sum(cert.RawSubjectPublicKeyInfo) + return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + for _, rawCert := range rawCerts { + c, err := x509.ParseCertificate(rawCert) + if err != nil { + return fmt.Errorf("parse peer cert: %w", err) + } + if bytes.Equal(sha256.New().Sum(c.RawSubjectPublicKeyInfo), spkiHash) { + return nil + } + } + return fmt.Errorf("no matching SPKI found") + }, nil +} + +// dummyCert returns a self-signed certificate for use in tls-psk server mode. (ed25519) +func dummyCert() []tlswithpks.Certificate { + // Generated with: + // openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem -days 100000 -nodes -subj "/CN=dummy" + certPEM := `-----BEGIN CERTIFICATE----- +MIIBNjCB6aADAgECAhRX020iAjrT4wTjwRdAJ+PPjpe33DAFBgMrZXAwEDEOMAwG +A1UEAwwFZHVtbXkwIBcNMjUwOTIxMTUxNzMwWhgPMjI5OTA3MDcxNTE3MzBaMBAx +DjAMBgNVBAMMBWR1bW15MCowBQYDK2VwAyEA/8RGhnpLT8uPAm8Ah0vEYWCskGrk +R3lqdOjspIidVmKjUzBRMB0GA1UdDgQWBBRMUX8P7I1KV1UxMjcJlIT42a72ozAf +BgNVHSMEGDAWgBRMUX8P7I1KV1UxMjcJlIT42a72ozAPBgNVHRMBAf8EBTADAQH/ +MAUGAytlcANBAEFf17f1XhfLek4D203mGz8BihBfXfeL6kADMMV+G2qpkqZPcnTI +NXPuT9B/6+hM7nD/vh7JKXTfSAEFo22rzwA= +-----END CERTIFICATE----- +` + keyPEM := `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIEsb9X3HHGBFSe5jKvqNmua6ZFplNaiBROtJ7ZZAJlRz +-----END PRIVATE KEY----- +` + cert, err := tlswithpks.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err != nil { + panic("dummyCert: " + err.Error()) + } + return []tlswithpks.Certificate{cert} +} diff --git a/uri/scheme.go b/uri/scheme.go new file mode 100644 index 0000000..6118b52 --- /dev/null +++ b/uri/scheme.go @@ -0,0 +1,43 @@ +package uri + +import ( + "fmt" + "strings" +) + +type Scheme struct { + Listener bool + Transport + Layers Layers +} + +func (s Scheme) String() string { + str := s.Transport.String() + for _, l := range s.Layers.Layers { + str += "+" + l.String() + } + return str +} + +func (s Scheme) MarshalText() ([]byte, error) { + return []byte(s.String()), nil +} + +func (s *Scheme) UnmarshalText(text []byte) error { + parts := strings.SplitN(string(text), "+", 2) + if len(parts) == 0 { + return fmt.Errorf("uri: empty scheme") + } + + if err := s.Transport.UnmarshalText([]byte(parts[0])); err != nil { + return err + } + + if len(parts) == 1 { + return nil + } + + s.Layers.Listener = s.Listener + + return s.Layers.UnmarshalText([]byte(parts[1])) +} diff --git a/uri/transport.go b/uri/transport.go new file mode 100644 index 0000000..a4b889c --- /dev/null +++ b/uri/transport.go @@ -0,0 +1,32 @@ +package uri + +import ( + "fmt" + "strings" +) + +type Transport string + +const ( + TransportTCP Transport = "tcp" + TransportUDP Transport = "udp" +) + +func (t Transport) String() string { + return string(t) +} + +func (t Transport) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +func (t *Transport) UnmarshalText(text []byte) error { + str := strings.ToLower(strings.TrimSpace(string(text))) + switch Transport(str) { + case TransportTCP, TransportUDP: + *t = Transport(str) + return nil + default: + return fmt.Errorf("uri: unknown transport %q", str) + } +} diff --git a/uri/uri.go b/uri/uri.go new file mode 100644 index 0000000..f98c7db --- /dev/null +++ b/uri/uri.go @@ -0,0 +1,39 @@ +package uri + +import ( + "fmt" + "strings" +) + +type URI struct { + // This flag must be set if the URI is being applied to a listener (server side) + // The parser takes this into account when validating parameters + Listener bool + Scheme + Addr string +} + +func (u URI) String() string { + return u.Scheme.String() + "://" + u.Addr +} + +func (u URI) MarshalText() ([]byte, error) { + return []byte(u.String()), nil +} + +func (u *URI) UnmarshalText(text []byte) error { + str := string(text) + parts := strings.SplitN(str, "://", 2) + if len(parts) < 2 { + return fmt.Errorf("uri: missing scheme delimiter in %q", str) + } + + u.Addr = strings.TrimSpace(parts[1]) + if u.Addr == "" { + return fmt.Errorf("uri: empty address in %q", str) + } + + u.Scheme.Listener = u.Listener + + return u.Scheme.UnmarshalText([]byte(parts[0])) +} From 6dbcc4aeb54c4ed7670bbeac5e33becade50292f Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:08:23 +0200 Subject: [PATCH 4/7] fix: lint --- aesgcm_conn.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aesgcm_conn.go b/aesgcm_conn.go index 851472f..dc30881 100644 --- a/aesgcm_conn.go +++ b/aesgcm_conn.go @@ -74,7 +74,7 @@ func NewAESGCMConn(c net.Conn, key []byte, opts ...AESGCMOption) (net.Conn, erro // Passive handshake (duplex): concurrently read peer IV while writing ours handshakeDeadline := time.Now().Add(5 * time.Second) _ = c.SetDeadline(handshakeDeadline) - defer c.SetDeadline(time.Time{}) // clear deadline after handshake + defer func() { _ = c.SetDeadline(time.Time{}) }() // clear deadline after handshake // Start read of peer's 12-byte IV readErrCh := make(chan error, 1) From 4e083788a1cc0f1c55f1c50f80375532fcba5fa0 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:08:43 +0200 Subject: [PATCH 5/7] feat: Add JSON marshaling and unmarshaling for Layers and update URI and Scheme structs --- uri/layer.go | 65 ++++++++++++++++++++++++++++++++------------------- uri/scheme.go | 6 ++--- uri/uri.go | 4 ++-- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/uri/layer.go b/uri/layer.go index 9e639e1..ca8ec17 100644 --- a/uri/layer.go +++ b/uri/layer.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/hex" + "encoding/json" "encoding/pem" "errors" "fmt" @@ -63,10 +64,19 @@ func (ls *Layers) UnmarshalText(text []byte) error { return nil } +func (ls Layers) MarshalJSON() ([]byte, error) { + return json.Marshal(ls.Layers) +} + +func (ls *Layers) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &ls.Layers) +} + type Layer struct { Listener bool - string - wrap func(net.Conn) (net.Conn, error) + Prot string + Params map[string]string + wrap func(net.Conn) (net.Conn, error) } func (l Layer) Wrap(conn net.Conn) (net.Conn, error) { @@ -77,24 +87,31 @@ func (l Layer) Wrap(conn net.Conn) (net.Conn, error) { } func (l Layer) String() string { - return l.string + pairs := make([]string, 0, len(l.Params)) + for k, v := range l.Params { + pairs = append(pairs, k+"="+v) + } + if len(pairs) > 0 { + return fmt.Sprintf("%s[%s]", l.Prot, strings.Join(pairs, ",")) + } + return l.Prot } func (l Layer) MarshalText() ([]byte, error) { - return []byte(l.string), nil + return []byte(l.String()), nil } func (l *Layer) UnmarshalText(text []byte) error { - l.string = string(text) + str := string(text) - prot := strings.ToLower(strings.TrimSpace(l.string)) - params := map[string]string{} - if idx := strings.Index(l.string, "["); idx != -1 { - if !strings.HasSuffix(l.string, "]") { - return fmt.Errorf("uri: missing ']' in layer %q", l.string) - } - prot = strings.ToLower(strings.TrimSpace(l.string[:idx])) - for pair := range strings.SplitSeq(l.string[idx+1:len(l.string)-1], ",") { + l.Prot = strings.ToLower(strings.TrimSpace(str)) + l.Params = map[string]string{} + if idx := strings.Index(str, "["); idx != -1 { + if !strings.HasSuffix(str, "]") { + return fmt.Errorf("uri: missing ']' in layer %q", str) + } + l.Prot = strings.ToLower(strings.TrimSpace(str[:idx])) + for pair := range strings.SplitSeq(str[idx+1:len(str)-1], ",") { kv := strings.SplitN(pair, "=", 2) if len(kv) != 2 { return fmt.Errorf("uri: invalid parameter %q", pair) @@ -104,14 +121,14 @@ func (l *Layer) UnmarshalText(text []byte) error { if key == "" { return fmt.Errorf("uri: empty parameter key") } - params[key] = value + l.Params[key] = value } } - switch prot { + switch l.Prot { case "framed": opts := []netx.FramedConnOption{} - for key, value := range params { + for key, value := range l.Params { switch key { case "maxsize": maxSize, err := strconv.ParseUint(value, 10, 31) @@ -128,7 +145,7 @@ func (l *Layer) UnmarshalText(text []byte) error { } case "buffered": opts := []netx.BufConnOption{} - for key, value := range params { + for key, value := range l.Params { switch key { case "size": size, err := strconv.ParseUint(value, 10, 31) @@ -146,7 +163,7 @@ func (l *Layer) UnmarshalText(text []byte) error { case "aesgcm": aeskey := []byte{} opts := []netx.AESGCMOption{} - for key, value := range params { + for key, value := range l.Params { switch key { case "key": var err error @@ -177,7 +194,7 @@ func (l *Layer) UnmarshalText(text []byte) error { var pass string var sshkey ssh.Signer // Host key for server, private key for client var pubkey ssh.PublicKey - for key, value := range params { + for key, value := range l.Params { switch key { case "pass": pass = value @@ -261,7 +278,7 @@ func (l *Layer) UnmarshalText(text []byte) error { MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, } - for key, value := range params { + for key, value := range l.Params { switch key { case "key": var err error @@ -322,7 +339,7 @@ func (l *Layer) UnmarshalText(text []byte) error { MaxVersion: tls.VersionTLS13, } id := utls.HelloChrome_Auto - for key, value := range params { + for key, value := range l.Params { switch key { case "cert": var err error @@ -375,7 +392,7 @@ func (l *Layer) UnmarshalText(text []byte) error { case "dtls": var certKey, cert []byte cfg := &dtls.Config{} - for key, value := range params { + for key, value := range l.Params { switch key { case "key": var err error @@ -429,7 +446,7 @@ func (l *Layer) UnmarshalText(text []byte) error { case "tlspsk": var identity string var psk []byte - for key, value := range params { + for key, value := range l.Params { switch key { case "key": var err error @@ -473,7 +490,7 @@ func (l *Layer) UnmarshalText(text []byte) error { case "dtlspsk": var identity string var psk []byte - for key, value := range params { + for key, value := range l.Params { switch key { case "key": var err error diff --git a/uri/scheme.go b/uri/scheme.go index 6118b52..85dfb03 100644 --- a/uri/scheme.go +++ b/uri/scheme.go @@ -6,9 +6,9 @@ import ( ) type Scheme struct { - Listener bool - Transport - Layers Layers + Listener bool + Transport `json:"transport"` + Layers Layers `json:"layers"` } func (s Scheme) String() string { diff --git a/uri/uri.go b/uri/uri.go index f98c7db..3b54992 100644 --- a/uri/uri.go +++ b/uri/uri.go @@ -9,8 +9,8 @@ type URI struct { // This flag must be set if the URI is being applied to a listener (server side) // The parser takes this into account when validating parameters Listener bool - Scheme - Addr string + Scheme `json:"scheme"` + Addr string `json:"addr"` } func (u URI) String() string { From 557b5c34cd4f6bf879783befdce7d94b38623707 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:16:13 +0200 Subject: [PATCH 6/7] fix: Update parameter names and improve documentation in README and URI format --- README.md | 25 +++++++++++-------------- Taskfile.yml | 2 +- cmd/netx/uri.go | 4 ++-- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6a6b55d..34f2130 100644 --- a/README.md +++ b/README.md @@ -202,38 +202,35 @@ Chains use the form `://host:port` where `` is a `+`-separated lis - `tls` - Transport Layer Security - Server params: `cert`, `key` - - Client params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided) + - Client params: `cert` (optional, for SPKI pinning), `servername` (required if cert not provided) - `utls` - TLS with client fingerprint camouflage via uTLS - Client-side only - - Params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided), `hello` (optional: chrome, firefox, ios, android, safari, edge, randomized, randomizednoalpn; default: chrome) + - Params: `cert` (optional, for SPKI pinning), `servername` (required if cert not provided), `hello` (optional: chrome, firefox, ios, android, safari, edge, randomized, randomizednoalpn; default: chrome) - `dtls` - Datagram Transport Layer Security - Server params: `cert`, `key` - - Client params: `cert` (optional, for SPKI pinning), `serverName` (required if cert not provided) + - Client params: `cert` (optional, for SPKI pinning), `servername` (required if cert not provided) - `tlspsk` - TLS with pre-shared key (TLS 1.2, cipher: TLS_PSK_WITH_AES_256_CBC_SHA) - - ⚠️ WARNING: Uses deprecated library, use at your own risk! - - Params: `key` (hex-encoded), `identity` + - Params: `key`, `identity` - `dtlspsk` - DTLS with pre-shared key (cipher: TLS_PSK_WITH_AES_128_GCM_SHA256) - - Params: `key` (hex-encoded), `identity` + - Params: `key`, `identity` - `aesgcm` - AES-GCM encryption with passive IV exchange - - Params: `key` (hex-encoded), `maxPacket` (optional, default: 32768) + - Params: `key`, `maxpacket` (optional, default: 32768) - `buffered` - Buffered read/write for better performance - - Params: `buf` (optional, default: 4096) + - Params: `size` (optional, default: 4096) - `framed` - Length-prefixed frames for packet semantics over streams - Params: `maxFrame` (optional, default: 32768) - `ssh` - SSH tunneling via "direct-tcpip" channels - - Server params: `hostKey`, `user` (optional, required with pass), `pass` (optional), `authKey` (optional, required if no pass) - - Client params: `hostKey` (or `insecure=true`), `user`, `pass` (optional), `key` (optional, required if no pass) + - Server params: `key` (optional, required with pass), `pass` (optional), `pubkey` (optional, required if no pass) + - Client params: `pubkey`, `pass` (optional), `key` (optional, required if no pass) **Notes:** - -- If `cert` is provided on the client for `tls`/`dtls`/`utls`, default validation is disabled and SPKI (SubjectPublicKeyInfo) pinning is performed instead -- Multiple wrappers can be chained on either side -- The tool uses `TunMaster` under the hood for efficient bidirectional relay +- All passwords, keys and certificates must be provided as hex-encoded strings. +- When using `cert` for client-side `tls`/`utls`/`dtls`, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed against the provided certificate. This is certificate pinning and will fail if the server presents a different key. diff --git a/Taskfile.yml b/Taskfile.yml index f137a47..d36c743 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -34,7 +34,7 @@ tasks: - env GOOS=windows GOARCH=arm64 go build -o build/netx_windows_arm64.exe cmd/netx/*.go # - env GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libnetx_windows_x64.dll cmd/netx/lib/main.go # # aarch64-w64-mingw32-gcc is experimental and not available - # # - env GOOS=windows GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libenetx_windows_arm64.dll cmd/lib/main.go + # # - env GOOS=windows GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-w64-mingw32-gcc go build -buildmode=c-shared -o build/libnetx_windows_arm64.dll cmd/lib/main.go # # macOS binaries - env GOOS=darwin GOARCH=amd64 go build -o build/netx_macos_x64 cmd/netx/*.go - env GOOS=darwin GOARCH=arm64 go build -o build/netx_macos_arm64 cmd/netx/*.go diff --git a/cmd/netx/uri.go b/cmd/netx/uri.go index e9cb4fb..49ea516 100644 --- a/cmd/netx/uri.go +++ b/cmd/netx/uri.go @@ -1,7 +1,7 @@ package main const uriFormat = `URI Format: - +[layer1param1key=layer1param1value,layer1param2key=layer1param2key,...]++...://
+ +[layer1param1key=layer1param1value,layer1param2key=layer1param2value,...]++...://
Examples: tcp+tls[cert=$(cat server.crt | xxd -p),key=$(cat server.key | xxd -p)]://:9000 @@ -36,7 +36,7 @@ const uriFormat = `URI Format: Notes: - All passwords, keys and certificates must be provided as hex-encoded strings. - - When using 'cert' for client-side TLS/DTLS, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed + - When using 'cert' for client-side TLS/uTLS/DTLS, default validation is disabled and a manual SPKI (SubjectPublicKeyInfo) hash comparison is performed against the provided certificate. This is certificate pinning and will fail if the server presents a different key. - SSH server must accept "direct-tcpip" channels (most do by default). ` From f40c42ae9f547652c2babd3a2518e2b47e070179 Mon Sep 17 00:00:00 2001 From: pedramktb <79080845+pedramktb@users.noreply.github.com> Date: Thu, 16 Oct 2025 19:19:03 +0200 Subject: [PATCH 7/7] fix: Update parameter names in README and URI format for consistency --- README.md | 4 ++-- cmd/netx/uri.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 34f2130..2577f7a 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ netx tun -h # Example: TCP TLS server to TCP TLS+buffered+framed+aesgcm client netx tun \ --from tcp+tls[cert=server.crt,key=server.key]://:9000 \ - --to tcp+tls[cert=client.crt]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 + --to tcp+tls[cert=client.crt]+buffered[size=8192]+framed[maxsize=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 # Example: UDP DTLS server to UDP aesgcm client netx tun \ @@ -225,7 +225,7 @@ Chains use the form `://host:port` where `` is a `+`-separated lis - Params: `size` (optional, default: 4096) - `framed` - Length-prefixed frames for packet semantics over streams - - Params: `maxFrame` (optional, default: 32768) + - Params: `maxsize` (optional, default: 32768) - `ssh` - SSH tunneling via "direct-tcpip" channels - Server params: `key` (optional, required with pass), `pass` (optional), `pubkey` (optional, required if no pass) diff --git a/cmd/netx/uri.go b/cmd/netx/uri.go index 49ea516..87e2d24 100644 --- a/cmd/netx/uri.go +++ b/cmd/netx/uri.go @@ -5,7 +5,7 @@ const uriFormat = `URI Format: Examples: tcp+tls[cert=$(cat server.crt | xxd -p),key=$(cat server.key | xxd -p)]://:9000 - tcp+tls[cert=$(cat client.crt | xxd -p)]+buffered[buf=8192]+framed[maxFrame=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 + tcp+tls[cert=$(cat client.crt | xxd -p)]+buffered[size=8192]+framed[maxsize=4096]+aesgcm[key=00112233445566778899aabbccddeeff]://example.com:9443 Supported transports: - tcp: TCP listener or dialer