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