Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ clash-configs/ 所有 profile 的客户端 YAML(不提交)

GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火墙;协议和客户端规则只维护一份。详细 seam 和 provider interface 见 [架构说明](docs/architecture.md)。

想快速理解整个仓库,先看[仓库总览与维护说明](docs/repository-guide.md);它把入口、部署链路、profile、直连/CDN 节点和日常操作放在一处。

## 配置

首次运行会从 `config/deploy.conf.example` 创建当前 profile 的本地状态。GCloud 固定使用
Expand All @@ -80,9 +82,16 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火
| `REALITY_PORT` | `443` | Reality 监听端口 |
| `REALITY_TARGET` / `REALITY_SNI` | `1.1.1.1:443` / 空 | Reality 目标与客户端 SNI |
| `HY2_PORT` | 随机 | Hysteria2 UDP 端口 |
| `HY2_PORT_RANGE` | 空 | 可选端口跳跃范围,例如 `30000-30010` |
| `HY2_OBFS_ENABLE` | `false` | 可选 Salamander 混淆;开启后不再表现为标准 HTTP/3 |
| `HY2_ACME_ENABLE` | `false` | 可选 Cloudflare DNS-01 真实证书 |
| `ANYTLS_PORT` | 随机 | AnyTLS TCP 端口 |
| `DEVICES` | `mac iphone` | 每个设备生成独立凭据和 YAML |
| `CDN_ENABLE` | `false` | 可选 Cloudflare Tunnel 出口 |
| `CDN_ONLY` | `false` | 仅使用 Cloudflare WS,并关闭直连代理端口 |
| `WARP_ENABLE` | `false` | 可选 Reality-WARP 节点;仅该节点的 Xray 出站经过 WARP |
| `WARP_SOCKS_PORT` | `40000` | 服务器本机 WARP SOCKS5 端口,不对公网开放 |
| `WARP_REALITY_PORT` | 随机 | Reality-WARP 直连端口;开启 WARP 时自动生成 |
| `PROJECT_ID` / `REGION` / `ZONE` | GCP 默认值 | 只由 GCP adapter 使用 |

每个 profile 内的敏感文件均已 gitignore:
Expand All @@ -109,12 +118,48 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火
- Clash Verge:Settings → Profiles → Import
- 手机:使用支持 Reality、Hysteria2 和 AnyTLS 的 Mihomo/Clash.Meta 兼容客户端

新生成的配置默认使用 `🛟 自动故障切换`:Reality 正常时行为不变,连接失败时按 Reality → CDN → Hysteria2 → AnyTLS 顺序切换。
`US-Reality-WARP` 会进入自动测速和手动选择,但不进入自动故障切换;测速策略会根据实测延迟决定是否使用它,默认故障链路不会被 WARP 拖慢。
如需真正隐藏源站 IP,把 `CDN_ENABLE=true` 和 `CDN_ONLY=true` 同时设置;这会关闭直连 Reality/Hysteria2/AnyTLS,保留 Cloudflare WS 入口。

### WARP 出站(低延迟优先的可选路径)

`WARP_ENABLE=true` 会在 VPS 上安装 Cloudflare WARP 的 SOCKS5 代理,并额外生成 `US-Reality-WARP`:客户端仍直连 VPS,只有该节点的 Xray 出站经过 WARP。现有 `US-Reality`、`US-HY2` 和 `US-AnyTLS` 不变。

这条路径不隐藏 VPS 入口 IP,因此不能和 `CDN_ONLY=true` 同时启用。WARP 只接入 Xray/Reality,Hysteria2 和 AnyTLS 暂不走全局策略路由;服务端会每 60 秒检查一次真实 SOCKS 出口并尝试自愈。

`HY2_PORT_RANGE`、`HY2_OBFS_ENABLE`、`HY2_ACME_ENABLE` 均为可选增强:开启后需要重新部署服务端并重新生成客户端 YAML;默认关闭时不改变已有协议行为。

修改 `DEVICES` 后重跑同一个 profile 的入口,即可增加或撤销设备。

