From a91a7ca11a54da2487babe708edc97b6d665a95f Mon Sep 17 00:00:00 2001 From: Maintainer Date: Wed, 14 Oct 2026 16:24:01 +0700 Subject: [PATCH] feat: publish generic Telegram WEB proxy stack --- .env.example | 5 + .gitignore | 14 ++ Dockerfile | 85 ++++++++++ README.md | 91 +++++++++++ SECURITY.md | 17 ++ compose.yml | 61 ++++++++ docker/entrypoint.sh | 145 +++++++++++++++++ docs/incidents/bridge-routing.md | 31 ++++ docs/runbook.md | 69 +++++++++ ingress/ingress.go | 88 +++++++++++ ingress/ingress.js | 108 +++++++++++++ install.sh | 257 +++++++++++++++++++++++++++++++ scripts/audit-public.sh | 167 ++++++++++++++++++++ site/index.html | 15 ++ 14 files changed, 1153 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 compose.yml create mode 100755 docker/entrypoint.sh create mode 100644 docs/incidents/bridge-routing.md create mode 100644 docs/runbook.md create mode 100644 ingress/ingress.go create mode 100755 ingress/ingress.js create mode 100755 install.sh create mode 100755 scripts/audit-public.sh create mode 100644 site/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9825e71 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TPROXY_HOSTNAME=proxy.example.com +MTPROXY_PUBLIC_IP=PUBLIC_IPV4_HERE +NPM_NETWORK_NAME=nginx-proxy-manager_default +MTPROXY_WORKERS=1 +MTPROXY_MAX_CONNECTIONS=4096 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..929b6e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +.env.* +!.env.example +.audit-private-patterns +*.token +*.key +*.pem +*.secret +*.backup +profiles.json +client-secret +telegram-proxy-secret +tg_webproxy_data/ +volumes/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d9ef52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1 + +ARG GO_VERSION=1.26.5 + +FROM debian:bookworm AS mtproxy-builder + +ARG MTPROXY_COMMIT=f36d8af769ffaeac36978d38c2c0f6d1104c2137 +ARG MTPROXY_SHA256=919795c416b870670841a21d1930ad97a24c7b84b9eb8c6f9e3de32f2fdf4655 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl libssl-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --output /tmp/mtproxy.tar.gz \ + "https://github.com/TelegramMessenger/MTProxy/archive/${MTPROXY_COMMIT}.tar.gz" \ + && echo "${MTPROXY_SHA256} /tmp/mtproxy.tar.gz" | sha256sum -c - \ + && mkdir -p /src/mtproxy \ + && tar -C /src/mtproxy --strip-components=1 -xzf /tmp/mtproxy.tar.gz \ + && make -C /src/mtproxy -j"$(nproc)" \ + && test -x /src/mtproxy/objs/bin/mtproto-proxy + +FROM golang:${GO_VERSION}-bookworm AS relay-builder + +ARG TPROXY_COMMIT=c8adb8b7c6b7fc46c12ae3acb68be9070c26a8e8 +ARG TPROXY_SHA256=a78b48f536180143dd7005785ca397310c36ee0b132e19949a45d64f586ab549 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --output /tmp/tproxy.tar.gz \ + "https://github.com/telegramdesktop/tproxy-server/archive/${TPROXY_COMMIT}.tar.gz" \ + && echo "${TPROXY_SHA256} /tmp/tproxy.tar.gz" | sha256sum -c - \ + && mkdir -p /src/tproxy \ + && tar -C /src/tproxy --strip-components=1 -xzf /tmp/tproxy.tar.gz + +WORKDIR /src/tproxy +RUN go test ./... \ + && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' \ + -o /out/tproxy-server ./cmd/tproxy-server + +WORKDIR /src/ingress +COPY ingress/ingress.go ./ingress.go +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' \ + -o /out/ingress ./ingress.go + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl hostname libssl3 openssl util-linux zlib1g \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --home-dir /nonexistent \ + --shell /usr/sbin/nologin mtproxy \ + && useradd --system --uid 10002 --home-dir /nonexistent \ + --shell /usr/sbin/nologin tproxy + +COPY --from=mtproxy-builder /src/mtproxy/objs/bin/mtproto-proxy /usr/local/bin/mtproto-proxy +COPY --from=relay-builder /out/tproxy-server /usr/local/bin/tproxy-server +COPY --from=relay-builder /out/ingress /usr/local/bin/tproxy-ingress +COPY docker/entrypoint.sh /usr/local/bin/tproxy-entrypoint +COPY site/ /srv/tproxy-site/ + +RUN chown root:root \ + /usr/local/bin/mtproto-proxy \ + /usr/local/bin/tproxy-server \ + /usr/local/bin/tproxy-ingress \ + /usr/local/bin/tproxy-entrypoint \ + && chmod 0755 \ + /usr/local/bin/mtproto-proxy \ + /usr/local/bin/tproxy-server \ + /usr/local/bin/tproxy-ingress \ + /usr/local/bin/tproxy-entrypoint \ + && find /srv/tproxy-site -type d -exec chmod 0755 {} + \ + && find /srv/tproxy-site -type f -exec chmod 0644 {} + + +VOLUME ["/data"] +EXPOSE 8088 + +ENTRYPOINT ["/usr/local/bin/tproxy-entrypoint"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..11c4f09 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Telegram WEB proxy + +Self-hosted Telegram WEB proxy deployment for Docker and Nginx Proxy Manager. +The repository contains no deployment-specific hostname, public address, +credential or generated client secret. + +## Architecture + +```text +Telegram Desktop + -> HTTPS / WebSocket +Nginx Proxy Manager + -> tg-web-proxy:8088 +Ingress + -> local tproxy-server + -> local MTProxy + -> Telegram DCs +``` + +Only Nginx Proxy Manager can reach the container on its shared Docker network; +the Compose file publishes no host ports. The public fallback page is served +only for requests without a query string, so authenticated bridge requests and +WebSocket traffic always reach `tproxy-server`. + +## Repository contents + +- `install.sh` — interactive installation on an existing Docker/NPM host; +- `compose.yml` and `Dockerfile` — reproducible, checksum-pinned build; +- `ingress/ingress.go` — canonical ingress used by new installations; +- `ingress/ingress.js` — equivalent emergency implementation; +- `docs/runbook.md` — checks, deployment, rollback and troubleshooting; +- `docs/incidents/bridge-routing.md` — routing regression design note; +- `scripts/audit-public.sh` — scans the working tree and Git history for + secrets, deployment identifiers and unexpected address literals. + +## Installation + +Requirements: + +- x86_64 Linux; +- Docker Engine with the Compose plugin; +- an existing Nginx Proxy Manager container and Docker network; +- a lowercase DNS hostname whose `A` record points to the server; +- outbound HTTPS access during the build and first start. + +Clone this repository, enter its directory and run: + +```sh +./install.sh +``` + +The installer asks for: + +1. the public proxy hostname; +2. the server's public IPv4 address; +3. the Nginx Proxy Manager Docker network, but only when it cannot be detected. + +For unattended use, provide the same values with `--hostname`, `--public-ip`, +`--npm-network` and `--yes`. + +The two cryptographic secrets are generated locally and remain in the named +Docker volume. The generated `.env` contains only the hostname, public address, +network name and capacity settings. The installer does not use an external IP +discovery service. + +When the container is healthy, the script prints the Nginx Proxy Manager Proxy +Host values and the generated client secret. Store that secret securely. + +## External network access + +The build downloads checksum-pinned source archives from the official MTProxy +and `tproxy-server` repositories. At startup, the container downloads Telegram's +official proxy secret and routing configuration over HTTPS. No access token or +API key is required. + +## Security check + +Never commit `.env`, access tokens, client secrets, generated bridge URLs, +`profiles.json`, MTProxy secret files or volume backups. + +Run this before every public release: + +```sh +./scripts/audit-public.sh +``` + +To check private deployment identifiers without publishing them, create an +ignored `.audit-private-patterns` file with one literal string per line. The +audit checks those strings in the working tree while the file stays local. + +A clean result is necessary, but it does not replace review of the actual diff. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b872397 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security policy + +Do not open a public issue containing a client secret, bridge URL, access token, +private key, `.env` file, Docker volume export or Portainer backup. + +If sensitive data is exposed, revoke or rotate it first. Then contact the +repository owner through a private Gitea channel and include only the minimum +information needed to reproduce the problem. + +Before publishing a change, run: + +```sh +./scripts/audit-public.sh +``` + +The audit covers the current tree and every locally reachable Git commit. It is +a guardrail, not a substitute for reviewing the diff and generated artifacts. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..264dc52 --- /dev/null +++ b/compose.yml @@ -0,0 +1,61 @@ +name: telegram-web-proxy + +services: + proxy: + build: + context: . + dockerfile: Dockerfile + args: + TPROXY_COMMIT: c8adb8b7c6b7fc46c12ae3acb68be9070c26a8e8 + TPROXY_SHA256: a78b48f536180143dd7005785ca397310c36ee0b132e19949a45d64f586ab549 + MTPROXY_COMMIT: f36d8af769ffaeac36978d38c2c0f6d1104c2137 + MTPROXY_SHA256: 919795c416b870670841a21d1930ad97a24c7b84b9eb8c6f9e3de32f2fdf4655 + image: telegram-web-proxy:local + container_name: tg-web-proxy + restart: unless-stopped + environment: + TPROXY_HOSTNAME: ${TPROXY_HOSTNAME:?set by install.sh} + MTPROXY_PUBLIC_IP: ${MTPROXY_PUBLIC_IP:?set by install.sh} + MTPROXY_WORKERS: ${MTPROXY_WORKERS:-1} + MTPROXY_MAX_CONNECTIONS: ${MTPROXY_MAX_CONNECTIONS:-4096} + volumes: + - tg_webproxy_data:/data + networks: + - npm_network + expose: + - "8088" + cap_drop: + - ALL + cap_add: + - SETUID + - SETGID + security_opt: + - no-new-privileges:true + pids_limit: 256 + mem_limit: 768m + cpus: 1.25 + healthcheck: + test: + - CMD-SHELL + - >- + curl -fsS http://127.0.0.1:8081/readyz >/dev/null && + curl -fsS -H "Host: $${TPROXY_HOSTNAME}" + http://127.0.0.1:8088/ >/dev/null + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + tg_webproxy_data: + name: tg_webproxy_data + +networks: + npm_network: + external: true + name: ${NPM_NETWORK_NAME:?set by install.sh} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..ee12eca --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +umask 077 + +hostname_value="${TPROXY_HOSTNAME:-}" +public_ip="${MTPROXY_PUBLIC_IP:-}" +workers="${MTPROXY_WORKERS:-1}" +max_connections="${MTPROXY_MAX_CONNECTIONS:-4096}" + +if [[ ! "$hostname_value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || + [[ "$hostname_value" != *.* ]]; then + echo "TPROXY_HOSTNAME must be a lowercase DNS hostname" >&2 + exit 2 +fi + +valid_ipv4() { + local ip=$1 octet + local -a octets + local IFS=. + read -r -a octets <<<"$ip" + [[ ${#octets[@]} -eq 4 ]] || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]{1,3}$ ]] || return 1 + ((10#$octet <= 255)) || return 1 + done +} + +if ! valid_ipv4 "$public_ip"; then + echo "MTPROXY_PUBLIC_IP must be a public IPv4 address" >&2 + exit 2 +fi +if [[ ! "$workers" =~ ^[1-9][0-9]*$ ]] || ((workers > 256)); then + echo "MTPROXY_WORKERS must be between 1 and 256" >&2 + exit 2 +fi +if [[ ! "$max_connections" =~ ^[1-9][0-9]*$ ]]; then + echo "MTPROXY_MAX_CONNECTIONS must be positive" >&2 + exit 2 +fi + +install -d -o root -g root -m 0700 /data/mtproxy +install -d -o tproxy -g tproxy -m 0700 /data/tproxy /run/tproxy + +atomic_download() { + local url=$1 destination=$2 minimum_size=$3 required_pattern=$4 temporary + temporary="${destination}.new" + rm -f "$temporary" + curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --output "$temporary" "$url" + [[ $(wc -c <"$temporary") -ge $minimum_size ]] + grep -q "$required_pattern" "$temporary" + chmod 0600 "$temporary" + mv -f "$temporary" "$destination" +} + +atomic_download \ + https://core.telegram.org/getProxySecret \ + /data/mtproxy/proxy-secret 128 '.' +atomic_download \ + https://core.telegram.org/getProxyConfig \ + /data/mtproxy/proxy-multi.conf 100 '^proxy_for ' +chown root:mtproxy /data/mtproxy/proxy-secret /data/mtproxy/proxy-multi.conf +chmod 0640 /data/mtproxy/proxy-secret /data/mtproxy/proxy-multi.conf + +if [[ ! -s /data/client-secret ]]; then + openssl rand -hex 16 > /data/client-secret +fi +client_secret="$(tr -d '\r\n' &2 + exit 2 +fi +chown root:root /data/client-secret +chmod 0600 /data/client-secret + +if [[ ! -s /data/tproxy/token.key ]]; then + head -c 32 /dev/urandom > /data/tproxy/token.key +fi +chown tproxy:tproxy /data/tproxy/token.key +chmod 0400 /data/tproxy/token.key + +cat > /data/tproxy/config.json < /run/tproxy/profiles.json <&2 + exit 1 +fi + +/usr/local/bin/mtproto-proxy \ + -u mtproxy \ + -p 8888 \ + -H 2398 \ + -S "$client_secret" \ + --nat-info "$local_ip:$public_ip" \ + --aes-pwd /data/mtproxy/proxy-secret /data/mtproxy/proxy-multi.conf \ + -M "$workers" \ + -C "$max_connections" & +mtproxy_pid=$! + +runuser -u tproxy -- /usr/local/bin/tproxy-server \ + -config /data/tproxy/config.json & +relay_pid=$! + +runuser -u tproxy -- env TPROXY_HOSTNAME="$hostname_value" \ + /usr/local/bin/tproxy-ingress & +ingress_pid=$! + +stop_all() { + trap - EXIT TERM INT + kill -TERM "$ingress_pid" "$relay_pid" "$mtproxy_pid" 2>/dev/null || true + wait "$ingress_pid" "$relay_pid" "$mtproxy_pid" 2>/dev/null || true +} +trap stop_all EXIT TERM INT + +set +e +wait -n "$mtproxy_pid" "$relay_pid" "$ingress_pid" +status=$? +set -e +exit "$status" diff --git a/docs/incidents/bridge-routing.md b/docs/incidents/bridge-routing.md new file mode 100644 index 0000000..fafd38f --- /dev/null +++ b/docs/incidents/bridge-routing.md @@ -0,0 +1,31 @@ +# Design note: bridge query routing + +## Failure mode + +A public fallback handler that matches every `GET /` request also captures the +authenticated bridge request because the bridge capability is carried in the +query string. The service may appear healthy while Telegram clients cannot +connect. + +## Required behavior + +Serve the fallback page only when the request has no query string: + +```go +if request.Method == http.MethodGet && + request.URL.Path == "/" && + request.URL.RawQuery == "" { + servePublicPage() +} +``` + +Requests with bridge parameters, carrier API requests and WebSocket upgrades +must be proxied to `tproxy-server`. + +## Verification + +- the public root returns the fallback page; +- an authenticated bridge request returns the bridge document and disables + caching; +- WebSocket traffic reaches the relay; +- a healthy admin endpoint is treated as necessary but not sufficient. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..077d2d0 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,69 @@ +# Telegram WEB proxy runbook + +## Components + +| Component | Name | Purpose | +| --- | --- | --- | +| Container | `tg-web-proxy` | Runs MTProxy, `tproxy-server` and ingress | +| Docker volume | `tg_webproxy_data` | Stores generated secrets and runtime data | +| Ingress port | `8088` | Receives HTTP and WebSocket traffic from NPM | + +No Docker host port is published. Nginx Proxy Manager and the proxy container +must share the external Docker network selected during installation. + +## Nginx Proxy Manager + +Create a Proxy Host using the values printed by `install.sh`: + +| Field | Value | +| --- | --- | +| Domain Names | the hostname entered during installation | +| Scheme | `http` | +| Forward Hostname / IP | `tg-web-proxy` | +| Forward Port | `8088` | +| Websockets Support | enabled | +| Force SSL | enabled | +| HTTP/2 Support | enabled | +| Certificate | a valid certificate for the selected hostname | + +## Expected responses + +| Request | Expected result | +| --- | --- | +| Plain `GET /` | HTTP 200 and the small fallback page | +| Authenticated `GET /?bridge=...` | bridge page with `Cache-Control: no-store` | +| Carrier API and WebSocket paths | proxied to `tproxy-server` | +| Wrong `Host` header | HTTP 421 | + +Never paste a bridge capability into tickets, logs or monitoring labels. It is +derived from the client secret and must be treated as sensitive. + +## Health checks + +1. Confirm that `tg-web-proxy` is running and healthy. +2. Request the public root over HTTPS and expect HTTP 200. +3. Confirm that the TLS certificate validates and HTTP redirects to HTTPS. +4. Validate an authenticated bridge request from a trusted diagnostic process + without printing or persisting the complete URL. +5. Confirm that a WebSocket upgrade reaches the relay. An invalid test token may + be rejected, but it must not return the public fallback page. + +## Updating + +Pull the reviewed repository revision and run `./install.sh` again. Docker builds +the new image before replacing the running container. Keep the named volume so +the client secret remains stable. + +## Rollback + +Keep the previously built image until the new deployment passes every health +check. To roll back, restore the previous Compose revision and start it with the +same `.env` and named volume. Do not recreate the volume during rollback. + +## Secret handling + +- Keep management-system access tokens short lived and revoke them after use. +- Keep `client-secret`, `profiles.json`, bridge URLs and MTProxy secret files out + of Git. +- The generated `.env` is for non-secret deployment settings only. +- Never attach a volume export or management-system backup to a public issue. diff --git a/ingress/ingress.go b/ingress/ingress.go new file mode 100644 index 0000000..7430aad --- /dev/null +++ b/ingress/ingress.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" +) + +const page = `Proxy Edge
Proxy Edge

Private edge transport

This endpoint provides resilient transport and availability services for authorized clients.

` + +func requestHostname(hostport string) string { + host := strings.TrimSpace(hostport) + if parsed, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(parsed) + } + return strings.ToLower(strings.Trim(host, "[]")) +} + +func main() { + hostname := strings.ToLower(strings.TrimSpace(os.Getenv("TPROXY_HOSTNAME"))) + if hostname == "" { + log.Fatal("TPROXY_HOSTNAME is required") + } + + target, err := url.Parse("http://127.0.0.1:8080") + if err != nil { + log.Fatal(err) + } + + proxy := &httputil.ReverseProxy{ + Rewrite: func(request *httputil.ProxyRequest) { + request.SetURL(target) + request.Out.Host = request.In.Host + + clientIP := strings.TrimSpace(request.In.Header.Get("X-Real-IP")) + if net.ParseIP(clientIP) == nil { + clientIP, _, _ = net.SplitHostPort(request.In.RemoteAddr) + } + request.Out.Header.Set("X-Real-IP", clientIP) + request.Out.Header.Set("X-Forwarded-For", clientIP) + if proto := request.In.Header.Get("X-Forwarded-Proto"); proto != "" { + request.Out.Header.Set("X-Forwarded-Proto", proto) + } + }, + ErrorHandler: func(response http.ResponseWriter, _ *http.Request, proxyErr error) { + log.Printf("relay error: %v", proxyErr) + http.Error(response, "upstream unavailable", http.StatusBadGateway) + }, + } + + handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if requestHostname(request.Host) != hostname { + http.Error(response, "misdirected request", http.StatusMisdirectedRequest) + return + } + + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("Referrer-Policy", "no-referrer") + + staticPath := request.URL.Path == "/" || + request.URL.Path == "/about" || + request.URL.Path == "/privacy" + if request.Method == http.MethodGet && request.URL.RawQuery == "" && staticPath { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.Header().Set("Cache-Control", "public, max-age=300") + _, _ = fmt.Fprint(response, page) + return + } + + proxy.ServeHTTP(response, request) + }) + + server := &http.Server{ + Addr: ":8088", + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 90 * time.Second, + } + + log.Printf("ingress listening on %s for %s", server.Addr, hostname) + log.Fatal(server.ListenAndServe()) +} diff --git a/ingress/ingress.js b/ingress/ingress.js new file mode 100755 index 0000000..8fbf2a6 --- /dev/null +++ b/ingress/ingress.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +'use strict'; + +const http = require('http'); +const net = require('net'); + +const expectedHostname = (process.env.TPROXY_HOSTNAME || '').toLowerCase(); +const upstreamHost = '127.0.0.1'; +const upstreamPort = 8080; +const listenPort = 8088; + +const page = 'Proxy Edge
Proxy Edge

Private edge transport

This endpoint provides resilient transport and availability services for authorized clients.

'; + +function requestHostname(req) { + return (req.headers.host || '').split(':', 1)[0].toLowerCase(); +} + +function acceptHost(req) { + return expectedHostname !== '' && requestHostname(req) === expectedHostname; +} + +function failMisdirected(response) { + response.writeHead(421, { + 'content-type': 'text/plain; charset=utf-8', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }); + response.end('misdirected request\n'); +} + +const server = http.createServer((req, res) => { + if (!acceptHost(req)) { + failMisdirected(res); + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const staticPath = + requestUrl.pathname === '/' || + requestUrl.pathname === '/about' || + requestUrl.pathname === '/privacy'; + + if (req.method === 'GET' && staticPath && requestUrl.search === '') { + res.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'public, max-age=300', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }); + res.end(page); + return; + } + + const upstream = http.request( + { + host: upstreamHost, + port: upstreamPort, + method: req.method, + path: req.url, + headers: req.headers, + }, + (upstreamResponse) => { + const headers = { ...upstreamResponse.headers }; + headers['x-content-type-options'] = 'nosniff'; + headers['referrer-policy'] = 'no-referrer'; + res.writeHead(upstreamResponse.statusCode || 502, headers); + upstreamResponse.pipe(res); + }, + ); + + upstream.on('error', (error) => { + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); + } + res.end('upstream unavailable\n'); + console.error(`relay error: ${error.message}`); + }); + req.pipe(upstream); +}); + +server.on('upgrade', (req, clientSocket, head) => { + if (!acceptHost(req)) { + clientSocket.end( + 'HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\n\r\n', + ); + return; + } + + const upstreamSocket = net.connect(upstreamPort, upstreamHost, () => { + let requestHead = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + for (let index = 0; index < req.rawHeaders.length; index += 2) { + requestHead += `${req.rawHeaders[index]}: ${req.rawHeaders[index + 1]}\r\n`; + } + upstreamSocket.write(`${requestHead}\r\n`); + if (head.length > 0) upstreamSocket.write(head); + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + }); + + upstreamSocket.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstreamSocket.destroy()); +}); + +server.headersTimeout = 10_000; +server.keepAliveTimeout = 90_000; +server.listen(listenPort, '0.0.0.0', () => { + console.log(`ingress listening on :${listenPort} for ${expectedHostname}`); +}); diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a337972 --- /dev/null +++ b/install.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +umask 077 + +hostname_value="${TPROXY_HOSTNAME:-}" +public_ip="${MTPROXY_PUBLIC_IP:-}" +npm_network="${NPM_NETWORK_NAME:-}" +assume_yes=false + +usage() { + cat <<'USAGE' +Usage: ./install.sh [options] + +Options: + --hostname DOMAIN Public proxy hostname + --public-ip IPV4 Public IPv4 address of this Docker host + --npm-network NAME Existing Docker network used by Nginx Proxy Manager + --yes Accept detected defaults without confirmation + -h, --help Show this help + +Without options the installer asks only for values it cannot detect safely. +The WEB proxy secret and relay signing key are generated locally and are never +sent to Gitea or written to .env. +USAGE +} + +while (($#)); do + case "$1" in + --hostname) + hostname_value="${2:-}" + shift 2 + ;; + --public-ip) + public_ip="${2:-}" + shift 2 + ;; + --npm-network) + npm_network="${2:-}" + shift 2 + ;; + --yes) + assume_yes=true + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(uname -s)" != Linux ]] || [[ "$(uname -m)" != x86_64 ]]; then + echo "This installer requires an x86_64 Linux Docker host." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker Engine with the Compose plugin is required." >&2 + exit 1 +fi + +docker_command=(docker) +if ! docker info >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then + docker_command=(sudo docker) + else + echo "The current user cannot access Docker. Run as root or configure Docker access." >&2 + exit 1 + fi +fi +if ! "${docker_command[@]}" compose version >/dev/null 2>&1; then + echo "The Docker Compose plugin is required." >&2 + exit 1 +fi + +valid_hostname() { + local value=$1 label + [[ ${#value} -le 253 ]] || return 1 + [[ "$value" == *.* ]] || return 1 + [[ "$value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || return 1 + local IFS=. + read -r -a labels <<<"$value" + for label in "${labels[@]}"; do + [[ -n "$label" && ${#label} -le 63 ]] || return 1 + [[ "$label" != -* && "$label" != *- ]] || return 1 + done +} + +valid_ipv4() { + local ip=$1 octet + local -a octets + local IFS=. + read -r -a octets <<<"$ip" + [[ ${#octets[@]} -eq 4 ]] || return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]{1,3}$ ]] || return 1 + ((10#$octet <= 255)) || return 1 + done +} + +public_ipv4() { + local ip=$1 + local -a octets + local IFS=. + valid_ipv4 "$ip" || return 1 + read -r -a octets <<<"$ip" + ((10#${octets[0]} != 0)) || return 1 + ((10#${octets[0]} != 10)) || return 1 + ((10#${octets[0]} != 127)) || return 1 + ! ((10#${octets[0]} == 100 && 10#${octets[1]} >= 64 && 10#${octets[1]} <= 127)) || return 1 + ! ((10#${octets[0]} == 169 && 10#${octets[1]} == 254)) || return 1 + ! ((10#${octets[0]} == 172 && 10#${octets[1]} >= 16 && 10#${octets[1]} <= 31)) || return 1 + ! ((10#${octets[0]} == 192 && 10#${octets[1]} == 0 && (10#${octets[2]} == 0 || 10#${octets[2]} == 2))) || return 1 + ! ((10#${octets[0]} == 192 && 10#${octets[1]} == 88 && 10#${octets[2]} == 99)) || return 1 + ! ((10#${octets[0]} == 192 && 10#${octets[1]} == 168)) || return 1 + ! ((10#${octets[0]} == 198 && (10#${octets[1]} == 18 || 10#${octets[1]} == 19))) || return 1 + ! ((10#${octets[0]} == 198 && 10#${octets[1]} == 51 && 10#${octets[2]} == 100)) || return 1 + ! ((10#${octets[0]} == 203 && 10#${octets[1]} == 0 && 10#${octets[2]} == 113)) || return 1 + ((10#${octets[0]} < 224)) || return 1 +} + +if [[ -z "$hostname_value" ]]; then + if [[ ! -t 0 ]]; then + echo "--hostname is required in non-interactive mode." >&2 + exit 2 + fi + read -r -p "Public hostname (for example, proxy.example.com): " hostname_value +fi +hostname_value="$(printf '%s' "$hostname_value" | tr '[:upper:]' '[:lower:]')" +if ! valid_hostname "$hostname_value"; then + echo "Invalid hostname: $hostname_value" >&2 + exit 2 +fi + +if [[ -z "$public_ip" ]]; then + if [[ ! -t 0 ]]; then + echo "--public-ip is required in non-interactive mode." >&2 + exit 2 + fi + read -r -p "Public IPv4 of this Docker host: " public_ip +fi +if ! public_ipv4 "$public_ip"; then + echo "Not a usable public IPv4 address: $public_ip" >&2 + exit 2 +fi + +if [[ -z "$npm_network" ]]; then + npm_container="$("${docker_command[@]}" ps --format '{{.Names}}' | + awk 'tolower($0) ~ /(nginx.*proxy|npm)/ { print; exit }')" + if [[ -n "$npm_container" ]]; then + npm_network="$("${docker_command[@]}" inspect \ + --format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}' \ + "$npm_container" | awk '$0 != "bridge" && $0 != "host" && $0 != "none" { print; exit }')" + fi +fi + +if [[ -z "$npm_network" ]]; then + for candidate in nginx-proxy-manager_default npm_default; do + if "${docker_command[@]}" network inspect "$candidate" >/dev/null 2>&1; then + npm_network=$candidate + break + fi + done +fi + +if [[ -z "$npm_network" ]]; then + if [[ ! -t 0 ]]; then + echo "--npm-network is required when Nginx Proxy Manager cannot be detected." >&2 + exit 2 + fi + read -r -p "Existing Nginx Proxy Manager Docker network: " npm_network +fi +if ! "${docker_command[@]}" network inspect "$npm_network" >/dev/null 2>&1; then + echo "Docker network does not exist: $npm_network" >&2 + exit 2 +fi + +repository="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +environment_file="$repository/.env" +temporary_environment="$(mktemp "$repository/.env.new.XXXXXX")" +trap 'rm -f "$temporary_environment"' EXIT +cat >"$temporary_environment" </dev/null || true)" + if [[ "$health" == healthy ]]; then + break + fi + if [[ "$health" == unhealthy || "$health" == exited || "$health" == dead ]]; then + break + fi + sleep 2 +done + +if [[ "$health" != healthy ]]; then + echo "The proxy did not become healthy (state: ${health:-unknown})." >&2 + "${docker_command[@]}" logs --tail 100 tg-web-proxy >&2 || true + exit 1 +fi + +client_secret="$("${docker_command[@]}" exec tg-web-proxy \ + sh -c 'tr -d "\\r\\n" < /data/client-secret')" +if [[ ! "$client_secret" =~ ^[0-9a-f]{32}$ ]]; then + echo "The proxy is healthy, but its client secret could not be read." >&2 + exit 1 +fi + +resolved_ip="$(getent ahostsv4 "$hostname_value" 2>/dev/null | awk 'NR == 1 { print $1 }')" + +cat <&2 +fi diff --git a/scripts/audit-public.sh b/scripts/audit-public.sh new file mode 100755 index 0000000..36b0976 --- /dev/null +++ b/scripts/audit-public.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +repository="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$repository" + +failures=0 + +report() { + printf 'FAIL: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +scan_file() { + local label=$1 file=$2 pattern=$3 + if LC_ALL=C grep -IqiE -- "$pattern" "$file"; then + report "$label in $file" + fi +} + +collect_matches() { + local result_name=$1 label=$2 output status + shift 2 + if output="$(LC_ALL=C "$@" 2>/dev/null)"; then + status=0 + else + status=$? + fi + if ((status > 1)); then + report "$label could not complete" + output='' + fi + printf -v "$result_name" '%s' "$output" +} + +scan_network_literals() { + local file=$1 context=$2 addresses domains address domain + collect_matches addresses "IPv4 scan for $context$file" \ + grep -IhoE -- '([0-9]{1,3}\.){3}[0-9]{1,3}' "$file" + while IFS= read -r address; do + [[ -z "$address" ]] && continue + case "$address" in + 127.0.0.1 | 0.0.0.0) ;; + *) report "unexpected IPv4 literal $address in $context$file" ;; + esac + done <<<"$(printf '%s\n' "$addresses" | sort -u)" + + collect_matches domains "hostname scan for $context$file" \ + grep -IhoE -- '([[:alnum:]][[:alnum:]-]*\.)+(com|org|net|ru|io|dev|app|cloud|info|biz|me|ai|local|localhost|de|uk|fr|us|ca|au|ch|nl|se|no|fi|pl|cz|kz|by|ua|su)([^[:alnum:]_.-]|$)' "$file" + while IFS= read -r domain; do + [[ -z "$domain" ]] && continue + domain="$(printf '%s' "$domain" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:].-]$//')" + case "$domain" in + github.com | core.telegram.org | example.com | *.example.com | *.localhost | \ + *.md | *.sh | *.json | *.yml | *.yaml | *.html | *.go | *.js | \ + *.gz | *.pem | *.key | *.p12 | *.pfx | *.example) ;; + *) report "unexpected hostname $domain in $context$file" ;; + esac + done <<<"$(printf '%s\n' "$domains" | sort -u)" +} + +scan_local_denylist() { + local file=$1 context=$2 literal + [[ -f .audit-private-patterns ]] || return 0 + while IFS= read -r literal || [[ -n "$literal" ]]; do + [[ -z "$literal" || "$literal" == \#* ]] && continue + if LC_ALL=C grep -IFqi -- "$literal" "$file"; then + report "locally denied identifier in $context$file" + fi + done < .audit-private-patterns +} + +# The scanner contains signatures for credentials, so it is excluded only from +# those signature checks. Address, hostname and optional local deny-list checks +# still inspect it. Every tracked or untracked file not ignored by Git is read. +while IFS= read -r -d '' file; do + [[ -f "$file" ]] || continue + if [[ "$file" != scripts/audit-public.sh ]]; then + scan_file "private-key material" "$file" \ + '-----BEGIN ([A-Z0-9]+ )?PRIVATE KEY-----' + scan_file "access-token pattern" "$file" \ + '(ptr_|gh[pousr]_|github_pat_|glpat-|xox[baprs]-)[A-Za-z0-9_+/=-]{16,}' + scan_file "cloud credential pattern" "$file" \ + '(AKIA|ASIA)[A-Z0-9]{16}|AIza[0-9A-Za-z_-]{35}' + scan_file "credential embedded in URL" "$file" \ + 'https?://[^/:[:space:]]+:[^/@[:space:]]+@' + scan_file "Bearer token" "$file" \ + 'Bearer[[:space:]]+[A-Za-z0-9._~+/-]{20,}={0,2}' + scan_file "32-hex client secret" "$file" \ + '(^|[^0-9a-f])[0-9a-f]{32}([^0-9a-f]|$)' + scan_file "credential assignment" "$file" \ + '(api[_-]?key|api[_-]?token|access[_-]?token|password|secret)[[:space:]]*[:=][[:space:]]*[^$[:space:]][A-Za-z0-9_+./=-]{15,}' + fi + scan_network_literals "$file" '' + scan_local_denylist "$file" '' +done < <(git ls-files --cached --others --exclude-standard -z) + +while IFS= read -r -d '' file; do + case "$file" in + .env.example) ;; + .env | .env.* | */.env | */.env.* | *client-secret* | *profiles.json | \ + *.pem | *.key | *.p12 | *.pfx | id_rsa | id_ed25519) + report "sensitive filename is tracked: $file" + ;; + esac +done < <(git ls-files -z) + +history_pattern='-----BEGIN ([A-Z0-9]+ )?PRIVATE KEY-----|(ptr_|gh[pousr]_|github_pat_|glpat-|xox[baprs]-)[A-Za-z0-9_+/=-]{16,}|(AKIA|ASIA)[A-Z0-9]{16}|AIza[0-9A-Za-z_-]{35}|https?://[^/:[:space:]]+:[^/@[:space:]]+@|Bearer[[:space:]]+[A-Za-z0-9._~+/-]{20,}={0,2}|(^|[^0-9a-f])[0-9a-f]{32}([^0-9a-f]|$)|(api[_-]?key|api[_-]?token|access[_-]?token|password|secret)[[:space:]]*[:=][[:space:]]*[^$[:space:]][A-Za-z0-9_+./=-]{15,}' +if revisions="$(git rev-list --all)"; then + : +else + report "Git history could not be enumerated" + revisions='' +fi +while IFS= read -r revision; do + [[ -z "$revision" ]] && continue + collect_matches matches "credential scan for Git history ${revision:0:12}" \ + git grep -IilE -- "$history_pattern" "$revision" -- . \ + ':(exclude)scripts/audit-public.sh' + if [[ -n "$matches" ]]; then + while IFS= read -r match; do + report "sensitive content in Git history: $match" + done <<<"$matches" + fi + collect_matches addresses "IPv4 scan for Git history ${revision:0:12}" \ + git grep -IhoE -- '([0-9]{1,3}\.){3}[0-9]{1,3}' "$revision" -- . + while IFS= read -r address; do + [[ -z "$address" ]] && continue + case "$address" in + 127.0.0.1 | 0.0.0.0) ;; + *) report "unexpected IPv4 literal $address in Git history ${revision:0:12}" ;; + esac + done <<<"$(printf '%s\n' "$addresses" | sort -u)" + + collect_matches domains "hostname scan for Git history ${revision:0:12}" \ + git grep -IhoE -- '([[:alnum:]][[:alnum:]-]*\.)+(com|org|net|ru|io|dev|app|cloud|info|biz|me|ai|local|localhost|de|uk|fr|us|ca|au|ch|nl|se|no|fi|pl|cz|kz|by|ua|su)([^[:alnum:]_.-]|$)' "$revision" -- . + while IFS= read -r domain; do + [[ -z "$domain" ]] && continue + domain="$(printf '%s' "$domain" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:].-]$//')" + case "$domain" in + github.com | core.telegram.org | example.com | *.example.com | *.localhost | \ + *.md | *.sh | *.json | *.yml | *.yaml | *.html | *.go | *.js | \ + *.gz | *.pem | *.key | *.p12 | *.pfx | *.example) ;; + *) report "unexpected hostname $domain in Git history ${revision:0:12}" ;; + esac + done <<<"$(printf '%s\n' "$domains" | sort -u)" +done <<<"$revisions" + +while IFS= read -r identity_email; do + [[ -z "$identity_email" || "$identity_email" == *noreply* ]] && continue + report "personal author or committer e-mail remains in Git history" + break +done < <(git log --all --format='%ae%n%ce' | sort -u) + +while IFS= read -r identity_name; do + [[ -z "$identity_name" || "$identity_name" == Maintainer ]] && continue + report "non-generic author or committer name remains in Git history" + break +done < <(git log --all --format='%an%n%cn' | sort -u) + +if ((failures > 0)); then + printf '\nPublic-release audit failed with %d finding(s).\n' "$failures" >&2 + exit 1 +fi + +echo "Public-release audit passed: no secrets, deployment identifiers or unexpected address literals found." diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..6dde26b --- /dev/null +++ b/site/index.html @@ -0,0 +1,15 @@ + + + + + + + Proxy Edge + + +
+

Proxy Edge

+

Private edge transport and availability endpoint.

+
+ +