Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.gitignore
Dockerfile
compose.yml
README.md
*.md
config.json
profiles.json
websites
coverage.out
*.test
*.prof
.DS_Store
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TPROXY_HOSTNAME=proxy.example.com
CLOUDFLARE_TUNNEL_TOKEN=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/*.test
/*.prof
.DS_Store
/.env
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1

FROM golang:1.25-alpine AS build

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/tproxy-server \
./cmd/tproxy-server

FROM alpine:3.22

RUN apk add --no-cache ca-certificates \
&& addgroup -S -g 10001 tproxy \
&& adduser -S -D -H -u 10001 -G tproxy tproxy

COPY --from=build /out/tproxy-server /usr/local/bin/tproxy-server

USER 10001:10001
ENTRYPOINT ["/usr/local/bin/tproxy-server"]
CMD ["-config", "/etc/tproxy-server/config.json"]
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,3 +600,72 @@ when overriding the defaults.

The complete architecture and implementation milestones remain in `PLAN.md`; the
normative wire format is in `PROTOCOL.md`.

## Docker Compose with Cloudflare Tunnel

The Compose stack publishes no host ports. The relay listens on port 8080 only
inside the private `tunnel` network, and `cloudflared` is the only HTTP ingress.
Cloudflared also joins a separate outbound network so it can reach Cloudflare;
the relay itself has no direct Internet egress.
The admin listener remains on loopback inside the relay container and is not
reachable from the tunnel container.

Before the first start, copy `.env.example` to `.env`, set the token issued on the
Cloudflare Zero Trust tunnel page, and set the same public hostname in
`TPROXY_HOSTNAME` and `public_hostname` inside `docker/config.json`. Replace the
example secret in `docker/profiles.json` with the same 16-byte hexadecimal
MTProxy secret used by the client:

```bash
cp .env.example .env
docker compose config
docker compose build
docker compose run --rm tproxy-server -config /etc/tproxy-server/config.json -check
docker compose up -d
docker compose ps
docker compose logs --tail=100 tproxy-server
docker compose logs --tail=100 cloudflared
```

In the Cloudflare tunnel configuration, create a Public Hostname whose service is
`http://tproxy-server:8080`. Cloudflare terminates public HTTPS; Caddy is not part
of this stack. Do not add a Compose `ports` mapping for the relay.

### Setup

Create `.env`:

```bash
cp .env.example .env
```

Configure it:

```dotenv
TPROXY_HOSTNAME=proxy.example.com
CLOUDFLARE_TUNNEL_TOKEN=token-issued-by-cloudflare
```

Set the same hostname in [`docker/config.json`](docker/config.json):

```json
"public_hostname": "proxy.example.com"
```

Configure this service URL for the Public Hostname in Cloudflare:

```text
http://tproxy-server:8080
```

Start the stack:

```bash
docker compose down --remove-orphans
docker compose up -d --build
docker compose ps
docker compose logs --tail=100 cloudflared
docker compose logs --tail=100 tproxy-server
```

Ports 80, 443, and 8080 do not need to be opened or published on the target host.
59 changes: 59 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
services:
tproxy-server:
build:
context: .
image: tproxy-server:local
container_name: tproxy-server
restart: unless-stopped
init: true
read_only: true
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
expose:
- "8080"
volumes:
- ./docker/config.json:/etc/tproxy-server/config.json:ro
- ./docker/site:/srv/tproxy-site:ro
secrets:
- source: tproxy_profiles
target: tproxy-profiles.json
healthcheck:
test: ["CMD", "/usr/local/bin/tproxy-server", "-config", "/etc/tproxy-server/config.json", "-check"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
stop_grace_period: 20s
networks:
- tunnel
- cloudflare

cloudflared:
image: cloudflare/cloudflared:2026.8.0
container_name: tproxy-cloudflared
restart: unless-stopped
init: true
read_only: true
user: "65532:65532"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN in .env}
depends_on:
tproxy-server:
condition: service_healthy
networks:
- tunnel

secrets:
tproxy_profiles:
file: ./docker/profiles.json

networks:
tunnel:
internal: true
cloudflare:
42 changes: 42 additions & 0 deletions docker/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"public_hostname": "proxy.example.com",
"listen": "0.0.0.0:8080",
"admin_listen": "127.0.0.1:8081",
"public_dir": "/srv/tproxy-site",
"profiles_file": "/run/secrets/tproxy-profiles.json",
"enable_pprof": false,
"limits": {
"max_header_bytes": 16384,
"max_body_bytes": 2097152,
"max_frame_payload": 1048576,
"carrier_batch_bytes": 2097152,
"max_streams_per_session": 128,
"max_closed_stream_ids": 4096,
"max_pending_per_session": 33554432,
"max_pending_global": 536870912,
"max_pending_items_per_session": 16384,
"max_pending_items_global": 262144,
"max_sessions_per_ip": 0,
"max_sessions_global": 128,
"max_streams_global": 4096,
"max_backend_dials_in_flight": 256,
"new_sessions_per_minute": 600,
"new_sessions_burst": 128,
"new_streams_per_minute": 6000,
"new_streams_burst": 512,
"max_bootstraps_per_ip": 0,
"max_bootstraps_global": 512,
"new_bootstraps_per_minute": 1200,
"new_bootstraps_burst": 256,
"max_profiles": 32
},
"timeouts": {
"backend_dial": "5s",
"long_poll": "25s",
"reconnect_grace": "2m",
"bootstrap_lifetime": "2m",
"read_header": "10s",
"idle": "75s",
"shutdown": "15s"
}
}
10 changes: 10 additions & 0 deletions docker/profiles.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"profiles": [
{
"name": "default",
"secret": "0123456789abcdef0123456789abcdef",
"backend": "127.0.0.1:2398",
"carrier_mode": "https"
}
]
}
13 changes: 13 additions & 0 deletions docker/site/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found</title>
</head>
<body>
<main>
<h1>Not found</h1>
</main>
</body>
</html>
14 changes: 14 additions & 0 deletions docker/site/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome</title>
</head>
<body>
<main>
<h1>Welcome</h1>
<p>This website is available.</p>
</main>
</body>
</html>
17 changes: 13 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,10 @@ func (c Config) validate() error {
if c.PublicHostname != strings.ToLower(c.PublicHostname) {
return errors.New("public_hostname must already be lowercase ASCII/IDNA")
}
if err := validateLoopbackAddress(c.Listen); err != nil {
if err := validateListenAddress(c.Listen, true); err != nil {
return fmt.Errorf("listen: %w", err)
}
if err := validateLoopbackAddress(c.AdminListen); err != nil {
if err := validateListenAddress(c.AdminListen, false); err != nil {
return fmt.Errorf("admin_listen: %w", err)
}
if c.Listen == c.AdminListen {
Expand Down Expand Up @@ -405,12 +405,19 @@ func DecodeSecret(value string) ([]byte, error) {
}

func validateLoopbackAddress(address string) error {
return validateListenAddress(address, false)
}

func validateListenAddress(address string, allowUnspecified bool) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
if ip == nil || (!ip.IsLoopback() && !(allowUnspecified && ip.IsUnspecified())) {
if allowUnspecified {
return errors.New("must use a numeric loopback or unspecified address")
}
return errors.New("must use a numeric loopback address")
}
value, err := strconv.Atoi(port)
Expand Down Expand Up @@ -445,7 +452,9 @@ func loadProfiles(path, host string, limits Limits) ([]Profile, error) {
extraPermissions := info.Mode().Perm() & 0077
credentialReadOnly := isSystemdCredential(path) &&
extraPermissions&0033 == 0
if extraPermissions != 0 && !credentialReadOnly {
dockerSecretReadOnly := filepath.Clean(filepath.Dir(path)) == "/run/secrets" &&
info.Mode().Perm()&0022 == 0
if extraPermissions != 0 && !credentialReadOnly && !dockerSecretReadOnly {
return nil, errors.New("profiles_file must not be readable or writable by group or others")
}
input, err := os.ReadFile(path)
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ func TestPublicSourceValidation(t *testing.T) {
}
}

func TestPublicListenerMayUseUnspecifiedAddress(t *testing.T) {
value := Defaults()
value.PublicHostname = "proxy.example.com"
value.PublicUpstream = "http://127.0.0.1:3000"
value.ProfilesFile = "profiles.json"
value.Listen = "0.0.0.0:8080"
if err := value.validate(); err != nil {
t.Fatalf("container public listener was rejected: %v", err)
}
value.AdminListen = "0.0.0.0:8081"
if err := value.validate(); err == nil {
t.Fatal("public admin listener was accepted")
}
}

func TestPlainSecretMayBeginWithEE(t *testing.T) {
secret, err := DecodeSecret("ee0102030405060708090a0b0c0d0e0f")
if err != nil || len(secret) != 16 || secret[0] != 0xee {
Expand Down