### Cloudflare CDN 首次启用

先把域名接入 Cloudflare,并在目标 profile 的 `.secrets.env` 写入最小权限 API Token:

```text
Account → Cloudflare Tunnel → Edit
Zone → Zone → Read
Zone → DNS → Edit
```

Zone 资源只选择目标域名。然后在该 profile 的 `deploy.conf` 设置:

```text
CDN_ENABLE=true
CDN_ONLY=false
CDN_HOSTNAME=cdn.example.com
CDN_TUNNEL_NAME=<profile>-cdn
```

重新运行对应部署入口即可自动创建/复用 Tunnel、配置 Ingress、写入橙云 CNAME 并生成 `US-CDN`。
首次建议保持 `CDN_ONLY=false`,确认 CDN 节点可用后再单独切换 CDN-only。缺少 API Token 或
Cloudflare 权限不足时,部署会在修改服务器前停止,不会留下半套 VPS 配置。

## 重跑与维护

- 两个入口均按幂等方式设计,会复用已有服务器和本地密钥。
- 只重新生成某个 profile 的客户端 YAML:`NETWORK_NODE_PROFILE=<profile> python3 core/gen-clash.py`
- 服务端组件版本在 `deploy.conf` 中固定;升级前先改版本并重新部署,避免重跑时无意升级。
- GCP 旧命令 `./deploy.sh` 仍可使用,但会提示改用 `./deploy-gcp.sh`。
- 通用排障见 [Troubleshooting](docs/troubleshooting.md)。
- 新服务器接入和隔离规则见 [Provider Onboarding](docs/provider-onboarding.md)。
Expand Down
35 changes: 33 additions & 2 deletions config/deploy.conf.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ REALITY_SNI=
HY2_PORT=
ANYTLS_PORT=

# Hysteria2 可选增强:端口跳跃 / QUIC 混淆。默认关闭,开启后需要重新生成并导入客户端 YAML。
# HY2_PORT_RANGE=30000-30010
# HY2_HOP_INTERVAL=15-30
HY2_PORT_RANGE=
HY2_HOP_INTERVAL=
HY2_OBFS_ENABLE=false
HY2_SNI=www.bing.com
HY2_MASQUERADE_URL=https://www.bing.com
# 可选:用 Cloudflare DNS-01 自动签发真实 HY2 证书;默认关闭,不影响现有自签名兼容路径。
HY2_ACME_ENABLE=false
HY2_ACME_DOMAIN=
HY2_ACME_EMAIL=
HY2_ACME_DNS_PROVIDER=cloudflare

# Hysteria2 Brutal 拥塞控制(可选,跨太平洋提速核心)。
# 同时填 UP/DOWN 才生效:客户端按固定带宽发送、无视丢包。务必填你实测速度的 ~80%,
# 填太高会自伤丢包反而更慢;两者都留空 = Hysteria2 默认动态 CC(与历史行为一致)。
Expand All @@ -37,13 +51,30 @@ DEVICES="mac iphone"
# ── Cloudflare CDN 套娃出口(可选)──
# 开启后多一条出口:客户端 → Cloudflare 边缘 → Cloudflare Tunnel → cloudflared@VM
# → xray VLESS+WS(127.0.0.1:8080) → 直出。客户端只与 Cloudflare anycast IP 通信,
# 真实 VM IP 对线路侧不可见。false 或缺 CF_API_TOKEN 时完全跳过,不影响其它三个节点
# 真实 VM IP 对线路侧不可见。false 时跳过;true 时必须先准备 CF_API_TOKEN,缺失会在改服务器前直接停止
CDN_ENABLE=false
# true 时只生成 Cloudflare WS 节点,并关闭直连代理端口;会失去原生 HY2/AnyTLS 直连能力。
CDN_ONLY=false
# CF 上指向本 tunnel 的子域(该域名需已托管在 Cloudflare)
CDN_HOSTNAME=cdn2.example.com
# 新建的独立 tunnel 名(与其它机器互不影响)
CDN_TUNNEL_NAME=vpn-us-cdn
# WebSocket 路径。留空 = 自动随机生成(写入 .secrets.env)
CDN_WS_PATH=

