#!/bin/sh # clash.sh - Clash Verge Rev install + configure for Ubuntu, for hub.skillnav.dev # # Purpose : Take an Ubuntu desktop from "nothing installed" to a working # Clash Verge Rev 2.5.x - the .deb, the privileged TUN service, # the app settings, a global DIRECT rule set, and a subscription. # Usage : curl -fsSL https://hub.skillnav.dev/clash.sh | sh -s -- # subcommands: install | sub | rules | settings | check | all # Idempotent: yes. Every step checks whether it is already done and skips it. # Config edits replace a managed key or a marked block in place, # so running twice leaves the files byte-identical. # Dry run : HUB_DRY_RUN=1 prints each command instead of running it. # HUB_DRY_RUN=1 sh clash.sh all # Local test: HUB_BASE overrides where the YAML templates are fetched from. # # Environment: # CLASH_SUB_URL required by `sub`. Your subscription URL. It is a # secret: it is never written to disk by this script # and is masked in every line this script prints. # CLASH_VERGE_VERSION pin a version, e.g. 2.5.2. Default: latest release. # CLASH_FORCE_STOP=1 let `settings` pkill a running app instead of # refusing (this script is piped into sh, so it can # never prompt - stdin is the pipe, not a terminal). # CLASH_FORCE_RULES=1 let `rules` overwrite a Script.js that has content # this script did not write. A backup is made first. # HUB_FAKE_LINUX=1 skip the Linux/apt guard. For testing the logic on # a non-Linux box together with HUB_DRY_RUN=1. # HUB_LIB_ONLY=1 define the functions but do not run main. Lets a # test harness source this file and call one function. # # Installs and configures only; never uninstalls, never deletes a config file. # Every file it rewrites is backed up next to the original first. # Contains no secrets and no internal hostnames; safe to serve publicly. set -eu # --------------------------------------------------------------------------- HUB_BASE="${HUB_BASE:-https://hub.skillnav.dev}" HUB_DRY_RUN="${HUB_DRY_RUN:-0}" HUB_FAKE_LINUX="${HUB_FAKE_LINUX:-0}" CLASH_SUB_URL="${CLASH_SUB_URL:-}" CLASH_VERGE_VERSION="${CLASH_VERGE_VERSION:-}" CLASH_FORCE_STOP="${CLASH_FORCE_STOP:-0}" CLASH_FORCE_RULES="${CLASH_FORCE_RULES:-0}" # Data directory. Fixed by the app: Tauri's data_dir() plus the app identifier. # $XDG_DATA_HOME is the only thing that can move it; there is no app-specific # override variable. CVR_ID="io.github.clash-verge-rev.clash-verge-rev" CVR_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}/${CVR_ID}" CVR_PROFILES="${CVR_HOME}/profiles" # The singleton HTTP server the app binds on startup. Also the only reliable # "app is ready" probe: the process can be up before the port is listening. CVR_PORT=33331 # mihomo's control API. On Linux the app talks to it over a unix socket only; # the TCP external-controller is disabled by default and is NOT a fallback. CVR_SOCKS="/tmp/verge/verge-mihomo.sock ${HOME}/.config/verge/verge-mihomo.sock ${CVR_HOME}/verge/verge-mihomo.sock" # The privileged systemd service that owns CAP_NET_ADMIN for TUN. CVR_SERVICE="clash-verge-service" CVR_SERVICE_SOCK="/tmp/verge/clash-verge-service.sock" GH_REPO="clash-verge-rev/clash-verge-rev" # Marker that makes the generated rule block replaceable instead of duplicated. HUB_MARK="hub:clash-rules v1" # --------------------------------------------------------------------------- log() { printf '==> %s\n' "$*"; } ok() { printf 'ok %s\n' "$*"; } note() { printf ' %s\n' "$*"; } warn() { printf '!! %s\n' "$*" >&2; } dry() { printf 'DRY %s\n' "$*"; } # run - execute it, or print it when HUB_DRY_RUN=1. run() { if [ "$HUB_DRY_RUN" = "1" ]; then dry "$*" else log "$*" sh -c "$*" fi } HUB_CHANGED=0 HUB_PENDING="" changed() { HUB_CHANGED=$((HUB_CHANGED + 1)); } pending() { HUB_PENDING="${HUB_PENDING} $1"; } # --- guards ---------------------------------------------------------------- require_linux() { [ "$HUB_FAKE_LINUX" = "1" ] && return 0 if [ "$(uname -s)" != "Linux" ]; then warn "clash.sh only runs on Linux. This machine reports $(uname -s)." note "For macOS use: curl -fsSL ${HUB_BASE}/mac.sh | sh" note "For Windows use: irm ${HUB_BASE}/win.ps1 | iex" exit 1 fi if ! command -v apt >/dev/null 2>&1; then warn "apt was not found. clash.sh targets Debian and Ubuntu only." note "On other distributions install Clash Verge Rev by hand:" note " https://github.com/${GH_REPO}/releases/latest" exit 1 fi return 0 } # --- small generic helpers ------------------------------------------------- # urlencode - percent-encode every byte outside the unreserved set. # Byte-wise so a non-ASCII URL survives; LC_ALL=C keeps awk on bytes. urlencode() { printf '%s' "$1" | LC_ALL=C awk ' BEGIN { for (i = 0; i < 256; i++) ord[sprintf("%c", i)] = i } { n = length($0) for (i = 1; i <= n; i++) { c = substr($0, i, 1) if (c ~ /[A-Za-z0-9._~-]/) printf "%s", c else printf "%%%02X", ord[c] } } ' } # mask_url - keep the scheme and host, hide the path and query. The token # lives in the part that gets hidden, so this is what may go into a log line. # Anything that does not look like a URL is hidden completely rather than # echoed, so a bare token passed by mistake still cannot leak. mask_url() { case "$1" in *://*) printf '%s' "$1" | sed -e 's#\(://[^/?#]*\).*#\1/***#' ;; *) printf '***' ;; esac } # fetch - download a template. It only reads, so it runs during a # dry run too: the printed plan is then built from the real template. # Timeouts are mandatory: this script is normally piped into sh, so an # unreachable hub would otherwise hang the session with no output at all. fetch() { [ "$HUB_DRY_RUN" = "1" ] && dry "curl -fsSL --connect-timeout 10 -m 30 $1 -o $2" if curl -fsSL --connect-timeout 10 -m 30 "$1" -o "$2" 2>/dev/null; then return 0 fi warn "could not download $1 (timed out or unreachable)" note "If the hub is down, point HUB_BASE at a copy you can reach:" note " HUB_BASE=file:///path/to/public sh clash.sh " return 1 } # backup_once - copy to .bak-, at most once per run. HUB_BACKED_UP="" backup_once() { f=$1 [ -f "$f" ] || return 0 case " $HUB_BACKED_UP " in *" $f "*) return 0 ;; esac HUB_BACKED_UP="${HUB_BACKED_UP} $f" b="${f}.bak-$(date +%Y%m%d%H%M%S)" if [ "$HUB_DRY_RUN" = "1" ]; then dry "cp -p $f $b" else cp -p "$f" "$b" note "backed up $(basename "$f") -> $(basename "$b")" fi return 0 } # yaml_set - upsert a top-level scalar key. Replaces the # first `^key:` line in place, drops any later duplicate of it, appends when # the key is absent. Pure: it always writes and never consults HUB_DRY_RUN, so # a test can call it directly. yaml_set() { awk -v key="$2" -v val="$3" ' BEGIN { done = 0 } { if ($0 ~ "^" key ":") { if (!done) { print key ": " val; done = 1 } } else print } END { if (!done) print key ": " val } ' "$1" > "$1.hubtmp" && mv "$1.hubtmp" "$1" } # yaml_has_top_key yaml_has_top_key() { [ -f "$1" ] && grep -q "^$2:" "$1"; } # block_upsert - replace everything between # the markers with the content, or append the whole marked block when the # markers are absent. Pure, like yaml_set. block_upsert() { f=$1; b=$2; e=$3; c=$4 if [ -f "$f" ] && grep -qF "$b" "$f"; then if ! grep -qF "$e" "$f"; then warn "$(basename "$f") has an opening marker but no closing one; rewriting it" { printf '%s\n' "$b"; cat "$c"; printf '%s\n' "$e"; } > "$f.hubtmp" else awk -v b="$b" -v e="$e" -v c="$c" ' index($0, b) { print; while ((getline l < c) > 0) print l; close(c); skip = 1; next } skip && index($0, e) { print; skip = 0; next } !skip { print } ' "$f" > "$f.hubtmp" fi else { [ -f "$f" ] && cat "$f"; printf '%s\n' "$b"; cat "$c"; printf '%s\n' "$e"; } > "$f.hubtmp" fi mv "$f.hubtmp" "$f" } # json_* - just enough JSON for the control API, no jq dependency. # grep -oE anchors on the quoted key, so "mode" cannot match "enhanced-mode". json_str() { printf '%s' "$2" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n 1 | sed -e 's/^[^:]*:[[:space:]]*"//' -e 's/"$//' || true; } json_num() { printf '%s' "$2" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*-?[0-9]+" | head -n 1 | grep -oE -- '-?[0-9]+$' || true; } json_bool() { printf '%s' "$2" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*(true|false)" | head -n 1 | grep -oE '(true|false)$' || true; } # --- app and service state ------------------------------------------------- app_running() { pgrep -x clash-verge >/dev/null 2>&1; } # The port answering is the real readiness signal, and /commands/pac is the # only route that does not pop the window open. singleton_up() { curl -fsS -m 1 "http://127.0.0.1:${CVR_PORT}/commands/pac" >/dev/null 2>&1; } service_active() { command -v systemctl >/dev/null 2>&1 || return 1 systemctl is-active --quiet "$CVR_SERVICE" 2>/dev/null } service_installed() { [ -f "/etc/systemd/system/${CVR_SERVICE}.service" ] || service_active } # TUN needs the root service. Without it the app rewrites enable_tun_mode back # to false at startup, so writing true would only look like it worked. service_available() { [ -S "$CVR_SERVICE_SOCK" ] || service_active; } find_socket() { for p in $CVR_SOCKS; do [ -S "$p" ] && { printf '%s' "$p"; return 0; } done return 1 } mihomo_api() { s=$(find_socket) || return 1 curl -fsS --unix-socket "$s" -m 10 "http://localhost$1" 2>/dev/null } # --- install --------------------------------------------------------------- detect_arch() { if command -v dpkg >/dev/null 2>&1; then dpkg --print-architecture else case "$(uname -m)" in x86_64|amd64) printf 'amd64' ;; aarch64|arm64) printf 'arm64' ;; armv7l|armv7) printf 'armhf' ;; *) printf 'unknown' ;; esac fi } latest_tag() { curl -fsSL --connect-timeout 10 -m 20 "https://api.github.com/repos/${GH_REPO}/releases/latest" 2>/dev/null \ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 || true } cmd_install() { log "install - the Clash Verge Rev .deb and the TUN service" arch=$(detect_arch) case "$arch" in amd64|arm64|armhf) ok "architecture: ${arch}" ;; *) warn "unsupported architecture: ${arch}. Only amd64, arm64 and armhf are published."; exit 1 ;; esac if [ -n "$CLASH_VERGE_VERSION" ]; then ver=${CLASH_VERGE_VERSION#v} tag="v${ver}" ok "version pinned by CLASH_VERGE_VERSION: ${ver}" else [ "$HUB_DRY_RUN" = "1" ] && dry "curl -fsSL --connect-timeout 10 -m 20 https://api.github.com/repos/${GH_REPO}/releases/latest # read tag_name" tag=$(latest_tag) if [ -z "$tag" ]; then warn "could not read the latest release tag from the GitHub API." note "Set the version explicitly and re-run:" note " CLASH_VERGE_VERSION=2.5.2 sh clash.sh install" exit 1 fi ver=${tag#v} ok "latest release: ${tag}" fi have=$(dpkg-query -W -f='${Version}' clash-verge 2>/dev/null || true) if [ -n "$have" ] && [ "$have" = "$ver" ]; then ok "clash-verge ${have} already installed" else [ -n "$have" ] && note "installed version is ${have}, target is ${ver}" # GitHub replaces the space in the bundled filename with a dot on upload. asset="Clash.Verge_${ver}_${arch}.deb" url="https://github.com/${GH_REPO}/releases/download/${tag}/${asset}" deb="/tmp/${asset}" # No total timeout here: the .deb is ~100 MB and a slow link is not a # failure. Bound the connect phase instead, and retry a dropped transfer. run "curl -fL --progress-bar --connect-timeout 20 --retry 2 '${url}' -o '${deb}'" # apt rather than dpkg -i, so the webkit, gtk and appindicator # dependencies resolve on their own. run "sudo apt install -y '${deb}'" changed fi log "TUN service" if service_installed && service_active; then ok "${CVR_SERVICE} already installed and active" elif service_installed; then note "${CVR_SERVICE} is installed but not active" run "sudo systemctl enable --now ${CVR_SERVICE}" changed else # The installer resolves the service group from CLASH_VERGE_SERVICE_GID, # then SUDO_UID or PKEXEC_UID, then SUDO_GID, and panics when all are # missing. Passing the GID explicitly survives both a root shell and a # sudo configuration that resets the environment. run "sudo CLASH_VERGE_SERVICE_GID=\"\$(id -g)\" /usr/bin/clash-verge-service-install" changed fi if [ "$HUB_DRY_RUN" != "1" ] && ! service_available; then warn "the TUN service is not answering yet; TUN mode stays off until it does" pending "tun-service" fi return 0 } # --- settings -------------------------------------------------------------- ensure_app_stopped() { app_running || return 0 if [ "$CLASH_FORCE_STOP" = "1" ]; then run "pkill -x clash-verge" [ "$HUB_DRY_RUN" = "1" ] || sleep 2 return 0 fi warn "Clash Verge is running. It rewrites verge.yaml from memory, so edits made now would be lost." note "Quit it from the tray, not just close the window, then re-run:" note " curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- settings" note "Or let this script stop it for you:" note " curl -fsSL ${HUB_BASE}/clash.sh | CLASH_FORCE_STOP=1 sh -s -- settings" # A dry run is meant to show the whole plan, so report the blocker and keep # printing instead of stopping here. if [ "$HUB_DRY_RUN" = "1" ]; then dry "a real run would stop here with exit 1" return 0 fi exit 1 } # apply_setting - dry-run aware wrapper around yaml_set. apply_setting() { if [ "$HUB_DRY_RUN" = "1" ]; then dry "set $(basename "$1") ${2}: ${3}" else yaml_set "$1" "$2" "$3" fi } # verify_yaml - a broken verge.yaml makes the app silently fall back to # defaults, so check before walking away. Uses python3 with PyYAML when it is # available and a cheap structural check otherwise. verify_yaml() { f=$1 if python3 -c 'import yaml' >/dev/null 2>&1; then if python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]))' "$f" >/dev/null 2>&1; then ok "$(basename "$f") parses as YAML" else warn "$(basename "$f") is NOT valid YAML. Restore the .bak- file next to it." pending "invalid-yaml" fi return 0 fi if grep -q "$(printf '\t')" "$f" 2>/dev/null; then warn "$(basename "$f") contains a tab character, which YAML forbids" pending "invalid-yaml" return 0 fi note "python3 with PyYAML is not available; skipped the YAML syntax check" return 0 } cmd_settings() { log "settings - verge.yaml and dns_config.yaml" ensure_app_stopped tmp_settings="/tmp/hub-clash-settings.$$.yaml" tmp_dns="/tmp/hub-clash-dns.$$.yaml" fetch "${HUB_BASE}/clash/settings.yaml" "$tmp_settings" || exit 1 fetch "${HUB_BASE}/clash/dns.yaml" "$tmp_dns" || exit 1 verge="${CVR_HOME}/verge.yaml" dnsf="${CVR_HOME}/dns_config.yaml" if [ "$HUB_DRY_RUN" = "1" ]; then dry "mkdir -p ${CVR_HOME}" else mkdir -p "$CVR_HOME" if [ ! -f "$verge" ]; then note "verge.yaml does not exist yet; creating it" printf '# Clash Verge Config\n' > "$verge" fi fi backup_once "$verge" if service_available; then tun=true ok "TUN service is available, so enable_tun_mode will be set to true" else tun=false warn "TUN service not available, so enable_tun_mode is written as false" note "The app forces that key back to false at startup when the service is" note "missing, so writing true here would only look like it worked." note "Install it with: curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- install" pending "tun-service" fi # Walk the template: one `key: value` per line, comments and blanks skipped. while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|'#'*) continue ;; esac case "$line" in *:*) ;; *) continue ;; esac key=${line%%:*} val=${line#*:} val=${val# } case "$key" in *[!a-z_0-9]*) continue ;; esac [ -n "$val" ] || continue [ "$key" = "enable_tun_mode" ] && val="$tun" apply_setting "$verge" "$key" "$val" done < "$tmp_settings" # The DNS settings only take effect when both the file and the switch above # are in place; writing the file alone does nothing. if [ -f "$dnsf" ]; then backup_once "$dnsf"; fi if [ "$HUB_DRY_RUN" = "1" ]; then dry "install -m 0644 ${dnsf}" else cp "$tmp_dns" "$dnsf" chmod 0644 "$dnsf" ok "wrote dns_config.yaml" changed fi rm -f "$tmp_settings" "$tmp_dns" if [ "$HUB_DRY_RUN" != "1" ]; then verify_yaml "$verge" ok "verge.yaml updated" changed note "Start the app for the new settings to reach the core." fi return 0 } # --- rules ----------------------------------------------------------------- # render_rules_js - compile the `prepend:` list into JS array # entries. YAML comments become JS comments so the grouping survives. render_rules_js() { awk ' BEGIN { inblock = 0; sq = sprintf("%c", 39) } /^prepend:[ \t]*$/ { inblock = 1; next } /^[A-Za-z_][A-Za-z0-9_-]*:/ { inblock = 0 } inblock == 1 { line = $0 sub(/^[ \t]+/, "", line) sub(/[ \t]+$/, "", line) if (line == "") next if (substr(line, 1, 1) == "#") { sub(/^#[ \t]?/, "", line) print " // " line next } if (substr(line, 1, 2) == "- ") { v = substr(line, 3) sub(/^[ \t]+/, "", v) q = substr(v, 1, 1) if ((q == "\"" || q == sq) && substr(v, length(v), 1) == q) v = substr(v, 2, length(v) - 2) gsub(/\\/, "\\\\", v) gsub(/"/, "\\\"", v) print " \"" v "\"," } } ' "$1" } # is_stock_script - true when the file is still the app's generated # no-op template, which may be replaced without losing anything. is_stock_script() { [ -f "$1" ] || return 1 compact=$(sed -e 's#//.*##' "$1" | tr -d ' \t\n\r') [ "$compact" = 'functionmain(config,profileName){returnconfig;}' ] } cmd_rules() { log "rules - the global DIRECT rule set" tmp_rules="/tmp/hub-clash-rules.$$.yaml" fetch "${HUB_BASE}/clash/rules.yaml" "$tmp_rules" || exit 1 count=$(render_rules_js "$tmp_rules" | grep -c '^ "' || true) if [ "${count:-0}" -lt 1 ]; then warn "no rules found under prepend: in rules.yaml, so there is nothing to do" rm -f "$tmp_rules" exit 1 fi ok "${count} rules to prepend" scriptf="${CVR_PROFILES}/Script.js" mergef="${CVR_PROFILES}/Merge.yaml" # Build the managed block body. body="/tmp/hub-clash-block.$$.js" { printf '// Generated from %s/clash/rules.yaml - do not edit inside this block.\n' "$HUB_BASE" printf '//\n' printf '// Why a script and not Merge.yaml: the merge enhancement is a plain deep\n' printf '// merge, so a rules: list there would REPLACE the subscription rules instead\n' printf '// of prepending to them. The global script item runs for every profile and\n' printf '// can prepend safely.\n' printf 'var HUB_CLASH_RULES = [\n' render_rules_js "$tmp_rules" printf '];\n' printf '\n' printf 'function main(config, profileName) {\n' printf ' if (!config) { return config; }\n' printf ' var existing = config.rules || [];\n' printf ' var out = [];\n' printf ' var seen = {};\n' printf ' var has = Object.prototype.hasOwnProperty;\n' printf ' var i;\n' printf ' for (i = 0; i < HUB_CLASH_RULES.length; i++) {\n' printf ' if (!has.call(seen, HUB_CLASH_RULES[i])) {\n' printf ' seen[HUB_CLASH_RULES[i]] = true;\n' printf ' out.push(HUB_CLASH_RULES[i]);\n' printf ' }\n' printf ' }\n' printf ' for (i = 0; i < existing.length; i++) {\n' printf ' if (typeof existing[i] === "string" && has.call(seen, existing[i])) { continue; }\n' printf ' out.push(existing[i]);\n' printf ' }\n' printf ' config.rules = out;\n' printf ' return config;\n' printf '}\n' } > "$body" begin="// ${HUB_MARK} BEGIN" end="// ${HUB_MARK} END" # Decide whether this script is allowed to touch Script.js. if [ ! -f "$scriptf" ]; then mode="create" elif grep -qF "$HUB_MARK" "$scriptf"; then mode="replace" elif is_stock_script "$scriptf"; then mode="adopt" else mode="foreign" fi if [ "$mode" = "foreign" ] && [ "$CLASH_FORCE_RULES" != "1" ]; then warn "${scriptf} already has content this script did not write." note "Refusing to overwrite it. Either merge the block by hand, or re-run with:" note " curl -fsSL ${HUB_BASE}/clash.sh | CLASH_FORCE_RULES=1 sh -s -- rules" rm -f "$tmp_rules" "$body" exit 1 fi if [ "$HUB_DRY_RUN" = "1" ]; then dry "mkdir -p ${CVR_PROFILES}" dry "write ${scriptf} (${mode}, ${count} rules, marker '${HUB_MARK}')" else mkdir -p "$CVR_PROFILES" case "$mode" in create) { printf '// Script.js - global script enhancement for Clash Verge Rev, item uid Script.\n' printf '//\n' printf '// Managed by %s/clash.sh. Only the marked block is rewritten;\n' "$HUB_BASE" printf '// anything added outside the markers is kept.\n' printf '\n' } > "$scriptf" ;; adopt|foreign) backup_once "$scriptf" printf '// Script.js - global script enhancement, managed by %s/clash.sh\n\n' "$HUB_BASE" > "$scriptf" ;; replace) backup_once "$scriptf" ;; esac block_upsert "$scriptf" "$begin" "$end" "$body" ok "Script.js written (${mode}) with ${count} rules" changed fi # store-selected belongs in the global merge. Only add it when the file has # no `profile:` key at all, because a second one would be a duplicate YAML # key and the app would fail to parse the file. if yaml_has_top_key "$mergef" profile; then ok "Merge.yaml already has a profile: block, so it was left untouched" else mbody="/tmp/hub-clash-merge.$$.yaml" printf 'profile:\n store-selected: true\n' > "$mbody" if [ "$HUB_DRY_RUN" = "1" ]; then dry "write ${mergef} (append profile.store-selected: true)" else backup_once "$mergef" block_upsert "$mergef" "# ${HUB_MARK} BEGIN" "# ${HUB_MARK} END" "$mbody" ok "Merge.yaml now sets profile.store-selected" changed fi rm -f "$mbody" fi rm -f "$tmp_rules" "$body" note "The rules reach the core the next time the app regenerates its config:" note "restart Clash Verge, or re-select the profile in the UI." return 0 } # --- sub ------------------------------------------------------------------- ensure_app_running() { if singleton_up; then ok "the app is already running and the singleton port answers" return 0 fi if [ "$HUB_DRY_RUN" = "1" ]; then dry "nohup /usr/bin/clash-verge >/dev/null 2>&1 &" dry "poll http://127.0.0.1:${CVR_PORT}/commands/pac until it answers, timeout 30s" return 0 fi if [ ! -x /usr/bin/clash-verge ]; then warn "/usr/bin/clash-verge is not installed." note "Run: curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- install" exit 1 fi log "starting Clash Verge" # There are no CLI flags. Silent start is a verge.yaml key, not an argument. nohup /usr/bin/clash-verge >/dev/null 2>&1 & i=0 while [ "$i" -lt 30 ]; do if singleton_up; then ok "singleton port ${CVR_PORT} came up after ${i}s" return 0 fi sleep 1 i=$((i + 1)) done warn "the app did not open port ${CVR_PORT} within 30s." note "A deep link sent now would be dropped. Start the app by hand and retry." exit 1 } cmd_sub() { log "sub - import a subscription" if [ -z "$CLASH_SUB_URL" ]; then warn "CLASH_SUB_URL is not set." note "The subscription URL is a secret, so it is not stored in this script." note "Pass it on the command line:" note " CLASH_SUB_URL='https://your-provider/sub?token=...' \\" note " curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- sub" exit 1 fi masked=$(mask_url "$CLASH_SUB_URL") ok "subscription: ${masked}" ensure_app_running # A cold-started app never reads argv for the deep link, so calling xdg-open # before the app is up loses it. Post to the singleton endpoint instead: it # is the same code path the app uses to forward a link to a running # instance, and it skips the desktop-file and mime lookup entirely. # # Double encoding is required. The endpoint percent-decodes `param` once, # which must yield clash://install-config?url=, and the app then # decodes that inner value. `url=` has to be the last query parameter, # because the app slices from `url=` to the end of the query rather than # parsing the query properly. inner=$(urlencode "$CLASH_SUB_URL") param="clash://install-config?url=${inner}" outer=$(urlencode "$param") endpoint="http://127.0.0.1:${CVR_PORT}/commands/scheme?param=" # Never print the real query string, because it carries the token. shown="clash://install-config?url=$(urlencode "$masked")" if [ "$HUB_DRY_RUN" = "1" ]; then dry "curl -fsS --connect-timeout 5 -m 10 '${endpoint}$(urlencode "$shown")'" note "the real command carries the encoded subscription URL, masked here" return 0 fi # The endpoint answers "ok" as soon as it hands the link off, so 10s is # generous. Without a bound a wedged app would hang the whole pipe. log "curl -fsS --connect-timeout 5 -m 10 '${endpoint}$(urlencode "$shown")'" if curl -fsS --connect-timeout 5 -m 10 "${endpoint}${outer}" >/dev/null 2>&1; then ok "deep link delivered" changed note "Clash Verge is downloading the subscription. Check the Profiles page." else warn "the singleton endpoint did not accept the deep link" note "Fallback, with the app already running:" note " xdg-open 'clash://install-config?url='" exit 1 fi return 0 } # --- check ----------------------------------------------------------------- CHECK_FAILS=0 CHECK_ROWS="" check_row() { CHECK_ROWS="${CHECK_ROWS}$1|$2|$3 " [ "$1" = "FAIL" ] && CHECK_FAILS=$((CHECK_FAILS + 1)) return 0 } cmd_check() { log "check - health of the install" if [ "$HUB_DRY_RUN" = "1" ]; then dry "dpkg-query -W -f='\${Version}' clash-verge" dry "systemctl is-active ${CVR_SERVICE} and test -S ${CVR_SERVICE_SOCK}" dry "pgrep -x clash-verge" dry "curl -fsS --unix-socket /tmp/verge/verge-mihomo.sock http://localhost/version" dry "curl -fsS --unix-socket /tmp/verge/verge-mihomo.sock http://localhost/configs # mode, mixed-port, tun" dry "ip link show " dry "grep -F '${HUB_MARK}' ${CVR_PROFILES}/Script.js" dry "curl -sS -o /dev/null -w '%{http_code}' -m 10 -x http://127.0.0.1:7897 https://cp.cloudflare.com/generate_204" dry "curl -sS -o /dev/null -w '%{http_code}' -m 10 -x http://127.0.0.1:7897 https://www.google.com/generate_204" dry "curl -fsS --unix-socket /tmp/verge/verge-mihomo.sock 'http://localhost/proxies/PROXY/delay?url=...&timeout=5000'" note "dry run: nothing was probed, so there is no PASS/FAIL table" return 0 fi # 1. package have=$(dpkg-query -W -f='${Version}' clash-verge 2>/dev/null || true) if [ -n "$have" ]; then check_row PASS "package" "clash-verge ${have}" else check_row FAIL "package" "clash-verge is not installed"; fi # 2. privileged service if service_active && [ -S "$CVR_SERVICE_SOCK" ]; then check_row PASS "tun service" "active, socket present" elif service_active; then check_row FAIL "tun service" "unit is active but ${CVR_SERVICE_SOCK} is missing" elif service_installed; then check_row FAIL "tun service" "unit installed but not active" else check_row FAIL "tun service" "not installed, run: clash.sh install" fi # 3. app process if app_running; then if singleton_up; then check_row PASS "app process" "running, singleton port ${CVR_PORT} answers" else check_row FAIL "app process" "running but port ${CVR_PORT} is not answering yet"; fi else check_row FAIL "app process" "clash-verge is not running" fi # 4. control API over the unix socket sock=$(find_socket || true) if [ -z "$sock" ]; then check_row FAIL "control api" "no mihomo socket found, is the core running?" else version=$(mihomo_api /version || true) if [ -n "$version" ]; then check_row PASS "control api" "${sock} -> $(json_str version "$version")" elif [ -r "$sock" ] && [ -w "$sock" ]; then check_row FAIL "control api" "${sock} exists but /version failed" else check_row FAIL "control api" "permission denied on ${sock}, it is root-owned in service mode" fi fi # 5 and 6. runtime config: mode, mixed port, TUN configs="" [ -n "$sock" ] && configs=$(mihomo_api /configs || true) port=7897 if [ -n "$configs" ]; then mode=$(json_str mode "$configs") mp=$(json_num "mixed-port" "$configs") [ -n "$mp" ] && port=$mp check_row PASS "mode / port" "mode=${mode:-?} mixed-port=${mp:-?}" tun_obj=$(printf '%s' "$configs" | grep -oE '"tun"[[:space:]]*:[[:space:]]*\{[^}]*\}' | head -n 1 || true) tun_on=$(json_bool enable "$tun_obj") dev=$(json_str device "$tun_obj") if [ "$tun_on" = "true" ]; then if [ -n "$dev" ] && ip link show "$dev" >/dev/null 2>&1; then check_row PASS "tun" "enabled, interface ${dev} is up" elif [ -n "$dev" ]; then check_row FAIL "tun" "enabled but interface ${dev} was not found" else check_row SKIP "tun" "enabled but the runtime config names no device" fi else check_row SKIP "tun" "tun.enable is ${tun_on:-unset}" fi else check_row FAIL "mode / port" "could not read /configs" check_row SKIP "tun" "no runtime config to read" fi # 7. rules marker if [ -f "${CVR_PROFILES}/Script.js" ] && grep -qF "$HUB_MARK" "${CVR_PROFILES}/Script.js"; then n=$(grep -c '^ "' "${CVR_PROFILES}/Script.js" || true) check_row PASS "rules" "marker present, ${n} rules" else check_row FAIL "rules" "marker not found in ${CVR_PROFILES}/Script.js" fi # 8. egress through the mixed port cf=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 -x "http://127.0.0.1:${port}" https://cp.cloudflare.com/generate_204 2>/dev/null || true) gg=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 -x "http://127.0.0.1:${port}" https://www.google.com/generate_204 2>/dev/null || true) if [ "$cf" = "204" ]; then check_row PASS "egress cloudflare" "204 via 127.0.0.1:${port}" else check_row FAIL "egress cloudflare" "expected 204, got ${cf:-no response}"; fi if [ "$gg" = "204" ]; then check_row PASS "egress google" "204 via 127.0.0.1:${port}" else check_row FAIL "egress google" "expected 204, got ${gg:-no response}"; fi # 9. delay of the PROXY group, when there is one if [ -n "$sock" ]; then grp=$(mihomo_api /proxies/PROXY || true) if [ -n "$grp" ] && printf '%s' "$grp" | grep -q '"type"'; then durl=$(urlencode 'https://www.gstatic.com/generate_204') d=$(mihomo_api "/proxies/PROXY/delay?url=${durl}&timeout=5000" || true) dv=$(json_num delay "$d") if [ -n "$dv" ]; then check_row PASS "proxy delay" "PROXY = ${dv} ms" else check_row FAIL "proxy delay" "PROXY returned no delay: ${d:-no response}"; fi else check_row SKIP "proxy delay" "no proxy group named PROXY" fi else check_row SKIP "proxy delay" "no control socket" fi printf '\n' printf '%s' "$CHECK_ROWS" | awk -F'|' '{ printf "%-5s %-18s %s\n", $1, $2, $3 }' printf '\n' if [ "$CHECK_FAILS" -gt 0 ]; then warn "${CHECK_FAILS} check(s) failed" return 1 fi ok "all checks passed" return 0 } # --- driver ---------------------------------------------------------------- usage() { cat < Subcommands install download the .deb for this architecture, apt install it, then install the clash-verge-service systemd unit that TUN needs settings apply verge.yaml keys and write dns_config.yaml (quit the app first) rules install the global DIRECT rule set covering Tailscale, Feishu, WeChat, LAN and mainland China, for every subscription sub import a subscription, needs CLASH_SUB_URL check PASS/FAIL/SKIP report, exits 1 if anything failed all install, then settings, rules, sub and check Environment CLASH_SUB_URL subscription URL, required by sub and all CLASH_VERGE_VERSION pin a version instead of using the latest release CLASH_FORCE_STOP=1 allow settings to stop a running app CLASH_FORCE_RULES=1 allow rules to overwrite a customised Script.js HUB_DRY_RUN=1 print commands instead of running them Examples curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- install CLASH_SUB_URL='https://provider/sub?token=...' \\ curl -fsSL ${HUB_BASE}/clash.sh | sh -s -- all USAGE } summary() { printf '\n' log "Summary" if [ "$HUB_DRY_RUN" = "1" ]; then note "Dry run - nothing was installed or modified." elif [ "$HUB_CHANGED" -eq 0 ]; then note "Everything was already in place. No changes made." else note "${HUB_CHANGED} step(s) made changes." fi [ -n "$HUB_PENDING" ] && note "Unfinished:${HUB_PENDING}. Re-run once resolved." note "Cheatsheet: ${HUB_BASE}/commands/linux/" return 0 } main() { if [ "$#" -lt 1 ]; then usage exit 1 fi subcmd=$1 case "$subcmd" in -h|--help|help) usage; exit 0 ;; esac require_linux log "hub clash bootstrap - ${subcmd}" [ "$HUB_DRY_RUN" = "1" ] && note "DRY RUN: commands are printed, not executed." note "data directory: ${CVR_HOME}" printf '\n' case "$subcmd" in install) cmd_install ;; settings) cmd_settings ;; rules) cmd_rules ;; sub) cmd_sub ;; check) cmd_check ;; all) cmd_install; printf '\n' cmd_settings; printf '\n' cmd_rules; printf '\n' cmd_sub; printf '\n' cmd_check || true ;; *) warn "unknown subcommand: ${subcmd}" printf '\n' usage exit 1 ;; esac summary } [ "${HUB_LIB_ONLY:-0}" = "1" ] || main "$@"