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
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,20 @@ jobs:
run: test -z "$(git ls-files profiles)"

- name: Check shell syntax
run: bash -n deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh
run: |
for script in deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh; do
bash -n "$script"
done

- name: Run unit tests
run: python3 -m unittest discover -s tests -p 'test_*.py' -v

- name: Validate with pinned Mihomo
run: |
curl --fail --location --retry 3 --max-time 120 \
https://github.com/MetaCubeX/mihomo/releases/download/v1.19.30/mihomo-linux-amd64-compatible-v1.19.30.gz \
--output "$RUNNER_TEMP/mihomo.gz"
echo "db214c7a2517e63c150d123178d16d102e03a241ccdae4e5e07ffbe9cf56c6f9 $RUNNER_TEMP/mihomo.gz" | sha256sum --check
gzip --decompress "$RUNNER_TEMP/mihomo.gz"
chmod 755 "$RUNNER_TEMP/mihomo"
MIHOMO_BIN="$RUNNER_TEMP/mihomo" python3 -m unittest discover -s tests -p 'test_mihomo_config.py' -v
105 changes: 87 additions & 18 deletions README.md

Large diffs are not rendered by default.

43 changes: 33 additions & 10 deletions config/deploy.conf.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
# ── 复制本文件为 deploy.conf 后按需修改(任一部署入口首次运行会自动创建)──
# 本文件只支持字面值 KEY=value、引号和注释;不执行 shell、不展开 $HOME 或命令。

# 停用机器可设为 false:跳过客户端输出,保留 profile 和凭据,不操作服务器。
CLIENT_CONFIG_ENABLE=true

# 客户端目标:stash(默认)/ mihomo。两者 DNS、HY2、TUN 字段分别生成。
CLIENT_TARGET=stash
# 默认日常分流:已知 AI 及其依赖优先走 AI 线路,普通国内流量默认直连。
# true 为可选严格模式:全部公网固定走同一 VPS 的 Reality/CDN,国内也代理。
# 严格模式不自动切到 HY2/AnyTLS/WARP;不需要时保持 false。
AI_STRICT_MODE=false

# GCP 项目 ID(仅 GCP 入口必填)。留空时 deploy-gcp.sh 会读取 gcloud 当前项目并提示确认
PROJECT_ID=
# GCP 管理账号。首次配置后自动保存,后续部署不跟随 gcloud 的全局默认账号。
GCP_ACCOUNT=
# 可选:本机 GCP 管理请求使用的 HTTP 代理(例如 http://127.0.0.1:7890)。
# IAP 无法连接、但本机代理可用时填写;不影响服务器出口或客户端分流。
GCP_HTTP_PROXY=
# 可选:本机恢复验收强制从物理网卡发起,避免 TUN / fake-IP 制造可达假象。
# macOS 常用 en0;留空沿用系统路由。CDN 解析经该网卡访问 AliDNS DoH。
# 只影响 cdn-check、部署后的 CDN 验收和换 IP 的 TCP 验收,不写入设备 YAML。
PROBE_INTERFACE=

# 区域 / 可用区。Free Tier 免费区:us-west1 / us-central1 / us-east1
REGION=us-west1
Expand All @@ -25,7 +45,7 @@ ANYTLS_PORT=

# Hysteria2 可选增强:端口跳跃 / QUIC 混淆。默认关闭,开启后需要重新生成并导入客户端 YAML。
# HY2_PORT_RANGE=30000-30010
# HY2_HOP_INTERVAL=15-30
# HY2_HOP_INTERVAL=30(整数秒,Stash/Mihomo 均支持;范围仅支持 Mihomo)
HY2_PORT_RANGE=
HY2_HOP_INTERVAL=
HY2_OBFS_ENABLE=false
Expand All @@ -45,18 +65,21 @@ HY2_ACME_DNS_PROVIDER=cloudflare
HY2_UP=
HY2_DOWN=

# 要生成客户端配置的设备名(空格分隔,可自由增删)
# 设备 ID:字母、数字、下划线,1-64 位,空格分隔。
# 移除设备后部署会自动轮换本 profile 的 AnyTLS 密码,剩余设备需重新导入 YAML。
DEVICES="mac iphone"

