Skip to content
wojciechpolakPublic

About

Discreet HTTPS and WebSocket proxy over an ECH-protected WSS relay, preserving end-to-end application TLS.

Topics

Resources

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

dproxy

dproxy is a discreet encrypted proxy for HTTPS and WebSocket traffic. It runs a local HTTP CONNECT proxy and carries each accepted connection through an ECH-capable WSS relay without terminating or inspecting the application's TLS session.

The d means discreet. dproxy is not a traffic-obfuscation tool or a TLS interception service.

Use dproxy when you want a tool's ordinary HTTPS requests to cross an untrusted network without exposing the destination or application data there. For example, set HTTPS_PROXY=http://127.0.0.1:18080 before running Codex or Claude Code. The same setup works with any client that honors HTTPS_PROXY.

Tip

Looking for discreet uploads and downloads instead? Check out DUD.

How dproxy works

flowchart TB
    accTitle: dproxy connection path
    accDescr: An HTTPS or WSS client connects to local dproxy, which crosses an ECH-capable WSS front end to remote dproxy and then reaches a public origin on TCP port 443.

    client["HTTPS or WSS client"]
    local["local dproxy"]
    frontend["ECH-capable WSS front end"]
    remote["remote dproxy"]
    origin["Public HTTPS or WSS origin"]

    client -->|"HTTP CONNECT on 127.0.0.1:18080"| local
    local -->|"DoH, TLS 1.3, mandatory ECH, and WSS"| frontend
    frontend -->|"WebSocket carrying pinned inner TLS 1.3"| remote
    remote -->|"TCP to a public address on port 443"| origin
Loading

Three encrypted layers have separate jobs:

  1. Outer TLS 1.3 with ECH protects the connection from the local network and hides the relay hostname in the TLS handshake.
  2. Pinned inner TLS 1.3 hides the token, target hostname, control protocol, and application bytes from the WSS front-end operator.
  3. The original application TLS session runs from the calling tool to the provider. dproxy never decrypts it.

Each local CONNECT gets one WSS connection, one inner TLS session, and one remote TCP connection. v1 does not multiplex connections.

Stack

Part Technology
Implementation Go 1.26.9, with no third-party modules in the shipped binary
Local interface HTTP/1.1 CONNECT through HTTPS_PROXY
Outer transport In-process DoH, DNS HTTPS records, TLS 1.3, mandatory ECH, and WSS
Private channel Inner TLS 1.3 with a pinned Ed25519 server identity
Tunnel protocol Length-prefixed dproxy/1 control messages, followed by an unframed TCP byte stream
Remote deployment Non-root Docker image behind a provider-neutral WSS front end; Cloudflare Tunnel is the included example
Distribution Reproducible macOS, Linux, and Windows binaries and archives, checksums, provenance, Homebrew, and GHCR

The shipped Go binary does not depend on Docker or Cloudflare; those belong to the remote deployment.

Security rules

dproxy fails closed. It does not retry with weaker transport settings.

  • DNS uses an in-process DNS-over-HTTPS resolver. There is no operating-system DNS fallback.
  • The outer connection requires TLS 1.3, a valid public certificate, an HTTPS DNS record with ECH configuration, and an accepted ECH handshake.
  • The inner connection requires TLS 1.3 and a matching remote identity pin. The remote can also require a pinned client certificate, making the inner session mutual TLS (mTLS). The certificate is required alongside the shared token, never instead of it.
  • By default, any hostname on port 443 is eligible. Operators can configure independent local and remote allowlists to narrow that policy.
  • Only hostname destinations on port 443 are accepted. IP literals and resolved private, loopback, link-local, multicast, unspecified, or otherwise reserved addresses are rejected.
  • Tokens, application traffic, and destination hostnames are omitted from logs by default.
  • At the default info level, the client and server each record a successful connection. The destination remains redacted unless the operator enables log.include_targets or passes --log-targets.

See the threat model for trust boundaries, known limits, and the threats v1 does not try to solve.

Requirements

  • macOS, Linux, or Windows on arm64 or amd64 for a prebuilt binary, or Go 1.26.9 to build it
  • Docker and an ECH-capable TLS/WSS front end for the remote relay deployment

The shipped binary uses only the Go standard library. Development tools live in a separate module under tools/.

Install with Homebrew

Install the latest stable release from the project tap:

brew install wojciechpolak/dproxy/dproxy
dproxy --version

Homebrew checks the architecture-specific archive against the SHA-256 digest in the formula. Releases also carry signed GitHub build provenance for independent verification. See the release guide for verification, upgrade, uninstall, and maintainer instructions.

Download a prebuilt binary

Download the latest stable Linux amd64 binary with curl:

