#!/usr/bin/env bash set -Eeuo pipefail # Debian/Ubuntu MicroSocks multi-instance installer. # Status messages go to stderr; created socks5:// links go to stdout. COUNT=0 COUNT_SET=0 START_PORT=1080 START_PORT_SET=0 LISTEN_ADDR="0.0.0.0" PUBLIC_HOST="" COMMON_USER="" COMMON_PASS="" NO_AUTH=0 AUTO_BIND=0 REPLACE=0 OPEN_FIREWALL=0 NON_INTERACTIVE=0 DRY_RUN=0 APT_UPDATED=0 MICROSOCKS_BIN="" WORK_DIR="" CONF_DIR="/etc/microsocks" UNIT_FILE="/etc/systemd/system/microsocks@.service" declare -a BIND_ADDRESSES=() declare -a RESOLVED_BINDS=() declare -a PORTS=() declare -a USERS=() declare -a PASSWORDS=() declare -a LOCAL_ADDRESSES=() log() { printf '[microsocks] %s\n' "$*" >&2 } die() { printf '[microsocks] 错误: %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' 用法:sudo ./setup-microsocks.sh [选项] -n, --count N 创建 N 个代理;多个 --bind 时可省略 --start-port PORT 起始端口,默认 1080 --listen IP 监听地址,默认 0.0.0.0 --host HOST 返回链接使用的公网 IP 或域名 --bind IP 绑定出站源地址;可重复指定 --auto-bind 自动轮流使用本机所有全局 IP 作为出口 --user USER 所有实例使用同一用户名 --password PASS 所有实例使用同一密码 --no-auth 仅允许在回环监听地址上关闭认证 --replace 覆盖相同端口的既有 microsocks 实例 --open-firewall 自动放行已启用的 UFW/firewalld 端口 --non-interactive 不询问确认 --dry-run 仅显示计划和链接,不修改系统 -h, --help 显示帮助 示例: sudo ./setup-microsocks.sh sudo ./setup-microsocks.sh --count 3 --start-port 1080 sudo ./setup-microsocks.sh --bind 10.0.14.56 --bind 10.0.225.167 EOF } need_value() { [[ $# -ge 2 && -n ${2:-} ]] || die "$1 缺少参数" } parse_args() { while (($#)); do case "$1" in -n|--count) need_value "$@"; COUNT=$2; COUNT_SET=1; shift 2 ;; --start-port) need_value "$@"; START_PORT=$2; START_PORT_SET=1; shift 2 ;; --listen) need_value "$@"; LISTEN_ADDR=$2; shift 2 ;; --host) need_value "$@"; PUBLIC_HOST=$2; shift 2 ;; --bind) need_value "$@"; BIND_ADDRESSES+=("$2"); shift 2 ;; --user) need_value "$@"; COMMON_USER=$2; shift 2 ;; --password) need_value "$@"; COMMON_PASS=$2; shift 2 ;; --auto-bind) AUTO_BIND=1; shift ;; --no-auth) NO_AUTH=1; shift ;; --replace) REPLACE=1; shift ;; --open-firewall) OPEN_FIREWALL=1; shift ;; --non-interactive) NON_INTERACTIVE=1; shift ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) die "未知参数: $1" ;; esac done } prompt_number() { local prompt=$1 default=$2 value read -r -p "$prompt [$default]: " value printf '%s' "${value:-$default}" } interactive_options() { [[ -t 0 && $NON_INTERACTIVE -eq 0 ]] || return 0 if [[ $COUNT_SET -eq 0 ]]; then COUNT=$(prompt_number "代理数量" 1) COUNT_SET=1 fi if [[ $START_PORT_SET -eq 0 ]]; then START_PORT=$(prompt_number "起始端口" 1080) fi if is_uint "$COUNT" && ((COUNT > 1 && ${#BIND_ADDRESSES[@]} == 0)); then local answer read -r -p "是否按检测到的本机 IP 自动分配出口?[y/N]: " answer [[ $answer =~ ^[Yy]$ ]] && AUTO_BIND=1 fi } is_uint() { [[ $1 =~ ^[0-9]+$ ]] } valid_token() { [[ $1 =~ ^[A-Za-z0-9._~-]{1,128}$ ]] } valid_ip() { python3 - "$1" <<'PY' >/dev/null 2>&1 import ipaddress import sys ipaddress.ip_address(sys.argv[1]) PY } valid_host() { local host=${1#[} host=${host%]} valid_ip "$host" && return 0 [[ $host =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] } validate_options() { if [[ $COUNT_SET -eq 0 ]]; then COUNT=$((${#BIND_ADDRESSES[@]} > 1 ? ${#BIND_ADDRESSES[@]} : 1)) fi is_uint "$COUNT" && ((COUNT >= 1)) || die "代理数量必须是正整数" is_uint "$START_PORT" || die "起始端口必须是整数" ((START_PORT >= 1024 && START_PORT + COUNT - 1 <= 65535)) || die "端口范围必须在 1024-65535" ((AUTO_BIND == 0 || ${#BIND_ADDRESSES[@]} == 0)) || die "--auto-bind 不能和 --bind 同时使用" [[ -z $COMMON_USER && -z $COMMON_PASS ]] || [[ -n $COMMON_USER && -n $COMMON_PASS ]] || die "--user 和 --password 必须同时指定" [[ -z $COMMON_USER ]] || valid_token "$COMMON_USER" || die "用户名只能使用字母、数字和 ._~-" [[ -z $COMMON_PASS ]] || valid_token "$COMMON_PASS" || die "密码只能使用字母、数字和 ._~-" [[ -z $PUBLIC_HOST ]] || valid_host "$PUBLIC_HOST" || die "--host 格式无效" valid_ip "$LISTEN_ADDR" || die "--listen 必须是 IPv4 或 IPv6 地址" } validate_auth_mode() { [[ $NO_AUTH -eq 0 ]] && return 0 [[ -z $COMMON_USER && -z $COMMON_PASS ]] || die "--no-auth 不能与用户名密码同时使用" case "$LISTEN_ADDR" in 127.*|::1) ;; *) die "为防止开放代理,--no-auth 仅允许监听回环地址" ;; esac } detect_os() { [[ -r /etc/os-release ]] || die "无法识别操作系统" # shellcheck disable=SC1091 source /etc/os-release local family="${ID:-} ${ID_LIKE:-}" [[ $family == *debian* || $family == *ubuntu* ]] || die "仅支持 Debian/Ubuntu 系统" command -v systemctl >/dev/null || die "未检测到 systemd" [[ $(ps -p 1 -o comm= 2>/dev/null) == systemd ]] || die "PID 1 不是 systemd" log "系统: ${PRETTY_NAME:-$ID}; 架构: $(uname -m)" } apt_update_once() { [[ $APT_UPDATED -eq 1 ]] && return 0 log "更新 APT 索引" DEBIAN_FRONTEND=noninteractive apt-get update APT_UPDATED=1 } ensure_base_tools() { local missing=() command for command in curl openssl python3 ip ss flock; do command -v "$command" >/dev/null || missing+=("$command") done ((${#missing[@]} == 0)) && return 0 [[ $DRY_RUN -eq 0 ]] || die "dry-run 缺少命令: ${missing[*]}" apt_update_once DEBIAN_FRONTEND=noninteractive apt-get install -y curl openssl python3 iproute2 util-linux ca-certificates } install_from_source() { apt_update_once DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential ca-certificates log "从上游源码编译 microsocks" git clone --depth 1 https://github.com/rofl0r/microsocks.git "$WORK_DIR/microsocks" make -C "$WORK_DIR/microsocks" install -m 0755 "$WORK_DIR/microsocks/microsocks" /usr/bin/microsocks } install_microsocks() { if command -v microsocks >/dev/null; then MICROSOCKS_BIN=$(command -v microsocks) return 0 fi apt_update_once log "尝试从 APT 安装 microsocks" if DEBIAN_FRONTEND=noninteractive apt-get install -y microsocks; then MICROSOCKS_BIN=$(command -v microsocks) return 0 fi install_from_source MICROSOCKS_BIN=/usr/bin/microsocks } oci_public_ip() { curl -fsS --max-time 2 -H 'Authorization: Bearer Oracle' \ http://169.254.169.254/opc/v2/vnics/ 2>/dev/null | python3 -c ' import json, sys items = json.load(sys.stdin) print(next((x.get("publicIp") for x in items if x.get("publicIp")), "")) ' 2>/dev/null } first_global_ipv6() { ip -o -6 addr show scope global | awk '{sub(/\/.*/, "", $4); print $4; exit}' } detect_public_host() { [[ -n $PUBLIC_HOST ]] && return 0 case "$LISTEN_ADDR" in 127.*|::1) PUBLIC_HOST=$LISTEN_ADDR; return 0 ;; esac if [[ $LISTEN_ADDR == *:* ]]; then [[ $LISTEN_ADDR == "::" ]] && PUBLIC_HOST=$(first_global_ipv6 || true) || PUBLIC_HOST=$LISTEN_ADDR [[ -n $PUBLIC_HOST ]] || die "没有可用于 IPv6 监听的全局地址" return 0 fi PUBLIC_HOST=$(oci_public_ip || true) [[ -n $PUBLIC_HOST ]] && return 0 PUBLIC_HOST=$(curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true) [[ -n $PUBLIC_HOST ]] && return 0 die "无法自动检测公网 IPv4,请使用 --host 指定,或改用 --listen ::" } discover_local_addresses() { mapfile -t LOCAL_ADDRESSES < <( ip -o addr show up scope global | awk '$2 !~ /^(docker|br-|veth|virbr|tailscale|wg)/ {sub(/\/.*/, "", $4); print $4}' | sort -u ) ((${#LOCAL_ADDRESSES[@]} > 0)) || die "没有检测到全局 IPv4/IPv6 地址" } address_is_local() { local target=$1 address for address in "${LOCAL_ADDRESSES[@]}"; do [[ $address == "$target" ]] && return 0 done return 1 } validate_bind_addresses() { local address for address in "${BIND_ADDRESSES[@]}"; do valid_ip "$address" || die "无效的 --bind 地址: $address" address_is_local "$address" || die "--bind 地址不在本机: $address" done local size=${#BIND_ADDRESSES[@]} ((size == 0 || size == 1 || size == COUNT)) || die "--bind 数量必须是 1 或代理数量 $COUNT" } interface_for_address() { local target=$1 ip -o addr show | awk -v target="$target" ' {address=$4; sub(/\/.*/, "", address)} address == target {interface=$2; sub(/@.*/, "", interface); print interface; exit} ' } route_for_address() { local address=$1 if [[ $address == *:* ]]; then ip -6 route get 2606:4700:4700::1111 from "$address" else ip -4 route get 1.1.1.1 from "$address" fi } validate_bind_routes() { local address expected route actual for address in "${RESOLVED_BINDS[@]}"; do [[ -n $address ]] || continue expected=$(interface_for_address "$address") route=$(route_for_address "$address" 2>/dev/null) || die "$address 没有可用的出站路由" actual=$(awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}' <<<"$route") [[ -n $expected && $actual == "$expected" ]] || die "$address 应从 $expected 出口,内核实际选择 ${actual:-未知接口};请先修复策略路由" done } prepare_binds() { local index size=${#BIND_ADDRESSES[@]} if ((size == 0 && AUTO_BIND == 0)); then for ((index = 0; index < COUNT; index++)); do RESOLVED_BINDS+=(""); done return 0 fi discover_local_addresses validate_bind_addresses for ((index = 0; index < COUNT; index++)); do if ((size == COUNT)); then RESOLVED_BINDS+=("${BIND_ADDRESSES[index]}") elif ((size == 1)); then RESOLVED_BINDS+=("${BIND_ADDRESSES[0]}") elif ((AUTO_BIND == 1)); then RESOLVED_BINDS+=("${LOCAL_ADDRESSES[index % ${#LOCAL_ADDRESSES[@]}]}") else RESOLVED_BINDS+=("") fi done } prepare_credentials() { local index for ((index = 0; index < COUNT; index++)); do if ((NO_AUTH == 1)); then USERS+=(""); PASSWORDS+=("") elif [[ -n $COMMON_USER ]]; then USERS+=("$COMMON_USER"); PASSWORDS+=("$COMMON_PASS") else USERS+=("proxy$(openssl rand -hex 3)") PASSWORDS+=("$(openssl rand -hex 16)") fi done } prepare_ports() { local index for ((index = 0; index < COUNT; index++)); do PORTS+=("$((START_PORT + index))") done } port_in_use() { local port=$1 ss -H -ltn | awk -v suffix=":$port" '$4 ~ suffix "$" {found=1} END {exit !found}' } validate_ports() { local port config for port in "${PORTS[@]}"; do config="$CONF_DIR/$port.conf" if [[ -e $config && $REPLACE -eq 0 ]]; then die "$config 已存在;使用 --replace 才能覆盖" fi if [[ ! -e $config ]] && port_in_use "$port"; then die "端口 $port 已被其他进程占用" fi done } format_url_host() { local host=${1#[} host=${host%]} [[ $host == *:* ]] && printf '[%s]' "$host" || printf '%s' "$host" } print_links() { local index host host=$(format_url_host "$PUBLIC_HOST") for ((index = 0; index < COUNT; index++)); do if [[ -n ${USERS[index]} ]]; then printf 'socks5://%s:%s@%s:%s\n' "${USERS[index]}" "${PASSWORDS[index]}" "$host" "${PORTS[index]}" else printf 'socks5://%s:%s\n' "$host" "${PORTS[index]}" fi done } show_plan() { local index bind auth log "计划创建 $COUNT 个实例,监听地址 $LISTEN_ADDR" for ((index = 0; index < COUNT; index++)); do bind=${RESOLVED_BINDS[index]:-自动} auth=${USERS[index]:-无认证} log "端口 ${PORTS[index]};出口 $bind;用户 $auth" done log "链接主机: $PUBLIC_HOST" } confirm_plan() { [[ -t 0 && $NON_INTERACTIVE -eq 0 ]] || return 0 local answer read -r -p "继续安装并启动服务?[Y/n]: " answer [[ ! $answer =~ ^[Nn]$ ]] || exit 0 } check_bind_support() { local has_bind=0 address help for address in "${RESOLVED_BINDS[@]}"; do [[ -n $address ]] && has_bind=1 done ((has_bind == 0)) && return 0 help=$({ "$MICROSOCKS_BIN" -h || true; } 2>&1) grep -Eq -- '(^|[[:space:]])-b([[:space:]]|$)' <<<"$help" || die "当前 microsocks 不支持 -b 出站绑定" } write_unit_header() { cat >"$1" <>"$1" <<'EOF' PrivateTmp=true ProtectSystem=strict ProtectHome=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictAddressFamilies=AF_INET AF_INET6 LockPersonality=true MemoryDenyWriteExecute=true CapabilityBoundingSet= [Install] WantedBy=multi-user.target EOF } write_unit_file() { local temporary="$WORK_DIR/microsocks@.service" write_unit_header "$temporary" write_unit_sandbox "$temporary" install -m 0644 "$temporary" "$UNIT_FILE" } write_instance_config() { local index=$1 port=${PORTS[$1]} temporary="$WORK_DIR/${PORTS[$1]}.conf" local auth_options="" bind_options="" [[ -z ${USERS[index]} ]] || auth_options="-u ${USERS[index]} -P ${PASSWORDS[index]}" [[ -z ${RESOLVED_BINDS[index]} ]] || bind_options="-b ${RESOLVED_BINDS[index]}" { printf 'LISTEN_ADDR=%s\n' "$LISTEN_ADDR" printf 'SOCKS_USER=%s\n' "${USERS[index]}" printf 'SOCKS_PASS=%s\n' "${PASSWORDS[index]}" printf 'AUTH_OPTIONS="%s"\n' "$auth_options" printf 'BIND_OPTIONS="%s"\n' "$bind_options" printf 'PUBLIC_HOST=%s\n' "$PUBLIC_HOST" } >"$temporary" install -m 0600 "$temporary" "$CONF_DIR/$port.conf" } install_configuration() { install -d -m 0750 "$CONF_DIR" write_unit_file local index for ((index = 0; index < COUNT; index++)); do write_instance_config "$index" done systemctl daemon-reload } start_services() { local port for port in "${PORTS[@]}"; do if systemctl is-active --quiet "microsocks@$port.service"; then systemctl restart "microsocks@$port.service" else systemctl enable --now "microsocks@$port.service" fi if ! systemctl is-active --quiet "microsocks@$port.service"; then journalctl -u "microsocks@$port.service" -n 30 --no-pager >&2 || true die "microsocks@$port 启动失败" fi done } open_detected_firewall() { [[ $OPEN_FIREWALL -eq 1 ]] || return 0 local port firewalld=0 if command -v ufw >/dev/null && ufw status | head -n 1 | grep -qw active; then for port in "${PORTS[@]}"; do ufw allow "$port/tcp"; done fi if command -v firewall-cmd >/dev/null && systemctl is-active --quiet firewalld; then firewalld=1 for port in "${PORTS[@]}"; do firewall-cmd --permanent --add-port="$port/tcp"; done fi ((firewalld == 0)) || firewall-cmd --reload } cleanup() { [[ -n ${WORK_DIR:-} && -d ${WORK_DIR:-} ]] || return 0 [[ $WORK_DIR == /tmp/microsocks-setup.* ]] || return 0 rm -rf -- "$WORK_DIR" } prepare_plan() { detect_public_host prepare_binds validate_bind_routes prepare_ports prepare_credentials validate_ports show_plan } main() { parse_args "$@" interactive_options detect_os [[ $DRY_RUN -eq 1 || $EUID -eq 0 ]] || die "请使用 root 或 sudo 执行" ensure_base_tools validate_options validate_auth_mode prepare_plan if [[ $DRY_RUN -eq 1 ]]; then log "dry-run:未修改系统;以下链接尚未生效" print_links return 0 fi confirm_plan exec 9>/run/lock/microsocks-setup.lock flock -n 9 || die "另一个安装任务正在运行" install_microsocks check_bind_support install_configuration start_services open_detected_firewall log "请同时在云平台安全组/安全列表中放行对应 TCP 端口" print_links } trap cleanup EXIT WORK_DIR=$(mktemp -d /tmp/microsocks-setup.XXXXXX) if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@" fi