diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d671e3e9..3bed63bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,6 +39,9 @@ jobs: cd build ctest --output-on-failure + - name: ss-setup TUI tests + run: bash tests/test_ss_setup.sh + - name: Stress test run: | python3 tests/stress_test.py --bin build/shared/bin/ --size 10 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ca505f4..6a04623c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -206,3 +206,6 @@ endif() if(LINUX) install(PROGRAMS src/ss-nat DESTINATION ${CMAKE_INSTALL_BINDIR}) endif() + +# Install ss-setup TUI tool +install(PROGRAMS scripts/ss-setup.sh DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME ss-setup) diff --git a/scripts/ss-setup.sh b/scripts/ss-setup.sh new file mode 100755 index 00000000..72fdaf41 --- /dev/null +++ b/scripts/ss-setup.sh @@ -0,0 +1,1367 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################### +# ss-setup -- Interactive TUI for shadowsocks-libev server/client setup +# +# Requires: whiptail or dialog, openssl (optional), curl (for plugin install) +# Usage: sudo ss-setup (full server setup + service management) +# ss-setup (config generation only, no service install) +############################################################################### + +readonly SS_SETUP_VERSION="1.0.0" +readonly CONFIG_DIR="/etc/shadowsocks-libev" +readonly SYSTEMD_UNIT_DIR="/etc/systemd/system" +readonly SYSTEMD_TEMPLATE="shadowsocks-libev-server@.service" + +# AEAD ciphers supported by shadowsocks-libev (from src/aead.c) +readonly AEAD_CIPHERS=( + "chacha20-ietf-poly1305" + "aes-256-gcm" + "aes-192-gcm" + "aes-128-gcm" + "xchacha20-ietf-poly1305" +) + +# Known SIP003 plugins and their GitHub repos +# Using a function instead of associative array for bash 3.x compatibility +plugin_repo() { + case "$1" in + simple-obfs) echo "shadowsocks/simple-obfs" ;; + v2ray-plugin) echo "shadowsocks/v2ray-plugin" ;; + xray-plugin) echo "teddysun/xray-plugin" ;; + kcptun) echo "xtaci/kcptun" ;; + *) echo "" ;; + esac +} + +readonly KNOWN_PLUGINS=("simple-obfs" "v2ray-plugin" "xray-plugin" "kcptun") + +# Globals set by the setup flows +TUI_BACKEND="" +TMPDIR_CLEANUP="" + +# Server config globals +CFG_SERVER="0.0.0.0" +CFG_SERVER_PORT="8388" +CFG_METHOD="chacha20-ietf-poly1305" +CFG_PASSWORD="" +CFG_TIMEOUT="300" +CFG_MODE="tcp_and_udp" +CFG_FAST_OPEN="false" +CFG_PLUGIN="" +CFG_PLUGIN_OPTS="" +CFG_INSTANCE_NAME="config" + +# Client config globals +CFG_CLIENT_SERVER="" +CFG_CLIENT_SERVER_PORT="8388" +CFG_CLIENT_LOCAL_PORT="1080" +CFG_CLIENT_METHOD="chacha20-ietf-poly1305" +CFG_CLIENT_PASSWORD="" +CFG_CLIENT_PLUGIN="" +CFG_CLIENT_PLUGIN_OPTS="" +CFG_CLIENT_OUTPUT="" + +############################################################################### +# Cleanup +############################################################################### + +cleanup() { + if [[ -n "${TMPDIR_CLEANUP}" && -d "${TMPDIR_CLEANUP}" ]]; then + rm -rf "${TMPDIR_CLEANUP}" + fi +} +# Only set trap when executed directly (not when sourced for testing) +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + trap cleanup EXIT +fi + +############################################################################### +# TUI Backend Detection & Wrappers +############################################################################### + +detect_tui_backend() { + if command -v whiptail >/dev/null 2>&1; then + TUI_BACKEND="whiptail" + elif command -v dialog >/dev/null 2>&1; then + TUI_BACKEND="dialog" + else + echo "Error: neither whiptail nor dialog found." >&2 + echo "Install one of them:" >&2 + echo " Debian/Ubuntu: sudo apt install whiptail" >&2 + echo " RHEL/Fedora: sudo dnf install newt" >&2 + echo " Arch: sudo pacman -S libnewt" >&2 + exit 1 + fi +} + +# tui_msgbox title message +tui_msgbox() { + local title="$1" msg="$2" + $TUI_BACKEND --title "$title" --msgbox "$msg" 20 70 +} + +# tui_yesno title message → exit code 0=yes 1=no +tui_yesno() { + local title="$1" msg="$2" + if $TUI_BACKEND --title "$title" --yesno "$msg" 12 70; then + return 0 + else + return 1 + fi +} + +# tui_inputbox title message default → prints value +tui_inputbox() { + local title="$1" msg="$2" default="${3:-}" + local result + result=$($TUI_BACKEND --title "$title" --inputbox "$msg" 10 70 "$default" 3>&1 1>&2 2>&3) || return $? + echo "$result" +} + +# tui_passwordbox title message → prints value +tui_passwordbox() { + local title="$1" msg="$2" + local result + result=$($TUI_BACKEND --title "$title" --passwordbox "$msg" 10 70 3>&1 1>&2 2>&3) || return $? + echo "$result" +} + +# tui_menu title message tag1 item1 tag2 item2 ... → prints selected tag +tui_menu() { + local title="$1" msg="$2" + shift 2 + local items=("$@") + local count=$(( ${#items[@]} / 2 )) + local result + result=$($TUI_BACKEND --title "$title" --menu "$msg" 20 70 "$count" "${items[@]}" 3>&1 1>&2 2>&3) || return $? + echo "$result" +} + +# tui_radiolist title message tag1 item1 status1 ... → prints selected tag +tui_radiolist() { + local title="$1" msg="$2" + shift 2 + local items=("$@") + local count=$(( ${#items[@]} / 3 )) + local result + result=$($TUI_BACKEND --title "$title" --radiolist "$msg" 20 70 "$count" "${items[@]}" 3>&1 1>&2 2>&3) || return $? + echo "$result" +} + +############################################################################### +# Utility Functions +############################################################################### + +check_root() { + if [[ $EUID -ne 0 ]]; then + tui_msgbox "Notice" "Not running as root.\n\nYou can still generate config files, but service installation and plugin management will be skipped.\n\nRe-run with sudo for full functionality." + return 1 + fi + return 0 +} + +is_root() { + [[ $EUID -eq 0 ]] +} + +validate_port() { + local port="$1" + if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then + return 0 + fi + return 1 +} + +validate_instance_name() { + local name="$1" + if [[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then + return 0 + fi + return 1 +} + +generate_password() { + local len="${1:-32}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 "$len" | tr -d '\n' + else + head -c "$len" /dev/urandom | base64 | tr -d '\n' + fi +} + +generate_random_port() { + local port + if [[ -r /dev/urandom ]]; then + port=$(( ($(od -An -tu2 -N2 /dev/urandom | tr -d ' ') % 55001) + 10000 )) + else + port=$(( (RANDOM % 55001) + 10000 )) + fi + echo "$port" +} + +json_escape() { + local str="$1" + str="${str//\\/\\\\}" + str="${str//\"/\\\"}" + str="${str//$'\n'/\\n}" + str="${str//$'\r'/\\r}" + str="${str//$'\t'/\\t}" + echo -n "$str" +} + +urlencode() { + local str="$1" + local encoded="" + local i c + for (( i = 0; i < ${#str}; i++ )); do + c="${str:$i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) encoded+="$c" ;; + *) encoded+=$(printf '%%%02X' "'$c") ;; + esac + done + echo -n "$encoded" +} + +# Generate SIP002 ss:// URI +# ss://BASE64URL(method:password)@host:port[/?plugin=URLENCODE(plugin;opts)] +generate_ss_uri() { + local method="$1" password="$2" server="$3" port="$4" + local plugin="${5:-}" plugin_opts="${6:-}" + + local userinfo + userinfo=$(echo -n "${method}:${password}" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') + + local uri="ss://${userinfo}@${server}:${port}" + + if [[ -n "$plugin" ]]; then + local plugin_str="$plugin" + if [[ -n "$plugin_opts" ]]; then + plugin_str="${plugin};${plugin_opts}" + fi + uri="${uri}/?plugin=$(urlencode "$plugin_str")" + fi + + echo "$uri" +} + +# Write JSON config from CFG_* globals (server mode) +# Produces valid JSON with no trailing commas; booleans unquoted +write_json_config() { + local outfile="$1" + local fast_open_val="false" + [[ "$CFG_FAST_OPEN" == "true" ]] && fast_open_val="true" + + { + printf '{\n' + printf ' "server": "%s",\n' "$(json_escape "$CFG_SERVER")" + printf ' "server_port": %s,\n' "$CFG_SERVER_PORT" + printf ' "password": "%s",\n' "$(json_escape "$CFG_PASSWORD")" + printf ' "timeout": %s,\n' "$CFG_TIMEOUT" + printf ' "method": "%s",\n' "$(json_escape "$CFG_METHOD")" + printf ' "mode": "%s",\n' "$(json_escape "$CFG_MODE")" + if [[ -n "$CFG_PLUGIN" ]]; then + printf ' "fast_open": %s,\n' "$fast_open_val" + printf ' "plugin": "%s"' "$(json_escape "$CFG_PLUGIN")" + if [[ -n "$CFG_PLUGIN_OPTS" ]]; then + printf ',\n' + printf ' "plugin_opts": "%s"' "$(json_escape "$CFG_PLUGIN_OPTS")" + fi + else + printf ' "fast_open": %s' "$fast_open_val" + fi + printf '\n}\n' + } > "$outfile" +} + +# Write JSON config for client mode +write_client_json_config() { + local outfile="$1" + + { + printf '{\n' + printf ' "server": "%s",\n' "$(json_escape "$CFG_CLIENT_SERVER")" + printf ' "server_port": %s,\n' "$CFG_CLIENT_SERVER_PORT" + printf ' "local_address": "127.0.0.1",\n' + printf ' "local_port": %s,\n' "$CFG_CLIENT_LOCAL_PORT" + printf ' "password": "%s",\n' "$(json_escape "$CFG_CLIENT_PASSWORD")" + printf ' "timeout": 300,\n' + printf ' "method": "%s",\n' "$(json_escape "$CFG_CLIENT_METHOD")" + if [[ -n "$CFG_CLIENT_PLUGIN" ]]; then + printf ' "mode": "tcp_and_udp",\n' + printf ' "plugin": "%s"' "$(json_escape "$CFG_CLIENT_PLUGIN")" + if [[ -n "$CFG_CLIENT_PLUGIN_OPTS" ]]; then + printf ',\n' + printf ' "plugin_opts": "%s"' "$(json_escape "$CFG_CLIENT_PLUGIN_OPTS")" + fi + else + printf ' "mode": "tcp_and_udp"' + fi + printf '\n}\n' + } > "$outfile" +} + +# Parse an existing config file and populate CFG_* globals +parse_existing_config() { + local file="$1" + local val + + val=$(grep -o '"server"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_SERVER="$val" + val=$(grep -o '"server_port"[[:space:]]*:[[:space:]]*[0-9]*' "$file" | head -1 | sed 's/.*:[[:space:]]*//') && [[ -n "$val" ]] && CFG_SERVER_PORT="$val" + val=$(grep -o '"password"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_PASSWORD="$val" + val=$(grep -o '"method"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_METHOD="$val" + val=$(grep -o '"timeout"[[:space:]]*:[[:space:]]*[0-9]*' "$file" | head -1 | sed 's/.*:[[:space:]]*//') && [[ -n "$val" ]] && CFG_TIMEOUT="$val" + val=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_MODE="$val" + val=$(grep -o '"fast_open"[[:space:]]*:[[:space:]]*[a-z]*' "$file" | head -1 | sed 's/.*:[[:space:]]*//') && [[ -n "$val" ]] && CFG_FAST_OPEN="$val" + val=$(grep -o '"plugin"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_PLUGIN="$val" + val=$(grep -o '"plugin_opts"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -1 | sed 's/.*:.*"\(.*\)"/\1/') && [[ -n "$val" ]] && CFG_PLUGIN_OPTS="$val" + + return 0 +} + +detect_arch() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7*|armhf) echo "armv7" ;; + i686|i386) echo "386" ;; + *) echo "$arch" ;; + esac +} + +detect_os() { + local os + os=$(uname -s | tr '[:upper:]' '[:lower:]') + case "$os" in + linux*) echo "linux" ;; + darwin*) echo "darwin" ;; + *) echo "$os" ;; + esac +} + +detect_distro() { + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + echo "${ID:-unknown}" + elif command -v lsb_release >/dev/null 2>&1; then + lsb_release -si | tr '[:upper:]' '[:lower:]' + else + echo "unknown" + fi +} + +has_systemd() { + command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]] +} + +systemd_version() { + local ver + ver=$(systemctl --version 2>/dev/null | head -1 | grep -oE '[0-9]+' | head -1) || ver="0" + echo "$ver" +} + +check_port_in_use() { + local port="$1" + if command -v ss >/dev/null 2>&1; then + ss -tlnp 2>/dev/null | grep -q ":${port} " && return 0 + elif command -v netstat >/dev/null 2>&1; then + netstat -tlnp 2>/dev/null | grep -q ":${port} " && return 0 + fi + return 1 +} + +find_installed_plugins() { + local plugins=() + local p + for p in "${KNOWN_PLUGINS[@]}"; do + if command -v "$p" >/dev/null 2>&1; then + plugins+=("$p") + fi + done + # Also check obfs-local (simple-obfs installs as obfs-local/obfs-server) + if command -v obfs-local >/dev/null 2>&1; then + local found=0 + for p in "${plugins[@]}"; do + [[ "$p" == "simple-obfs" ]] && found=1 + done + [[ $found -eq 0 ]] && plugins+=("obfs-local") + fi + echo "${plugins[*]}" +} + +############################################################################### +# Server Setup Flow +############################################################################### + +server_ask_listen_address() { + local addr + while true; do + addr=$(tui_inputbox "Listen Address" "Enter the server listen address:" "$CFG_SERVER") || return 1 + if [[ -n "$addr" ]]; then + CFG_SERVER="$addr" + return 0 + fi + tui_msgbox "Error" "Address cannot be empty." + done +} + +server_ask_port() { + local choice + choice=$(tui_menu "Server Port" "Choose how to set the server port:" \ + "manual" "Enter port manually (default: ${CFG_SERVER_PORT})" \ + "random" "Generate a random high port") || return 1 + + if [[ "$choice" == "random" ]]; then + CFG_SERVER_PORT=$(generate_random_port) + tui_msgbox "Random Port" "Selected port: ${CFG_SERVER_PORT}" + return 0 + fi + + local port + while true; do + port=$(tui_inputbox "Server Port" "Enter the server port (1-65535):" "$CFG_SERVER_PORT") || return 1 + if validate_port "$port"; then + if check_port_in_use "$port"; then + if ! tui_yesno "Port In Use" "Port ${port} appears to be in use.\n\nContinue anyway?"; then + continue + fi + fi + CFG_SERVER_PORT="$port" + return 0 + fi + tui_msgbox "Error" "Invalid port number. Must be 1-65535." + done +} + +server_ask_method() { + local items=() + local cipher + for cipher in "${AEAD_CIPHERS[@]}"; do + if [[ "$cipher" == "$CFG_METHOD" ]]; then + items+=("$cipher" "" "ON") + else + items+=("$cipher" "" "OFF") + fi + done + + local method + method=$(tui_radiolist "Encryption Method" "Select the AEAD cipher to use:" "${items[@]}") || return 1 + CFG_METHOD="$method" +} + +server_ask_password() { + if tui_yesno "Password" "Auto-generate a secure random password?\n\n(Select No to enter manually)"; then + CFG_PASSWORD=$(generate_password 32) + tui_msgbox "Generated Password" "Password (save this!):\n\n${CFG_PASSWORD}" + return 0 + fi + + local pass1 pass2 + while true; do + pass1=$(tui_passwordbox "Password" "Enter the password:") || return 1 + if [[ -z "$pass1" ]]; then + tui_msgbox "Error" "Password cannot be empty." + continue + fi + pass2=$(tui_passwordbox "Confirm Password" "Confirm the password:") || return 1 + if [[ "$pass1" == "$pass2" ]]; then + CFG_PASSWORD="$pass1" + return 0 + fi + tui_msgbox "Error" "Passwords do not match. Try again." + done +} + +server_ask_timeout() { + local timeout + while true; do + timeout=$(tui_inputbox "Timeout" "Connection timeout in seconds:" "$CFG_TIMEOUT") || return 1 + if [[ "$timeout" =~ ^[0-9]+$ ]] && (( timeout > 0 )); then + CFG_TIMEOUT="$timeout" + return 0 + fi + tui_msgbox "Error" "Timeout must be a positive integer." + done +} + +server_ask_mode() { + local items=() + local modes=("tcp_only" "tcp_and_udp" "udp_only") + local m + for m in "${modes[@]}"; do + if [[ "$m" == "$CFG_MODE" ]]; then + items+=("$m" "" "ON") + else + items+=("$m" "" "OFF") + fi + done + + local mode + mode=$(tui_radiolist "Network Mode" "Select the network mode:" "${items[@]}") || return 1 + CFG_MODE="$mode" +} + +server_ask_fast_open() { + # Check if TCP Fast Open is available (Linux only) + if [[ -f /proc/sys/net/ipv4/tcp_fastopen ]]; then + local tfo_val + tfo_val=$(cat /proc/sys/net/ipv4/tcp_fastopen 2>/dev/null) || tfo_val="0" + if (( tfo_val >= 2 )); then + CFG_FAST_OPEN="true" + fi + if tui_yesno "TCP Fast Open" "Enable TCP Fast Open?\n\n(Current kernel setting: ${tfo_val})\nRequires kernel support and sysctl net.ipv4.tcp_fastopen >= 2 for server."; then + CFG_FAST_OPEN="true" + else + CFG_FAST_OPEN="false" + fi + else + CFG_FAST_OPEN="false" + tui_msgbox "TCP Fast Open" "TCP Fast Open is not available on this system.\nSetting to disabled." + fi +} + +server_ask_plugin() { + local installed + installed=$(find_installed_plugins) + + local items=("none" "No plugin") + local p + for p in $installed; do + items+=("$p" "$(command -v "$p" 2>/dev/null || echo "$p")") + done + items+=("custom" "Enter custom plugin path") + + local choice + choice=$(tui_menu "SIP003 Plugin" "Select a plugin (optional):" "${items[@]}") || return 1 + + if [[ "$choice" == "none" ]]; then + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + return 0 + fi + + if [[ "$choice" == "custom" ]]; then + local plugin_path + plugin_path=$(tui_inputbox "Custom Plugin" "Enter the plugin binary name or full path:" "") || return 1 + if [[ -z "$plugin_path" ]]; then + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + return 0 + fi + CFG_PLUGIN="$plugin_path" + else + CFG_PLUGIN="$choice" + fi + + local opts + opts=$(tui_inputbox "Plugin Options" "Enter plugin options (or leave empty):" "$CFG_PLUGIN_OPTS") || return 1 + CFG_PLUGIN_OPTS="$opts" +} + +server_ask_instance_name() { + local name + while true; do + name=$(tui_inputbox "Instance Name" "Enter a name for this config instance.\nThe config will be saved as ${CONFIG_DIR}/.json" "$CFG_INSTANCE_NAME") || return 1 + if ! validate_instance_name "$name"; then + tui_msgbox "Error" "Invalid name. Use only letters, numbers, hyphens, and underscores." + continue + fi + if [[ -f "${CONFIG_DIR}/${name}.json" ]]; then + local action + action=$(tui_menu "Config Exists" "Config '${name}.json' already exists:" \ + "overwrite" "Overwrite the existing config" \ + "edit" "Load and edit the existing config" \ + "rename" "Choose a different name") || return 1 + case "$action" in + overwrite) + CFG_INSTANCE_NAME="$name" + return 0 + ;; + edit) + parse_existing_config "${CONFIG_DIR}/${name}.json" + CFG_INSTANCE_NAME="$name" + return 2 # signal to restart setup with loaded values + ;; + rename) + continue + ;; + esac + else + CFG_INSTANCE_NAME="$name" + return 0 + fi + done +} + +server_generate_config() { + local config_file="${CONFIG_DIR}/${CFG_INSTANCE_NAME}.json" + + if is_root; then + mkdir -p "$CONFIG_DIR" + write_json_config "$config_file" + chmod 640 "$config_file" + else + # Non-root: write to current directory + config_file="${CFG_INSTANCE_NAME}.json" + write_json_config "$config_file" + fi + + echo "$config_file" +} + +server_install_systemd_template() { + if ! has_systemd; then + return 1 + fi + + local template_path="${SYSTEMD_UNIT_DIR}/${SYSTEMD_TEMPLATE}" + + # Only install if missing + if [[ -f "$template_path" ]]; then + return 0 + fi + + local ss_server_path + ss_server_path=$(command -v ss-server 2>/dev/null) || ss_server_path="/usr/local/bin/ss-server" + + local sd_ver + sd_ver=$(systemd_version) + + if (( sd_ver >= 232 )); then + cat > "$template_path" < "$template_path" </dev/null + systemctl restart "$unit" 2>/dev/null + + if systemctl is-active --quiet "$unit"; then + return 0 + else + return 1 + fi +} + +server_show_summary() { + local config_file="$1" + local uri + uri=$(generate_ss_uri "$CFG_METHOD" "$CFG_PASSWORD" "$CFG_SERVER" "$CFG_SERVER_PORT" "$CFG_PLUGIN" "$CFG_PLUGIN_OPTS") + + local service_status="(not installed)" + if is_root && has_systemd; then + local unit="shadowsocks-libev-server@${CFG_INSTANCE_NAME}.service" + if systemctl is-active --quiet "$unit" 2>/dev/null; then + service_status="active (running)" + else + service_status="inactive or failed" + fi + fi + + local summary="" + summary+="Config file: ${config_file}\n" + summary+="\n" + summary+="Server: ${CFG_SERVER}:${CFG_SERVER_PORT}\n" + summary+="Method: ${CFG_METHOD}\n" + summary+="Password: ${CFG_PASSWORD}\n" + summary+="Mode: ${CFG_MODE}\n" + summary+="Fast Open: ${CFG_FAST_OPEN}\n" + if [[ -n "$CFG_PLUGIN" ]]; then + summary+="Plugin: ${CFG_PLUGIN}\n" + [[ -n "$CFG_PLUGIN_OPTS" ]] && summary+="Opts: ${CFG_PLUGIN_OPTS}\n" + fi + summary+="Service: ${service_status}\n" + summary+="\n" + summary+="ss:// URI (for client import):\n" + summary+="${uri}\n" + + tui_msgbox "Server Setup Complete" "$summary" +} + +server_setup() { + while true; do + server_ask_instance_name + local rc=$? + if [[ $rc -eq 1 ]]; then + return # user cancelled + fi + # rc=2 means config was loaded for editing, restart the flow + # rc=0 means proceed + + server_ask_listen_address || return + server_ask_port || return + server_ask_method || return + server_ask_password || return + server_ask_timeout || return + server_ask_mode || return + server_ask_fast_open + server_ask_plugin || return + + # Confirm before writing + local confirm_msg="" + confirm_msg+="Server: ${CFG_SERVER}:${CFG_SERVER_PORT}\n" + confirm_msg+="Method: ${CFG_METHOD}\n" + confirm_msg+="Mode: ${CFG_MODE}\n" + confirm_msg+="Fast Open: ${CFG_FAST_OPEN}\n" + confirm_msg+="Instance: ${CFG_INSTANCE_NAME}\n" + [[ -n "$CFG_PLUGIN" ]] && confirm_msg+="Plugin: ${CFG_PLUGIN}\n" + + if ! tui_yesno "Confirm" "Review your settings:\n\n${confirm_msg}\nProceed?"; then + if tui_yesno "Restart" "Start over with new settings?"; then + continue + fi + return + fi + + break + done + + # Generate config + local config_file + config_file=$(server_generate_config) + + # Install systemd service if root + if is_root && has_systemd; then + if tui_yesno "Systemd Service" "Install and start a systemd service for this instance?"; then + if server_install_service "$CFG_INSTANCE_NAME"; then + tui_msgbox "Service Started" "Service shadowsocks-libev-server@${CFG_INSTANCE_NAME} is now running." + else + tui_msgbox "Service Error" "Service failed to start.\n\nCheck: journalctl -u shadowsocks-libev-server@${CFG_INSTANCE_NAME} -n 20" + fi + fi + fi + + # Show summary + server_show_summary "$config_file" +} + +############################################################################### +# Client Config Flow +############################################################################### + +client_ask_server_address() { + local addr + while true; do + addr=$(tui_inputbox "Server Address" "Enter the remote ss-server address (IP or hostname):" "$CFG_CLIENT_SERVER") || return 1 + if [[ -n "$addr" ]]; then + CFG_CLIENT_SERVER="$addr" + return 0 + fi + tui_msgbox "Error" "Server address cannot be empty." + done +} + +client_ask_server_port() { + local port + while true; do + port=$(tui_inputbox "Server Port" "Enter the remote ss-server port:" "$CFG_CLIENT_SERVER_PORT") || return 1 + if validate_port "$port"; then + CFG_CLIENT_SERVER_PORT="$port" + return 0 + fi + tui_msgbox "Error" "Invalid port number. Must be 1-65535." + done +} + +client_ask_local_port() { + local port + while true; do + port=$(tui_inputbox "Local Port" "Enter the local SOCKS5 listen port:" "$CFG_CLIENT_LOCAL_PORT") || return 1 + if validate_port "$port"; then + CFG_CLIENT_LOCAL_PORT="$port" + return 0 + fi + tui_msgbox "Error" "Invalid port number. Must be 1-65535." + done +} + +client_ask_method() { + local items=() + local cipher + for cipher in "${AEAD_CIPHERS[@]}"; do + if [[ "$cipher" == "$CFG_CLIENT_METHOD" ]]; then + items+=("$cipher" "" "ON") + else + items+=("$cipher" "" "OFF") + fi + done + + local method + method=$(tui_radiolist "Encryption Method" "Select the cipher (must match server):" "${items[@]}") || return 1 + CFG_CLIENT_METHOD="$method" +} + +client_ask_password() { + local pass + while true; do + pass=$(tui_passwordbox "Password" "Enter the password (must match server):") || return 1 + if [[ -n "$pass" ]]; then + CFG_CLIENT_PASSWORD="$pass" + return 0 + fi + tui_msgbox "Error" "Password cannot be empty." + done +} + +client_ask_plugin() { + local installed + installed=$(find_installed_plugins) + + local items=("none" "No plugin") + local p + for p in $installed; do + items+=("$p" "$(command -v "$p" 2>/dev/null || echo "$p")") + done + items+=("custom" "Enter custom plugin path") + + local choice + choice=$(tui_menu "SIP003 Plugin" "Select a plugin (must match server):" "${items[@]}") || return 1 + + if [[ "$choice" == "none" ]]; then + CFG_CLIENT_PLUGIN="" + CFG_CLIENT_PLUGIN_OPTS="" + return 0 + fi + + if [[ "$choice" == "custom" ]]; then + local plugin_path + plugin_path=$(tui_inputbox "Custom Plugin" "Enter the plugin binary name or full path:" "") || return 1 + if [[ -z "$plugin_path" ]]; then + CFG_CLIENT_PLUGIN="" + CFG_CLIENT_PLUGIN_OPTS="" + return 0 + fi + CFG_CLIENT_PLUGIN="$plugin_path" + else + CFG_CLIENT_PLUGIN="$choice" + fi + + local opts + opts=$(tui_inputbox "Plugin Options" "Enter plugin options (or leave empty):" "$CFG_CLIENT_PLUGIN_OPTS") || return 1 + CFG_CLIENT_PLUGIN_OPTS="$opts" +} + +client_ask_output_path() { + local default_path + if is_root; then + default_path="${CONFIG_DIR}/client.json" + else + default_path="${HOME}/ss-client.json" + fi + [[ -n "$CFG_CLIENT_OUTPUT" ]] && default_path="$CFG_CLIENT_OUTPUT" + + local path + path=$(tui_inputbox "Output Path" "Where to save the client config:" "$default_path") || return 1 + CFG_CLIENT_OUTPUT="$path" +} + +client_generate_config() { + local outdir + outdir=$(dirname "$CFG_CLIENT_OUTPUT") + if [[ ! -d "$outdir" ]]; then + mkdir -p "$outdir" 2>/dev/null || { + tui_msgbox "Error" "Cannot create directory: ${outdir}" + return 1 + } + fi + write_client_json_config "$CFG_CLIENT_OUTPUT" + chmod 640 "$CFG_CLIENT_OUTPUT" 2>/dev/null || true +} + +client_show_summary() { + local uri + uri=$(generate_ss_uri "$CFG_CLIENT_METHOD" "$CFG_CLIENT_PASSWORD" "$CFG_CLIENT_SERVER" "$CFG_CLIENT_SERVER_PORT" "$CFG_CLIENT_PLUGIN" "$CFG_CLIENT_PLUGIN_OPTS") + + local summary="" + summary+="Config file: ${CFG_CLIENT_OUTPUT}\n" + summary+="\n" + summary+="Server: ${CFG_CLIENT_SERVER}:${CFG_CLIENT_SERVER_PORT}\n" + summary+="Local: 127.0.0.1:${CFG_CLIENT_LOCAL_PORT}\n" + summary+="Method: ${CFG_CLIENT_METHOD}\n" + if [[ -n "$CFG_CLIENT_PLUGIN" ]]; then + summary+="Plugin: ${CFG_CLIENT_PLUGIN}\n" + [[ -n "$CFG_CLIENT_PLUGIN_OPTS" ]] && summary+="Opts: ${CFG_CLIENT_PLUGIN_OPTS}\n" + fi + summary+="\n" + summary+="ss:// URI:\n${uri}\n" + summary+="\n" + summary+="Usage:\n ss-local -c ${CFG_CLIENT_OUTPUT}\n" + + tui_msgbox "Client Config Complete" "$summary" +} + +client_setup() { + client_ask_server_address || return + client_ask_server_port || return + client_ask_local_port || return + client_ask_method || return + client_ask_password || return + client_ask_plugin || return + client_ask_output_path || return + + # Confirm + local confirm_msg="" + confirm_msg+="Server: ${CFG_CLIENT_SERVER}:${CFG_CLIENT_SERVER_PORT}\n" + confirm_msg+="Local: 127.0.0.1:${CFG_CLIENT_LOCAL_PORT}\n" + confirm_msg+="Method: ${CFG_CLIENT_METHOD}\n" + confirm_msg+="Output: ${CFG_CLIENT_OUTPUT}\n" + [[ -n "$CFG_CLIENT_PLUGIN" ]] && confirm_msg+="Plugin: ${CFG_CLIENT_PLUGIN}\n" + + if ! tui_yesno "Confirm" "Review your settings:\n\n${confirm_msg}\nProceed?"; then + return + fi + + client_generate_config || return + client_show_summary +} + +############################################################################### +# Plugin Installation Flow +############################################################################### + +plugin_download_github_release() { + local repo="$1" binary_name="$2" + local os arch api_url assets_json download_url asset_name + + os=$(detect_os) + arch=$(detect_arch) + + if ! command -v curl >/dev/null 2>&1; then + tui_msgbox "Error" "curl is required for downloading plugins." + return 1 + fi + + api_url="https://api.github.com/repos/${repo}/releases/latest" + + tui_msgbox "Downloading" "Fetching latest release info from:\n${repo}\n\nPlease wait..." + + assets_json=$(curl -sS --connect-timeout 15 --max-time 30 "$api_url") || { + tui_msgbox "Error" "Failed to fetch release info from GitHub.\n\nCheck your network connection." + return 1 + } + + # Find matching asset URL -- look for os and arch in filename + download_url="" + # Try common naming patterns + local patterns=() + patterns+=("${os}-${arch}") + patterns+=("${os}_${arch}") + # Map arch names for different projects + case "$arch" in + amd64) patterns+=("${os}-amd64" "${os}_amd64" "${os}-x86_64" "${os}_x86_64" "linux-64") ;; + arm64) patterns+=("${os}-arm64" "${os}_arm64" "${os}-aarch64" "${os}_aarch64") ;; + esac + + local p + for p in "${patterns[@]}"; do + download_url=$(echo "$assets_json" | grep -o '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*'"$p"'[^"]*"' | head -1 | sed 's/.*"\(http[^"]*\)".*/\1/') + [[ -n "$download_url" ]] && break + done + + if [[ -z "$download_url" ]]; then + tui_msgbox "Error" "Could not find a matching release for ${os}-${arch}.\n\nYou may need to build from source or download manually." + return 1 + fi + + asset_name=$(basename "$download_url") + + TMPDIR_CLEANUP=$(mktemp -d) + local tmpdir="$TMPDIR_CLEANUP" + + tui_msgbox "Downloading" "Downloading:\n${asset_name}\n\nThis may take a moment..." + + if ! curl -sSL --connect-timeout 15 --max-time 120 -o "${tmpdir}/${asset_name}" "$download_url"; then + tui_msgbox "Error" "Download failed." + return 1 + fi + + # Extract based on file type + local extract_dir="${tmpdir}/extract" + mkdir -p "$extract_dir" + + case "$asset_name" in + *.tar.gz|*.tgz) + tar xzf "${tmpdir}/${asset_name}" -C "$extract_dir" || { + tui_msgbox "Error" "Failed to extract archive." + return 1 + } + ;; + *.zip) + unzip -q "${tmpdir}/${asset_name}" -d "$extract_dir" || { + tui_msgbox "Error" "Failed to extract archive." + return 1 + } + ;; + *) + # Assume it's the binary itself + cp "${tmpdir}/${asset_name}" "${extract_dir}/${binary_name}" + chmod +x "${extract_dir}/${binary_name}" + ;; + esac + + # Find the binary + local found_binary="" + found_binary=$(find "$extract_dir" -name "$binary_name" -type f 2>/dev/null | head -1) + + # Some plugins have different names in the archive + if [[ -z "$found_binary" ]]; then + # Try finding any executable + found_binary=$(find "$extract_dir" -type f -executable 2>/dev/null | head -1) + fi + if [[ -z "$found_binary" ]]; then + found_binary=$(find "$extract_dir" -type f -name "${binary_name}*" 2>/dev/null | head -1) + fi + + if [[ -z "$found_binary" ]]; then + tui_msgbox "Error" "Could not find '${binary_name}' in the downloaded archive.\n\nContents of archive:\n$(ls -la "$extract_dir" 2>/dev/null)" + return 1 + fi + + chmod +x "$found_binary" + cp "$found_binary" "/usr/local/bin/${binary_name}" + + if command -v "$binary_name" >/dev/null 2>&1; then + tui_msgbox "Success" "${binary_name} installed to /usr/local/bin/${binary_name}" + return 0 + else + tui_msgbox "Warning" "Installed to /usr/local/bin/${binary_name} but it was not found in PATH." + return 0 + fi +} + +plugin_install_simple_obfs() { + local distro + distro=$(detect_distro) + + local method + method=$(tui_menu "Install simple-obfs" "Choose installation method:" \ + "package" "Install via package manager (if available)" \ + "source" "Build from source (requires git, build tools)") || return 1 + + if [[ "$method" == "package" ]]; then + case "$distro" in + ubuntu|debian) + apt-get update && apt-get install -y simple-obfs && { + tui_msgbox "Success" "simple-obfs installed via apt." + return 0 + } + tui_msgbox "Notice" "Package not available. Trying source build..." + ;; + *) + tui_msgbox "Notice" "No package available for ${distro}. Building from source..." + ;; + esac + fi + + # Build from source + if ! command -v git >/dev/null 2>&1; then + tui_msgbox "Error" "git is required to build from source." + return 1 + fi + if ! command -v make >/dev/null 2>&1; then + tui_msgbox "Error" "make is required to build from source." + return 1 + fi + + TMPDIR_CLEANUP=$(mktemp -d) + local tmpdir="$TMPDIR_CLEANUP" + + tui_msgbox "Building" "Cloning and building simple-obfs...\nThis may take a few minutes." + + ( + cd "$tmpdir" + git clone https://github.com/shadowsocks/simple-obfs.git + cd simple-obfs + git submodule update --init --recursive + ./autogen.sh + ./configure + make + make install + ) || { + tui_msgbox "Error" "Build failed. Check that build dependencies are installed:\n autoconf, automake, libtool, libev-dev" + return 1 + } + + if command -v obfs-local >/dev/null 2>&1; then + tui_msgbox "Success" "simple-obfs built and installed.\nBinaries: obfs-local, obfs-server" + else + tui_msgbox "Warning" "Build completed but obfs-local not found in PATH." + fi +} + +plugin_install_custom() { + local path + path=$(tui_inputbox "Custom Plugin" "Enter the full path to the plugin binary:" "") || return 1 + + if [[ -z "$path" ]]; then + return 1 + fi + + if [[ ! -f "$path" ]]; then + tui_msgbox "Error" "File not found: ${path}" + return 1 + fi + + if [[ ! -x "$path" ]]; then + chmod +x "$path" + fi + + local name + name=$(basename "$path") + + if tui_yesno "Symlink" "Create a symlink in /usr/local/bin/${name}?"; then + ln -sf "$path" "/usr/local/bin/${name}" + tui_msgbox "Done" "Symlinked ${path} → /usr/local/bin/${name}" + else + tui_msgbox "Done" "Plugin at ${path} is ready to use.\nSpecify the full path when configuring." + fi +} + +plugin_install() { + if ! is_root; then + tui_msgbox "Root Required" "Plugin installation requires root privileges.\n\nRe-run with sudo." + return + fi + + local items=() + local p + for p in "${KNOWN_PLUGINS[@]}"; do + local status="not installed" + if command -v "$p" >/dev/null 2>&1; then + status="installed" + fi + items+=("$p" "$(plugin_repo "$p") (${status})") + done + items+=("custom" "Install a custom binary") + + local choice + choice=$(tui_menu "Install Plugin" "Select a plugin to install:" "${items[@]}") || return + + case "$choice" in + simple-obfs) + plugin_install_simple_obfs + ;; + v2ray-plugin) + plugin_download_github_release "$(plugin_repo v2ray-plugin)" "v2ray-plugin" + ;; + xray-plugin) + plugin_download_github_release "$(plugin_repo xray-plugin)" "xray-plugin" + ;; + kcptun) + plugin_download_github_release "$(plugin_repo kcptun)" "kcptun-client" + ;; + custom) + plugin_install_custom + ;; + esac +} + +############################################################################### +# Service Management +############################################################################### + +service_list_instances() { + local instances=() + + # Scan config files + if [[ -d "$CONFIG_DIR" ]]; then + local f name + for f in "${CONFIG_DIR}"/*.json; do + [[ -f "$f" ]] || continue + name=$(basename "$f" .json) + local status="no service" + local unit="shadowsocks-libev-server@${name}.service" + if has_systemd; then + if systemctl is-active --quiet "$unit" 2>/dev/null; then + status="running" + elif systemctl is-enabled --quiet "$unit" 2>/dev/null; then + status="stopped (enabled)" + fi + fi + instances+=("$name" "${status}") + done + fi + + if [[ ${#instances[@]} -eq 0 ]]; then + tui_msgbox "No Instances" "No config files found in ${CONFIG_DIR}/" + return 1 + fi + + local choice + choice=$(tui_menu "Instances" "Select an instance:" "${instances[@]}") || return 1 + echo "$choice" +} + +service_action_menu() { + local name="$1" + local unit="shadowsocks-libev-server@${name}.service" + + while true; do + local action + action=$(tui_menu "Manage: ${name}" "Select an action:" \ + "status" "View service status" \ + "start" "Start the service" \ + "stop" "Stop the service" \ + "restart" "Restart the service" \ + "enable" "Enable on boot" \ + "disable" "Disable on boot" \ + "logs" "View recent logs" \ + "back" "Return to instance list") || return + + case "$action" in + status) + local st + st=$(systemctl status "$unit" 2>&1 || true) + tui_msgbox "Status: ${name}" "$st" + ;; + start) + systemctl start "$unit" 2>&1 && \ + tui_msgbox "Started" "Service ${unit} started." || \ + tui_msgbox "Error" "Failed to start ${unit}." + ;; + stop) + systemctl stop "$unit" 2>&1 && \ + tui_msgbox "Stopped" "Service ${unit} stopped." || \ + tui_msgbox "Error" "Failed to stop ${unit}." + ;; + restart) + systemctl restart "$unit" 2>&1 && \ + tui_msgbox "Restarted" "Service ${unit} restarted." || \ + tui_msgbox "Error" "Failed to restart ${unit}." + ;; + enable) + systemctl enable "$unit" 2>&1 && \ + tui_msgbox "Enabled" "Service ${unit} enabled on boot." || \ + tui_msgbox "Error" "Failed to enable ${unit}." + ;; + disable) + systemctl disable "$unit" 2>&1 && \ + tui_msgbox "Disabled" "Service ${unit} disabled." || \ + tui_msgbox "Error" "Failed to disable ${unit}." + ;; + logs) + local logs + logs=$(journalctl -u "$unit" -n 50 --no-pager 2>&1 || echo "(no logs available)") + tui_msgbox "Logs: ${name}" "$logs" + ;; + back) + return + ;; + esac + done +} + +service_manage() { + if ! is_root; then + tui_msgbox "Root Required" "Service management requires root privileges.\n\nRe-run with sudo." + return + fi + + if ! has_systemd; then + tui_msgbox "No systemd" "systemd was not detected on this system.\n\nService management is not available." + return + fi + + while true; do + local instance + instance=$(service_list_instances) || return + service_action_menu "$instance" + done +} + +############################################################################### +# Main Menu +############################################################################### + +main_menu() { + while true; do + local choice + choice=$(tui_menu "ss-setup v${SS_SETUP_VERSION}" "shadowsocks-libev setup tool" \ + "server" "Setup ss-server (generate config + service)" \ + "client" "Generate ss-local client config" \ + "plugin" "Install a SIP003 plugin" \ + "service" "Manage running services" \ + "exit" "Exit") || break + + case "$choice" in + server) server_setup ;; + client) client_setup ;; + plugin) plugin_install ;; + service) service_manage ;; + exit) break ;; + esac + done +} + +############################################################################### +# Entry Point +############################################################################### + +main() { + # Non-interactive check + if [[ ! -t 0 ]]; then + echo "Error: ss-setup requires an interactive terminal." >&2 + echo "Usage: ss-setup" >&2 + exit 1 + fi + + detect_tui_backend + check_root || true # warn but continue + main_menu + + echo "Goodbye." +} + +# Only run main when executed directly (not when sourced for testing) +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bba512f5..b27effaf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,3 +116,8 @@ endif() ss_add_test(test_crypto "test_crypto.c;${PROJECT_SOURCE_DIR}/src/crypto.c;${PROJECT_SOURCE_DIR}/src/aead.c;${PROJECT_SOURCE_DIR}/src/stream.c;${PROJECT_SOURCE_DIR}/src/ppbloom.c;${PROJECT_SOURCE_DIR}/src/cache.c;${PROJECT_SOURCE_DIR}/src/base64.c;${PROJECT_SOURCE_DIR}/src/utils.c" "${TEST_CRYPTO_LIBS};m") + +# test_ss_setup - bash unit tests for the ss-setup TUI tool +add_test(NAME test_ss_setup + COMMAND bash "${PROJECT_SOURCE_DIR}/tests/test_ss_setup.sh" + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}") diff --git a/tests/test_ss_setup.sh b/tests/test_ss_setup.sh new file mode 100755 index 00000000..a6c70290 --- /dev/null +++ b/tests/test_ss_setup.sh @@ -0,0 +1,881 @@ +#!/usr/bin/env bash +############################################################################### +# test_ss_setup.sh -- Unit tests for ss-setup.sh utility functions +# +# Sources ss-setup.sh (which skips main() due to BASH_SOURCE guard) and +# exercises every pure/utility function that doesn't require a TUI backend. +############################################################################### +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SS_SETUP="${PROJECT_ROOT}/scripts/ss-setup.sh" + +PASS=0 +FAIL=0 +TMPDIR_TEST="" + +cleanup_test() { + if [[ -n "$TMPDIR_TEST" && -d "$TMPDIR_TEST" ]]; then + rm -rf "$TMPDIR_TEST" + fi +} +trap cleanup_test EXIT + +TMPDIR_TEST=$(mktemp -d) + +# Source the script under test (main() won't run due to BASH_SOURCE guard) +# shellcheck source=../scripts/ss-setup.sh +source "$SS_SETUP" + +############################################################################### +# Test helpers +############################################################################### + +assert_eq() { + local test_name="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name}" >&2 + echo " expected: '${expected}'" >&2 + echo " actual: '${actual}'" >&2 + fi +} + +assert_match() { + local test_name="$1" pattern="$2" actual="$3" + if [[ "$actual" =~ $pattern ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name}" >&2 + echo " expected match: '${pattern}'" >&2 + echo " actual: '${actual}'" >&2 + fi +} + +assert_ok() { + local test_name="$1" + shift + if "$@" 2>/dev/null; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name} (command returned non-zero)" >&2 + fi +} + +assert_fail() { + local test_name="$1" + shift + if "$@" 2>/dev/null; then + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name} (expected failure but got success)" >&2 + else + PASS=$((PASS + 1)) + fi +} + +assert_contains() { + local test_name="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name}" >&2 + echo " expected to contain: '${needle}'" >&2 + echo " actual: '${haystack}'" >&2 + fi +} + +assert_not_contains() { + local test_name="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: ${test_name}" >&2 + echo " expected NOT to contain: '${needle}'" >&2 + echo " actual: '${haystack}'" >&2 + fi +} + +############################################################################### +# Tests: AEAD_CIPHERS constant +############################################################################### + +test_aead_ciphers_count() { + assert_eq "AEAD_CIPHERS has 5 entries" "5" "${#AEAD_CIPHERS[@]}" +} + +test_aead_ciphers_contains_chacha20() { + local found=0 + for c in "${AEAD_CIPHERS[@]}"; do + [[ "$c" == "chacha20-ietf-poly1305" ]] && found=1 + done + assert_eq "AEAD_CIPHERS contains chacha20-ietf-poly1305" "1" "$found" +} + +test_aead_ciphers_contains_aes256gcm() { + local found=0 + for c in "${AEAD_CIPHERS[@]}"; do + [[ "$c" == "aes-256-gcm" ]] && found=1 + done + assert_eq "AEAD_CIPHERS contains aes-256-gcm" "1" "$found" +} + +test_aead_ciphers_contains_aes128gcm() { + local found=0 + for c in "${AEAD_CIPHERS[@]}"; do + [[ "$c" == "aes-128-gcm" ]] && found=1 + done + assert_eq "AEAD_CIPHERS contains aes-128-gcm" "1" "$found" +} + +############################################################################### +# Tests: validate_port +############################################################################### + +test_validate_port_valid() { + assert_ok "validate_port 1" validate_port "1" + assert_ok "validate_port 80" validate_port "80" + assert_ok "validate_port 443" validate_port "443" + assert_ok "validate_port 8388" validate_port "8388" + assert_ok "validate_port 65535" validate_port "65535" +} + +test_validate_port_invalid() { + assert_fail "validate_port 0" validate_port "0" + assert_fail "validate_port 65536" validate_port "65536" + assert_fail "validate_port -1" validate_port "-1" + assert_fail "validate_port abc" validate_port "abc" + assert_fail "validate_port empty" validate_port "" + assert_fail "validate_port 99999" validate_port "99999" + assert_fail "validate_port 8.8" validate_port "8.8" +} + +############################################################################### +# Tests: validate_instance_name +############################################################################### + +test_validate_instance_name_valid() { + assert_ok "instance: config" validate_instance_name "config" + assert_ok "instance: my-server" validate_instance_name "my-server" + assert_ok "instance: server_1" validate_instance_name "server_1" + assert_ok "instance: Test123" validate_instance_name "Test123" + assert_ok "instance: a" validate_instance_name "a" + assert_ok "instance: A-B_c-1" validate_instance_name "A-B_c-1" +} + +test_validate_instance_name_invalid() { + assert_fail "instance: empty" validate_instance_name "" + assert_fail "instance: has space" validate_instance_name "has space" + assert_fail "instance: has.dot" validate_instance_name "has.dot" + assert_fail "instance: has/slash" validate_instance_name "has/slash" + assert_fail "instance: has@at" validate_instance_name "has@at" + assert_fail "instance: has:colon" validate_instance_name "has:colon" +} + +############################################################################### +# Tests: generate_password +############################################################################### + +test_generate_password_nonempty() { + local pw + pw=$(generate_password 32) + assert_match "password is non-empty" ".+" "$pw" +} + +test_generate_password_length() { + # base64 of 16 bytes = 24 chars (before newline strip) + local pw + pw=$(generate_password 16) + # Should be at least 20 chars (base64 encoding of 16 bytes) + local len=${#pw} + if (( len >= 20 )); then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: password length too short: ${len} chars" >&2 + fi +} + +test_generate_password_no_newlines() { + local pw + pw=$(generate_password 32) + assert_not_contains "password has no newline" $'\n' "$pw" +} + +test_generate_password_different() { + local pw1 pw2 + pw1=$(generate_password 32) + pw2=$(generate_password 32) + if [[ "$pw1" != "$pw2" ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: two passwords are identical: '${pw1}'" >&2 + fi +} + +############################################################################### +# Tests: generate_random_port +############################################################################### + +test_generate_random_port_range() { + local port + for _ in $(seq 1 10); do + port=$(generate_random_port) + if (( port >= 10000 && port <= 65000 )); then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL: random port ${port} out of range 10000-65000" >&2 + fi + done +} + +test_generate_random_port_numeric() { + local port + port=$(generate_random_port) + assert_match "random port is numeric" "^[0-9]+$" "$port" +} + +############################################################################### +# Tests: json_escape +############################################################################### + +test_json_escape_plain() { + local result + result=$(json_escape "hello world") + assert_eq "json_escape plain" "hello world" "$result" +} + +test_json_escape_quotes() { + local result + result=$(json_escape 'say "hi"') + assert_eq "json_escape quotes" 'say \"hi\"' "$result" +} + +test_json_escape_backslash() { + local result + result=$(json_escape 'path\to\file') + assert_eq "json_escape backslash" 'path\\to\\file' "$result" +} + +test_json_escape_tab() { + local result + result=$(json_escape "col1 col2") + assert_eq "json_escape tab" 'col1\tcol2' "$result" +} + +test_json_escape_newline() { + local result + result=$(json_escape "line1 +line2") + assert_eq "json_escape newline" 'line1\nline2' "$result" +} + +test_json_escape_empty() { + local result + result=$(json_escape "") + assert_eq "json_escape empty" "" "$result" +} + +############################################################################### +# Tests: urlencode +############################################################################### + +test_urlencode_plain() { + local result + result=$(urlencode "hello") + assert_eq "urlencode plain" "hello" "$result" +} + +test_urlencode_space() { + local result + result=$(urlencode "hello world") + assert_eq "urlencode space" "hello%20world" "$result" +} + +test_urlencode_special() { + local result + result=$(urlencode "a=b&c=d") + assert_eq "urlencode special" "a%3Db%26c%3Dd" "$result" +} + +test_urlencode_semicolon() { + local result + result=$(urlencode "obfs-local;obfs=http") + assert_eq "urlencode semicolon" "obfs-local%3Bobfs%3Dhttp" "$result" +} + +test_urlencode_safe_chars() { + local result + result=$(urlencode "a-b_c.d~e") + assert_eq "urlencode safe chars" "a-b_c.d~e" "$result" +} + +test_urlencode_empty() { + local result + result=$(urlencode "") + assert_eq "urlencode empty" "" "$result" +} + +############################################################################### +# Tests: generate_ss_uri +############################################################################### + +test_ss_uri_basic() { + local uri + uri=$(generate_ss_uri "aes-256-gcm" "testpass" "1.2.3.4" "8388" "" "") + # Should start with ss:// + assert_match "ss uri starts with ss://" "^ss://" "$uri" + # Should contain @host:port + assert_contains "ss uri has host:port" "@1.2.3.4:8388" "$uri" + # Should NOT have plugin query + assert_not_contains "ss uri no plugin query" "/?plugin=" "$uri" +} + +test_ss_uri_with_plugin() { + local uri + uri=$(generate_ss_uri "chacha20-ietf-poly1305" "mypass" "example.com" "443" "v2ray-plugin" "server;tls;host=example.com") + assert_match "ss uri+plugin starts with ss://" "^ss://" "$uri" + assert_contains "ss uri+plugin has host:port" "@example.com:443" "$uri" + assert_contains "ss uri+plugin has plugin param" "/?plugin=" "$uri" + # v2ray-plugin should be URL-encoded with the opts + assert_contains "ss uri+plugin has v2ray" "v2ray-plugin" "$uri" +} + +test_ss_uri_with_plugin_no_opts() { + local uri + uri=$(generate_ss_uri "aes-128-gcm" "pw" "10.0.0.1" "9000" "obfs-local" "") + assert_contains "ss uri plugin-no-opts has plugin" "/?plugin=" "$uri" + assert_contains "ss uri plugin-no-opts has obfs-local" "obfs-local" "$uri" +} + +test_ss_uri_base64_encoding() { + # Verify the userinfo part is valid base64url + local uri + uri=$(generate_ss_uri "aes-256-gcm" "test" "1.2.3.4" "8388" "" "") + # Extract the base64 part between ss:// and @ + local b64_part + b64_part=$(echo "$uri" | sed 's|^ss://\([^@]*\)@.*|\1|') + # base64url should only contain [A-Za-z0-9_-] + assert_match "ss uri base64url valid chars" "^[A-Za-z0-9_-]+$" "$b64_part" +} + +############################################################################### +# Tests: write_json_config (server) +############################################################################### + +test_write_json_config_basic() { + local outfile="${TMPDIR_TEST}/server_basic.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="testpassword123" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "server json has server" '"server": "0.0.0.0"' "$content" + assert_contains "server json has port" '"server_port": 8388' "$content" + assert_contains "server json has password" '"password": "testpassword123"' "$content" + assert_contains "server json has timeout" '"timeout": 300' "$content" + assert_contains "server json has method" '"method": "aes-256-gcm"' "$content" + assert_contains "server json has mode" '"mode": "tcp_and_udp"' "$content" + assert_contains "server json has fast_open false" '"fast_open": false' "$content" + assert_not_contains "server json no plugin" '"plugin"' "$content" + # No trailing comma before closing brace + assert_not_contains "server json no trailing comma" ', +}' "$content" +} + +test_write_json_config_fast_open_true() { + local outfile="${TMPDIR_TEST}/server_tfo.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="443" + CFG_PASSWORD="pw" + CFG_TIMEOUT="60" + CFG_METHOD="chacha20-ietf-poly1305" + CFG_MODE="tcp_only" + CFG_FAST_OPEN="true" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "server json fast_open true" '"fast_open": true' "$content" + assert_not_contains "server json fast_open not quoted" '"fast_open": "true"' "$content" +} + +test_write_json_config_with_plugin() { + local outfile="${TMPDIR_TEST}/server_plugin.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="443" + CFG_PASSWORD="pw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="v2ray-plugin" + CFG_PLUGIN_OPTS="server;tls;host=example.com" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "server json has plugin" '"plugin": "v2ray-plugin"' "$content" + assert_contains "server json has plugin_opts" '"plugin_opts": "server;tls;host=example.com"' "$content" +} + +test_write_json_config_with_plugin_no_opts() { + local outfile="${TMPDIR_TEST}/server_plugin_noopts.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="pw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="obfs-local" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "server json plugin no opts has plugin" '"plugin": "obfs-local"' "$content" + assert_not_contains "server json plugin no opts has no plugin_opts" '"plugin_opts"' "$content" +} + +test_write_json_config_password_special_chars() { + local outfile="${TMPDIR_TEST}/server_special.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD='pass"word\with/special' + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + # Quotes and backslashes should be escaped + assert_contains "special password escaped quote" '\"' "$content" + assert_contains "special password escaped backslash" '\\' "$content" +} + +############################################################################### +# Tests: write_client_json_config +############################################################################### + +test_write_client_json_config_basic() { + local outfile="${TMPDIR_TEST}/client_basic.json" + CFG_CLIENT_SERVER="1.2.3.4" + CFG_CLIENT_SERVER_PORT="8388" + CFG_CLIENT_LOCAL_PORT="1080" + CFG_CLIENT_PASSWORD="clientpw" + CFG_CLIENT_METHOD="aes-256-gcm" + CFG_CLIENT_PLUGIN="" + CFG_CLIENT_PLUGIN_OPTS="" + + write_client_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "client json has server" '"server": "1.2.3.4"' "$content" + assert_contains "client json has server_port" '"server_port": 8388' "$content" + assert_contains "client json has local_address" '"local_address": "127.0.0.1"' "$content" + assert_contains "client json has local_port" '"local_port": 1080' "$content" + assert_contains "client json has password" '"password": "clientpw"' "$content" + assert_contains "client json has method" '"method": "aes-256-gcm"' "$content" + assert_contains "client json has timeout" '"timeout": 300' "$content" + assert_contains "client json has mode" '"mode": "tcp_and_udp"' "$content" + assert_not_contains "client json no plugin" '"plugin"' "$content" +} + +test_write_client_json_config_with_plugin() { + local outfile="${TMPDIR_TEST}/client_plugin.json" + CFG_CLIENT_SERVER="example.com" + CFG_CLIENT_SERVER_PORT="443" + CFG_CLIENT_LOCAL_PORT="1080" + CFG_CLIENT_PASSWORD="pw" + CFG_CLIENT_METHOD="chacha20-ietf-poly1305" + CFG_CLIENT_PLUGIN="v2ray-plugin" + CFG_CLIENT_PLUGIN_OPTS="tls;host=example.com" + + write_client_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "client json plugin" '"plugin": "v2ray-plugin"' "$content" + assert_contains "client json plugin_opts" '"plugin_opts": "tls;host=example.com"' "$content" +} + +############################################################################### +# Tests: parse_existing_config (round-trip) +############################################################################### + +test_parse_existing_config_roundtrip() { + local outfile="${TMPDIR_TEST}/roundtrip.json" + + # Set known values + CFG_SERVER="10.20.30.40" + CFG_SERVER_PORT="9999" + CFG_PASSWORD="roundtrip_pw" + CFG_TIMEOUT="600" + CFG_METHOD="aes-128-gcm" + CFG_MODE="udp_only" + CFG_FAST_OPEN="true" + CFG_PLUGIN="obfs-local" + CFG_PLUGIN_OPTS="obfs=http;obfs-host=example.com" + + write_json_config "$outfile" + + # Reset globals + CFG_SERVER="" + CFG_SERVER_PORT="" + CFG_PASSWORD="" + CFG_TIMEOUT="" + CFG_METHOD="" + CFG_MODE="" + CFG_FAST_OPEN="" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + # Parse back + parse_existing_config "$outfile" + + assert_eq "roundtrip server" "10.20.30.40" "$CFG_SERVER" + assert_eq "roundtrip port" "9999" "$CFG_SERVER_PORT" + assert_eq "roundtrip password" "roundtrip_pw" "$CFG_PASSWORD" + assert_eq "roundtrip timeout" "600" "$CFG_TIMEOUT" + assert_eq "roundtrip method" "aes-128-gcm" "$CFG_METHOD" + assert_eq "roundtrip mode" "udp_only" "$CFG_MODE" + assert_eq "roundtrip fast_open" "true" "$CFG_FAST_OPEN" + assert_eq "roundtrip plugin" "obfs-local" "$CFG_PLUGIN" + assert_eq "roundtrip plugin_opts" "obfs=http;obfs-host=example.com" "$CFG_PLUGIN_OPTS" +} + +test_parse_existing_config_no_plugin() { + local outfile="${TMPDIR_TEST}/roundtrip_noplugin.json" + + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="simplepw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + # Reset and parse + CFG_SERVER="" + CFG_SERVER_PORT="" + CFG_PASSWORD="" + CFG_METHOD="" + CFG_MODE="" + CFG_PLUGIN="should_be_cleared_if_found" + + parse_existing_config "$outfile" + + assert_eq "roundtrip-noplugin server" "0.0.0.0" "$CFG_SERVER" + assert_eq "roundtrip-noplugin port" "8388" "$CFG_SERVER_PORT" + assert_eq "roundtrip-noplugin password" "simplepw" "$CFG_PASSWORD" + assert_eq "roundtrip-noplugin method" "aes-256-gcm" "$CFG_METHOD" + assert_eq "roundtrip-noplugin mode" "tcp_and_udp" "$CFG_MODE" +} + +############################################################################### +# Tests: detect_os +############################################################################### + +test_detect_os() { + local os + os=$(detect_os) + # Should be one of the known values + assert_match "detect_os returns known value" "^(linux|darwin|freebsd|openbsd|netbsd)$" "$os" +} + +############################################################################### +# Tests: detect_arch +############################################################################### + +test_detect_arch() { + local arch + arch=$(detect_arch) + # Should be one of the known mapped values or raw uname -m + assert_match "detect_arch returns a value" ".+" "$arch" +} + +############################################################################### +# Tests: KNOWN_PLUGINS constant +############################################################################### + +test_known_plugins_count() { + assert_eq "KNOWN_PLUGINS has 4 entries" "4" "${#KNOWN_PLUGINS[@]}" +} + +test_known_plugins_entries() { + local found_simpleobfs=0 found_v2ray=0 found_xray=0 found_kcptun=0 + for p in "${KNOWN_PLUGINS[@]}"; do + case "$p" in + simple-obfs) found_simpleobfs=1 ;; + v2ray-plugin) found_v2ray=1 ;; + xray-plugin) found_xray=1 ;; + kcptun) found_kcptun=1 ;; + esac + done + assert_eq "KNOWN_PLUGINS has simple-obfs" "1" "$found_simpleobfs" + assert_eq "KNOWN_PLUGINS has v2ray-plugin" "1" "$found_v2ray" + assert_eq "KNOWN_PLUGINS has xray-plugin" "1" "$found_xray" + assert_eq "KNOWN_PLUGINS has kcptun" "1" "$found_kcptun" +} + +############################################################################### +# Tests: plugin_repo function +############################################################################### + +test_plugin_repos() { + assert_eq "plugin_repo simple-obfs" "shadowsocks/simple-obfs" "$(plugin_repo simple-obfs)" + assert_eq "plugin_repo v2ray-plugin" "shadowsocks/v2ray-plugin" "$(plugin_repo v2ray-plugin)" + assert_eq "plugin_repo xray-plugin" "teddysun/xray-plugin" "$(plugin_repo xray-plugin)" + assert_eq "plugin_repo kcptun" "xtaci/kcptun" "$(plugin_repo kcptun)" + assert_eq "plugin_repo unknown" "" "$(plugin_repo unknown)" +} + +############################################################################### +# Tests: SS_SETUP_VERSION +############################################################################### + +test_version_set() { + assert_match "SS_SETUP_VERSION is semver" "^[0-9]+\.[0-9]+\.[0-9]+$" "$SS_SETUP_VERSION" +} + +############################################################################### +# Tests: Config directory constant +############################################################################### + +test_config_dir() { + assert_eq "CONFIG_DIR" "/etc/shadowsocks-libev" "$CONFIG_DIR" +} + +############################################################################### +# Tests: JSON output is valid (no trailing commas, booleans unquoted) +############################################################################### + +test_json_no_trailing_comma() { + local outfile="${TMPDIR_TEST}/no_trailing.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="pw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + # Check that the file doesn't have ",\n}" pattern (trailing comma) + if grep -qP ',\s*\}' "$outfile" 2>/dev/null || grep -q ',$' "$outfile" 2>/dev/null; then + # Try a more portable check + local last_data_line + last_data_line=$(grep -v '^[[:space:]]*[{}]' "$outfile" | tail -1) + if [[ "$last_data_line" == *"," ]]; then + FAIL=$((FAIL + 1)) + echo "FAIL: JSON has trailing comma on last data line: ${last_data_line}" >&2 + else + PASS=$((PASS + 1)) + fi + else + PASS=$((PASS + 1)) + fi +} + +test_json_booleans_unquoted() { + local outfile="${TMPDIR_TEST}/bool_check.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="pw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="true" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_not_contains "boolean not quoted string" '"fast_open": "true"' "$content" + assert_contains "boolean is unquoted true" '"fast_open": true' "$content" +} + +test_json_integers_unquoted() { + local outfile="${TMPDIR_TEST}/int_check.json" + CFG_SERVER="0.0.0.0" + CFG_SERVER_PORT="8388" + CFG_PASSWORD="pw" + CFG_TIMEOUT="300" + CFG_METHOD="aes-256-gcm" + CFG_MODE="tcp_and_udp" + CFG_FAST_OPEN="false" + CFG_PLUGIN="" + CFG_PLUGIN_OPTS="" + + write_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "server_port is unquoted int" '"server_port": 8388' "$content" + assert_not_contains "server_port not quoted string" '"server_port": "8388"' "$content" + assert_contains "timeout is unquoted int" '"timeout": 300' "$content" + assert_not_contains "timeout not quoted string" '"timeout": "300"' "$content" +} + +############################################################################### +# Tests: Client JSON integers unquoted +############################################################################### + +test_client_json_integers_unquoted() { + local outfile="${TMPDIR_TEST}/client_int_check.json" + CFG_CLIENT_SERVER="1.2.3.4" + CFG_CLIENT_SERVER_PORT="443" + CFG_CLIENT_LOCAL_PORT="1080" + CFG_CLIENT_PASSWORD="pw" + CFG_CLIENT_METHOD="aes-256-gcm" + CFG_CLIENT_PLUGIN="" + CFG_CLIENT_PLUGIN_OPTS="" + + write_client_json_config "$outfile" + + local content + content=$(cat "$outfile") + + assert_contains "client server_port unquoted" '"server_port": 443' "$content" + assert_contains "client local_port unquoted" '"local_port": 1080' "$content" + assert_contains "client timeout unquoted" '"timeout": 300' "$content" +} + +############################################################################### +# Run all tests +############################################################################### + +echo "Running ss-setup unit tests..." +echo + +# Constants +test_aead_ciphers_count +test_aead_ciphers_contains_chacha20 +test_aead_ciphers_contains_aes256gcm +test_aead_ciphers_contains_aes128gcm +test_known_plugins_count +test_known_plugins_entries +test_plugin_repos +test_version_set +test_config_dir + +# Validation +test_validate_port_valid +test_validate_port_invalid + +test_validate_instance_name_valid +test_validate_instance_name_invalid + +# Password generation +test_generate_password_nonempty +test_generate_password_length +test_generate_password_no_newlines +test_generate_password_different + +# Random port +test_generate_random_port_range +test_generate_random_port_numeric + +# JSON escaping +test_json_escape_plain +test_json_escape_quotes +test_json_escape_backslash +test_json_escape_tab +test_json_escape_newline +test_json_escape_empty + +# URL encoding +test_urlencode_plain +test_urlencode_space +test_urlencode_special +test_urlencode_semicolon +test_urlencode_safe_chars +test_urlencode_empty + +# ss:// URI +test_ss_uri_basic +test_ss_uri_with_plugin +test_ss_uri_with_plugin_no_opts +test_ss_uri_base64_encoding + +# Server JSON config +test_write_json_config_basic +test_write_json_config_fast_open_true +test_write_json_config_with_plugin +test_write_json_config_with_plugin_no_opts +test_write_json_config_password_special_chars + +# Client JSON config +test_write_client_json_config_basic +test_write_client_json_config_with_plugin +test_client_json_integers_unquoted + +# Parse existing config (round-trip) +test_parse_existing_config_roundtrip +test_parse_existing_config_no_plugin + +# Platform detection +test_detect_os +test_detect_arch + +# JSON validity +test_json_no_trailing_comma +test_json_booleans_unquoted +test_json_integers_unquoted + +echo +echo "=================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "=================================" + +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +exit 0