curl -fL -o dproxy \
  https://github.com/wojciechpolak/dproxy/releases/latest/download/dproxy-linux-amd64
sudo install -m 0755 dproxy /usr/local/bin/dproxy
dproxy --version

Release assets are plain HTTPS downloads, so GitHub CLI is not required. /releases/latest/download/ resolves to the newest stable release and never to a prerelease tag. Use /releases/download/vX.Y.Z/ to pin a version. Choose the platform with dproxy-linux-amd64, dproxy-linux-arm64, dproxy-darwin-amd64, dproxy-darwin-arm64, dproxy-windows-amd64.exe, or dproxy-windows-arm64.exe.

With GitHub CLI installed, the same download is:

gh release download vX.Y.Z --pattern 'dproxy-linux-amd64'
sudo install -m 0755 dproxy-linux-amd64 /usr/local/bin/dproxy

On Windows, download the executable with PowerShell and put it in a directory on PATH:

Invoke-WebRequest `
  https://github.com/wojciechpolak/dproxy/releases/latest/download/dproxy-windows-amd64.exe `
  -OutFile dproxy.exe
.\dproxy.exe --version

Use dproxy-windows-arm64.exe on Windows on ARM.

The release guide explains how to verify the binary checksum and GitHub build attestation before installing it.

Build from source

git clone https://github.com/wojciechpolak/dproxy.git
cd dproxy
make build
./bin/dproxy version

The binary is written to bin/dproxy.

Deploy the remote relay

The v1 relay is a non-root Go container behind a WSS front end. The front end must publish ECH in DNS with public names distinct from the relay hostname, accept ECH with TLS 1.3, present a publicly trusted certificate, support WebSockets, and forward /v1/tunnel to dproxy over HTTP/1.1.

The published remote-server image supports Linux on arm64 and amd64. Pull the latest stable release with:

docker pull ghcr.io/wojciechpolak/dproxy:latest

The relay authenticates clients with a shared token. It can additionally require a pinned client certificate: see Require pinned client certificates.

Stable releases also publish X.Y.Z and X.Y tags. Use a full X.Y.Z tag when you need deliberate upgrades. To use the published image with the included Compose deployment, remove the build block from the dproxy service and set:

image: ghcr.io/wojciechpolak/dproxy:latest

The release guide includes the image attestation command.

The relay hostname can be public, but the relay is not an open proxy. Remote dproxy requires the shared secret token before it resolves or connects to any destination. A client without the token cannot use the relay. Treat the token as a bearer credential: anyone who obtains a copy can use the relay until you rotate it.

The included Compose setup uses Cloudflare Tunnel because it provides this combination conveniently and keeps the dproxy listener off the host network. Cloudflare is not required. Another container host, ingress, or edge service is compatible when it meets the same transport and listener-isolation rules.

Follow the deployment guide to create the token and persistent inner-TLS identity, configure the /v1/tunnel public hostname, start the containers, and record the server's sha256: identity pin.

Do not expose the dproxy container directly or put credentials in the image, environment, or command line.

Configure the local client

Transfer these two values from the remote deployment to each client:

  • the contents of secrets/dproxy_token, which become the client's token file;
  • the identity_pin=sha256:... value printed when the server starts, which becomes server_pin in the client configuration.

Use an authenticated secure channel for both. Keep secrets/dproxy_token confidential because it is a bearer credential. The identity pin is safe to disclose, but the client must receive the correct value so it can detect an impostor. Never copy state/identity.pem to a client. It contains the server's private key. The Cloudflare Tunnel token is also server-side only.

If the relay requires pinned client certificates, one value goes back to the relay operator. Set client_identity_file in the client configuration, start the client, and send the client_pin=sha256:... value from its startup record to the relay operator for client_pins. The identity file is created on first use with owner-only permissions and must never leave the client.

The client token file must contain the exact same bytes as secrets/dproxy_token. On macOS and Linux, store it under the XDG configuration directory with mode 0600. dproxy uses $XDG_CONFIG_HOME when set and ~/.config otherwise, including on macOS.

config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
mkdir -p "$config_home/dproxy"
install -m 600 /path/to/copied/dproxy_token "$config_home/dproxy/token"
cp configs/client.example.toml "$config_home/dproxy/client.toml"

On Windows, dproxy uses %AppData%\dproxy unless XDG_CONFIG_HOME is set. Copy the token and configuration with PowerShell, then remove inherited permissions from the token so only the current account can read it:

$configHome = Join-Path $env:AppData "dproxy"
New-Item -ItemType Directory -Force $configHome | Out-Null
$token = Join-Path $configHome "token"
Copy-Item C:\path\to\dproxy_token $token
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
icacls $token /inheritance:r /grant:r "${account}:(R)"
Copy-Item .\configs\client.example.toml (Join-Path $configHome "client.toml")

