feat: publish generic Telegram WEB proxy stack

This commit is contained in:
Maintainer
2026-10-14 16:37:48 +07:00
commit a91a7ca11a
14 changed files with 1153 additions and 0 deletions
+5
View File
@@ -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
+14
View File
@@ -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/
+85
View File
@@ -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"]
+91
View File
@@ -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.
+17
View File
@@ -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.
+61
View File
@@ -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}
+145
View File
@@ -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' </data/client-secret)"
if [[ ! "$client_secret" =~ ^[0-9a-f]{32}$ ]]; then
echo "persisted client secret is invalid" >&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 <<JSON
{
"public_hostname": "$hostname_value",
"base_path": "",
"listen": "127.0.0.1:8080",
"admin_listen": "127.0.0.1:8081",
"public_dir": "/srv/tproxy-site",
"token_key_file": "/data/tproxy/token.key",
"static_routes": "exact",
"profiles_file": "/run/tproxy/profiles.json",
"enable_pprof": false
}
JSON
cat > /run/tproxy/profiles.json <<JSON
{"profiles":[{"name":"default","secret":"$client_secret","backend":"127.0.0.1:2398","carrier_mode":"websocket"}]}
JSON
chown tproxy:tproxy /data/tproxy/config.json /run/tproxy/profiles.json
chmod 0400 /data/tproxy/config.json /run/tproxy/profiles.json
runuser -u tproxy -- /usr/local/bin/tproxy-server \
-config /data/tproxy/config.json \
-profiles-file /run/tproxy/profiles.json -check
local_ip="$(hostname -I | tr ' ' '\n' | awk '/^[0-9]+\./ {print; exit}')"
if ! valid_ipv4 "$local_ip"; then
echo "could not determine the container IPv4 address" >&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"
+31
View File
@@ -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.
+69
View File
@@ -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.
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"fmt"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
)
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Proxy Edge</title><style>body{margin:0;background:#0b1220;color:#dce7f7;font:16px system-ui,sans-serif}main{max-width:720px;margin:16vh auto;padding:32px}h1{font-size:42px;margin:0 0 12px;color:#fff}p{line-height:1.6;color:#9eb2cc}.badge{display:inline-block;padding:6px 10px;border:1px solid #274262;border-radius:999px;color:#86b7ea;font-size:13px}</style></head><body><main><span class="badge">Proxy Edge</span><h1>Private edge transport</h1><p>This endpoint provides resilient transport and availability services for authorized clients.</p></main></body></html>`
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())
}
+108
View File
@@ -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 = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Proxy Edge</title><style>body{margin:0;background:#0b1220;color:#dce7f7;font:16px system-ui,sans-serif}main{max-width:720px;margin:16vh auto;padding:32px}h1{font-size:42px;margin:0 0 12px;color:#fff}p{line-height:1.6;color:#9eb2cc}.badge{display:inline-block;padding:6px 10px;border:1px solid #274262;border-radius:999px;color:#86b7ea;font-size:13px}</style></head><body><main><span class="badge">Proxy Edge</span><h1>Private edge transport</h1><p>This endpoint provides resilient transport and availability services for authorized clients.</p></main></body></html>';
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}`);
});
Executable
+257
View File
@@ -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" <<ENV
TPROXY_HOSTNAME=$hostname_value
MTPROXY_PUBLIC_IP=$public_ip
NPM_NETWORK_NAME=$npm_network
MTPROXY_WORKERS=1
MTPROXY_MAX_CONNECTIONS=4096
ENV
chmod 0600 "$temporary_environment"
mv -f "$temporary_environment" "$environment_file"
trap - EXIT
compose=("${docker_command[@]}" compose --env-file "$environment_file" -f "$repository/compose.yml")
"${compose[@]}" config --quiet
"${compose[@]}" build --pull
"${compose[@]}" up -d --remove-orphans
health=
for _ in $(seq 1 90); do
health="$("${docker_command[@]}" inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
tg-web-proxy 2>/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 <<RESULT
Telegram WEB proxy is healthy.
Nginx Proxy Manager Proxy Host:
Domain: $hostname_value
Scheme: http
Forward host: tg-web-proxy
Forward port: 8088
WebSockets: enabled
Force SSL: enabled
HTTP/2: enabled
Telegram WEB proxy secret (store it securely):
$client_secret
Public IPv4: $public_ip
Detected DNS IPv4: ${resolved_ip:-not resolved}
The secret remains in the Docker volume tg_webproxy_data and is not stored in .env.
RESULT
if [[ -n "$resolved_ip" && "$resolved_ip" != "$public_ip" ]]; then
echo "WARNING: DNS currently resolves to $resolved_ip, not $public_ip." >&2
fi
+167
View File
@@ -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."
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Proxy Edge</title>
</head>
<body>
<main>
<h1>Proxy Edge</h1>
<p>Private edge transport and availability endpoint.</p>
</main>
</body>
</html>