# 隐私模式:只决定「🇨🇳 国内流量」组的首次默认选项;true 默认代理,false 默认直连
# CN 直连时域名使用国内加密 DoH;其他域名和 STUN 仍走代理。客户端可随时手动切换该组;
# 局域网与原有 Apple/Spotify 分流保持不变
PRIVACY_MODE=true
# 仅在 AI_STRICT_MODE=false 时生效:决定国内流量组的首次默认选项
# true 默认代理,false 默认直连;日常模式的国内域名使用国内加密 DoH。
# 国内 DoH 解析器 IP 显式关联国内流量组,跟随该组选择;其他业务 DNS 走 AI 出口
PRIVACY_MODE=false

# ── Cloudflare CDN 套娃出口(可选)──
# 开启后多一条出口:客户端 → Cloudflare 边缘 → Cloudflare Tunnel → cloudflared@VM
# ── Cloudflare CDN 备用入口(可选)──
# 应对本地网络无法直连 VPS IP;出口仍是同一 VPS,不改变目标网站看到的出口 IP。
# 开启后多一条入口:客户端 → Cloudflare 边缘 → Cloudflare Tunnel → cloudflared@VM
# → xray VLESS+WS(127.0.0.1:8080) → 直出。客户端只与 Cloudflare anycast IP 通信,
# 真实 VM IP 对线路侧不可见。false 时跳过;true 时必须先准备 CF_API_TOKEN,缺失会在改服务器前直接停止。
# 走 CDN 时线路侧不直连 VM;Reality 优先模式仍会尝试真实 IP。false 时跳过;
# true 时必须先准备 CF_API_TOKEN,缺失会在改服务器前直接停止。
CDN_ENABLE=false
# true 时只生成 Cloudflare WS 节点,并关闭直连代理端口;会失去原生 HY2/AnyTLS 直连能力。
CDN_ONLY=false
Expand All @@ -75,7 +98,7 @@ WARP_ENABLE=false
WARP_SOCKS_PORT=40000
WARP_REALITY_PORT=

# 固定服务端组件版本,避免重跑时自动升级造成兼容性漂移
# 固定服务端组件版本;升级须同步更新 core/download.sh 中已核验的 SHA256
XRAY_VERSION=v26.3.27
HYSTERIA_VERSION=app/v2.10.0
ANYTLS_VERSION=0.0.13
Expand Down
204 changes: 204 additions & 0 deletions core/cdn_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Direct TLS → WebSocket → VLESS → verified HTTPS probe using the standard library.

