#!/usr/bin/env bash set -euo pipefail # ============================================ # Clash.Meta 配置生成器 # # 从 deploy.sh 输出的节点信息文件(*info*.md,内含 vless:// 链接) # 生成 Clash.Meta / mihomo 可用的 YAML 配置。 # # 用法: # bash gen-clash.sh # 交互式选择节点文件 # bash gen-clash.sh a.md b.md # 直接指定 # bash gen-clash.sh -o my.yaml a.md # 指定输出文件 # bash gen-clash.sh --all # 使用全部 *info*.md # # 本脚本仅在本地运行,不会上传到 VPS。 # ============================================ # 不切换工作目录:候选文件在脚本所在目录里找, # 但命令行显式传入的路径仍相对调用者的当前目录解析 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m' log() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } OUTPUT="${SCRIPT_DIR}/vpn-vps-xray-optimized.yaml" USE_ALL=0 FILES=() usage() { sed -n '4,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' exit 0 } while [[ $# -gt 0 ]]; do case "$1" in -o|--output) OUTPUT="${2:?-o 需要一个文件名}"; shift 2 ;; --all) USE_ALL=1; shift ;; -h|--help) usage ;; -*) error "未知参数: $1"; usage ;; *) FILES+=("$1"); shift ;; esac done # ===== 收集候选文件 ===== mapfile -t CANDIDATES < <(ls -1 "${SCRIPT_DIR}"/*info*.md 2>/dev/null || true) if [ "${#FILES[@]}" -eq 0 ]; then if [ "${#CANDIDATES[@]}" -eq 0 ]; then error "当前目录下找不到任何 *info*.md 节点信息文件" error "请先在 VPS 上执行 bash deploy.sh --show,把输出保存为 xxx-info.md 放到这里" exit 1 fi if [ "$USE_ALL" -eq 1 ]; then FILES=("${CANDIDATES[@]}") else echo "" echo -e "${CYAN}可用的节点信息文件:${NC}" for i in "${!CANDIDATES[@]}"; do f="${CANDIDATES[$i]}" # 顺带把链接里的备注名显示出来,方便辨认 tag=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null | sed 's/.*#//' || true) printf " %2d) %-45s %s\n" "$((i + 1))" "${f#"${SCRIPT_DIR}"/}" "${tag:+[${tag}]}" done echo "" read -r -p "选择要写入配置的节点(空格分隔序号,回车=全选): " picks if [ -z "${picks// /}" ]; then FILES=("${CANDIDATES[@]}") else for n in $picks; do if ! [[ "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ] || [ "$n" -gt "${#CANDIDATES[@]}" ]; then error "无效序号: $n" exit 1 fi FILES+=("${CANDIDATES[$((n - 1))]}") done fi fi fi # ===== 工具函数 ===== urldecode() { local s="${1//+/ }" printf '%b' "${s//%/\\x}" } # 从 query string 中取出指定参数 qs_get() { local query="$1" key="$2" kv local IFS='&' for kv in $query; do if [ "${kv%%=*}" = "$key" ]; then urldecode "${kv#*=}" return fi done } # ===== 解析所有节点 ===== NAMES=(); SERVERS=(); PORTS=(); UUIDS=(); SNIS=(); PBKS=(); SIDS=(); FPS=(); FLOWS=(); SECS=() declare -A name_seen=() for f in "${FILES[@]}"; do [ -f "$f" ] || { error "文件不存在: $f"; exit 1; } link=$(grep -m1 -o 'vless://[^[:space:]]*' "$f" 2>/dev/null || true) if [ -z "$link" ]; then warn "跳过 ${f#"${SCRIPT_DIR}"/}:未找到 vless:// 链接" continue fi rest="${link#vless://}" # 备注名(# 之后) if [[ "$rest" == *"#"* ]]; then name=$(urldecode "${rest##*#}") rest="${rest%%#*}" else name="" fi uuid="${rest%%@*}" hpq="${rest#*@}" hostport="${hpq%%\?*}" query="" [[ "$hpq" == *"?"* ]] && query="${hpq#*\?}" # 兼容 IPv6 字面量 [::1]:443 if [[ "$hostport" == \[* ]]; then server="${hostport%%]*}]" port="${hostport##*]:}" else server="${hostport%%:*}" port="${hostport##*:}" fi sec=$(qs_get "$query" security); sec="${sec:-none}" sni=$(qs_get "$query" sni) pbk=$(qs_get "$query" pbk) sid=$(qs_get "$query" sid) fp=$(qs_get "$query" fp); fp="${fp:-chrome}" flow=$(qs_get "$query" flow) net=$(qs_get "$query" type); net="${net:-tcp}" if [ -z "$uuid" ] || [ -z "$server" ] || [ -z "$port" ]; then error "${f#"${SCRIPT_DIR}"/} 的链接缺少 uuid/server/port,无法解析" exit 1 fi if [ "$sec" = "reality" ] && { [ -z "$pbk" ] || [ -z "$sni" ]; }; then error "${f#"${SCRIPT_DIR}"/} 是 reality 节点但缺少 pbk 或 sni" exit 1 fi if [ "$net" != "tcp" ]; then warn "${f#"${SCRIPT_DIR}"/}: transport=${net},本生成器目前只支持 tcp,已跳过" continue fi [ -z "$name" ] && name="${server}" # 名称去重,Clash 要求 proxy name 唯一 base="$name"; n=2 while [ -n "${name_seen[$name]:-}" ]; do name="${base}-${n}"; n=$((n + 1)) done name_seen["$name"]=1 NAMES+=("$name"); SERVERS+=("$server"); PORTS+=("$port"); UUIDS+=("$uuid") SNIS+=("$sni"); PBKS+=("$pbk"); SIDS+=("$sid") FPS+=("$fp"); FLOWS+=("$flow"); SECS+=("$sec") log "解析成功: ${name} (${server}:${port}, ${sec})" done if [ "${#NAMES[@]}" -eq 0 ]; then error "没有解析出任何可用节点" exit 1 fi # ===== 备份既有输出 ===== if [ -f "$OUTPUT" ]; then cp "$OUTPUT" "${OUTPUT}.bak" log "已备份原配置: ${OUTPUT}.bak" fi # ===== 生成 YAML ===== { cat <<'HEADER' # ============================================================ # 由 gen-clash.sh 自动生成,请勿手工编辑(重新生成会覆盖) # # ⚠ 需要 Clash.Meta / mihomo 内核(Clash Verge Rev、ClashX Meta、 # FlClash 等)。原版 Clash / Clash Premium 不支持 VLESS + Reality。 # # ⚠ 本文件含真实 UUID 与服务器地址,等同凭据,切勿提交到版本库或外传。 # ============================================================ mixed-port: 7890 allow-lan: false mode: rule log-level: info # fake-ip 下开启嗅探,才能从流量中还原域名,让域名类规则对直连 IP 也生效 sniffer: enable: true sniff: HTTP: ports: [80, 8080-8880] override-destination: true TLS: ports: [443, 8443] QUIC: ports: [443, 8443] skip-domain: - "+.push.apple.com" dns: enable: true ipv6: false enhanced-mode: fake-ip fake-ip-range: 198.18.0.1/16 # 这些域名必须拿到真实 IP,否则局域网发现、时间同步、 # 网络连通性检测等会出问题 fake-ip-filter: - "*.lan" - "*.local" - "*.localdomain" - "*.home.arpa" - "+.msftconnecttest.com" - "+.msftncsi.com" - "time.*.com" - "ntp.*.com" - "+.pool.ntp.org" - "localhost.ptlogin2.qq.com" # 用于解析下面 fallback 里 DoH 服务器自身的域名 default-nameserver: - 223.5.5.5 - 119.29.29.29 nameserver: - 223.5.5.5 - 119.29.29.29 # fallback 的意义是绕过投毒,必须用加密 DNS—— # 明文 UDP 的 8.8.8.8 在国内同样会被污染,等于没有 fallback fallback: - https://1.1.1.1/dns-query - https://dns.google/dns-query fallback-filter: geoip: true geoip-code: CN ipcidr: - 240.0.0.0/4 - 0.0.0.0/32 proxies: HEADER for i in "${!NAMES[@]}"; do echo " - name: \"${NAMES[$i]}\"" echo " type: vless" echo " server: ${SERVERS[$i]}" echo " port: ${PORTS[$i]}" echo " uuid: ${UUIDS[$i]}" echo " network: tcp" echo " udp: true" if [ "${SECS[$i]}" = "reality" ]; then echo " tls: true" [ -n "${FLOWS[$i]}" ] && echo " flow: ${FLOWS[$i]}" echo " servername: ${SNIS[$i]}" echo " client-fingerprint: ${FPS[$i]}" echo " reality-opts:" echo " public-key: ${PBKS[$i]}" [ -n "${SIDS[$i]}" ] && echo " short-id: ${SIDS[$i]}" else echo " tls: false" fi echo "" done cat <<'GROUPS_HEAD' proxy-groups: - name: "Proxy" type: select proxies: - Auto-Select GROUPS_HEAD for n in "${NAMES[@]}"; do echo " - \"${n}\""; done echo " - DIRECT" echo "" cat <<'AUTO_HEAD' - name: "Auto-Select" type: url-test url: "http://www.gstatic.com/generate_204" interval: 300 tolerance: 50 proxies: AUTO_HEAD for n in "${NAMES[@]}"; do echo " - \"${n}\""; done echo "" echo "rules:" echo " # 代理服务器自身必须直连。否则 TUN / 系统代理会把「连接代理服务器」" echo " # 这件事本身也丢进代理,形成回环,表现为 REALITY 握手拿到真证书。" declare -A srv_seen=() for s in "${SERVERS[@]}"; do [ -n "${srv_seen[$s]:-}" ] && continue srv_seen["$s"]=1 if [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo " - IP-CIDR,${s}/32,DIRECT,no-resolve" elif [[ "$s" == \[*\] ]]; then echo " - IP-CIDR6,${s//[\[\]]/}/128,DIRECT,no-resolve" else echo " - DOMAIN,${s},DIRECT" fi done cat <<'RULES' # 本机与局域网直连(fake-ip 下不加这条,路由器/NAS 会被丢进代理) - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve - IP-CIDR,10.0.0.0/8,DIRECT,no-resolve - IP-CIDR,172.16.0.0/12,DIRECT,no-resolve - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve - IP-CIDR,169.254.0.0/16,DIRECT,no-resolve # 域名规则放在 IP 规则之前:GEOSITE 直接按域名命中, # 不必先做一次真实 DNS 解析(fake-ip 下 GEOIP 前置会触发额外解析) - GEOSITE,cn,DIRECT - GEOIP,CN,DIRECT # 兜底。MATCH 是终结规则,其后任何规则都不可达 - MATCH,Proxy RULES } > "$OUTPUT" echo "" log "已生成: ${OUTPUT}(${#NAMES[@]} 个节点)" for n in "${NAMES[@]}"; do echo " - ${n}"; done echo "" warn "该文件含真实凭据,已被根 .gitignore 排除,请勿提交或外传"