Proxy-Over-SMTP is a SOCKS4/5, HTTP and HTTPS proxy tunnel that opens with a fake SMTP session and carries the payload through a selectable XOR or AES-256-GCM stream. This project is inspired by smtp-tunnel-proxy.
A client exposes one local proxy port that speaks SOCKS4/4a, SOCKS5, HTTP and HTTPS. A server answers a fake SMTP handshake, then carries every proxied connection as a multiplexed stream inside a single TCP connection.
The command line changed completely. Old command lines no longer work: Cobra rejects single-dash long flags such as -secret and -mode. Update every service unit, Docker command and script before upgrading.
| Before | Now |
|---|---|
-mode server |
server subcommand |
-mode client |
client subcommand |
-server ADDR |
server --listen ADDR |
-client ADDR |
client --listen ADDR |
-remote ADDR |
client --remote ADDR |
-secret X (default THIS_IS_YOUR_SECRET_WORD) |
--secret X or env PROXY_OVER_SMTP_SECRET. Required, no default. |
-log-file PATH (default ./proxy-over-smtp.log) |
--log-file PATH. Off by default, logs go to stdout only |
AUDIT: ... plain text lines |
slog structured lines (--log-format text or json) |
-allow-private |
server --allow-private |
Client and server must be upgraded together. The tunnel uses smux protocol v2 (per-stream flow control) and a challenge-response handshake. The handshake is now a real RFC 5321 envelope with the proof carried as the X-PROOF parameter on MAIL FROM, so old and new binaries do not interoperate on any of the three counts. The RFC alignment sweep on top of it β case-insensitive verbs, HELO, any EHLO argument, the 250/502/500/501/503 reply mapping β changed only error-path replies, so an older client still works against a newer server.
- π SMTP Disguise: Every connection opens with a plausible RFC 5321 mail transaction β
220/EHLO/MAIL FROM/RCPT TO/DATA/354β with the HMAC proof hidden in an ESMTP parameter. Commands are case-insensitive andHELOis accepted as an alias forEHLO, the way a real relay answers. A failure gets the reply a real server gives it:250forNOOP/RSET,502forVRFY/EXPN/HELP,500for a malformed line, an over-long line or a bad proof,501for an opening command whose argument is missing,503for a command out of order,221forQUIT. Only an I/O error, a timeout or a peer that closes without a word passes silently, and every reply is the last thing the session sends. - π Selectable Cipher:
--cipher aes(default) wraps the tunnel in AES-256-GCM records for confidentiality and integrity;--cipher xorkeeps a fast rolling-key XOR for obfuscation only. - π€ Challenge-Response Handshake: The server sends a fresh nonce and the client answers with an HMAC of the secret. The secret is never sent on the wire, and both stream keys are derived from it per connection.
- β‘ Multiplexed Tunnel: One TCP connection carries many streams via smux, with keepalive, for low latency and fewer handshakes.
- π§ Tuned Sockets:
TCP_NODELAY, TCP keepalive, andSO_REUSEADDR/SO_REUSEPORTon Unix listeners are set in code, and send/receive buffers stay under kernel autotuning so bulk transfers are not capped by a fixed buffer/RTT limit. Nothing to configure: see Socket options. - 𧦠Many Proxy Protocols, One Port: SOCKS4/4a, SOCKS5, HTTP (plain and
CONNECT) and HTTPS (TLS proxy listener) are auto-detected from the first byte. Works with browsers,curl,git,aptand anything that honorshttp_proxy/https_proxyor SOCKS. - π Graceful Shutdown: On
SIGINT/SIGTERMthe listener stops and active connections drain until done or--drain-timeout(default 30s). A second signal forces exit. - π Structured Logs:
slogevents on stdout (text or JSON), onetunnel openedline per proxied connection. Optional log file. - π± Twelve-Factor: All settings from flags or
PROXY_OVER_SMTP_*env vars. The secret has no default. - π¦ Static Binaries:
CGO_ENABLED=0builds for Linux, macOS, and Windows, plus a Docker image.
graph LR
App["Browser / curl<br/>(SOCKS4/5, HTTP, HTTPS)"] --> Client["Client<br/>:1080"]
Client -- "fake SMTP handshake<br/>+ XOR / AES-256-GCM<br/>+ smux" --> Server["Server<br/>:465"]
Server -- "protocol detect + negotiate<br/>per stream" --> Target["Target host"]
See docs/ARCHITECTURE.md and docs/WORKFLOWS.md for details.
- Go (1.25+)
- Make (For Builds)
- GoReleaser (Optional, for mass binaries)
- Docker (Optional, for containers)
- Install Docker following the official guide.
- Run the server side:
docker run -d \ -p 465:465 \ -e PROXY_OVER_SMTP_SECRET="change-me" \ --name proxy-over-smtp-server \ --rm dimaskiddo/proxy-over-smtp:latest \ server --listen 0.0.0.0:465 - Run the client side:
docker run -d \ -p 1080:1080 \ -e PROXY_OVER_SMTP_SECRET="change-me" \ --name proxy-over-smtp-client \ --rm dimaskiddo/proxy-over-smtp:latest \ client --listen 0.0.0.0:1080 --remote 192.168.1.100:465 - Point your browser or tool at
127.0.0.1:1080(or your client port) as a SOCKS or HTTP proxy. See Using the proxy. - Docker sends
SIGKILLafter 10s by default, which cuts the drain. Give it more time than--drain-timeout:docker run --stop-timeout 35 ..., orstop_grace_period: 35sin Compose. In Kubernetes setterminationGracePeriodSecondsabove the drain timeout.
The image entrypoint is proxy-over-smtp and the default command is server. That default is an explicit subcommand, so it always wins over PROXY_OVER_SMTP_MODE; the variable is for runs whose command line carries no subcommand at all.
- Download the latest release from the Releases Page and extract it.
- Run server and client (set the secret in the environment so it stays out of
psand shell history):
chmod 755 proxy-over-smtp
export PROXY_OVER_SMTP_SECRET="change-me"
# Server
./proxy-over-smtp server --listen 0.0.0.0:465
# Client
./proxy-over-smtp client --listen 0.0.0.0:1080 --remote 192.168.1.100:465(PowerShell)
$env:PROXY_OVER_SMTP_SECRET = "change-me"
# Server
.\proxy-over-smtp.exe server --listen 0.0.0.0:465
# Client
.\proxy-over-smtp.exe client --listen 0.0.0.0:1080 --remote 192.168.1.100:465git clone -b master https://github.com/dimaskiddo/proxy-over-smtp.git
cd proxy-over-smtp
make vendor # Pull vendor packages
make run ARGS="server --secret change-me" # Run from source
make build # Build binary for this platform
make release # (Optional) Mass binaries via GoReleaser, output in dist/proxy-over-smtp <command> [flags]
server Run the tunnel server
client Run the local proxy client (SOCKS4/5, HTTP, HTTPS)
update Update this binary to the latest GitHub release
version Print version information (also --version)
version prints one line, the release tag and the commit it was built from:
Proxy-Over-SMTP v0.5.0~c04bfca
With no subcommand, PROXY_OVER_SMTP_MODE picks the mode instead β useful where the command line is fixed, such as a systemd unit, a container or a Compose service:
# systemd unit
Environment=PROXY_OVER_SMTP_MODE=server
Environment=PROXY_OVER_SMTP_SECRET=change-me
ExecStart=/usr/local/bin/proxy-over-smtpIt accepts server or client only, and an explicit subcommand always wins. update and version are never selected this way, so a stray environment variable cannot self-replace the binary.
Server and client use the same binary and the same secret. Every flag can also be set through an environment variable named PROXY_OVER_SMTP_ plus the flag name in upper case with - as _. Precedence: flag, then env, then default.
| Flag | Env | Default | Commands | Purpose |
|---|---|---|---|---|
| β | PROXY_OVER_SMTP_MODE |
empty | server, client | Mode to run when no subcommand is given: server or client. An explicit subcommand wins |
--listen |
PROXY_OVER_SMTP_LISTEN |
server 0.0.0.0:465, client 0.0.0.0:1080 |
server, client | Listen address |
--remote |
PROXY_OVER_SMTP_REMOTE |
127.0.0.1:465 |
client | Server address the client dials |
--secret |
PROXY_OVER_SMTP_SECRET |
none, required | server, client | Shared secret: handshake authentication and stream key master. Prefer the env var |
--cipher |
PROXY_OVER_SMTP_CIPHER |
aes |
server, client | Stream cipher: xor or aes. Both ends must match |
--max-streams |
PROXY_OVER_SMTP_MAX_STREAMS |
128 |
server, client | Max concurrent streams per session. Enforced by the server; on the client it is the per-session load at which the pool grows |
--pool-min |
PROXY_OVER_SMTP_POOL_MIN |
2 |
client | Floor the client's session pool shrinks back to. The pool dials on demand, so it never starts here. Client-only |
--pool-max |
PROXY_OVER_SMTP_POOL_MAX |
8 |
client | Max tunnel sessions the client opens under load. Both must satisfy 1 <= min <= max <= 16 |
--allow-private |
PROXY_OVER_SMTP_ALLOW_PRIVATE |
false |
server | Allow loopback, private and link-local targets (blocked by default) |
--tls-cert |
PROXY_OVER_SMTP_TLS_CERT |
empty | client | PEM certificate. With --tls-key, enables the HTTPS (TLS) proxy listener |
--tls-key |
PROXY_OVER_SMTP_TLS_KEY |
empty | client | PEM private key for --tls-cert. Both or neither |
--drain-timeout |
PROXY_OVER_SMTP_DRAIN_TIMEOUT |
30s |
server, client | Max time to let active connections finish on shutdown. 0 closes them at once |
--log-level |
PROXY_OVER_SMTP_LOG_LEVEL |
info |
all | debug, info, warn, error |
--log-format |
PROXY_OVER_SMTP_LOG_FORMAT |
text |
all | text or json |
--log-file |
PROXY_OVER_SMTP_LOG_FILE |
empty | all | Also append logs to this file |
--auto-update |
PROXY_OVER_SMTP_AUTO_UPDATE |
false |
server, client | Check for new releases, install and restart in place |
--update-interval |
PROXY_OVER_SMTP_UPDATE_INTERVAL |
24h |
server, client | Auto-update check interval, minimum 1h |
--update-api (PROXY_OVER_SMTP_UPDATE_API) is hidden: it overrides the release endpoint for tests and mirrors. It is trusted for both the archive and its checksum, so point it only at a source you control.
--listen and --remote must be host:port; a malformed value is rejected at startup instead of at bind time.
The client runs several tunnel sessions, not one. Each local connection is pinned to a session for its whole life, and a new session is dialed once every open one is loaded, up to --pool-max. Idle sessions are released back down to --pool-min.
This raises aggregate throughput: N sessions cost N TCP connections and give roughly N times the ceiling of one. It does not raise the speed of a single TCP flow, which still rides one session and is still capped by window / RTT. Browsers and download managers open many connections, so a pool is what a fast link actually uses. Rule of thumb: sessions β target Gbps Γ· 1.3, capped at 16.
proxy-over-smtp client --remote host:465 --pool-min 2 --pool-max 8The server needs no matching flag. One client holding N sessions is N connections to it, and each session is capped by --max-streams as usual, so N clients at --pool-max 8 can present up to 8N session handlers.
The client port detects the protocol per connection, so one --listen serves all of these:
curl -x socks5h://127.0.0.1:1080 https://example.com # SOCKS5, DNS on the server
curl -x socks4a://127.0.0.1:1080 https://example.com # SOCKS4a, DNS on the server
curl -x socks4://127.0.0.1:1080 https://203.0.113.5 # SOCKS4, IPv4 targets only
curl -x http://127.0.0.1:1080 http://example.com # HTTP proxy, plain request
curl -x http://127.0.0.1:1080 https://example.com # HTTP proxy, CONNECT
export http_proxy=http://127.0.0.1:1080 https_proxy=http://127.0.0.1:1080
git ls-remote https://github.com/dimaskiddo/proxy-over-smtpHTTPS proxy (TLS to the client). Give the client a certificate and https:// proxy URLs work on the same port. Plain connections keep working.
proxy-over-smtp client --tls-cert proxy.crt --tls-key proxy.key --remote 192.168.1.100:465
curl -x https://127.0.0.1:1080 --proxy-cacert proxy.crt https://example.comNotes:
- No proxy authentication. SOCKS4
USERIDandProxy-Authorizationare ignored, so bind the client to a trusted interface. - Plain HTTP requests use one request per connection (
Connection: close), and onlyhttp://absolute-form requests are proxied. - HTTP and SOCKS refusals map as: blocked target is
403/ SOCKS50x02, timeout is504, other failures502. SOCKS4 always replies0x5B. - The certificate loads once at start. Restart to rotate it. TLS covers only the hop from your application to the client. The client-to-server hop is protected by
--cipher.
SIGINT / SIGTERM stops accepting new connections, then waits for active ones to finish, up to --drain-timeout. Logs show draining active=N, then shutdown complete. At the deadline, or on a second signal, remaining connections are closed and the log shows drain interrupted, connections closed. On the server, idle tunnels close at once and the client re-dials on its next connection.
proxy-over-smtp version # Proxy-Over-SMTP v0.5.0~c04bfca
proxy-over-smtp update --check # Print current and latest version only
proxy-over-smtp update # Download, verify and replace this binary
proxy-over-smtp update --force # Reinstall even if up to date, or from a dev buildThe version line is the release tag plus the commit the binary was built from (~ separates them). A build with no known commit prints the tag alone, and a source build prints Proxy-Over-SMTP dev.
update replaces the binary on disk. Running instances keep the old code until restarted.
With --auto-update, a running server or client checks at start and then every --update-interval. On a newer release it swaps the binary, drains connections and re-executes itself with the same arguments (same PID on Unix). Dev builds never auto-update.
A newer tag is not the only trigger: when the tag is unchanged but the commit differs, update reinstalls and auto-update restarts. That covers a release that was re-tagged or rebuilt in place, where the version string alone would look identical. The comparison needs both commits known, so it is skipped when either side is unknown or the running build is dirty.
Notes:
- Downloads are verified against the release
checksums.txt(sha256). That proves integrity, not authenticity: releases are not signed. - The commit comparison is unordered: a tag that moves backwards counts as different too, so a re-tag can cause one reinstall per
--update-intervaluntil it settles.update --checkshows both sides before anything is replaced. - Auto-update can leave server and client on different versions. If a release changes the wire protocol, old and new do not interoperate. Update the server first, then clients.
- Docker: the swap lives in the container layer and a container restart reverts it. Pull a new image instead.
- Windows: the re-executed process is a child, so it detaches from a service manager. Prefer manual
updateplus a restart there. - The binary location must be writable by the running user.
Logs are an event stream on stdout. Redirect or collect them with your process manager (Docker, systemd, Kubernetes). --log-file additionally appends to a file.
time=2026-10-07T17:10:31.227+07:00 level=INFO msg="tunnel opened" peer=203.0.113.5:56810 proto=socks5 target=example.com:443
time=2026-10-07T17:10:32.242+07:00 level=WARN msg="target unreachable" proto=socks5 err="dial tcp 10.0.0.1:80: target address not allowed" target=10.0.0.1:80
time=2026-10-07T17:10:33.514+07:00 level=INFO msg="connection closed" peer=127.0.0.1:49822 up="1.049 MB" down="0.000 MB" dur=1.204s
The first two lines are the server; the third is the client. tunnel opened and connection closed are the two audit lines: the server names the destination, the client names the local application that used the proxy and how much it moved. Field order is part of the format on both sides: peer first, target last on every line that carries it, so the destination starts at the same column down a page of events. The client's up and down are printed as megabytes, always three decimals and always the MB suffix, where MB is 10^6 bytes β the unit on the label is the unit of the number. The client does not parse the request, so its line never contains a target β the destination stays on the server, and a client's log can leave the host without carrying browsing destinations.
--log-format json emits the same events as JSON lines. Handshake and protocol rejections appear at --log-level debug. The secret is never logged, on either side.
The tunnel holds 80% of line rate up to 1 Gbps, measured over multiple concurrent streams. One stream cannot fill a high-latency link: each smux stream carries a fixed 512KB window, so a single stream tops out near window / RTT β about 205 Mbps at 20 ms RTT and 82 Mbps at 50 ms. Browsers and download managers already open many connections.
A session aggregates its streams up to a 16MB receive buffer, and --pool-min/--pool-max add sessions, each with its own buffer, so the client's aggregate bound is pool Γ 16MB / RTT. Rule of thumb: sessions β target Gbps Γ· 1.3, capped at 16. One flow never crosses a session, so a single TCP connection is always capped by window / RTT no matter how large the pool.
Measured on one host: ~1.1 Gbps per stream (aes), ~1.3 Gbps aggregate for a single session, and ~190 MB/s (xor) across two pooled sessions.
Every number, table and caveat: Architecture β Throughput. To reproduce them by hand: Workflows β Verifying Throughput.
- Architecture: module map, handshake, transport stack, design decisions.
- Workflows: pipeline flows and error recovery.
make test # plain go test
make test-race # race detector; needs CGO, so it is a separate target- Dimas Restu Hidayanto - Initial Work - DimasKiddo
See also the list of contributors who participated in this project.
- Go
- xtaci/smux - Stream multiplexer
- spf13/cobra - CLI framework
- golang.org/x/sys - Per-OS socket option constants
- GoReleaser - Automated binaries build
- Make - Automated execution
- Docker - Containerization
DO WITH YOUR OWN RISK (DWYOR). This software is provided "as is", without warranty of any kind, express or implied. Use of this software may involve risks, including but not limited to service disruption or data loss. The authors are not responsible for any damage caused by the use of this application.
Distributed under the MIT License. See LICENSE for more information.
Proxy-Over-SMTP β Mail traffic on the wire, your proxy underneath. ππ¨