diff --git a/README.md b/README.md index d207668..4d6d344 100644 --- a/README.md +++ b/README.md @@ -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 固定使用 @@ -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: @@ -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=-cdn +``` + +重新运行对应部署入口即可自动创建/复用 Tunnel、配置 Ingress、写入橙云 CNAME 并生成 `US-CDN`。 +首次建议保持 `CDN_ONLY=false`,确认 CDN 节点可用后再单独切换 CDN-only。缺少 API Token 或 +Cloudflare 权限不足时,部署会在修改服务器前停止,不会留下半套 VPS 配置。 + ## 重跑与维护 - 两个入口均按幂等方式设计,会复用已有服务器和本地密钥。 - 只重新生成某个 profile 的客户端 YAML:`NETWORK_NODE_PROFILE= python3 core/gen-clash.py` +- 服务端组件版本在 `deploy.conf` 中固定;升级前先改版本并重新部署,避免重跑时无意升级。 - GCP 旧命令 `./deploy.sh` 仍可使用,但会提示改用 `./deploy-gcp.sh`。 - 通用排障见 [Troubleshooting](docs/troubleshooting.md)。 - 新服务器接入和隔离规则见 [Provider Onboarding](docs/provider-onboarding.md)。 diff --git a/config/deploy.conf.example b/config/deploy.conf.example index f438eb5..21c6705 100644 --- a/config/deploy.conf.example +++ b/config/deploy.conf.example @@ -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(与历史行为一致)。 @@ -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。 diff --git a/core/cloudflare.sh b/core/cloudflare.sh index cf9a951..e7ee43c 100755 --- a/core/cloudflare.sh +++ b/core/cloudflare.sh @@ -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)" @@ -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" @@ -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'*}" @@ -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\"}")" @@ -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 @@ -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" diff --git a/core/deploy.sh b/core/deploy.sh index 4adb0ff..6f159ac 100755 --- a/core/deploy.sh +++ b/core/deploy.sh @@ -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" @@ -45,6 +54,12 @@ 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" \ @@ -52,14 +67,18 @@ run_deploy() { 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 @@ -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 diff --git a/core/gen-clash.py b/core/gen-clash.py index c5f7232..da90478 100755 --- a/core/gen-clash.py +++ b/core/gen-clash.py @@ -41,16 +41,6 @@ def load_kv(path): env.update(load_kv(STATE_DIR / "deploy.conf")) env.update(load_kv(STATE_DIR / ".secrets.env")) -REQUIRED = [ - "STATIC_IP", - "REALITY_PORT", "REALITY_PUBLIC", "REALITY_SHORTID", - "HY2_PORT", - "ANYTLS_PORT", "ANYTLS_PASS", -] -missing = [k for k in REQUIRED if not env.get(k)] -if missing: - sys.exit(f"ERROR: 缺少必要变量 {missing}(应由部署入口自动生成,请检查 .secrets.env)") - devices = env.get("DEVICES", "mac iphone").split() # ── CDN 套娃出口(可选)── @@ -59,7 +49,45 @@ def load_kv(path): # 关闭时所有 CDN 占位符为空,与历史行为完全一致(向后兼容)。 CDN_HOSTNAME = env.get("CDN_HOSTNAME", "") CDN_WS_PATH = env.get("CDN_WS_PATH", "").lstrip("/") +CDN_ONLY = env.get("CDN_ONLY", "false") == "true" cdn_on = env.get("CDN_ENABLE", "false") == "true" and bool(CDN_HOSTNAME) and bool(CDN_WS_PATH) +if CDN_ONLY and not cdn_on: + sys.exit("ERROR: CDN_ONLY=true 但 CDN_ENABLE/CDN_HOSTNAME/CDN_WS_PATH 不完整") + +WARP_ENABLE = env.get("WARP_ENABLE", "false") == "true" +WARP_REALITY_PORT = env.get("WARP_REALITY_PORT", "").strip() +if WARP_ENABLE and CDN_ONLY: + sys.exit("ERROR: WARP_ENABLE=true 不能与 CDN_ONLY=true 同时使用(会重新暴露 VPS 入口)") + +required = ["DEVICES"] +if not CDN_ONLY: + required += [ + "STATIC_IP", + "REALITY_PORT", "REALITY_PUBLIC", "REALITY_SHORTID", + "HY2_PORT", + "ANYTLS_PORT", "ANYTLS_PASS", + ] +else: + required += ["CDN_HOSTNAME", "CDN_WS_PATH"] +if WARP_ENABLE: + required.append("WARP_REALITY_PORT") +missing = [k for k in required if not env.get(k)] +if missing: + sys.exit(f"ERROR: 缺少必要变量 {missing}(应由部署入口自动生成,请检查 profile 状态)") + +HY2_PORT_RANGE = env.get("HY2_PORT_RANGE", "").strip() +HY2_HOP_INTERVAL = env.get("HY2_HOP_INTERVAL", "").strip() +HY2_SNI = env.get("HY2_SNI", "www.bing.com").strip() or "www.bing.com" +HY2_ACME_ENABLE = env.get("HY2_ACME_ENABLE", "false") == "true" +HY2_ACME_DOMAIN = env.get("HY2_ACME_DOMAIN", "").strip() +if HY2_ACME_ENABLE and not HY2_ACME_DOMAIN: + sys.exit("ERROR: HY2_ACME_ENABLE=true 但缺少 HY2_ACME_DOMAIN") +HY2_CLIENT_SNI = HY2_ACME_DOMAIN if HY2_ACME_ENABLE else HY2_SNI +HY2_SKIP_CERT_VERIFY = "false" if HY2_ACME_ENABLE else "true" +HY2_OBFS_ENABLE = env.get("HY2_OBFS_ENABLE", "false") == "true" +HY2_OBFS_PASSWORD = env.get("HY2_OBFS_PASSWORD", "").strip() +if HY2_OBFS_ENABLE and not HY2_OBFS_PASSWORD: + sys.exit("ERROR: HY2_OBFS_ENABLE=true 但缺少 HY2_OBFS_PASSWORD") CDN_REF = '\n - "US-CDN"' if cdn_on else "" # ── Hysteria2 Brutal 拥塞控制(可选)── @@ -93,8 +121,90 @@ def cdn_proxy_block(dev_cdn_uuid): f" Host: {CDN_HOSTNAME}\n" ) + +def warp_reality_proxy_block(dev_warp_uuid): + """US-Reality-WARP: direct Reality ingress, WARP-only server egress.""" + if not WARP_ENABLE: + return "" + return f''' - name: "US-Reality-WARP" + type: vless + server: {env['STATIC_IP']} + port: {WARP_REALITY_PORT} + uuid: {dev_warp_uuid} + network: tcp + tls: true + udp: true + flow: xtls-rprx-vision + servername: "{env.get('REALITY_SNI', '')}" + sni: "{env.get('REALITY_SNI', '')}" + client-fingerprint: chrome + reality-opts: + public-key: {env['REALITY_PUBLIC']} + short-id: "{env['REALITY_SHORTID']}" +''' + + +def node_ref_block(names): + return "\n".join(f' - "{name}"' for name in names) + + +def direct_proxy_blocks(dev_uuid, hy2_password): + if CDN_ONLY: + return "", "", "" + + hy2_port = ( + f" ports: {HY2_PORT_RANGE}\n" + if HY2_PORT_RANGE + else f" port: {env['HY2_PORT']}\n" + ) + hy2_hop = f" hop-interval: {HY2_HOP_INTERVAL}\n" if HY2_HOP_INTERVAL else "" + hy2_obfs = "" + if HY2_OBFS_ENABLE: + hy2_obfs = ( + " obfs: salamander\n" + f" obfs-password: {HY2_OBFS_PASSWORD}\n" + ) + + reality = f''' - name: "US-Reality" + type: vless + server: {env['STATIC_IP']} + port: {env['REALITY_PORT']} + uuid: {dev_uuid} + network: tcp + tls: true + udp: true + flow: xtls-rprx-vision + servername: "{env.get('REALITY_SNI', '')}" + sni: "{env.get('REALITY_SNI', '')}" + client-fingerprint: chrome + reality-opts: + public-key: {env['REALITY_PUBLIC']} + short-id: "{env['REALITY_SHORTID']}" +''' + hy2 = f''' - name: "US-HY2" + type: hysteria2 + server: {env['STATIC_IP']} +{hy2_port} password: "{hy2_password}" + auth: "{hy2_password}" + sni: {HY2_CLIENT_SNI} + skip-cert-verify: {HY2_SKIP_CERT_VERIFY} + alpn: + - h3 +{HY2_BW}{hy2_hop}{hy2_obfs}''' + anytls = f''' - name: "US-AnyTLS" + type: anytls + server: {env['STATIC_IP']} + port: {env['ANYTLS_PORT']} + password: "{env['ANYTLS_PASS']}" + sni: {HY2_CLIENT_SNI} + skip-cert-verify: {HY2_SKIP_CERT_VERIFY} + client-fingerprint: chrome + udp: true +''' + return reality, hy2, anytls + TEMPLATE = """# Clash.Meta / Mihomo config — device: {DEVICE} -# Server: {STATIC_IP} | primary: VLESS+Reality:{REALITY_PORT} | fallback: Hysteria2:{HY2_PORT}/udp, AnyTLS:{ANYTLS_PORT}/tcp +# Server: {SERVER_LABEL} mixed-port: 7890 allow-lan: false @@ -170,71 +280,39 @@ def cdn_proxy_block(dev_cdn_uuid): - 240.0.0.0/4 proxies: - - name: "US-Reality" - type: vless - server: {STATIC_IP} - port: {REALITY_PORT} - uuid: {DEV_UUID} - network: tcp - tls: true - udp: true - flow: xtls-rprx-vision - servername: "{REALITY_SNI}" - sni: "{REALITY_SNI}" - client-fingerprint: chrome - reality-opts: - public-key: {REALITY_PUBLIC} - short-id: "{REALITY_SHORTID}" - - - name: "US-HY2" - type: hysteria2 - server: {STATIC_IP} - port: {HY2_PORT} - password: "{HY2_PASSWORD}" - auth: "{HY2_PASSWORD}" - sni: www.bing.com - skip-cert-verify: true - alpn: - - h3 -{HY2_BW} - - name: "US-AnyTLS" - type: anytls - server: {STATIC_IP} - port: {ANYTLS_PORT} - password: "{ANYTLS_PASS}" - sni: www.bing.com - skip-cert-verify: true - client-fingerprint: chrome - udp: true +{REALITY_PROXY}{HY2_PROXY}{ANYTLS_PROXY} +{WARP_PROXY} {CDN_PROXY} proxy-groups: - name: "🚀 代理策略" type: select proxies: - - "US-Reality" - - "US-HY2" + - "🛟 自动故障切换" - "⚡ 自动测速" - "🔧 手动选择"{CDN_REF} - - "US-AnyTLS" - DIRECT + - name: "🛟 自动故障切换" + type: fallback + lazy: true + url: https://www.gstatic.com/generate_204 + interval: 300 + proxies: +{FALLBACK_PROXIES} + - name: "⚡ 自动测速" type: url-test lazy: true url: https://www.gstatic.com/generate_204 interval: 600 tolerance: 150 - proxies:{CDN_REF} - - "US-Reality" - - "US-HY2" - - "US-AnyTLS" + proxies: +{AUTO_PROXIES} - name: "🔧 手动选择" type: select - proxies:{CDN_REF} - - "US-Reality" - - "US-HY2" - - "US-AnyTLS" + proxies: +{MANUAL_PROXIES} - DIRECT - name: "🌐 代理流量" @@ -242,10 +320,8 @@ def cdn_proxy_block(dev_cdn_uuid): proxies: - "🚀 代理策略" - "⚡ 自动测速" - - "🔧 手动选择"{CDN_REF} - - "US-Reality" - - "US-HY2" - - "US-AnyTLS" + - "🔧 手动选择" +{ALL_PROXIES} - DIRECT - name: "↪️ 直连流量" @@ -463,18 +539,48 @@ def cdn_proxy_block(dev_cdn_uuid): for dev in devices: uuid = env.get(f"REALITY_UUID_{dev}") hy2pw = env.get(f"HY2_PASS_{dev}") - if not uuid or not hy2pw: + if not CDN_ONLY and (not uuid or not hy2pw): sys.exit(f"ERROR: 设备 {dev} 缺少 REALITY_UUID_{dev} / HY2_PASS_{dev}") dev_cdn_uuid = env.get(f"CDN_UUID_{dev}", "") if cdn_on and not dev_cdn_uuid: sys.exit(f"ERROR: CDN_ENABLE=true 但设备 {dev} 缺少 CDN_UUID_{dev}") + dev_warp_uuid = env.get(f"WARP_REALITY_UUID_{dev}", "") + if WARP_ENABLE and not dev_warp_uuid: + sys.exit(f"ERROR: WARP_ENABLE=true 但设备 {dev} 缺少 WARP_REALITY_UUID_{dev}") + + reality_proxy, hy2_proxy, anytls_proxy = direct_proxy_blocks( + uuid or "", f"{dev}:{hy2pw}" if hy2pw else "" + ) + warp_proxy = warp_reality_proxy_block(dev_warp_uuid) + direct_nodes = [] if CDN_ONLY else ["US-Reality", "US-HY2", "US-AnyTLS"] + warp_nodes = ["US-Reality-WARP"] if WARP_ENABLE else [] + fallback_nodes = direct_nodes[:1] + if cdn_on: + fallback_nodes.append("US-CDN") + fallback_nodes.extend(direct_nodes[1:]) + auto_nodes = fallback_nodes + warp_nodes + all_nodes = (["US-CDN"] if cdn_on else []) + direct_nodes + warp_nodes + if not fallback_nodes: + sys.exit("ERROR: 没有可用的代理节点") + yaml = TEMPLATE.format( DEVICE=dev, - DEV_UUID=uuid, - HY2_PASSWORD=f"{dev}:{hy2pw}", + SERVER_LABEL=( + f"{env['STATIC_IP']} | primary: VLESS+Reality:{env['REALITY_PORT']} | " + f"fallback: Hysteria2:{env['HY2_PORT']}/udp, AnyTLS:{env['ANYTLS_PORT']}/tcp" + if not CDN_ONLY + else "Cloudflare Tunnel only" + ), + REALITY_PROXY=reality_proxy, + HY2_PROXY=hy2_proxy, + ANYTLS_PROXY=anytls_proxy, + WARP_PROXY=warp_proxy, CDN_PROXY=cdn_proxy_block(dev_cdn_uuid), CDN_REF=CDN_REF, - HY2_BW=HY2_BW, + FALLBACK_PROXIES=node_ref_block(fallback_nodes), + AUTO_PROXIES=node_ref_block(auto_nodes), + MANUAL_PROXIES=node_ref_block(all_nodes), + ALL_PROXIES=node_ref_block(all_nodes), **env, ) filename = f"{PROFILE}-{dev}.yaml" if PROFILE else f"{dev}.yaml" diff --git a/core/secrets.sh b/core/secrets.sh index 2f9afd6..9da180f 100755 --- a/core/secrets.sh +++ b/core/secrets.sh @@ -39,6 +39,9 @@ ensure_port() { # HY2/AnyTLS 端口:deploy.conf 已指定则沿用,否则随机高位端口(落入 .secrets.env) ensure_port HY2_PORT 30000 39999 ensure_port ANYTLS_PORT 20000 29999 +if [ "${WARP_ENABLE:-false}" = "true" ]; then + ensure_port WARP_REALITY_PORT 40000 49999 +fi # Reality short-id 与 AnyTLS 共享密码 ensure_secret REALITY_SHORTID "$(rand_short)" @@ -48,10 +51,16 @@ ensure_secret ANYTLS_PASS "$(rand_psk)" for d in ${DEVICES:-mac iphone}; do ensure_secret "REALITY_UUID_$d" "$(gen_uuid)" ensure_secret "HY2_PASS_$d" "$(rand_psk)" + if [ "${WARP_ENABLE:-false}" = "true" ]; then + ensure_secret "WARP_REALITY_UUID_$d" "$(gen_uuid)" + fi done # ── Cloudflare CDN 套娃出口的密钥(仅 CDN_ENABLE=true 时)── if [ "${CDN_ENABLE:-false}" = "true" ]; then + if [ -z "$(secret_get CF_API_TOKEN)" ]; then + die "CDN_ENABLE=true 但缺 CF_API_TOKEN;请先写入 $SECRETS_FILE 后重跑" + fi # WS 路径:deploy.conf 指定则沿用,否则随机(不带前导斜杠,gen-clash 与服务端统一加) if [ -n "${CDN_WS_PATH:-}" ]; then setkv CDN_WS_PATH "${CDN_WS_PATH#/}" @@ -62,13 +71,14 @@ if [ "${CDN_ENABLE:-false}" = "true" ]; then for d in ${DEVICES:-mac iphone}; do ensure_secret "CDN_UUID_$d" "$(gen_uuid)" done - # CF_API_TOKEN 由用户提供(建隧道用),不自动生成;缺失则提示但不中断 - if [ -z "$(secret_get CF_API_TOKEN)" ]; then - warn "CDN_ENABLE=true 但 .secrets.env 缺 CF_API_TOKEN。" - warn "请把 Cloudflare API token(权限 Account>Cloudflare Tunnel:Edit + Zone>DNS:Edit)写入:" - warn " echo 'CF_API_TOKEN=' >> $SECRETS_FILE" - warn "否则 core/cloudflare.sh 无法建隧道,CDN 出口会被跳过。" - fi +fi + +if [ "${HY2_OBFS_ENABLE:-false}" = "true" ]; then + ensure_secret HY2_OBFS_PASSWORD "$(rand_psk)" +fi + +if [ "${HY2_ACME_ENABLE:-false}" = "true" ] && [ -z "$(secret_get HY2_ACME_DNS_TOKEN)" ]; then + warn "HY2_ACME_ENABLE=true 但缺 HY2_ACME_DNS_TOKEN;请把 Cloudflare DNS API token 写入 .secrets.env 后重跑" fi ok "密钥就绪(已写入 .secrets.env)" diff --git a/core/setup-server.sh b/core/setup-server.sh index cec4b14..d740cf2 100755 --- a/core/setup-server.sh +++ b/core/setup-server.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Runs ON the GCP VM. Reads /tmp/server-env.sh for credentials, installs -# Xray (VLESS+Reality), Hysteria2, and AnyTLS, then prints +# Xray (VLESS+Reality), Hysteria2, AnyTLS, and optional Cloudflare WARP, then prints # REALITY_PUBLIC_KEY= on stdout so the local deployer can pick it up. set -euo pipefail export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" @@ -11,9 +11,43 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${1:-/tmp/server-env.sh}" # shellcheck disable=SC1090 . "$ENV_FILE" +XRAY_VERSION="${XRAY_VERSION:-v26.3.27}" +HYSTERIA_VERSION="${HYSTERIA_VERSION:-app/v2.10.0}" +ANYTLS_VERSION="${ANYTLS_VERSION:-0.0.13}" +CLOUDFLARED_VERSION="${CLOUDFLARED_VERSION:-2026.7.2}" +WARP_ENABLE="${WARP_ENABLE:-false}" +WARP_SOCKS_PORT="${WARP_SOCKS_PORT:-40000}" +HY2_SNI="${HY2_SNI:-www.bing.com}" +HY2_MASQUERADE_URL="${HY2_MASQUERADE_URL:-https://www.bing.com}" +HY2_PORT_RANGE="${HY2_PORT_RANGE:-}" +HY2_HOP_INTERVAL="${HY2_HOP_INTERVAL:-}" +HY2_ACME_ENABLE="${HY2_ACME_ENABLE:-false}" +HY2_ACME_DNS_PROVIDER="${HY2_ACME_DNS_PROVIDER:-cloudflare}" : "${REALITY_PORT:?}" "${REALITY_TARGET:?}" "${REALITY_SHORTID:?}" "${DEVICES:?}" REALITY_SNI="${REALITY_SNI:-}" : "${HY2_PORT:?}" "${ANYTLS_PORT:?}" "${ANYTLS_PASS:?}" +if [ "$WARP_ENABLE" = "true" ]; then + : "${WARP_REALITY_PORT:?WARP_ENABLE=true 但缺 WARP_REALITY_PORT}" + [ "${CDN_ONLY:-false}" != "true" ] || { + echo "WARP_ENABLE=true 不能与 CDN_ONLY=true 同时使用" >&2 + exit 1 + } +fi + +case "$REALITY_TARGET" in + *:*) ;; + *) echo "REALITY_TARGET 必须是 host:port" >&2; exit 1 ;; +esac +if [ "$HY2_ACME_ENABLE" = "true" ]; then + : "${HY2_ACME_DOMAIN:?HY2_ACME_ENABLE=true 但缺 HY2_ACME_DOMAIN}" + : "${HY2_ACME_EMAIL:?HY2_ACME_ENABLE=true 但缺 HY2_ACME_EMAIL}" + : "${HY2_ACME_DNS_TOKEN:?HY2_ACME_ENABLE=true 但缺 HY2_ACME_DNS_TOKEN}" + [ "$HY2_ACME_DNS_PROVIDER" = "cloudflare" ] || { + echo "当前只实现 Hysteria2 Cloudflare DNS-01,HY2_ACME_DNS_PROVIDER 必须为 cloudflare" >&2 + exit 1 + } + HY2_SNI="$HY2_ACME_DOMAIN" +fi vv() { eval "printf '%s' \"\${$1:-}\""; } # indirect var read @@ -43,7 +77,9 @@ sysctl net.ipv4.tcp_congestion_control echo "=== [2/8] Installing prerequisites ===" sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq -sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl unzip xz-utils openssl +WARP_PACKAGES="" +[ "$WARP_ENABLE" = "true" ] && WARP_PACKAGES="gnupg" +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl unzip xz-utils openssl $WARP_PACKAGES ARCH="$(uname -m)" @@ -54,7 +90,7 @@ case "$ARCH" in *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac download_file /tmp/xray.zip \ - "https://github.com/XTLS/Xray-core/releases/latest/download/${XRAY_ZIP}" + "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${XRAY_ZIP}" sudo unzip -oq /tmp/xray.zip -d /usr/local/bin xray sudo chmod 0755 /usr/local/bin/xray print_first_line /usr/local/bin/xray version @@ -65,7 +101,7 @@ case "$ARCH" in aarch64) HY2_BIN="hysteria-linux-arm64" ;; esac download_file /tmp/hysteria \ - "https://github.com/apernet/hysteria/releases/latest/download/${HY2_BIN}" + "https://github.com/apernet/hysteria/releases/download/${HYSTERIA_VERSION}/${HY2_BIN}" sudo install -m 0755 /tmp/hysteria /usr/local/bin/hysteria print_first_line /usr/local/bin/hysteria version @@ -74,8 +110,8 @@ case "$ARCH" in x86_64) AT_ARCH="amd64" ;; aarch64) AT_ARCH="arm64" ;; esac -AT_VER="$(fetch_url https://api.github.com/repos/anytls/anytls-go/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"].removeprefix("v"))')" -[ -n "$AT_VER" ] || { echo "Failed to fetch anytls latest version"; exit 1; } +AT_VER="$ANYTLS_VERSION" +[ -n "$AT_VER" ] || { echo "ANYTLS_VERSION 不能为空"; exit 1; } download_file /tmp/anytls.zip \ "https://github.com/anytls/anytls-go/releases/download/v${AT_VER}/anytls_${AT_VER}_linux_${AT_ARCH}.zip" sudo rm -rf /tmp/anytls-extract @@ -90,11 +126,36 @@ if [ "${CDN_ENABLE:-false}" = "true" ]; then aarch64) CF_BIN="cloudflared-linux-arm64" ;; esac download_file /tmp/cloudflared \ - "https://github.com/cloudflare/cloudflared/releases/latest/download/${CF_BIN}" + "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${CF_BIN}" sudo install -m 0755 /tmp/cloudflared /usr/local/bin/cloudflared print_first_line /usr/local/bin/cloudflared --version fi +if [ "$WARP_ENABLE" = "true" ]; then + echo "=== [5c] Installing Cloudflare WARP proxy ===" + curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ + | gpg --yes --dearmor --output /tmp/cloudflare-warp-archive-keyring.gpg + sudo install -m 0644 /tmp/cloudflare-warp-archive-keyring.gpg \ + /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg + echo 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ bookworm main' \ + | sudo tee /etc/apt/sources.list.d/cloudflare-client.list > /dev/null + sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cloudflare-warp + sudo systemctl enable --now warp-svc + sudo warp-cli --accept-tos registration new >/dev/null 2>&1 || true + sudo warp-cli --accept-tos mode proxy >/dev/null 2>&1 + sudo warp-cli --accept-tos proxy port "$WARP_SOCKS_PORT" >/dev/null 2>&1 + sudo warp-cli --accept-tos connect >/dev/null 2>&1 + sleep 5 + sudo curl -fsS --proxy "socks5h://127.0.0.1:${WARP_SOCKS_PORT}" \ + --max-time 10 https://1.1.1.1/cdn-cgi/trace | grep -E '^(ip|warp)=' || { + echo "WARP SOCKS 出口验证失败" >&2 + exit 1 + } +else + sudo systemctl disable --now warp-svc >/dev/null 2>&1 || true +fi + echo "=== [6/8] Generating Reality keypair ===" REALITY_PRIVATE="" if [ -f /usr/local/etc/xray/config.json ]; then @@ -116,7 +177,7 @@ for svc_user in xray hysteria anytls; do fi done -xray_clients=""; hy2_users=""; first=1 +xray_clients=""; warp_clients=""; hy2_users=""; first=1; warp_first=1 for d in $DEVICES; do uuid="$(vv "REALITY_UUID_$d")" hy2pw="$(vv "HY2_PASS_$d")" @@ -124,6 +185,14 @@ for d in $DEVICES; do sep=","; [ $first -eq 1 ] && sep="" xray_clients="${xray_clients}${sep} {\"id\": \"$uuid\", \"flow\": \"xtls-rprx-vision\"}" + if [ "$WARP_ENABLE" = "true" ]; then + warp_uuid="$(vv "WARP_REALITY_UUID_$d")" + [ -n "$warp_uuid" ] || { echo "missing WARP uuid for device $d"; exit 1; } + warp_sep=","; [ $warp_first -eq 1 ] && warp_sep="" + warp_clients="${warp_clients}${warp_sep} + {\"id\": \"$warp_uuid\", \"flow\": \"xtls-rprx-vision\"}" + warp_first=0 + fi hy2_users="${hy2_users} ${d}: ${hy2pw}" first=0 @@ -143,7 +212,7 @@ if [ "${CDN_ENABLE:-false}" = "true" ]; then {\"id\": \"$cuuid\"}" cfirst=0 done - CDN_INBOUND=", + CDN_INBOUND=" { \"listen\": \"127.0.0.1\", \"port\": 8080, @@ -160,55 +229,146 @@ if [ "${CDN_ENABLE:-false}" = "true" ]; then }" fi -sudo mkdir -p /usr/local/etc/xray -sudo tee /usr/local/etc/xray/config.json > /dev/null <&2; exit 1; } + +XRAY_OUTBOUNDS='{"protocol":"freedom"}' +XRAY_ROUTING="" +if [ "$WARP_ENABLE" = "true" ]; then + XRAY_OUTBOUNDS="${XRAY_OUTBOUNDS}, + {\"tag\": \"warp-outbound\", \"protocol\": \"freedom\", \"settings\": {\"domainStrategy\": \"UseIPv6v4\"}, \"proxySettings\": {\"tag\": \"warp-socks\"}}, + {\"tag\": \"warp-socks\", \"protocol\": \"socks\", \"settings\": {\"servers\": [{\"address\": \"127.0.0.1\", \"port\": ${WARP_SOCKS_PORT}}]}}" + XRAY_ROUTING=', + "routing": { + "rules": [ + {"type": "field", "inboundTag": ["warp-reality"], "outboundTag": "warp-outbound"} + ] + }' +fi + +sudo mkdir -p /usr/local/etc/xray +sudo tee /usr/local/etc/xray/config.json > /dev/null </dev/null 2>&1 - sudo openssl req -new -x509 -days 3650 -key /tmp/hy2.key \ - -out /etc/hysteria/cert.crt -subj "/CN=www.bing.com" >/dev/null 2>&1 - sudo mv /tmp/hy2.key /etc/hysteria/cert.key +HY2_TLS_BLOCK="" +HY2_ACME_BLOCK="" +if [ "$HY2_ACME_ENABLE" = "true" ]; then + HY2_ACME_BLOCK=" +acme: + domains: + - ${HY2_ACME_DOMAIN} + email: ${HY2_ACME_EMAIL} + type: dns + dns: + name: cloudflare + config: + cloudflare_api_token: \"${HY2_ACME_DNS_TOKEN}\"" +else + if [ ! -f /etc/hysteria/cert.crt ] || [ ! -f /etc/hysteria/cert.key ]; then + sudo openssl ecparam -genkey -name prime256v1 -out /tmp/hy2.key >/dev/null 2>&1 + sudo openssl req -new -x509 -days 3650 -key /tmp/hy2.key \ + -out /etc/hysteria/cert.crt -subj "/CN=${HY2_SNI}" >/dev/null 2>&1 + sudo mv /tmp/hy2.key /etc/hysteria/cert.key + fi + sudo chown root:hysteria /etc/hysteria/cert.crt /etc/hysteria/cert.key + sudo chmod 640 /etc/hysteria/cert.crt /etc/hysteria/cert.key + HY2_TLS_BLOCK=" +tls: + cert: /etc/hysteria/cert.crt + key: /etc/hysteria/cert.key" +fi + +HY2_LISTEN="${HY2_PORT}" +HY2_CAPS="CAP_NET_BIND_SERVICE" +if [ -n "$HY2_PORT_RANGE" ]; then + HY2_LISTEN="$HY2_PORT_RANGE" + HY2_CAPS="CAP_NET_BIND_SERVICE CAP_NET_ADMIN" +fi +HY2_OBFS_BLOCK="" +if [ "${HY2_OBFS_ENABLE:-false}" = "true" ]; then + : "${HY2_OBFS_PASSWORD:?HY2_OBFS_ENABLE=true 但缺 HY2_OBFS_PASSWORD}" + HY2_OBFS_BLOCK=" +obfs: + type: salamander + salamander: + password: ${HY2_OBFS_PASSWORD}" fi -sudo chown root:hysteria /etc/hysteria/cert.crt /etc/hysteria/cert.key -sudo chmod 640 /etc/hysteria/cert.crt /etc/hysteria/cert.key sudo tee /etc/hysteria/config.yaml > /dev/null < /dev/null <<'UNIT' +sudo tee /etc/systemd/system/hysteria.service > /dev/null < /dev/null <