# ── 可选:直连 VPS 的 Reality + WARP 出站节点 ──
# true 时额外生成 US-Reality-WARP:客户端仍直连 VPS,只有服务端出站经过 WARP。
# 它不隐藏 VPS 入口 IP;会参加自动测速,但不会加入自动故障切换,避免故障时拖慢默认链路。
# 与 CDN_ONLY=true 互斥;WARP_REALITY_PORT 留空时自动生成 40000-49999 端口。
WARP_ENABLE=false
WARP_SOCKS_PORT=40000
WARP_REALITY_PORT=

# 固定服务端组件版本,避免重跑时自动升级造成兼容性漂移。
XRAY_VERSION=v26.3.27
HYSTERIA_VERSION=app/v2.10.0
ANYTLS_VERSION=0.0.13
CLOUDFLARED_VERSION=2026.7.2
# 注意:CF_API_TOKEN(建隧道用)与 CF_TUNNEL_TOKEN(cloudflared 连接用)属密钥,
# 不写在本文件,只放 .secrets.env。首次运行会提示你把 CF_API_TOKEN 写进去。
# 不写在本文件,只放 .secrets.env。Token 权限需要 Account>Cloudflare Tunnel:Edit、
# Zone>Zone:Read、Zone>DNS:Edit,并限制到目标 zone。
12 changes: 8 additions & 4 deletions core/cloudflare.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# 幂等地在 Cloudflare 上为本 VM 建一条独立 Tunnel + 配 ingress + DNS,
# 并把 cloudflared 的 connector token 写入 .secrets.env(CF_TUNNEL_TOKEN)。
# 需要 .secrets.env 里有 CF_API_TOKEN(权限 Account>Cloudflare Tunnel:Edit + Zone>DNS:Edit)。
# 需要 .secrets.env 里有 CF_API_TOKEN(权限 Account>Cloudflare Tunnel:Edit + Zone>Zone:Read + Zone>DNS:Edit)。
# 仅在 CDN_ENABLE=true 时由共享部署流水线调用。
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
Expand All @@ -15,6 +15,8 @@ load_secrets
: "${CDN_TUNNEL_NAME:=vpn-us-cdn}"
CF_API_TOKEN="$(secret_get CF_API_TOKEN)"
[ -n "$CF_API_TOKEN" ] || die "缺 CF_API_TOKEN,请写入 .secrets.env(见 secrets.sh 提示)后重跑"
command -v curl >/dev/null 2>&1 || die "缺少 curl,无法调用 Cloudflare API"
command -v python3 >/dev/null 2>&1 || die "缺少 python3,无法解析 Cloudflare API 响应"

API="https://api.cloudflare.com/client/v4"

Expand All @@ -24,10 +26,12 @@ cf_api() {
for attempt in 1 2 3; do
if [ -n "$body" ]; then
resp="$(curl -sS -w $'\n%{http_code}' -X "$method" "$API$path" \
--connect-timeout 15 --max-time 60 \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" --data "$body" 2>/dev/null || true)"
else
resp="$(curl -sS -w $'\n%{http_code}' -X "$method" "$API$path" \
--connect-timeout 15 --max-time 60 \
-H "Authorization: Bearer $CF_API_TOKEN" 2>/dev/null || true)"
fi
http="${resp##*$'\n'}"; resp="${resp%$'\n'*}"
Expand Down Expand Up @@ -70,7 +74,7 @@ ok "zone=$ZONE_NAME account=$ACCOUNT_ID"

say "[CF 2/5] 建/复用 Tunnel:$CDN_TUNNEL_NAME"
LIST_JSON="$(cf_api GET "/accounts/$ACCOUNT_ID/cfd_tunnel?name=$CDN_TUNNEL_NAME&is_deleted=false")"
TUNNEL_ID="$(printf '%s' "$LIST_JSON" | jget '["result"][0]["id"]')"
TUNNEL_ID="$(printf '%s' "$LIST_JSON" | jget '["result"][0]["id"]' || true)"
if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" = "None" ]; then
CREATE_JSON="$(cf_api POST "/accounts/$ACCOUNT_ID/cfd_tunnel" \
"{\"name\":\"$CDN_TUNNEL_NAME\",\"config_src\":\"cloudflare\"}")"
Expand All @@ -88,7 +92,7 @@ ok "ingress 已设置"

