#!/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