Files
Archive/shadowsocks-libev/scripts/ss-setup.sh
T
2026-02-07 19:47:43 +01:00

1368 lines
42 KiB
Bash
Executable File

#!/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}/<name>.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" <<UNIT
[Unit]
Description=Shadowsocks-Libev Custom Server Service for %I
Documentation=man:ss-server(1)
After=network-online.target
[Service]
Type=simple
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
DynamicUser=true
LimitNOFILE=32768
ExecStart=${ss_server_path} -c /etc/shadowsocks-libev/%i.json
[Install]
WantedBy=multi-user.target
UNIT
else
cat > "$template_path" <<UNIT
[Unit]
Description=Shadowsocks-Libev Custom Server Service for %I
Documentation=man:ss-server(1)
After=network-online.target
[Service]
Type=simple
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
User=nobody
Group=nogroup
LimitNOFILE=32768
ExecStart=${ss_server_path} -c /etc/shadowsocks-libev/%i.json
[Install]
WantedBy=multi-user.target
UNIT
fi
systemctl daemon-reload
}
server_install_service() {
local name="$1"
local unit="shadowsocks-libev-server@${name}.service"
server_install_systemd_template || {
tui_msgbox "No systemd" "systemd not detected. Skipping service installation.\n\nStart manually:\n ss-server -c ${CONFIG_DIR}/${name}.json"
return 1
}
systemctl enable "$unit" 2>/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