say "[CF 4/5] 配置 DNS:$CDN_HOSTNAME CNAME -> $TUNNEL_ID.cfargotunnel.com (proxied)"
DNS_JSON="$(cf_api GET "/zones/$ZONE_ID/dns_records?type=CNAME&name=$CDN_HOSTNAME")"
REC_ID="$(printf '%s' "$DNS_JSON" | jget '["result"][0]["id"]')"
REC_ID="$(printf '%s' "$DNS_JSON" | jget '["result"][0]["id"]' || true)"
DNS_BODY="{\"type\":\"CNAME\",\"name\":\"$CDN_HOSTNAME\",\"content\":\"$TUNNEL_ID.cfargotunnel.com\",\"proxied\":true}"
if [ -z "$REC_ID" ] || [ "$REC_ID" = "None" ]; then
cf_api POST "/zones/$ZONE_ID/dns_records" "$DNS_BODY" >/dev/null
Expand All @@ -100,7 +104,7 @@ fi

say "[CF 5/5] 取 connector token 写入 .secrets.env"
TOKEN_JSON="$(cf_api GET "/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token")"
CONNECTOR_TOKEN="$(printf '%s' "$TOKEN_JSON" | jget '["result"]')"
CONNECTOR_TOKEN="$(printf '%s' "$TOKEN_JSON" | jget '["result"]' || true)"
[ -n "$CONNECTOR_TOKEN" ] && [ "$CONNECTOR_TOKEN" != "None" ] || die "未取得 connector token"
setkv CF_TUNNEL_TOKEN "$CONNECTOR_TOKEN"
setkv CDN_TUNNEL_ID "$TUNNEL_ID"
Expand Down
60 changes: 43 additions & 17 deletions core/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,32 @@

