commit 35143415e80eabc1d87899b79485c16ceed15580 Author: Maxim Kalistratov Date: Sun Jul 26 05:11:28 2026 +0400 Initial release diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8643d9a --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +COMPOSE_PROJECT_NAME=egress-proxy + +# Authenticated upstream HTTP proxy +UPSTREAM_HOST= +UPSTREAM_PORT=8080 +UPSTREAM_USERNAME= +UPSTREAM_PASSWORD= + +# Native tarampampam/3proxy settings +LOG_OUTPUT=/dev/stdout +PROXY_PORT=3128 +SOCKS_PORT=1080 +PRIMARY_RESOLVER=1.1.1.1 +SECONDARY_RESOLVER=8.8.8.8 +DNS_CACHE_SIZE=65536 +MAX_CONNECTIONS=100 +NOFILE_SOFT=2048 +NOFILE_HARD=4096 + +# 3proxy chaining and access control +PARENT_RETRIES=3 + +# Comma-separated source addresses/CIDRs, or * for everyone. +# Examples: +# CLIENT_ALLOW=192.168.0.0/16 +# CLIENT_ALLOW=192.168.1.10,192.168.1.11 +CLIENT_ALLOW=* + +# Additional arguments appended to the generated listeners. +PROXY_EXTRA_ARGS= +SOCKS_EXTRA_ARGS= + +# Standalone port publishing +BIND_ADDRESS=127.0.0.1 +HTTP_PUBLIC_PORT=3128 +SOCKS_PUBLIC_PORT=1080 + +# Existing Traefik integration +TRAEFIK_NETWORK=traefik +TRAEFIK_ROUTER_PREFIX=chained-3proxy +TRAEFIK_HTTP_ENTRYPOINT=proxy-http +TRAEFIK_SOCKS_ENTRYPOINT=proxy-socks + +# Existing containerized Nginx/HAProxy integration +EDGE_NETWORK=edge +EDGE_SERVICE_ALIAS=chained-3proxy + +# Smoke tests +TEST_IMAGE=curlimages/curl:8.14.1 + +# These endpoints must return only the public IP address. +TEST_HTTP_URL=http://api.ipify.org +TEST_HTTPS_URL=https://api.ipify.org + +TEST_ATTEMPTS=20 +TEST_RETRY_DELAY=1 +TEST_TIMEOUT=15 + +# Strongest validation method when the upstream has a fixed exit IP. +EXPECTED_EXIT_IP= + +# Disable this if the upstream rotates between several exit IPs. +ASSERT_SAME_PROXY_EXIT=true + +# Disable this if direct internet access is blocked or if the upstream +# legitimately uses the same public IP as the Docker host. +ASSERT_DIFFERENT_FROM_DIRECT=true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..526c8a3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b19d444 --- /dev/null +++ b/README.md @@ -0,0 +1,594 @@ +# Egress Proxy + +> [!WARNING] +> This project was created with the assistance of an AI agent. +> +> Although it has been tested in production, it is provided as-is. I do not accept responsibility for configuration errors, service interruptions, exposed credentials, open proxy abuse, or security vulnerabilities. + +A Docker Compose wrapper around [tarampampam/3proxy-docker](https://github.com/tarampampam/3proxy-docker) designed to expose local HTTP and SOCKS proxy endpoints and route their traffic through an authenticated upstream HTTP proxy. + +The stack preserves the dynamic configuration provided by the original image and adds optional deployment integrations for standalone Docker, Traefik, Nginx, HAProxy, and other TCP reverse proxies. + +## Requirements + +* Docker Engine +* Docker Compose v2 +* An upstream HTTP proxy +* Upstream proxy hostname or IP address +* Upstream proxy port +* Upstream proxy username and password +* Optional external Docker network when integrating with Traefik, Nginx, or HAProxy + +The upstream proxy must support the HTTP `CONNECT` method for HTTPS and SOCKS TCP forwarding. + +## Features + +* HTTP forward proxy on port `3128` +* SOCKS proxy on port `1080` +* Routes supported traffic through an authenticated upstream HTTP proxy +* No authentication required from local proxy clients +* Dynamic configuration through environment variables +* Preserves native `3proxy-docker` configuration options +* Does not replace the original image entrypoint +* Does not mount a complete custom `3proxy.cfg` +* Uses the image's native `EXTRA_CONFIG` mechanism +* Standalone deployment with directly published ports +* Traefik TCP router integration +* Nginx and HAProxy integration through a shared Docker network +* Optional PROXY protocol support with Traefik +* Configurable client IP allowlist +* Configurable DNS resolvers, connection limits, ports, and listener arguments +* Automatic restart through Docker restart policies +* Smoke-test suite for HTTP, HTTPS, and SOCKS forwarding +* Optional verification of the expected upstream exit IP + +## Repository Structure + +```text +. +├── .env.example +├── .gitattributes +├── .gitignore +├── README.md +├── compose.yaml +├── compose.standalone.yaml +├── compose.traefik.yaml +├── compose.external-network.yaml +├── compose.test.yaml +└── tests/ + ├── run.sh + └── smoke.sh +``` + +## How It Works + +The base image generates the normal 3proxy configuration using its built-in environment-variable support. + +This project adds parent routing rules through `EXTRA_CONFIG`. + +The generated configuration is equivalent to: + +```text +parentretries 3 +auth iponly + +allow * * * * HTTP,HTTPS +parent 1000 http "proxy.example.com" 8080 "username" "password" + +allow * * * * CONNECT +parent 1000 connect "proxy.example.com" 8080 "username" "password" + +deny * +``` + +The operation-specific access rules are important: + +* `HTTP,HTTPS` handles requests received by the HTTP proxy listener. +* `CONNECT` handles TCP connections received by the SOCKS listener. +* `http` is used as the upstream parent type for the HTTP listener. +* `connect` is used to tunnel SOCKS TCP traffic through the upstream HTTP proxy. +* SOCKS UDP association and bind operations are not supported by an upstream HTTP proxy. + +The stack uses: + +```text +auth iponly +``` + +This does not require clients to provide a username or password. It enables 3proxy ACL and parent-routing processing. + +Using: + +```text +auth none +``` + +would disable the ACL chain and may cause requests to bypass the upstream proxy. + +## Configuration + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +At minimum, configure the upstream proxy: + +```dotenv +UPSTREAM_HOST=proxy.example.com +UPSTREAM_PORT=8080 +UPSTREAM_USERNAME=proxy-user +UPSTREAM_PASSWORD='proxy-password' +``` + +The `.env` file is ignored by Git and must not be committed. + +### Credentials with Special Characters + +Use single quotes for values containing `$`, `#`, spaces, or similar characters: + +```dotenv +UPSTREAM_PASSWORD='my$p@ssword#123' +``` + +Avoid double quotes and newline characters inside upstream credentials because the values are inserted into quoted 3proxy configuration arguments. + +## Available Environment Variables + +### Image and Project + +```dotenv +COMPOSE_PROJECT_NAME=egress-proxy +THREEPROXY_IMAGE=ghcr.io/tarampampam/3proxy:2 +``` + +### Upstream Proxy + +```dotenv +UPSTREAM_HOST=proxy.example.com +UPSTREAM_PORT=8080 +UPSTREAM_USERNAME=proxy-user +UPSTREAM_PASSWORD='proxy-password' +``` + +### Local Proxy Listeners + +```dotenv +PROXY_PORT=3128 +SOCKS_PORT=1080 +``` + +### DNS + +```dotenv +PRIMARY_RESOLVER=1.1.1.1 +SECONDARY_RESOLVER=8.8.8.8 +DNS_CACHE_SIZE=65536 +``` + +### Limits + +```dotenv +MAX_CONNECTIONS=100 +PARENT_RETRIES=3 +NOFILE_SOFT=2048 +NOFILE_HARD=4096 +``` + +### Client Access + +Allow all clients: + +```dotenv +CLIENT_ALLOW=* +``` + +Allow a private network: + +```dotenv +CLIENT_ALLOW=192.168.0.0/16 +``` + +Allow several addresses or networks: + +```dotenv +CLIENT_ALLOW=192.168.0.0/16,203.0.113.10 +``` + +### Listener Arguments + +Additional arguments can be appended to the generated listeners: + +```dotenv +PROXY_EXTRA_ARGS= +SOCKS_EXTRA_ARGS= +``` + +These values preserve the customization options provided by the original `3proxy-docker` image. + +## Validate the Configuration + +Before starting the stack, validate the merged Compose configuration. + +Standalone deployment: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + config +``` + +Traefik deployment: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.traefik.yaml \ + config +``` + +External network deployment: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.external-network.yaml \ + config +``` + +Test deployment: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.test.yaml \ + config +``` + +## Standalone Deployment + +Use the standalone overlay when there is no existing reverse proxy. + +By default, the proxy binds only to localhost: + +```dotenv +BIND_ADDRESS=127.0.0.1 +HTTP_PUBLIC_PORT=3128 +SOCKS_PUBLIC_PORT=1080 +``` + +Start the stack: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + up -d +``` + +Test the HTTP proxy: + +```bash +curl \ + --proxy http://127.0.0.1:3128 \ + https://api.ipify.org +``` + +Test the SOCKS proxy: + +```bash +curl \ + --proxy socks5h://127.0.0.1:1080 \ + https://api.ipify.org +``` + +View logs: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + logs -f proxy +``` + +Stop the stack: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + down +``` + +### LAN Access + +To expose the proxy on all server interfaces: + +```dotenv +BIND_ADDRESS=0.0.0.0 +``` + +Restrict clients when exposing the service outside localhost: + +```dotenv +CLIENT_ALLOW=192.168.0.0/16 +``` + +Do not expose an unauthenticated proxy to the public internet without an IP allowlist, firewall rules, VPN, private network, or equivalent access control. + +## Traefik Deployment + +3proxy must be exposed through Traefik TCP routers, not normal HTTP routers. + +The HTTP proxy and SOCKS listener are separate TCP services and require dedicated Traefik entrypoints. + +Add the following static configuration to the existing Traefik service: + +```yaml +services: + traefik: + command: + - --entrypoints.proxy-http.address=:3128/tcp + - --entrypoints.proxy-socks.address=:1080/tcp + + ports: + - "3128:3128/tcp" + - "1080:1080/tcp" +``` + +Create the shared network if it does not already exist: + +```bash +docker network create traefik +``` + +Configure the integration: + +```dotenv +TRAEFIK_NETWORK=traefik +TRAEFIK_ROUTER_PREFIX=egress-proxy +TRAEFIK_HTTP_ENTRYPOINT=proxy-http +TRAEFIK_SOCKS_ENTRYPOINT=proxy-socks +``` + +Start the stack: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.traefik.yaml \ + up -d +``` + +The Traefik overlay: + +* Creates one TCP router and service for the HTTP proxy. +* Creates one TCP router and service for SOCKS. +* Uses `HostSNI(*)` as a catch-all TCP routing rule. +* Sends PROXY protocol v1 to 3proxy. +* Enables `-H` on both 3proxy listeners. +* Preserves the original client IP. +* Does not publish ports directly from the 3proxy container. + +Do not combine `compose.standalone.yaml` and `compose.traefik.yaml`. + +In Traefik mode, Traefik should be the only service publishing ports `3128` and `1080`. + +## Existing Nginx or HAProxy + +Use the external-network overlay when an existing containerized TCP proxy should connect to 3proxy through Docker networking. + +Create the external network: + +```bash +docker network create edge +``` + +Configure it: + +```dotenv +EDGE_NETWORK=edge +EDGE_SERVICE_ALIAS=egress-proxy +``` + +Start the stack: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.external-network.yaml \ + up -d +``` + +Other containers attached to the same network can reach: + +```text +egress-proxy:3128 +egress-proxy:1080 +``` + +### Nginx Example + +Nginx must use the `stream` context rather than the regular `http` context: + +```nginx +stream { + upstream egress_http_proxy { + server egress-proxy:3128; + } + + upstream egress_socks_proxy { + server egress-proxy:1080; + } + + server { + listen 3128; + proxy_pass egress_http_proxy; + } + + server { + listen 1080; + proxy_pass egress_socks_proxy; + } +} +``` + +Without PROXY protocol, 3proxy sees the Nginx container address instead of the original client address. + +In that case, client access restrictions should be applied in Nginx, or PROXY protocol should be configured on both sides. + +## Smoke Tests + +The test suite starts the proxy on an internal Docker network and verifies: + +1. An HTTP request through the HTTP proxy listener. +2. An HTTPS request through the HTTP proxy listener. +3. An HTTPS request through the SOCKS listener. +4. That the requests return a public exit IP. +5. Optionally, that all listeners use the expected upstream exit IP. +6. Optionally, that proxied traffic does not use the server's direct public IP. + +Run the tests: + +```bash +sh tests/run.sh +``` + +The command exits with: + +* `0` when all checks pass. +* A non-zero code when a request or assertion fails. + +The test stack is removed automatically after completion. + +### Fixed Exit IP + +When the upstream proxy has a fixed public IP, configure: + +```dotenv +EXPECTED_EXIT_IP=203.0.113.20 +``` + +This is the strongest validation method. + +All HTTP, HTTPS, and SOCKS requests must return that exact IP. + +### Rotating Exit IPs + +When the upstream proxy uses rotating IP addresses: + +```dotenv +EXPECTED_EXIT_IP= +ASSERT_SAME_PROXY_EXIT=false +ASSERT_DIFFERENT_FROM_DIRECT=true +``` + +The test verifies that each request succeeds and does not use the server's direct public IP. + +### No Direct Internet Access + +When the test container cannot access the internet without the proxy: + +```dotenv +ASSERT_DIFFERENT_FROM_DIRECT=false +``` + +Set `EXPECTED_EXIT_IP` when possible. + +## Applying Configuration Changes + +After modifying `.env`, recreate the container. + +Standalone: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + up -d --force-recreate +``` + +Traefik: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.traefik.yaml \ + up -d --force-recreate +``` + +External network: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.external-network.yaml \ + up -d --force-recreate +``` + +## Diagnostics + +View logs: + +```bash +docker compose logs -f proxy +``` + +View service status: + +```bash +docker compose ps +``` + +Inspect the final merged configuration: + +```bash +docker compose \ + -f compose.yaml \ + -f compose.standalone.yaml \ + config +``` + +Inspect the generated 3proxy configuration: + +```bash +docker compose cp \ + proxy:/etc/3proxy/3proxy.cfg \ + ./generated-3proxy.cfg +``` + +The generated configuration contains upstream credentials. Delete the copied file after debugging: + +```bash +rm -f generated-3proxy.cfg +``` + +## Security Considerations + +* Local clients are not required to authenticate. +* Restrict access using `CLIENT_ALLOW`, firewall rules, a VPN, or a private network. +* Do not expose an unrestricted proxy to the public internet. +* Upstream credentials are stored in `.env`. +* Docker administrators can inspect container environment variables. +* Never commit `.env`. +* Rotate credentials accidentally committed, logged, or shared publicly. +* Review generated Compose and 3proxy configuration before deployment. +* Pin the container image to a specific version when reproducible deployments are required. +* Keep Docker, Docker Compose, Traefik, Nginx, and the base image updated. +* Treat access to the Docker socket as administrative access to the host. + +## Limitations + +* The upstream must be an HTTP proxy. +* SOCKS UDP forwarding is not supported. +* SOCKS bind operations are not supported. +* Client authentication is not enabled. +* Reverse proxies must support raw TCP forwarding. +* Normal hostname-based HTTP routing is not applicable to SOCKS traffic. +* Hostname-based routing is generally not useful for a forward proxy because destination hostnames belong to outbound requests rather than the proxy service itself. + +## License + +Use, modify, and distribute this project under the terms of the repository license. diff --git a/compose-standalone.yaml b/compose-standalone.yaml new file mode 100644 index 0000000..5c6db22 --- /dev/null +++ b/compose-standalone.yaml @@ -0,0 +1,5 @@ +services: + proxy: + ports: + - "${BIND_ADDRESS:-127.0.0.1}:${HTTP_PUBLIC_PORT:-3128}:${PROXY_PORT:-3128}/tcp" + - "${BIND_ADDRESS:-127.0.0.1}:${SOCKS_PUBLIC_PORT:-1080}:${SOCKS_PORT:-1080}/tcp" diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..c0216a9 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,45 @@ +services: + test: + image: ${TEST_IMAGE:-curlimages/curl:8.14.1} + restart: "no" + + depends_on: + - proxy + + environment: + PROXY_HOST: proxy + PROXY_PORT: "${PROXY_PORT:-3128}" + SOCKS_PORT: "${SOCKS_PORT:-1080}" + + TEST_HTTP_URL: "${TEST_HTTP_URL:-http://api.ipify.org}" + TEST_HTTPS_URL: "${TEST_HTTPS_URL:-https://api.ipify.org}" + TEST_ATTEMPTS: "${TEST_ATTEMPTS:-20}" + TEST_RETRY_DELAY: "${TEST_RETRY_DELAY:-1}" + TEST_TIMEOUT: "${TEST_TIMEOUT:-15}" + + EXPECTED_EXIT_IP: "${EXPECTED_EXIT_IP:-}" + ASSERT_SAME_PROXY_EXIT: "${ASSERT_SAME_PROXY_EXIT:-true}" + ASSERT_DIFFERENT_FROM_DIRECT: "${ASSERT_DIFFERENT_FROM_DIRECT:-true}" + + entrypoint: + - /bin/sh + - /tests/smoke.sh + + command: [] + + volumes: + - ./tests/smoke.sh:/tests/smoke.sh:ro + + read_only: true + + tmpfs: + - /tmp:size=1m,mode=1777 + + cap_drop: + - ALL + + security_opt: + - no-new-privileges:true + + networks: + - proxy \ No newline at end of file diff --git a/compose.traefik.yaml b/compose.traefik.yaml new file mode 100644 index 0000000..2d6960d --- /dev/null +++ b/compose.traefik.yaml @@ -0,0 +1,35 @@ +services: + proxy: + environment: + # Traefik sends PROXY protocol v1 to preserve the original client IP. + # + # Existing custom listener arguments are appended after -H. + PROXY_EXTRA_ARGS: "-H ${PROXY_EXTRA_ARGS:-}" + SOCKS_EXTRA_ARGS: "-H ${SOCKS_EXTRA_ARGS:-}" + + networks: + - proxy + - traefik + + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}" + + # HTTP proxy router and service + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http.entrypoints=${TRAEFIK_HTTP_ENTRYPOINT:-proxy-http}" + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http.rule=HostSNI(`*`)" + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http.service=${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http" + - "traefik.tcp.services.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http.loadbalancer.server.port=${PROXY_PORT:-3128}" + - "traefik.tcp.services.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-http.loadbalancer.proxyprotocol.version=1" + + # SOCKS router and service + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks.entrypoints=${TRAEFIK_SOCKS_ENTRYPOINT:-proxy-socks}" + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks.rule=HostSNI(`*`)" + - "traefik.tcp.routers.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks.service=${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks" + - "traefik.tcp.services.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks.loadbalancer.server.port=${SOCKS_PORT:-1080}" + - "traefik.tcp.services.${TRAEFIK_ROUTER_PREFIX:-chained-3proxy}-socks.loadbalancer.proxyprotocol.version=1" + +networks: + traefik: + external: true + name: "${TRAEFIK_NETWORK:-traefik}" \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5a032a8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,68 @@ +name: ${COMPOSE_PROJECT_NAME:-chained-3proxy} + +services: + proxy: + image: ghcr.io/tarampampam/3proxy:2 + restart: unless-stopped + + environment: + LOG_OUTPUT: "${LOG_OUTPUT:-/dev/stdout}" + PROXY_PORT: "${PROXY_PORT:-3128}" + SOCKS_PORT: "${SOCKS_PORT:-1080}" + PRIMARY_RESOLVER: "${PRIMARY_RESOLVER:-1.1.1.1}" + SECONDARY_RESOLVER: "${SECONDARY_RESOLVER:-8.8.8.8}" + DNS_CACHE_SIZE: "${DNS_CACHE_SIZE:-65536}" + MAX_CONNECTIONS: "${MAX_CONNECTIONS:-100}" + + PROXY_EXTRA_ARGS: "${PROXY_EXTRA_ARGS:-}" + SOCKS_EXTRA_ARGS: "${SOCKS_EXTRA_ARGS:-}" + + # EXTRA_CONFIG is inserted before both generated listeners. + # + # Operation-specific ACLs select the correct parent type: + # + # - HTTP/HTTPS operations belong to the HTTP proxy listener. + # - CONNECT belongs to the SOCKS listener. + # + # auth iponly does not require client credentials, but enables + # ACL and parent routing. + EXTRA_CONFIG: | + parentretries ${PARENT_RETRIES:-3} + auth iponly + + allow * ${CLIENT_ALLOW:-*} * * HTTP,HTTPS + parent 1000 http "${UPSTREAM_HOST:?Set UPSTREAM_HOST in .env}" ${UPSTREAM_PORT:-8080} "${UPSTREAM_USERNAME:?Set UPSTREAM_USERNAME in .env}" "${UPSTREAM_PASSWORD:?Set UPSTREAM_PASSWORD in .env}" + + allow * ${CLIENT_ALLOW:-*} * * CONNECT + parent 1000 connect "${UPSTREAM_HOST:?Set UPSTREAM_HOST in .env}" ${UPSTREAM_PORT:-8080} "${UPSTREAM_USERNAME:?Set UPSTREAM_USERNAME in .env}" "${UPSTREAM_PASSWORD:?Set UPSTREAM_PASSWORD in .env}" + + deny * + + expose: + - "${PROXY_PORT:-3128}" + - "${SOCKS_PORT:-1080}" + + read_only: true + + tmpfs: + # The native image entrypoint generates /etc/3proxy/3proxy.cfg. + - /etc/3proxy:size=1m,mode=0755,uid=10001,gid=10001 + - /tmp:size=1m,mode=1777 + + cap_drop: + - ALL + + security_opt: + - no-new-privileges:true + + ulimits: + nofile: + soft: ${NOFILE_SOFT:-2048} + hard: ${NOFILE_HARD:-4096} + + networks: + - proxy + +networks: + proxy: + driver: bridge \ No newline at end of file diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 0000000..56ad9a3 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +set -eu + +compose() { + docker compose \ + -f compose.yaml \ + -f compose.test.yaml \ + "$@" +} + +cleanup() { + compose down --remove-orphans +} + +trap cleanup EXIT + +compose up \ + --force-recreate \ + --abort-on-container-exit \ + --exit-code-from test \ No newline at end of file diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100644 index 0000000..3ac8797 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,161 @@ +#!/bin/sh + +set -eu + +PROXY_HOST="${PROXY_HOST:-proxy}" +PROXY_PORT="${PROXY_PORT:-3128}" +SOCKS_PORT="${SOCKS_PORT:-1080}" + +TEST_HTTP_URL="${TEST_HTTP_URL:-http://api.ipify.org}" +TEST_HTTPS_URL="${TEST_HTTPS_URL:-https://api.ipify.org}" +TEST_ATTEMPTS="${TEST_ATTEMPTS:-20}" +TEST_RETRY_DELAY="${TEST_RETRY_DELAY:-1}" +TEST_TIMEOUT="${TEST_TIMEOUT:-15}" + +EXPECTED_EXIT_IP="${EXPECTED_EXIT_IP:-}" +ASSERT_SAME_PROXY_EXIT="${ASSERT_SAME_PROXY_EXIT:-true}" +ASSERT_DIFFERENT_FROM_DIRECT="${ASSERT_DIFFERENT_FROM_DIRECT:-true}" + +normalize_response() { + printf '%s' "$1" | tr -d '[:space:]' +} + +request() { + label="$1" + proxy_url="$2" + target_url="$3" + attempt=1 + + while [ "$attempt" -le "$TEST_ATTEMPTS" ]; do + printf 'Testing %s, attempt %s/%s...\n' \ + "$label" \ + "$attempt" \ + "$TEST_ATTEMPTS" >&2 + + if [ -n "$proxy_url" ]; then + if response="$( + curl -q \ + --fail \ + --silent \ + --show-error \ + --location \ + --connect-timeout 5 \ + --max-time "$TEST_TIMEOUT" \ + --noproxy "" \ + --proxy "$proxy_url" \ + "$target_url" \ + 2>/tmp/curl-error + )"; then + response="$(normalize_response "$response")" + + if [ -n "$response" ]; then + printf '%s' "$response" + return 0 + fi + fi + else + if response="$( + curl -q \ + --fail \ + --silent \ + --show-error \ + --location \ + --connect-timeout 5 \ + --max-time "$TEST_TIMEOUT" \ + --noproxy "*" \ + "$target_url" \ + 2>/tmp/curl-error + )"; then + response="$(normalize_response "$response")" + + if [ -n "$response" ]; then + printf '%s' "$response" + return 0 + fi + fi + fi + + attempt=$((attempt + 1)) + + if [ "$attempt" -le "$TEST_ATTEMPTS" ]; then + sleep "$TEST_RETRY_DELAY" + fi + done + + printf '%s failed after %s attempts.\n' \ + "$label" \ + "$TEST_ATTEMPTS" >&2 + + if [ -s /tmp/curl-error ]; then + cat /tmp/curl-error >&2 + fi + + return 1 +} + +assert_expected_ip() { + label="$1" + actual="$2" + + if [ "$actual" != "$EXPECTED_EXIT_IP" ]; then + printf 'FAIL: %s used an unexpected exit IP.\n' "$label" >&2 + printf 'Expected: %s\n' "$EXPECTED_EXIT_IP" >&2 + printf 'Actual: %s\n' "$actual" >&2 + exit 1 + fi +} + +printf '%s\n' 'Starting 3proxy smoke tests...' + +http_ip="$( + request \ + 'HTTP listener with an HTTP target' \ + "http://${PROXY_HOST}:${PROXY_PORT}" \ + "$TEST_HTTP_URL" +)" + +https_ip="$( + request \ + 'HTTP listener with an HTTPS target' \ + "http://${PROXY_HOST}:${PROXY_PORT}" \ + "$TEST_HTTPS_URL" +)" + +socks_ip="$( + request \ + 'SOCKS listener with remote DNS' \ + "socks5h://${PROXY_HOST}:${SOCKS_PORT}" \ + "$TEST_HTTPS_URL" +)" + +printf 'HTTP target exit IP: %s\n' "$http_ip" +printf 'HTTPS target exit IP: %s\n' "$https_ip" +printf 'SOCKS exit IP: %s\n' "$socks_ip" + +if [ -n "$EXPECTED_EXIT_IP" ]; then + assert_expected_ip 'HTTP target' "$http_ip" + assert_expected_ip 'HTTPS target' "$https_ip" + assert_expected_ip 'SOCKS target' "$socks_ip" +elif [ "$ASSERT_SAME_PROXY_EXIT" = 'true' ]; then + if [ "$http_ip" != "$https_ip" ] || [ "$http_ip" != "$socks_ip" ]; then + printf '%s\n' \ + 'FAIL: proxy listeners returned different exit IPs.' >&2 + exit 1 + fi +fi + +if [ "$ASSERT_DIFFERENT_FROM_DIRECT" = 'true' ]; then + direct_ip="$(request 'direct connection' '' "$TEST_HTTPS_URL")" + + printf 'Direct exit IP: %s\n' "$direct_ip" + + if [ "$http_ip" = "$direct_ip" ] || \ + [ "$https_ip" = "$direct_ip" ] || \ + [ "$socks_ip" = "$direct_ip" ]; then + printf '%s\n' \ + 'FAIL: at least one proxied request used the direct exit IP.' >&2 + exit 1 + fi +fi + +printf '%s\n' 'PASS: all proxy smoke tests succeeded.' \ No newline at end of file