This deliberately ignores HTTP proxy environment variables. Set PROBE_INTERFACE
to bind both DNS and proxy traffic to a physical interface outside a local TUN.
"""
import base64
import hashlib
import os
from pathlib import Path
import ssl
import struct
import sys
import time
import uuid

from settings import load_settings, validate
from net_probe import connect


def exact(stream, size):
result = bytearray()
while len(result) < size:
block = stream.recv(size - len(result))
if not block:
raise ValueError('CDN 连接提前关闭')
result.extend(block)
return bytes(result)


def websocket_handshake(sock, host, path):
key = base64.b64encode(os.urandom(16)).decode()
sock.sendall((f'GET /{path.lstrip("/")} HTTP/1.1\r\nHost: {host}\r\n'
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
head = bytearray()
while not head.endswith(b'\r\n\r\n'):
if len(head) >= 16384:
raise ValueError('CDN 握手响应过大')
head.extend(exact(sock, 1))
lines = head.decode('iso-8859-1').split('\r\n')
fields = lines[0].split()
if len(fields) < 2 or fields[1] != '101':
status = fields[1] if len(fields) > 1 and fields[1].isdigit() else 'unknown'
raise ValueError(f'CDN WebSocket 握手失败(HTTP {status});检查 Tunnel、路径及 WAF')
headers = dict((k.lower(), v.strip()) for k, v in (line.split(':', 1) for line in lines[1:] if ':' in line))
expected = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
if (headers.get('sec-websocket-accept') != expected or headers.get('upgrade', '').lower() != 'websocket'
or 'upgrade' not in [token.strip() for token in headers.get('connection', '').lower().split(',')]):
raise ValueError('CDN WebSocket 握手校验失败')


class WebSocket:
def __init__(self, sock):
self.sock = sock
self.buffer = b''

def send(self, data, opcode=2):
mask = os.urandom(4)
size = len(data)
if size < 126:
length = bytes([size | 128])
elif size < 65536:
length = bytes([126 | 128]) + struct.pack('!H', size)
else:
length = bytes([127 | 128]) + struct.pack('!Q', size)
payload = bytes(value ^ mask[i % 4] for i, value in enumerate(data))
self.sock.sendall(bytes([128 | opcode]) + length + mask + payload)

def recv(self, size):
while not self.buffer:
first, second = exact(self.sock, 2)
opcode, length = first & 15, second & 127
if first & 112 or second & 128:
raise ValueError('CDN WebSocket 帧格式错误')
if length == 126:
length = struct.unpack('!H', exact(self.sock, 2))[0]
elif length == 127:
length = struct.unpack('!Q', exact(self.sock, 8))[0]
if length > 1048576:
raise ValueError('CDN WebSocket 帧过大')
data = exact(self.sock, length)
if opcode == 9:
self.send(data, opcode=10)
elif opcode == 10:
continue
elif opcode in (0, 2):
self.buffer = data
else:
raise ValueError('CDN WebSocket 关闭或返回非二进制数据')
data, self.buffer = self.buffer[:size], self.buffer[size:]
return data


class Vless:
def __init__(self, websocket, device_uuid, target):
self.ws = websocket
self.response = False
host = target.encode('idna')
# Version, UUID, empty addons, TCP command, port, domain address type.
self.ws.send(b'\x00' + uuid.UUID(device_uuid).bytes + b'\x00\x01'
+ struct.pack('!H', 443) + b'\x02' + bytes([len(host)]) + host)

def send(self, data):
self.ws.send(data)

def recv(self, size):
if not self.response:
version, addons = exact(self.ws, 2)
if version != 0:
raise ValueError('VLESS 响应版本错误')
exact(self.ws, addons)
self.response = True
return self.ws.recv(size)


def https_probe(transport, target):
incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO()
tls = ssl.create_default_context().wrap_bio(incoming, outgoing, server_hostname=target)

def flush():
while outgoing.pending:
transport.send(outgoing.read())

def exchange(operation):
while True:
try:
result = operation()
flush()
return result
except ssl.SSLWantReadError:
flush()
data = transport.recv(16384)
if not data:
raise ValueError('HTTPS 探测连接提前关闭')
incoming.write(data)

exchange(tls.do_handshake)
request = f'GET /generate_204 HTTP/1.1\r\nHost: {target}\r\nConnection: close\r\n\r\n'.encode()
while request:
request = request[exchange(lambda: tls.write(request)):]
head = bytearray()
while b'\r\n' not in head:
if len(head) > 16384:
raise ValueError('HTTPS 探测响应过大')
block = exchange(lambda: tls.read(4096))
if not block:
raise ValueError('HTTPS 探测连接提前关闭')
head.extend(block)
status = bytes(head).split(b'\r\n', 1)[0].split()
if len(status) < 2 or status[1] != b'204':
raise ValueError('CDN 已建立连接,但 HTTPS 探测未返回预期的 204')


class DeadlineSocket:
def __init__(self, sock, deadline):
self.sock, self.deadline = sock, deadline

def arm(self):
remaining = self.deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError()
self.sock.settimeout(remaining)

def sendall(self, data):
self.arm()
self.sock.sendall(data)

def recv(self, size):
self.arm()
return self.sock.recv(size)


def check_cdn(settings, timeout=20):
validate(settings)
required = ('CDN_HOSTNAME', 'CDN_WS_PATH')
devices = settings.get('DEVICES', '').split()
if settings.get('CDN_ENABLE') != 'true' or any(not settings.get(k) for k in required) or not devices:
raise ValueError('CDN 未启用或连接参数不完整')
for device in devices:
if not settings.get('CDN_UUID_' + device):
raise ValueError('CDN 设备凭据不完整')
try:
device_uuid = str(uuid.UUID(settings['CDN_UUID_' + device]))
except ValueError:
raise ValueError('CDN 设备 UUID 格式无效') from None
host = settings['CDN_HOSTNAME']
deadline = time.monotonic() + timeout
try:
with connect(host, 443, timeout, settings.get('PROBE_INTERFACE', '')) as raw:
with ssl.create_default_context().wrap_socket(raw, server_hostname=host) as secured:
sock = DeadlineSocket(secured, deadline)
websocket_handshake(sock, host, settings['CDN_WS_PATH'])
transport = Vless(WebSocket(sock), device_uuid, 'www.gstatic.com')
https_probe(transport, 'www.gstatic.com')
except (OSError, ssl.SSLError):
raise ValueError('CDN DNS、TLS 或网络连接失败/超时;未输出地址和凭据') from None


if __name__ == '__main__':
try:
check_cdn(load_settings(Path(sys.argv[1])))
print('CDN 所有设备的 TLS / WebSocket / VLESS / HTTPS 请求通过')
except (ValueError, OSError) as exc:
sys.exit(str(exc))
Loading