build_server_env() {
local target="$1" k d
export_var() { printf 'export %s=%q\n' "$1" "$2"; }
{
for k in REALITY_PORT REALITY_TARGET REALITY_SNI DEVICES; do
printf "export %s='%s'\n" "$k" "$(varval "$k")"
for k in REALITY_PORT REALITY_TARGET REALITY_SNI DEVICES HY2_PORT_RANGE HY2_HOP_INTERVAL HY2_SNI HY2_OBFS_ENABLE HY2_MASQUERADE_URL HY2_ACME_ENABLE HY2_ACME_DOMAIN HY2_ACME_EMAIL HY2_ACME_DNS_PROVIDER WARP_ENABLE WARP_SOCKS_PORT XRAY_VERSION HYSTERIA_VERSION ANYTLS_VERSION CLOUDFLARED_VERSION; do
export_var "$k" "$(varval "$k")"
done
for k in REALITY_SHORTID HY2_PORT ANYTLS_PORT ANYTLS_PASS; do
printf "export %s='%s'\n" "$k" "$(secret_get "$k")"
for k in REALITY_SHORTID HY2_PORT ANYTLS_PORT ANYTLS_PASS WARP_REALITY_PORT; do
export_var "$k" "$(secret_get "$k")"
done
for d in $DEVICES; do
printf "export REALITY_UUID_%s='%s'\n" "$d" "$(secret_get "REALITY_UUID_$d")"
printf "export HY2_PASS_%s='%s'\n" "$d" "$(secret_get "HY2_PASS_$d")"
export_var "REALITY_UUID_$d" "$(secret_get "REALITY_UUID_$d")"
export_var "HY2_PASS_$d" "$(secret_get "HY2_PASS_$d")"
export_var "WARP_REALITY_UUID_$d" "$(secret_get "WARP_REALITY_UUID_$d")"
done
printf "export CDN_ENABLE='%s'\n" "${CDN_ENABLE:-false}"
export_var CDN_ENABLE "${CDN_ENABLE:-false}"
export_var CDN_ONLY "${CDN_ONLY:-false}"
if [ "${HY2_OBFS_ENABLE:-false}" = "true" ]; then
export_var HY2_OBFS_PASSWORD "$(secret_get HY2_OBFS_PASSWORD)"
fi
if [ "${HY2_ACME_ENABLE:-false}" = "true" ]; then
export_var HY2_ACME_DNS_TOKEN "$(secret_get HY2_ACME_DNS_TOKEN)"
fi
if [ "${CDN_ENABLE:-false}" = "true" ]; then
printf "export CDN_WS_PATH='%s'\n" "$(secret_get CDN_WS_PATH)"
printf "export CF_TUNNEL_TOKEN='%s'\n" "$(secret_get CF_TUNNEL_TOKEN)"
export_var CDN_WS_PATH "$(secret_get CDN_WS_PATH)"
export_var CF_TUNNEL_TOKEN "$(secret_get CF_TUNNEL_TOKEN)"
for d in $DEVICES; do
printf "export CDN_UUID_%s='%s'\n" "$d" "$(secret_get "CDN_UUID_$d")"
export_var "CDN_UUID_$d" "$(secret_get "CDN_UUID_$d")"
done
fi
} > "$target"
Expand All @@ -45,21 +54,31 @@ run_deploy() {
load_conf
REALITY_TARGET="${REALITY_TARGET:-${REALITY_SNI}:443}"
export REALITY_TARGET
if [ "${CDN_ONLY:-false}" = "true" ] && [ "${CDN_ENABLE:-false}" != "true" ]; then
die "CDN_ONLY=true 必须同时设置 CDN_ENABLE=true"
fi
if [ "${WARP_ENABLE:-false}" = "true" ] && [ "${CDN_ONLY:-false}" = "true" ]; then
die "WARP_ENABLE=true 不能与 CDN_ONLY=true 同时使用(会重新暴露 VPS 入口)"
fi
ok "$PROVIDER_DESCRIPTION 设备=[$DEVICES]"

PROFILE_NAME="$PROFILE_NAME" \
NETWORK_NODE_STATE_DIR="$STATE_DIR" \
bash "$PROJECT_DIR/core/secrets.sh"
load_secrets

provider_provision
load_secrets

# Cloudflare setup is local/API-only. Do it before touching the host so a
# missing permission or unreachable API cannot leave a half-updated server.
if [ "${CDN_ENABLE:-false}" = "true" ]; then
bash "$PROJECT_DIR/core/cloudflare.sh"
PROFILE_NAME="$PROFILE_NAME" \
NETWORK_NODE_STATE_DIR="$STATE_DIR" \
bash "$PROJECT_DIR/core/cloudflare.sh"
load_secrets
fi

provider_provision
load_secrets

local srv_env srv_out rc pub
DEPLOY_TMP_DIR="$(mktemp -d)"
trap cleanup_deploy_tmp EXIT
Expand Down Expand Up @@ -87,9 +106,16 @@ run_deploy() {

printf '\n\033[1;32m=== 部署完成 ===\033[0m\n'
provider_print_summary
echo " Reality : TCP $REALITY_PORT"
echo " Hysteria2 : UDP $HY2_PORT"
echo " AnyTLS : TCP $ANYTLS_PORT"
if [ "${CDN_ONLY:-false}" = "true" ]; then
echo " 直连端口 : 已关闭(CDN-only)"
else
echo " Reality : TCP $REALITY_PORT"
if [ "${WARP_ENABLE:-false}" = "true" ]; then
echo " Reality-WARP: TCP $WARP_REALITY_PORT"
fi
echo " Hysteria2 : UDP $HY2_PORT"
echo " AnyTLS : TCP $ANYTLS_PORT"
fi
if [ "${CDN_ENABLE:-false}" = "true" ]; then
echo " CDN : $CDN_HOSTNAME"
fi
Expand Down
Loading