From df806124b3257c96ca676acd7f1823c22107ebe5 Mon Sep 17 00:00:00 2001 From: wnlen <62139570+wnlen@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:27:54 +0800 Subject: [PATCH] Fix Hysteria2 and AnyTLS subscription support --- README.md | 32 ++++++- scripts/core/clashctl.sh | 84 +++++++++++++++- scripts/core/common.sh | 26 +++++ scripts/core/completion.sh | 14 ++- scripts/core/config.sh | 73 ++++++++++++-- .../dev/check-runtime-config-normalization.sh | 95 +++++++++++++++++-- scripts/dev/check-sub-update-compat.sh | 5 +- 7 files changed, 311 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f94fdb1..93d6628 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ bash install.sh clash secret 123 🔐 设置密钥 📌 高级 clash lan 🏠 局域网代理管理 + clash ipv6 🌐 IPv6 / IPv6-only 节点管理 clash tun 🧪 Tun 模式管理(需root方式安装) clash boot 🚦 开机代理接管管理 clash mixin 🧩 Mixin 配置管理 @@ -158,6 +159,32 @@ clash lan off ------ +## 🌐 IPv6 / Hysteria2 + +Mihomo 支持 Hysteria2。若节点服务端只有 IPv6 地址,需要同时启用内核 IPv6 与 DNS AAAA: + +```bash +clash config kernel mihomo +clash ipv6 on +clash ipv6 status +``` + +其他可用操作: + +```bash +clash ipv6 off +clash ipv6 auto +``` + +- `on`:同时写入 `ipv6: true` 和 `dns.ipv6: true`。 +- `off`:同时关闭内核 IPv6 与 DNS AAAA。 +- `auto`:保留订阅原有的 IPv6 设置;订阅未配置时继续使用兼容默认值。 +- 使用 IPv6-only 节点前,宿主机仍需具备可用的 IPv6 默认路由。HY2 使用 UDP / QUIC,网络或防火墙也必须允许相应 UDP 流量。 + +也可以在 `.env` 中设置 `CLASH_IPV6=true`、`false` 或 `auto`,然后执行 `clash config regen`。 + +------ + OpenWrt 下 root/system 安装会把 `clash`、`clashon`、`clashoff` 等命令入口写入 `/usr/bin`,运行状态、日志和内核二进制仍保存在项目目录的 `runtime/` 下。仅脚本模式不会注册开机自启,设备重启后需要重新执行 `clashon`。 ## 🧰 常用管理命令 @@ -204,7 +231,7 @@ clash add "file:///绝对路径/clash.yaml" home - Clash / Mihomo YAML - Base64 订阅 -- 分享链接(`vmess` / `vless` / `trojan` / `tuic`) +- 分享链接(`vmess` / `vless` / `trojan` / `tuic` / `hysteria2` / `hy2` / `anytls`) ### 开机接管(内核 + 代理) @@ -321,12 +348,15 @@ CLASH_DOWNLOAD_BASE=https://github.com/WindSpiritSR/clash/releases/download CLASH_BUNDLED_ASSET_ENABLED=true CLASH_SHELL_AUTO_RESTORE_PROXY=true CLASH_PREDOWNLOAD_GEO=true +CLASH_IPV6=auto ``` 按需设置即可,不需要每项都写。 - `CLASH_SHELL_AUTO_RESTORE_PROXY`:控制登录 Shell 是否自动恢复上次 `clashon` 写入的代理变量。默认 `true` 保持兼容;如果不希望 SSH 远程登录后自动带上 `http_proxy` / `https_proxy`,设为 `false`,之后仍可手动执行 `clashon`。 - `CLASH_PREDOWNLOAD_GEO`:控制安装期是否预下载 GEO 数据。默认 `true`,会提前下载 `Country.mmdb`、`geoip.metadb`、`GeoIP.dat`、`GeoSite.dat` 等规则分流常用资源;临时部署、只想先跑起来时可设为 `false` 跳过安装期预下载。注意:当最终运行配置实际使用 `GEOIP` 规则时,启动前仍会按需准备 `Country.mmdb`,否则 Mihomo 无法可靠加载该配置。 +- `CLASH_IPV6`:可设为 `true`、`false` 或 `auto`。`true` 同时启用内核 IPv6 与 DNS AAAA,适用于 IPv6-only 节点;`auto` 保留订阅中的 IPv6 设置。 +- `CLASH_SUBSCRIPTION_UA`:覆盖拉取订阅时的 User-Agent。默认使用 `clash-verge/v2.4.0`,以便订阅服务返回 Mihomo 支持的 Hysteria2、AnyTLS 等现代协议节点;如果服务商有专用 UA 要求,可在此显式设置。 #### GitHub 下载加速 diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index 19d09b9..93a0c7f 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -37,6 +37,7 @@ Usage: mixin 🧩 Mixin 配置管理 relay 🔗 多跳节点管理 lan 🏠 局域网代理管理 + ipv6 🌐 IPv6 / IPv6-only 节点管理 📦 Subscription: @@ -49,6 +50,7 @@ Usage: clashui 🕹️ 查看 Web 控制台 secret 🔑 查看或设置 Web 密钥 lan on|off|status 🏠 开启 / 关闭局域网代理 + ipv6 on|off|auto|status 🌐 管理内核与 DNS IPv6 🧪 Transparent proxy: tun 🧪 Tun 模式管理 @@ -3290,6 +3292,7 @@ cmd_ui_help_summary() { printf ' %-18s %s\n' "clash ls" "📜 查看订阅列表" echo "📌 高级" printf ' %-18s %s\n' "clash lan" "🏠 局域网代理管理" + printf ' %-18s %s\n' "clash ipv6" "🌐 IPv6 / IPv6-only 节点管理" printf ' %-18s %s\n' "clash tun" "🧪 Tun 模式管理" printf ' %-18s %s\n' "clash mixin" "🧩 Mixin 配置管理" printf ' %-18s %s\n' "clash sub" "🧩 订阅高级管理(启用 / 禁用 / 重命名 / 删除)" @@ -4403,6 +4406,84 @@ cmd_lan() { esac } +print_ipv6_status() { + local mode mode_text kernel_ipv6 dns_ipv6 kernel + + mode="$(config_ipv6_mode)" + kernel_ipv6="$(runtime_config_ipv6_enabled 2>/dev/null || echo "等待运行配置生成")" + dns_ipv6="$(runtime_config_dns_ipv6_enabled 2>/dev/null || echo "等待运行配置生成")" + kernel="$(runtime_kernel_type 2>/dev/null || echo unknown)" + + case "$mode" in + true) mode_text="已开启" ;; + false) mode_text="已关闭" ;; + *) mode_text="自动(保留订阅设置)" ;; + esac + + ui_title "🌐 IPv6" + ui_kv "🔧" "配置模式" "$mode_text" + ui_kv "🚀" "当前内核" "$kernel" + ui_kv "🌐" "内核 IPv6" "$kernel_ipv6" + ui_kv "🔎" "DNS AAAA" "$dns_ipv6" + if [ "$kernel" != "mihomo" ]; then + ui_warn "Hysteria2 建议使用 Mihomo:clash config kernel mihomo" + fi + ui_blank +} + +cmd_ipv6() { + local action + prepare + + action="${1:-status}" + case "$action" in + on|enable) + set_config_ipv6_mode true + regenerate_config + apply_runtime_change_after_config_mutation + success "IPv6 与 DNS AAAA 已开启" + print_ipv6_status + print_config_apply_feedback + ;; + off|disable) + set_config_ipv6_mode false + regenerate_config + apply_runtime_change_after_config_mutation + success "IPv6 与 DNS AAAA 已关闭" + print_ipv6_status + print_config_apply_feedback + ;; + auto|reset) + set_config_ipv6_mode auto + regenerate_config + apply_runtime_change_after_config_mutation + success "IPv6 已恢复为自动模式" + print_ipv6_status + print_config_apply_feedback + ;; + status) + print_ipv6_status + ui_next "IPv6-only / Hysteria2 节点不可用时执行:clash ipv6 on" + ui_blank + ;; + -h|--help|help) + echo "📜 用法:" + echo " clash ipv6 status" + echo " clash ipv6 on" + echo " clash ipv6 off" + echo " clash ipv6 auto" + echo + echo "🌐 说明:" + echo " on 同时启用内核 IPv6 与 DNS AAAA" + echo " off 同时关闭内核 IPv6 与 DNS AAAA" + echo " auto 保留订阅中的 IPv6 设置,缺省时沿用兼容默认值" + ;; + *) + die_usage "未知的 ipv6 子命令:$action" "clash ipv6 on|off|auto|status" + ;; + esac +} + print_profile_use_feedback() { local profile="$1" @@ -7262,7 +7343,7 @@ cmd_sub() { case "${1:-}" in update) - regenerate_config + regenerate_config "manual-refresh" apply_runtime_change_after_config_mutation print_config_regen_feedback print_config_apply_feedback @@ -8087,6 +8168,7 @@ case "$cmd" in dev) cmd_dev "$@" ;; config) cmd_config "$@" ;; lan) cmd_lan "$@" ;; + ipv6) cmd_ipv6 "$@" ;; mixin) cmd_mixin "$@" ;; relay) cmd_relay "$@" ;; profile) cmd_profile "$@" ;; diff --git a/scripts/core/common.sh b/scripts/core/common.sh index 55543a7..e89f093 100644 --- a/scripts/core/common.sh +++ b/scripts/core/common.sh @@ -1796,6 +1796,32 @@ runtime_config_allow_lan() { esac } +runtime_config_ipv6_enabled() { + local file + local value + file="$(runtime_config_file)" + [ -s "$file" ] || return 1 + + value="$("$(yq_bin)" eval '.ipv6' "$file" 2>/dev/null | head -n 1)" + case "$value" in + false) echo "false" ;; + *) echo "true" ;; + esac +} + +runtime_config_dns_ipv6_enabled() { + local file + local value + file="$(runtime_config_file)" + [ -s "$file" ] || return 1 + + value="$("$(yq_bin)" eval '.dns.ipv6' "$file" 2>/dev/null | head -n 1)" + case "$value" in + true) echo "true" ;; + *) echo "false" ;; + esac +} + runtime_port_value_is_valid() { local port="${1:-}" diff --git a/scripts/core/completion.sh b/scripts/core/completion.sh index c5b4920..3c6ebaa 100644 --- a/scripts/core/completion.sh +++ b/scripts/core/completion.sh @@ -213,6 +213,17 @@ _clash_for_linux_complete_lan() { fi } +_clash_for_linux_complete_ipv6() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" on off auto status enable disable reset help -h --help + fi +} + _clash_for_linux_complete_mixin() { local cur="$1" local rel_index="$2" @@ -347,7 +358,7 @@ _clash_for_linux_complete_top_level() { COMPREPLY=() _clash_for_linux_add_matches "$cur" \ add use ls health select mode test on off status status-next \ - boot log logs doctor ui secret tun dev config lan mixin \ + boot log logs doctor ui secret tun dev config lan ipv6 mixin \ relay profile sub proxy upgrade update completion help \ -h --help } @@ -426,6 +437,7 @@ _clash_for_linux_complete_command() { boot) _clash_for_linux_complete_boot "$cur" "$rel_index" "$arg1" ;; config) _clash_for_linux_complete_config "$cur" "$rel_index" "$arg1" ;; lan) _clash_for_linux_complete_lan "$cur" "$rel_index" ;; + ipv6) _clash_for_linux_complete_ipv6 "$cur" "$rel_index" ;; mixin) _clash_for_linux_complete_mixin "$cur" "$rel_index" ;; relay) _clash_for_linux_complete_relay "$cur" "$rel_index" "$arg1" ;; sub) _clash_for_linux_complete_sub "$cur" "$rel_index" "$arg1" ;; diff --git a/scripts/core/config.sh b/scripts/core/config.sh index 1793cd7..d9f9804 100644 --- a/scripts/core/config.sh +++ b/scripts/core/config.sh @@ -276,6 +276,46 @@ config_bool_env_value() { esac } +config_ipv6_mode() { + local value + + value="${CLASH_IPV6:-}" + [ -n "${value:-}" ] || value="$(read_env_value "CLASH_IPV6" 2>/dev/null || true)" + + case "${value:-auto}" in + true|1|yes|on) + echo "true" + ;; + false|0|no|off) + echo "false" + ;; + auto|"") + echo "auto" + ;; + *) + echo "auto" + ;; + esac +} + +set_config_ipv6_mode() { + local mode="$1" + + case "$mode" in + true|false) + write_env_value "CLASH_IPV6" "$mode" + export CLASH_IPV6="$mode" + ;; + auto) + unset_env_value "CLASH_IPV6" || true + unset CLASH_IPV6 2>/dev/null || true + ;; + *) + die "IPv6 模式只允许 true / false / auto" + ;; + esac +} + tun_auto_route() { config_bool_env_value "CLASH_TUN_AUTO_ROUTE" "true" } @@ -483,7 +523,7 @@ local_subscription_share_links_to_subconverter_url() { sub(/[[:space:]]+$/, "") } $0 == "" || $0 ~ /^#/ { next } - $0 ~ /^(vmess|vless|trojan|tuic):\/\// { + $0 ~ /^(vmess|vless|trojan|tuic|hysteria2|hy2|anytls):\/\// { if (out != "") { out = out "|" } @@ -577,7 +617,7 @@ local_subscription_convert_url_from_url() { fi rm -f "$decoded_file" 2>/dev/null || true - die "本地订阅不是 Clash YAML,且无法识别为 Base64 或 vmess/vless/trojan/tuic 分享链接:$local_path" + die "本地订阅不是 Clash YAML,且无法识别为 Base64 或 vmess/vless/trojan/tuic/hysteria2/hy2/anytls 分享链接:$local_path" } download_candidate_probe() { @@ -801,7 +841,8 @@ normalize_runtime_config() { local file="$1" local mixed_port controller tun_enable_value tun_stack_value dns_port_value controller_secret_value local tun_auto_route_value tun_auto_redirect_value tun_strict_route_value tun_dns_hijack_value - local dashboard_dir_value dashboard_url_value allow_lan_value + local dashboard_dir_value dashboard_url_value allow_lan_value ipv6_mode_value + local ipv6_value dns_ipv6_value local resolved_ports err_file output [ -s "$file" ] || die "待规范化的配置文件不存在:$file" @@ -822,11 +863,27 @@ normalize_runtime_config() { dashboard_dir_value="$(runtime_dashboard_dir)" dashboard_url_value="$DEFAULT_DASHBOARD_UI_URL" allow_lan_value="$(config_allow_lan 2>/dev/null || echo true)" + ipv6_mode_value="$(config_ipv6_mode)" + + case "$ipv6_mode_value" in + true|false) + ipv6_value="$ipv6_mode_value" + dns_ipv6_value="$ipv6_mode_value" + ;; + *) + ipv6_value="$("$(yq_bin)" eval '.ipv6' "$file" 2>/dev/null | head -n 1 || true)" + dns_ipv6_value="$("$(yq_bin)" eval '.dns.ipv6' "$file" 2>/dev/null | head -n 1 || true)" + case "$ipv6_value" in true|false) ;; *) ipv6_value="true" ;; esac + case "$dns_ipv6_value" in true|false) ;; *) dns_ipv6_value="false" ;; esac + ;; + esac err_file="$(mktemp)" if ! mixed_port="$mixed_port" \ controller="$controller" \ allow_lan_value="$allow_lan_value" \ + ipv6_value="$ipv6_value" \ + dns_ipv6_value="$dns_ipv6_value" \ tun_enable_value="$tun_enable_value" \ tun_stack_value="$tun_stack_value" \ tun_auto_route_value="$tun_auto_route_value" \ @@ -847,6 +904,7 @@ normalize_runtime_config() { .["allow-lan"] = (env(allow_lan_value) == "true") | .mode = "rule" | .["log-level"] = (.["log-level"] // "info") | + .ipv6 = (env(ipv6_value) == "true") | .tun.enable = (env(tun_enable_value) == "true") | .tun.stack = env(tun_stack_value) | @@ -858,7 +916,7 @@ normalize_runtime_config() { .dns.enable = (.dns.enable // true) | .dns["enhanced-mode"] = (.dns["enhanced-mode"] // "fake-ip") | - .dns.ipv6 = false | + .dns.ipv6 = (env(dns_ipv6_value) == "true") | .dns.listen = env(dns_listen_value) | .proxies = (.proxies // []) | @@ -3311,7 +3369,7 @@ subscription_yaml_has_no_nodes() { } subconverter_default_subscription_user_agent() { - echo "clash.meta/1.18.0 clash/1.18.0 subconverter/0.9.0" + subscription_user_agent } subconverter_subscription_user_agent() { @@ -4044,6 +4102,7 @@ fetch_subscription_source() { generate_config() { local active_source + local fetch_reason="${1:-auto}" local out_file="$RUNTIME_DIR/config.yaml" local tmp_dir source_file candidate_file local selected_csv="" included_csv="" failed_csv="" @@ -4073,7 +4132,7 @@ generate_config() { selected_csv="$active_source" - if ! fetch_subscription_source "$active_source" "$source_file" "auto"; then + if ! fetch_subscription_source "$active_source" "$source_file" "$fetch_reason"; then failed_csv="$active_source" if ! read_compile_error >/dev/null 2>&1; then if [ -n "${SUBCONVERTER_LAST_ERROR_SUMMARY:-}" ]; then @@ -4128,5 +4187,5 @@ generate_config() { } regenerate_config() { - generate_config + generate_config "${1:-auto}" } diff --git a/scripts/dev/check-runtime-config-normalization.sh b/scripts/dev/check-runtime-config-normalization.sh index b318505..56c3818 100755 --- a/scripts/dev/check-runtime-config-normalization.sh +++ b/scripts/dev/check-runtime-config-normalization.sh @@ -60,10 +60,14 @@ export RUNTIME_DIR="$tmp_dir/runtime" export BIN_DIR="$tmp_dir/bin" export LOG_DIR="$tmp_dir/logs" export CONFIG_DIR="$tmp_dir/config" +unset CLASH_IPV6 # shellcheck source=scripts/core/config.sh source "$PROJECT_DIR/scripts/core/config.sh" +# Keep this regression test independent from the developer's project .env. +read_env_value() { return 1; } + resolve_runtime_ports() { printf 'MIXED_PORT_RESOLVED=7891\n' printf 'EXTERNAL_CONTROLLER_RESOLVED=0.0.0.0:9090\n' @@ -90,31 +94,110 @@ mixed-port: 7890 external-controller: 0.0.0.0:9090 secret: old-secret allow-lan: false -proxies: [] +ipv6: true +dns: + ipv6: true +proxies: + - name: hy2-ipv6 + type: hysteria2 + server: "2001:db8::1" + port: 443 + password: test-password + - name: anytls + type: anytls + server: 192.0.2.1 + port: 443 + password: test-password + sni: example.com proxy-groups: [] rules: [] YAML normalize_runtime_config "$sample_config" -assert_yq() { +assert_file_yq() { local name="$1" - local expr="$2" - local expected="$3" + local file="$2" + local expr="$3" + local expected="$4" local actual - actual="$("$BIN_DIR/yq" eval "$expr" "$sample_config")" + actual="$("$BIN_DIR/yq" eval "$expr" "$file")" if [ "$actual" != "$expected" ]; then echo "not ok - $name: got '$actual', expected '$expected'" >&2 - "$BIN_DIR/yq" eval '.' "$sample_config" >&2 || true + "$BIN_DIR/yq" eval '.' "$file" >&2 || true return 1 fi echo "ok - $name" } +assert_yq() { + assert_file_yq "$1" "$sample_config" "$2" "$3" +} + assert_yq "keeps resolved mixed-port" '.["mixed-port"]' "7891" assert_yq "removes legacy port" 'has("port")' "false" assert_yq "removes legacy socks-port" 'has("socks-port")' "false" assert_yq "removes legacy redir-port" 'has("redir-port")' "false" assert_yq "removes legacy tproxy-port" 'has("tproxy-port")' "false" assert_yq "keeps controller normalization" '.["external-controller"]' "0.0.0.0:9090" +assert_yq "preserves subscription IPv6" '.ipv6' "true" +assert_yq "preserves subscription DNS IPv6" '.dns.ipv6' "true" +assert_yq "preserves Hysteria2 proxy type" '.proxies[0].type' "hysteria2" +assert_yq "preserves IPv6-only proxy server" '.proxies[0].server' "2001:db8::1" +assert_yq "preserves AnyTLS proxy type" '.proxies[1].type' "anytls" +assert_yq "preserves AnyTLS SNI" '.proxies[1].sni' "example.com" + +CLASH_IPV6=false normalize_runtime_config "$sample_config" +assert_yq "IPv6 off disables kernel IPv6" '.ipv6' "false" +assert_yq "IPv6 off disables DNS IPv6" '.dns.ipv6' "false" + +unset CLASH_IPV6 +normalize_runtime_config "$sample_config" +assert_yq "auto preserves disabled kernel IPv6" '.ipv6' "false" +assert_yq "auto preserves disabled DNS IPv6" '.dns.ipv6' "false" + +CLASH_IPV6=true normalize_runtime_config "$sample_config" +assert_yq "IPv6 on enables kernel IPv6" '.ipv6' "true" +assert_yq "IPv6 on enables DNS IPv6" '.dns.ipv6' "true" +unset CLASH_IPV6 + +default_config="$tmp_dir/default-ipv6-config.yaml" +cat > "$default_config" <<'YAML' +proxies: [] +proxy-groups: [] +rules: [] +YAML + +normalize_runtime_config "$default_config" +assert_file_yq "auto keeps kernel IPv6 default enabled" "$default_config" '.ipv6' "true" +assert_file_yq "auto keeps legacy DNS IPv6 default disabled" "$default_config" '.dns.ipv6' "false" + +default_subscription_ua="$( + unset CLASH_SUBSCRIPTION_UA CLASH_SUB_UA + subconverter_subscription_user_agent +)" +if [ "$default_subscription_ua" != "clash-verge/v2.4.0" ]; then + echo "not ok - modern protocol subscription UA: got '$default_subscription_ua'" >&2 + exit 1 +fi +echo "ok - modern protocol subscription UA" + +share_links="$tmp_dir/modern-share-links.txt" +cat > "$share_links" <<'EOF' +vmess://legacy +hysteria2://password@example.com:443 +hy2://password@example.com:443 +anytls://password@example.com:443 +EOF + +converted_links="$(local_subscription_share_links_to_subconverter_url "$share_links")" +case "$converted_links" in + *"hysteria2://"*"hy2://"*"anytls://"*) + echo "ok - recognizes Hysteria2, hy2 and AnyTLS share links" + ;; + *) + echo "not ok - modern share links were dropped: $converted_links" >&2 + exit 1 + ;; +esac diff --git a/scripts/dev/check-sub-update-compat.sh b/scripts/dev/check-sub-update-compat.sh index 5dee5ff..7e9bf4d 100644 --- a/scripts/dev/check-sub-update-compat.sh +++ b/scripts/dev/check-sub-update-compat.sh @@ -19,7 +19,7 @@ calls_file="$tmp_dir/sub-update-calls" output_file="$tmp_dir/sub-update-output" prepare() { :; } -regenerate_config() { printf 'regenerate\n' >> "$calls_file"; } +regenerate_config() { printf 'regenerate:%s\n' "${1:-}" >> "$calls_file"; } apply_runtime_change_after_config_mutation() { printf 'apply\n' >> "$calls_file"; } print_config_regen_feedback() { printf 'regen-feedback\n' >> "$calls_file"; } print_config_apply_feedback() { printf 'apply-feedback\n' >> "$calls_file"; } @@ -31,7 +31,7 @@ if ! ( cmd_sub update ) > "$output_file" 2>&1; then exit 1 fi -expected_calls="$(printf '%s\n' regenerate apply regen-feedback apply-feedback)" +expected_calls="$(printf '%s\n' regenerate:manual-refresh apply regen-feedback apply-feedback)" actual_calls="$(cat "$calls_file")" if [ "$actual_calls" != "$expected_calls" ]; then echo "not ok - clashctl sub update should follow the config regen path" >&2 @@ -40,6 +40,7 @@ if [ "$actual_calls" != "$expected_calls" ]; then fi echo "ok - clashctl sub update follows the config regen path" +echo "ok - clashctl sub update bypasses stale subscription cache" # Verify both the sourced-shell compatibility function and the installed command wrapper. # shellcheck source=../core/common.sh