feat: publish generic Telegram WEB proxy stack
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
@@ -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}
|
||||||
Executable
+145
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Executable
+108
@@ -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
@@ -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
|
||||||
Executable
+167
@@ -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."
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user