Without --config, dproxy client and dproxy test load dproxy/client.toml from that directory. dproxy server looks for dproxy/server.toml. A missing discovered file is fine, so every setting can still come from flags.

Edit client.toml:

  • set server to the public wss:// relay URL;
  • set server_pin to the transferred identity pin;
  • keep ech = "required";
  • leave allowlist and allowlist_file unset to allow every public hostname on port 443. To restrict access to OpenAI and Anthropic, copy configs/allowlist.example and configure that file on both the client and server.

Check the complete transport without proxying application traffic:

./bin/dproxy test

Start the local proxy:

./bin/dproxy client

The native process intentionally stays in the foreground. Run a direct download or source build under the supervisor provided by the operating system instead of expecting dproxy to fork or manage a PID file.

The Homebrew formula installs dproxy on macOS and Linux. On macOS it also supplies an optional per-user service. Configure the client first, then manage it with:

brew services start dproxy
brew services info dproxy
brew services restart dproxy
brew services stop dproxy

Do not run these commands with sudo. Without sudo, Homebrew creates a per-user LaunchAgent that starts at login and reads that user's configuration. Using sudo creates a system service with a different home directory and more privilege than the loopback client needs. Restart the service after changing client.toml. Its stderr log is under $(brew --prefix)/var/log/dproxy.log.

If XDG_CONFIG_HOME is set only by shell startup files, a LaunchAgent will not inherit it. Put XDG_CONFIG_HOME=/absolute/path in ~/.homebrew/services/dproxy.env, then restart the service, or keep the service configuration under ~/.config/dproxy.

Point a client at it:

export HTTPS_PROXY=http://127.0.0.1:18080
export https_proxy="$HTTPS_PROXY"

In PowerShell:

$env:HTTPS_PROXY = "http://127.0.0.1:18080"

The local listener accepts HTTP/1.1 CONNECT only. It rejects forward HTTP requests, non-443 ports, IP literals, and destinations outside a configured allowlist.

Run dproxy client --help, dproxy server --help, or dproxy test --help for all flags. --config overrides automatic discovery. Explicit flags override values read from the TOML file. Unknown configuration keys are startup errors.

Run the client in a dev container

There is no separate client image. The published image contains the same static Linux binary, and dproxy client runs from it. The client listener is loopback-only, so the client must share a network namespace with the program that uses it. A published port cannot reach it, and binding 0.0.0.0 is a startup error.

In a dev container, copy the binary into the dev container image. This needs neither Homebrew nor Go:

COPY --from=ghcr.io/wojciechpolak/dproxy:X.Y.Z /dproxy /usr/local/bin/dproxy

Pin a full X.Y.Z tag or an image digest, and verify the image attestation as described in the release guide. Inside the dev container, configure the token and client.toml as above, run dproxy client, and set HTTPS_PROXY=http://127.0.0.1:18080 for the tools that use it. Bind-mount the token from the host instead of copying it into the image or committing it under .devcontainer/. The token file must be mode 0600 and owned by the container user. If you use client_identity_file, keep it on a persistent writable mount: a rebuild that recreates it changes the client pin.

When the dev container is a Compose project, the client can instead run as a sidecar from the published image. The application container joins the sidecar's network namespace, so both see the same 127.0.0.1:18080:

services:
  dproxy-client:
    image: ghcr.io/wojciechpolak/dproxy:X.Y.Z
    command: ["client", "--config", "/etc/dproxy/client.toml"]
    user: "${DPROXY_UID:-65532}:${DPROXY_GID:-65532}"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    volumes:
      - ./dproxy/client.toml:/etc/dproxy/client.toml:ro
      - ./secrets/dproxy_token:/run/secrets/dproxy_token:ro

  app:
    network_mode: "service:dproxy-client"
    environment:
      HTTPS_PROXY: http://127.0.0.1:18080

The image has no default token path, so set token_file = "/run/secrets/dproxy_token" in the mounted client.toml. With Docker-outside-of-Docker, where the dev container uses the host's Docker socket, docker run --network host joins the host's network, not the dev container's. Its loopback listener is then unreachable from the dev container.

Development

Run the local quality gate before submitting a change:

make check

It checks formatting, Markdown, module tidiness, the zero-dependency policy, static analysis, unit tests, coverage, race detection, and known vulnerabilities. The full CI-equivalent gate also runs deterministic end-to-end tests:

make ci

The testing guide covers the end-to-end and public deployment checks.

Documentation

License

dproxy is available under the MIT License.

About

Discreet HTTPS and WebSocket proxy over an ECH-protected WSS relay, preserving end-to-end application TLS.

Topics

Resources

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages