#!/usr/bin/env bash # TerminalPhone — Encrypted Push-to-Talk Voice over Tor # A walkie-talkie style voice chat using Tor hidden services # License: MIT set -euo pipefail #============================================================================= # CONFIGURATION #============================================================================= APP_NAME="TerminalPhone" VERSION="1.0.6" BASE_DIR="$(dirname "$(readlink -f "$0")")" DATA_DIR="$BASE_DIR/.terminalphone" TOR_DIR="$DATA_DIR/tor_data" TOR_CONF="$DATA_DIR/torrc" ONION_FILE="$TOR_DIR/hidden_service/hostname" SECRET_FILE="$DATA_DIR/shared_secret" CONFIG_FILE="$DATA_DIR/config" AUDIO_DIR="$DATA_DIR/audio" PID_DIR="$DATA_DIR/pids" PTT_FLAG="$DATA_DIR/run/ptt_$$" CONNECTED_FLAG="$DATA_DIR/run/connected_$$" RECV_PIPE="$DATA_DIR/run/recv_$$" SEND_PIPE="$DATA_DIR/run/send_$$" CIPHER_RUNTIME_FILE="$DATA_DIR/run/cipher_$$" AUTO_LISTEN_FLAG="$DATA_DIR/run/autolisten_$$" AUTO_LISTEN_PID="" # Defaults LISTEN_PORT=7777 TOR_SOCKS_PORT=9050 OPUS_BITRATE=16 # kbps — good balance of quality and bandwidth for Tor OPUS_FRAMESIZE=60 # ms SAMPLE_RATE=8000 # Hz PTT_KEY=" " # spacebar CHUNK_DURATION=1 # seconds per audio chunk CIPHER="aes-256-cbc" # OpenSSL cipher for encryption SNOWFLAKE_ENABLED=0 # Snowflake bridge (off by default) AUTO_LISTEN=0 # Auto-listen after Tor starts (off by default) # ANSI Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' MAGENTA='\033[0;35m' CYAN='\033[0;36m' WHITE='\033[1;37m' DIM='\033[2m' BOLD='\033[1m' BLINK='\033[5m' NC='\033[0m' # No Color BG_RED='\033[41m' BG_GREEN='\033[42m' TOR_PURPLE='\033[38;2;125;70;152m' # Platform detection IS_TERMUX=0 if [ -n "${TERMUX_VERSION:-}" ] || { [ -n "${PREFIX:-}" ] && [[ "${PREFIX:-}" == *"com.termux"* ]]; }; then IS_TERMUX=1 fi # State TOR_PID="" CALL_ACTIVE=0 ORIGINAL_STTY="" #============================================================================= # HELPERS #============================================================================= cleanup() { # Restore terminal if [ -n "$ORIGINAL_STTY" ]; then stty "$ORIGINAL_STTY" 2>/dev/null || true fi stty sane 2>/dev/null || true # Kill background processes kill_bg_processes # Remove temp files rm -f "$PTT_FLAG" "$CONNECTED_FLAG" "$RECV_PIPE" "$SEND_PIPE" rm -rf "$AUDIO_DIR" 2>/dev/null || true echo -e "\n${GREEN}${APP_NAME} shut down cleanly.${NC}" } kill_bg_processes() { # Kill any child processes local pids pids=$(jobs -p 2>/dev/null) || true if [ -n "$pids" ]; then kill $pids 2>/dev/null || true wait $pids 2>/dev/null || true fi # Kill stored PIDs if [ -d "$PID_DIR" ]; then for pidfile in "$PID_DIR"/*.pid; do [ -f "$pidfile" ] || continue local pid pid=$(cat "$pidfile" 2>/dev/null) || continue kill "$pid" 2>/dev/null || true done rm -f "$PID_DIR"/*.pid 2>/dev/null || true fi # Kill our Tor instance if running if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then kill "$TOR_PID" 2>/dev/null || true fi } save_pid() { local name="$1" pid="$2" mkdir -p "$PID_DIR" echo "$pid" > "$PID_DIR/${name}.pid" } log_info() { echo -e "${CYAN}[INFO]${NC} $1" } log_ok() { echo -e "${GREEN}[ OK]${NC} $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_err() { echo -e "${RED}[FAIL]${NC} $1" } uid() { echo "$(date +%s%N 2>/dev/null || date +%s)_${RANDOM}" } load_config() { if [ -f "$CONFIG_FILE" ]; then source "$CONFIG_FILE" fi if [ -f "$SECRET_FILE" ]; then SHARED_SECRET=$(cat "$SECRET_FILE") else SHARED_SECRET="" fi } save_config() { mkdir -p "$DATA_DIR" cat > "$CONFIG_FILE" << EOF LISTEN_PORT=$LISTEN_PORT TOR_SOCKS_PORT=$TOR_SOCKS_PORT OPUS_BITRATE=$OPUS_BITRATE OPUS_FRAMESIZE=$OPUS_FRAMESIZE PTT_KEY="$PTT_KEY" CIPHER="$CIPHER" SNOWFLAKE_ENABLED=$SNOWFLAKE_ENABLED AUTO_LISTEN=$AUTO_LISTEN EOF } #============================================================================= # DEPENDENCY INSTALLER #============================================================================= check_dep() { command -v "$1" &>/dev/null } install_deps() { echo -e "\n${BOLD}${CYAN}═══ Dependency Installer ═══${NC}\n" local deps_needed=() local all_deps local pkg_names_apt="tor opus-tools sox socat openssl alsa-utils" local pkg_names_dnf="tor opus-tools sox socat openssl alsa-utils" local pkg_names_pacman="tor opus-tools sox socat openssl alsa-utils" local pkg_names_pkg="tor opus-tools sox socat openssl-tool ffmpeg termux-api" # Shared deps + platform-specific if [ $IS_TERMUX -eq 1 ]; then all_deps=(tor opusenc opusdec sox socat openssl ffmpeg termux-microphone-record) else all_deps=(tor opusenc opusdec sox socat openssl arecord aplay) fi # Check which deps are missing for dep in "${all_deps[@]}"; do if check_dep "$dep"; then log_ok "$dep found" else deps_needed+=("$dep") log_warn "$dep NOT found" fi done if [ ${#deps_needed[@]} -eq 0 ]; then echo -e "\n${GREEN}All dependencies are installed!${NC}" return 0 fi echo -e "\n${YELLOW}Missing dependencies: ${deps_needed[*]}${NC}" echo -e "Attempting to install...\n" # Use sudo only if available and not on Termux local SUDO="sudo" if [ $IS_TERMUX -eq 1 ]; then SUDO="" log_info "Termux detected — installing without sudo" elif ! check_dep sudo; then SUDO="" fi # Detect package manager and install if [ $IS_TERMUX -eq 1 ]; then log_info "Detected Termux" log_info "Upgrading existing packages first..." pkg upgrade -y pkg install -y $pkg_names_pkg echo -e "\n${YELLOW}${BOLD}NOTE:${NC} You must also install the ${BOLD}Termux:API${NC} app from F-Droid" echo -e " for microphone access.\n" elif check_dep apt-get; then log_info "Detected apt package manager" $SUDO apt-get update -qq $SUDO apt-get install -y $pkg_names_apt elif check_dep dnf; then log_info "Detected dnf package manager" $SUDO dnf install -y $pkg_names_dnf elif check_dep pacman; then log_info "Detected pacman package manager" $SUDO pacman -S --noconfirm $pkg_names_pacman else log_err "No supported package manager found!" log_err "Please install manually: tor, opus-tools, sox, socat, openssl, alsa-utils" return 1 fi # Verify echo -e "\n${BOLD}Verifying installation...${NC}" local failed=0 for dep in "${all_deps[@]}"; do if check_dep "$dep"; then log_ok "$dep" else log_err "$dep still missing!" failed=1 fi done if [ $failed -eq 0 ]; then echo -e "\n${GREEN}${BOLD}All dependencies installed successfully!${NC}" else echo -e "\n${RED}Some dependencies could not be installed.${NC}" return 1 fi } #============================================================================= # TOR HIDDEN SERVICE #============================================================================= setup_tor() { mkdir -p "$TOR_DIR/hidden_service" chmod 700 "$TOR_DIR/hidden_service" cat > "$TOR_CONF" << EOF SocksPort $TOR_SOCKS_PORT DataDirectory $TOR_DIR/data HiddenServiceDir $TOR_DIR/hidden_service HiddenServicePort $LISTEN_PORT 127.0.0.1:$LISTEN_PORT Log notice file $TOR_DIR/tor.log EOF # Append snowflake bridge config if enabled if [ "$SNOWFLAKE_ENABLED" -eq 1 ] && check_dep snowflake-client; then local sf_bin sf_bin=$(command -v snowflake-client) cat >> "$TOR_CONF" << EOF UseBridges 1 ClientTransportPlugin snowflake exec $sf_bin Bridge snowflake 192.0.2.3:80 2B280B23E1107BB62ABFC40DDCC8824814F80A72 fingerprint=2B280B23E1107BB62ABFC40DDCC8824814F80A72 url=https://snowflake-broker.torproject.net/ ice=stun:stun.l.google.com:19302,stun:stun.voip.blackberry.com:3478 EOF log_info "Snowflake bridge enabled in torrc" elif [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then log_warn "Snowflake enabled but snowflake-client not found — skipping bridge" fi chmod 600 "$TOR_CONF" } install_snowflake() { if check_dep snowflake-client; then log_ok "snowflake-client already installed" return 0 fi log_info "Installing snowflake-client..." local SUDO="sudo" if [ $IS_TERMUX -eq 1 ]; then SUDO="" elif ! check_dep sudo; then SUDO="" fi if [ $IS_TERMUX -eq 1 ]; then pkg install -y snowflake-client elif check_dep apt-get; then $SUDO apt-get update -qq $SUDO apt-get install -y snowflake-client elif check_dep dnf; then $SUDO dnf install -y snowflake-client elif check_dep pacman; then $SUDO pacman -S --noconfirm snowflake-pt-client else log_err "No supported package manager found. Install snowflake-client manually." return 1 fi if check_dep snowflake-client; then log_ok "snowflake-client installed successfully" else log_err "snowflake-client installation failed" return 1 fi } start_tor() { if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then log_info "Tor is already running (PID $TOR_PID)" return 0 fi setup_tor # Clear old log so we only see fresh output local tor_log="$TOR_DIR/tor.log" > "$tor_log" log_info "Starting Tor..." if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then echo -e " ${DIM}Snowflake bridge enabled — this may take longer than usual, please be patient.${NC}" fi tor -f "$TOR_CONF" &>/dev/null & TOR_PID=$! save_pid "tor" "$TOR_PID" # Monitor bootstrap progress from the log local waited=0 local timeout=120 local last_pct="" while [ $waited -lt $timeout ]; do # Check if Tor is still running if ! kill -0 "$TOR_PID" 2>/dev/null; then echo "" log_err "Tor process died! Check $tor_log" [ -f "$tor_log" ] && tail -5 "$tor_log" 2>/dev/null return 1 fi # Parse latest bootstrap line from log local bootstrap_line="" bootstrap_line=$(grep -o "Bootstrapped [0-9]*%.*" "$tor_log" 2>/dev/null | tail -1 || true) if [ -n "$bootstrap_line" ]; then local pct="" pct=$(echo "$bootstrap_line" | grep -o '[0-9]*%' || true) if [ -n "$pct" ] && [ "$pct" != "$last_pct" ]; then echo -ne "\r ${DIM}${bootstrap_line}${NC} " last_pct="$pct" fi # Check for 100% if [[ "$bootstrap_line" == *"100%"* ]]; then echo "" break fi else # No bootstrap line yet, show waiting indicator echo -ne "\r ${DIM}Waiting for Tor to start...${NC} " fi sleep 1 waited=$((waited + 1)) done if [ $waited -ge $timeout ]; then echo "" log_err "Timed out waiting for Tor to bootstrap ($timeout seconds)" return 1 fi # Wait for onion address file (should appear quickly after 100%) local addr_wait=0 while [ ! -f "$ONION_FILE" ] && [ $addr_wait -lt 15 ]; do sleep 1 addr_wait=$((addr_wait + 1)) done if [ -f "$ONION_FILE" ]; then local onion onion=$(cat "$ONION_FILE") log_ok "Tor hidden service active" echo -e " ${BOLD}${GREEN}Your address: ${WHITE}${onion}${NC}" return 0 else log_err "Tor bootstrapped but hidden service address not found" return 1 fi } stop_tor() { stop_auto_listener if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then kill "$TOR_PID" 2>/dev/null || true wait "$TOR_PID" 2>/dev/null || true TOR_PID="" log_ok "Tor stopped" else log_info "Tor is not running" fi } get_onion() { if [ -f "$ONION_FILE" ]; then cat "$ONION_FILE" else echo "" fi } rotate_onion() { echo -e "\n${BOLD}${CYAN}═══ Rotate Onion Address ═══${NC}\n" local old_onion old_onion=$(get_onion) if [ -n "$old_onion" ]; then echo -e " ${DIM}Current: ${old_onion}${NC}" fi echo -e " ${YELLOW}This will generate a new .onion address.${NC}" echo -e " ${YELLOW}The old address will stop working.${NC}\n" echo -ne " ${BOLD}Continue? [y/N]: ${NC}" read -r confirm if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then log_info "Cancelled" return fi stop_tor rm -rf "$TOR_DIR/hidden_service" log_info "Old hidden service keys deleted" start_tor } #============================================================================= # ENCRYPTION #============================================================================= set_shared_secret() { echo -e "\n${BOLD}${CYAN}═══ Set Shared Secret ═══${NC}\n" echo -e "${DIM}Both parties must use the same secret for the call to work.${NC}" echo -e "${DIM}Share this secret securely (in person, via encrypted message, etc.)${NC}\n" if [ -n "$SHARED_SECRET" ]; then echo -e "Current secret: ${DIM}(set)${NC}" else echo -e "Current secret: ${DIM}(none)${NC}" fi echo -ne "\n${BOLD}Enter shared secret: ${NC}" read -r new_secret if [ -z "$new_secret" ]; then log_warn "Secret not changed" return fi SHARED_SECRET="$new_secret" mkdir -p "$DATA_DIR" echo "$SHARED_SECRET" > "$SECRET_FILE" chmod 600 "$SECRET_FILE" log_ok "Shared secret saved" } # Encrypt a file encrypt_file() { local infile="$1" outfile="$2" local c="$CIPHER" [ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE") openssl enc -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" \ -in "$infile" -out "$outfile" 2>/dev/null } # Decrypt a file decrypt_file() { local infile="$1" outfile="$2" local c="$CIPHER" [ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE") openssl enc -d -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" \ -in "$infile" -out "$outfile" 2>/dev/null } #============================================================================= # AUDIO PIPELINE #============================================================================= # Record a timed chunk of raw audio (used by audio test) audio_record() { local outfile="$1" local duration="${2:-$CHUNK_DURATION}" if [ $IS_TERMUX -eq 1 ]; then local tmp_rec="$AUDIO_DIR/tmrec_$(uid).m4a" rm -f "$tmp_rec" termux-microphone-record -l "$((duration + 1))" -f "$tmp_rec" &>/dev/null sleep "$duration" termux-microphone-record -q &>/dev/null || true sleep 0.5 if [ -s "$tmp_rec" ]; then ffmpeg -y -i "$tmp_rec" -f s16le -ar "$SAMPLE_RATE" -ac 1 \ "$outfile" &>/dev/null || log_warn "ffmpeg conversion failed" fi rm -f "$tmp_rec" else arecord -f S16_LE -r "$SAMPLE_RATE" -c 1 -t raw -d "$duration" \ -q "$outfile" 2>/dev/null fi } # Start continuous recording in background (returns immediately) # Sets REC_PID and REC_FILE globals start_recording() { local _id=$(uid) if [ $IS_TERMUX -eq 1 ]; then REC_FILE="$AUDIO_DIR/msg_${_id}.m4a" rm -f "$REC_FILE" termux-microphone-record -l 120 -f "$REC_FILE" &>/dev/null & REC_PID=$! else REC_FILE="$AUDIO_DIR/msg_${_id}.raw" arecord -f S16_LE -r "$SAMPLE_RATE" -c 1 -t raw -q "$REC_FILE" 2>/dev/null & REC_PID=$! fi } # Stop recording and send the message # Encodes, encrypts, base64-encodes, and writes to fd 4 stop_and_send() { local _id=$(uid) local raw_file="$AUDIO_DIR/tx_${_id}.raw" local opus_file="$AUDIO_DIR/tx_${_id}.opus" local enc_file="$AUDIO_DIR/tx_enc_${_id}.opus" # Stop the recording if [ $IS_TERMUX -eq 1 ]; then termux-microphone-record -q &>/dev/null || true kill "$REC_PID" 2>/dev/null || true wait "$REC_PID" 2>/dev/null || true sleep 0.3 # let file flush # Convert m4a → raw PCM if [ -s "$REC_FILE" ]; then ffmpeg -y -i "$REC_FILE" -f s16le -ar "$SAMPLE_RATE" -ac 1 \ "$raw_file" &>/dev/null || true fi rm -f "$REC_FILE" else kill "$REC_PID" 2>/dev/null || true wait "$REC_PID" 2>/dev/null || true raw_file="$REC_FILE" # already in raw format fi REC_PID="" REC_FILE="" # Encode → encrypt → send if [ -s "$raw_file" ]; then opusenc --raw --raw-rate "$SAMPLE_RATE" --raw-chan 1 \ --bitrate "$OPUS_BITRATE" --framesize "$OPUS_FRAMESIZE" \ --speech --quiet \ "$raw_file" "$opus_file" 2>/dev/null if [ -s "$opus_file" ]; then encrypt_file "$opus_file" "$enc_file" 2>/dev/null if [ -s "$enc_file" ]; then local enc_size enc_size=$(stat -c%s "$enc_file" 2>/dev/null || echo 0) local size_kb=$(( enc_size * 10 / 1024 )) local size_whole=$(( size_kb / 10 )) local size_frac=$(( size_kb % 10 )) local b64 b64=$(base64 -w 0 "$enc_file" 2>/dev/null) echo "AUDIO:${b64}" >&4 2>/dev/null || true LAST_SENT_INFO="${size_whole}.${size_frac}KB" fi fi fi rm -f "$raw_file" "$opus_file" "$enc_file" 2>/dev/null } # Play audio (platform-aware) audio_play() { local infile="$1" local rate="${2:-48000}" if [ $IS_TERMUX -eq 1 ]; then # Termux: use Android's native media player termux-media-player play "$infile" &>/dev/null || true # Wait for playback to finish while termux-media-player info 2>/dev/null | grep -q "playing"; do sleep 0.5 done else # Linux: use ALSA aplay aplay -f S16_LE -r "$rate" -c 1 -q "$infile" 2>/dev/null fi } # Play an opus file play_chunk() { local opus_file="$1" if [ $IS_TERMUX -eq 1 ]; then # Termux: decode to wav, play via Android media player local wav_file="$AUDIO_DIR/play_$(uid).wav" opusdec --quiet "$opus_file" "$wav_file" 2>/dev/null || true if [ -s "$wav_file" ]; then audio_play "$wav_file" fi rm -f "$wav_file" else # Linux: pipe decode directly to aplay opusdec --quiet --rate 48000 "$opus_file" - 2>/dev/null | \ aplay -f S16_LE -r 48000 -c 1 -q 2>/dev/null || true fi } #============================================================================= # CALL CLEANUP — RESET EVERYTHING TO FRESH STATE #============================================================================= cleanup_call() { # Restore terminal to sane state if [ -n "$ORIGINAL_STTY" ]; then stty "$ORIGINAL_STTY" 2>/dev/null || true fi stty sane 2>/dev/null || true ORIGINAL_STTY="" # Close pipe file descriptors to unblock any blocking reads # NOTE: must use { } group so 2>/dev/null doesn't permanently redirect stderr { exec 3<&-; } 2>/dev/null || true { exec 4>&-; } 2>/dev/null || true # Kill all call-related processes by PID files for pidfile in "$PID_DIR"/socat.pid "$PID_DIR"/socat_call.pid "$PID_DIR"/recv_loop.pid; do if [ -f "$pidfile" ]; then local pid pid=$(cat "$pidfile" 2>/dev/null) if [ -n "$pid" ]; then kill "$pid" 2>/dev/null || true kill -9 "$pid" 2>/dev/null || true fi rm -f "$pidfile" fi done # Kill recording process if active if [ -n "$REC_PID" ]; then kill "$REC_PID" 2>/dev/null || true kill -9 "$REC_PID" 2>/dev/null || true REC_PID="" fi # Wait briefly for processes to die sleep 0.2 # Remove all runtime files for this PID rm -f "$PTT_FLAG" "$CONNECTED_FLAG" rm -f "$RECV_PIPE" "$SEND_PIPE" rm -f "$CIPHER_RUNTIME_FILE" rm -f "$DATA_DIR/run/remote_id_$$" rm -f "$DATA_DIR/run/remote_cipher_$$" rm -f "$DATA_DIR/run/incoming_$$" # Clean temp audio files rm -f "$AUDIO_DIR"/*.opus "$AUDIO_DIR"/*.bin "$AUDIO_DIR"/*.txt "$AUDIO_DIR"/*.wav 2>/dev/null || true # Reset state variables CALL_ACTIVE=0 REC_PID="" } #============================================================================= # AUTO-LISTEN (BACKGROUND LISTENER) #============================================================================= start_auto_listener() { # Only start if auto-listen is enabled and Tor is running if [ "$AUTO_LISTEN" -ne 1 ]; then return 0; fi if [ -z "$SHARED_SECRET" ]; then return 0; fi if [ -z "$TOR_PID" ] || ! kill -0 "$TOR_PID" 2>/dev/null; then return 0; fi # Stop any existing listener first stop_auto_listener mkdir -p "$AUDIO_DIR" "$DATA_DIR/run" rm -f "$RECV_PIPE" "$SEND_PIPE" "$AUTO_LISTEN_FLAG" mkfifo "$RECV_PIPE" "$SEND_PIPE" socat "TCP-LISTEN:$LISTEN_PORT,reuseaddr" \ "SYSTEM:touch $AUTO_LISTEN_FLAG; cat $SEND_PIPE & cat > $RECV_PIPE" & AUTO_LISTEN_PID=$! save_pid "socat" "$AUTO_LISTEN_PID" } stop_auto_listener() { if [ -n "$AUTO_LISTEN_PID" ]; then kill "$AUTO_LISTEN_PID" 2>/dev/null || true kill -9 "$AUTO_LISTEN_PID" 2>/dev/null || true AUTO_LISTEN_PID="" fi rm -f "$AUTO_LISTEN_FLAG" "$RECV_PIPE" "$SEND_PIPE" } # Check if an incoming call arrived on the background listener check_auto_listen() { if [ -f "$AUTO_LISTEN_FLAG" ]; then rm -f "$AUTO_LISTEN_FLAG" touch "$CONNECTED_FLAG" echo -e "\n ${GREEN}${BOLD}Incoming call detected!${NC}" >&2 sleep 0.5 in_call_session "$RECV_PIPE" "$SEND_PIPE" "" cleanup_call # Restart listener for next call start_auto_listener return 0 fi return 1 } # Start listening for incoming calls (manual, blocking) listen_for_call() { if [ -z "$SHARED_SECRET" ]; then log_err "No shared secret set! Use option 4 first." return 1 fi start_tor || return 1 # Stop auto-listener if running (we'll do manual listen) stop_auto_listener local onion onion=$(get_onion) echo -e "\n${BOLD}${CYAN}═══ Listening for Calls ═══${NC}\n" echo -e " ${GREEN}Your address:${NC} ${BOLD}${WHITE}$onion${NC}" echo -e " ${GREEN}Listening on:${NC} port $LISTEN_PORT" echo -e "\n ${DIM}Share your .onion address with the caller.${NC}" echo -e " ${DIM}Press Ctrl+C to stop listening.${NC}\n" mkdir -p "$AUDIO_DIR" log_info "Waiting for incoming connection..." rm -f "$RECV_PIPE" "$SEND_PIPE" mkfifo "$RECV_PIPE" "$SEND_PIPE" local incoming_flag="$DATA_DIR/run/incoming_$$" rm -f "$incoming_flag" socat "TCP-LISTEN:$LISTEN_PORT,reuseaddr" \ "SYSTEM:touch $incoming_flag; cat $SEND_PIPE & cat > $RECV_PIPE" & local socat_pid=$! save_pid "socat" "$socat_pid" while [ ! -f "$incoming_flag" ]; do if ! kill -0 "$socat_pid" 2>/dev/null; then log_err "Listener stopped unexpectedly" rm -f "$RECV_PIPE" "$SEND_PIPE" "$incoming_flag" # Restart auto-listener if enabled start_auto_listener return 1 fi sleep 0.5 done touch "$CONNECTED_FLAG" log_ok "Call connected!" in_call_session "$RECV_PIPE" "$SEND_PIPE" "" cleanup_call # Restart auto-listener if enabled start_auto_listener } # Call a remote .onion address call_remote() { if [ -z "$SHARED_SECRET" ]; then log_err "No shared secret set! Use option 4 first." return 1 fi echo -e "\n${BOLD}${CYAN}═══ Make a Call ═══${NC}\n" echo -ne " ${BOLD}Enter .onion address: ${NC}" read -r remote_onion if [ -z "$remote_onion" ]; then log_warn "No address entered" return 1 fi # Append .onion if not present if [[ "$remote_onion" != *.onion ]]; then remote_onion="${remote_onion}.onion" fi start_tor || return 1 echo -e "\n ${DIM}Connecting to ${remote_onion}:${LISTEN_PORT} via Tor...${NC}" mkdir -p "$AUDIO_DIR" touch "$CONNECTED_FLAG" # Create named pipes rm -f "$RECV_PIPE" "$SEND_PIPE" mkfifo "$RECV_PIPE" "$SEND_PIPE" # Connect via Tor SOCKS proxy using socat socat "SOCKS4A:127.0.0.1:${remote_onion}:${LISTEN_PORT},socksport=${TOR_SOCKS_PORT}" \ "SYSTEM:cat $SEND_PIPE & cat > $RECV_PIPE" & local socat_pid=$! save_pid "socat_call" "$socat_pid" # Animated connecting indicator while socat establishes connection ( local dots="" while true; do for dots in "." ".." "..." " "; do echo -ne "\r ${CYAN}${BOLD}Connecting${dots}${NC} " >&2 sleep 0.3 done done ) & local spinner_pid=$! # Give socat a moment to connect sleep 2 if kill -0 "$socat_pid" 2>/dev/null; then in_call_session "$RECV_PIPE" "$SEND_PIPE" "$remote_onion" "$spinner_pid" else # Kill spinner and show error kill "$spinner_pid" 2>/dev/null || true wait "$spinner_pid" 2>/dev/null || true echo -ne "\r " >&2 echo "" >&2 log_err "Failed to connect. Check the address and ensure Tor is running." fi # Full cleanup after call ends cleanup_call } #============================================================================= # IN-CALL SESSION — PTT VOICE LOOP #============================================================================= # Draw the call header (reusable for redraw after settings) draw_call_header() { local _remote="${1:-}" local _rcipher="${2:-}" clear >&2 # Row counter for ANSI cursor positioning (clear sets cursor to row 1) local _r=1 if [ -n "$_remote" ]; then echo -e "\n${BOLD}${BG_GREEN}${WHITE} CALL CONNECTED ${NC} ${CYAN}${_remote}${NC}\n" >&2 else echo -e "\n${BOLD}${BG_GREEN}${WHITE} CALL CONNECTED ${NC}\n" >&2 fi _r=4 # \n(row1) + header(row2) + \n(row3) + echo-newline → cursor at row 4 # Cipher info CIPHER_ROW=$_r local cipher_upper="${CIPHER^^}" if [ -n "$_rcipher" ]; then local rcipher_upper="${_rcipher^^}" if [ "$_rcipher" = "$CIPHER" ]; then echo -e " ${GREEN}●${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2 echo -e " ${GREEN}●${NC} Remote cipher: ${WHITE}${rcipher_upper}${NC}" >&2 else echo -e " ${RED}●${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2 echo -e " ${RED}●${NC} Remote cipher: ${WHITE}${rcipher_upper}${NC}" >&2 fi else echo -e " ${GREEN}●${NC} Local cipher: ${WHITE}${cipher_upper}${NC}" >&2 echo -e " ${DIM}●${NC} Remote cipher: ${DIM}waiting...${NC}" >&2 fi _r=$((_r + 2)) # Snowflake bridge info if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then local tor_log="$TOR_DIR/tor.log" echo "" >&2; _r=$((_r + 1)) echo -e " ${TOR_PURPLE}●${NC} ${BOLD}Snowflake bridge${NC}" >&2; _r=$((_r + 1)) if [ -f "$tor_log" ]; then local bridge_line="" bridge_line=$(grep "new bridge descriptor" "$tor_log" 2>/dev/null | tail -1 || true) if [ -n "$bridge_line" ]; then local bridge_name="" bridge_name=$(echo "$bridge_line" | sed -n "s/.*new bridge descriptor '\([^']*\)'.*/\1/p" || true) local bridge_fp="" bridge_fp=$(echo "$bridge_line" | sed -n 's/.*(\(fresh\|stale\)): \(.*\)/\2/p' || true) if [ -n "$bridge_name" ]; then local fp_display="$bridge_fp" if [ ${#fp_display} -gt 40 ]; then fp_display="${fp_display:0:40}..." fi echo -e " ${DIM}descriptor:${NC} ${WHITE}${bridge_name}${NC} ${DIM}— ${fp_display}${NC}" >&2 _r=$((_r + 1)) fi fi local proxy_line="" proxy_line=$(grep 'Managed proxy.*snowflake' "$tor_log" 2>/dev/null | tail -1 || true) if [ -n "$proxy_line" ]; then if echo "$proxy_line" | grep -q "connected"; then echo -e " ${DIM}transport:${NC} ${GREEN}connected${NC}" >&2 else echo -e " ${DIM}transport:${NC} ${YELLOW}connecting...${NC}" >&2 fi _r=$((_r + 1)) fi fi fi echo "" >&2; _r=$((_r + 1)) # Static placeholders — updated in-place via ANSI positioning echo -e " ${DIM}Last sent: --${NC}" >&2 SENT_INFO_ROW=$_r _r=$((_r + 1)) echo -e " ${DIM}Last recv: --${NC}" >&2 RECV_INFO_ROW=$_r _r=$((_r + 1)) echo "" >&2; _r=$((_r + 1)) # Static status bar if [ $IS_TERMUX -eq 1 ]; then echo -ne " ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2 else echo -ne " ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2 fi STATUS_ROW=$_r echo "" >&2 echo "" >&2 } in_call_session() { local recv_pipe="$1" local send_pipe="$2" local known_remote="${3:-}" local spinner_pid="${4:-}" CALL_ACTIVE=1 rm -f "$PTT_FLAG" mkdir -p "$AUDIO_DIR" # Write cipher to runtime file so subshells can track changes echo "$CIPHER" > "$CIPHER_RUNTIME_FILE" # Open persistent file descriptors for the pipes exec 3< "$recv_pipe" # fd 3 = read from remote exec 4> "$send_pipe" # fd 4 = write to remote # Send our onion address and cipher for handshake local my_onion my_onion=$(get_onion) if [ -n "$my_onion" ]; then echo "ID:${my_onion}" >&4 2>/dev/null || true fi echo "CIPHER:${CIPHER}" >&4 2>/dev/null || true # Remote address and cipher (populated by handshake / receive loop) local remote_id_file="$DATA_DIR/run/remote_id_$$" local remote_cipher_file="$DATA_DIR/run/remote_cipher_$$" rm -f "$remote_id_file" "$remote_cipher_file" # If we don't know the remote address yet (listener), wait briefly for handshake local remote_display="$known_remote" local remote_cipher="" if [ -z "$remote_display" ]; then # Read first line — should be ID local first_line="" if read -r -t 3 first_line <&3 2>/dev/null; then if [[ "$first_line" == ID:* ]]; then remote_display="${first_line#ID:}" echo "$remote_display" > "$remote_id_file" elif [[ "$first_line" == CIPHER:* ]]; then remote_cipher="${first_line#CIPHER:}" fi fi fi # Try to read CIPHER: line (quick, non-blocking) if [ -z "$remote_cipher" ]; then local cline="" if read -r -t 1 cline <&3 2>/dev/null; then if [[ "$cline" == CIPHER:* ]]; then remote_cipher="${cline#CIPHER:}" fi fi fi # Save remote cipher for later redraws if [ -n "$remote_cipher" ]; then echo "$remote_cipher" > "$remote_cipher_file" fi # Kill connecting spinner and draw call header if [ -n "$spinner_pid" ]; then kill "$spinner_pid" 2>/dev/null || true wait "$spinner_pid" 2>/dev/null || true fi draw_call_header "$remote_display" "$remote_cipher" # Start receive handler in background # Protocol: ID:, PTT_START, PTT_STOP, PING, # or "AUDIO:" ( while [ -f "$CONNECTED_FLAG" ]; do local line="" if read -r line <&3 2>/dev/null; then case "$line" in PTT_START) printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 printf ' \033[42;1;37m REMOTE TALKING \033[0m ' >&2 printf '\033[u' >&2 ;; PTT_STOP) # silent — Last recv row provides feedback ;; PING) # silent — no display update ;; ID:*) # Caller ID received (save but don't print — already in header) local remote_addr="${line#ID:}" echo "$remote_addr" > "$remote_id_file" ;; CIPHER:*) # Remote side sent/changed their cipher — save and update display local rc="${line#CIPHER:}" echo "$rc" > "$remote_cipher_file" 2>/dev/null || true # Read current local cipher from runtime file local _cur_cipher="$CIPHER" [ -f "$CIPHER_RUNTIME_FILE" ] && _cur_cipher=$(cat "$CIPHER_RUNTIME_FILE") local _cu="${_cur_cipher^^}" local _ru="${rc^^}" # Update cipher lines in-place using ANSI cursor positioning (rows 4-5) local _dot_color if [ "$rc" = "$_cur_cipher" ]; then _dot_color="$GREEN" else _dot_color="$RED" fi printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$CIPHER_ROW" >&2 printf ' %b●%b Local cipher: %b%s%b\r\n' "$_dot_color" "$NC" "$WHITE" "$_cu" "$NC" >&2 printf '\033[K' >&2 printf ' %b●%b Remote cipher: %b%s%b' "$_dot_color" "$NC" "$WHITE" "$_ru" "$NC" >&2 printf '\033[u' >&2 ;; MSG:*) # Encrypted text message received local msg_b64="${line#MSG:}" local _mid=$(uid) local msg_enc="$AUDIO_DIR/msg_enc_${_mid}.bin" local msg_dec="$AUDIO_DIR/msg_dec_${_mid}.txt" echo "$msg_b64" | base64 -d > "$msg_enc" 2>/dev/null || true if [ -s "$msg_enc" ]; then if decrypt_file "$msg_enc" "$msg_dec" 2>/dev/null; then local msg_text msg_text=$(cat "$msg_dec" 2>/dev/null) echo -e "\n ${MAGENTA}${BOLD}[MSG]${NC} ${WHITE}${msg_text}${NC}" >&2 fi fi rm -f "$msg_enc" "$msg_dec" 2>/dev/null ;; AUDIO:*) # Extract base64 data, decode, decrypt, play local b64_data="${line#AUDIO:}" local _rid=$(uid) local enc_file="$AUDIO_DIR/recv_enc_${_rid}.opus" local dec_file="$AUDIO_DIR/recv_dec_${_rid}.opus" echo "$b64_data" | base64 -d > "$enc_file" 2>/dev/null || true if [ -s "$enc_file" ]; then if decrypt_file "$enc_file" "$dec_file" 2>/dev/null; then # Calculate recv size local _enc_sz=0 _enc_sz=$(stat -c%s "$enc_file" 2>/dev/null || echo 0) local _sz_kb=$(( _enc_sz * 10 / 1024 )) local _sz_w=$(( _sz_kb / 10 )) local _sz_f=$(( _sz_kb % 10 )) local _recv_info="${_sz_w}.${_sz_f}KB" # Update static "Last recv" row via ANSI positioning printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$RECV_INFO_ROW" >&2 printf ' \033[2mLast recv: \033[0m\033[1;37m%s\033[0m' "$_recv_info" >&2 printf '\033[u' >&2 play_chunk "$dec_file" 2>/dev/null || true fi fi rm -f "$enc_file" "$dec_file" 2>/dev/null ;; HANGUP) # Remote party hung up echo -e "\r\n\r\n ${YELLOW}${BOLD}Remote party hung up.${NC}" >&2 rm -f "$CONNECTED_FLAG" break ;; esac else # Pipe closed or error — connection lost echo -e "\r\n\r\n ${RED}${BOLD}Connection lost.${NC}" >&2 rm -f "$CONNECTED_FLAG" break fi done ) & local recv_pid=$! save_pid "recv_loop" "$recv_pid" # Main PTT input loop ORIGINAL_STTY=$(stty -g) stty raw -echo -icanon min 0 time 1 REC_PID="" REC_FILE="" LAST_SENT_INFO="" local ptt_active=0 # Status bar is already drawn by draw_call_header while [ -f "$CONNECTED_FLAG" ]; do local key="" key=$(dd bs=1 count=1 2>/dev/null) || true if [ "$key" = "$PTT_KEY" ]; then if [ $IS_TERMUX -eq 1 ]; then # TERMUX: Toggle mode if [ $ptt_active -eq 0 ]; then ptt_active=1 printf '\033[s' >&2; printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 printf ' \033[41;1;37m \u25cf RECORDING \033[0m \033[2m[SPACE]=Send\033[0m ' >&2 printf '\033[u' >&2 start_recording else ptt_active=0 stop_and_send echo "PTT_STOP" >&4 2>/dev/null || true # Update Last sent + status bar printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$SENT_INFO_ROW" >&2 printf ' \033[2mLast sent: \033[0m\033[1;37m%s\033[0m' "$LAST_SENT_INFO" >&2 printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 printf ' \033[1;32m Sent! \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 printf '\033[u' >&2 fi else # LINUX: Hold-to-talk if [ $ptt_active -eq 0 ]; then ptt_active=1 printf '\033[s' >&2; printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 printf ' \033[41;1;37;5m \u25cf RECORDING \033[0m ' >&2 printf '\033[u' >&2 stty time 5 # longer timeout to span keyboard repeat delay start_recording fi fi elif [ "$key" = "q" ] || [ "$key" = "Q" ]; then # If recording, cancel it if [ $ptt_active -eq 1 ] && [ -n "$REC_PID" ]; then if [ $IS_TERMUX -eq 1 ]; then termux-microphone-record -q &>/dev/null || true fi kill "$REC_PID" 2>/dev/null || true wait "$REC_PID" 2>/dev/null || true rm -f "$REC_FILE" 2>/dev/null REC_PID="" REC_FILE="" fi echo -e "\r\n${YELLOW}Hanging up...${NC}" >&2 echo "HANGUP" >&4 2>/dev/null || true rm -f "$PTT_FLAG" "$CONNECTED_FLAG" break elif [ -z "$key" ]; then # No key pressed (timeout) — on Linux, release = stop and send if [ $IS_TERMUX -eq 0 ] && [ $ptt_active -eq 1 ]; then stty time 1 # restore fast timeout for key detection ptt_active=0 stop_and_send echo "PTT_STOP" >&4 2>/dev/null || true # Update Last sent + status bar printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$SENT_INFO_ROW" >&2 printf ' \033[2mLast sent: \033[0m\033[1;37m%s\033[0m' "$LAST_SENT_INFO" >&2 printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 printf ' \033[1;32m Sent! \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 printf '\033[u' >&2 fi elif [ "$key" = "t" ] || [ "$key" = "T" ]; then # Text chat mode # Switch to cooked mode for text input stty "$ORIGINAL_STTY" 2>/dev/null || stty sane echo "" >&2 echo -ne " ${CYAN}${BOLD}MSG>${NC} " >&2 local chat_msg="" read -r chat_msg if [ -n "$chat_msg" ]; then # Encrypt and send local _cid=$(uid) local chat_plain="$AUDIO_DIR/chat_${_cid}.txt" local chat_enc="$AUDIO_DIR/chat_enc_${_cid}.bin" echo -n "$chat_msg" > "$chat_plain" encrypt_file "$chat_plain" "$chat_enc" 2>/dev/null if [ -s "$chat_enc" ]; then local chat_b64 chat_b64=$(base64 -w 0 "$chat_enc" 2>/dev/null) echo "MSG:${chat_b64}" >&4 2>/dev/null || true echo -e " ${DIM}[you] ${chat_msg}${NC}" >&2 fi rm -f "$chat_plain" "$chat_enc" 2>/dev/null fi # Switch back to raw mode for PTT stty raw -echo -icanon min 0 time 1 # Restore status bar to Ready printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 if [ $IS_TERMUX -eq 1 ]; then printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 else printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 fi printf '\033[u' >&2 elif [ "$key" = "s" ] || [ "$key" = "S" ]; then # Mid-call settings stty "$ORIGINAL_STTY" 2>/dev/null || stty sane # Flush any leftover raw mode input read -r -t 0.1 -n 10000 2>/dev/null || true settings_menu # Redraw call header and switch back to raw mode local _rd="" _rc="" [ -f "$remote_id_file" ] && _rd=$(cat "$remote_id_file" 2>/dev/null) [ -z "$_rd" ] && _rd="$known_remote" [ -f "$remote_cipher_file" ] && _rc=$(cat "$remote_cipher_file" 2>/dev/null) draw_call_header "$_rd" "$_rc" stty raw -echo -icanon min 0 time 1 # Restore status bar to Ready (header was redrawn, STATUS_ROW is fresh) printf '\033[s' >&2 printf '\033[%d;1H\033[K' "$STATUS_ROW" >&2 if [ $IS_TERMUX -eq 1 ]; then printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 else printf ' \033[1;32m Ready \033[0m \033[2m[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up\033[0m ' >&2 fi printf '\033[u' >&2 fi done echo -e "\n${BOLD}${RED} CALL ENDED ${NC}\n" } #============================================================================= # AUDIO TEST (LOOPBACK) #============================================================================= test_audio() { echo -e "\n${BOLD}${CYAN}═══ Audio Loopback Test ═══${NC}\n" # Check dependencies first local missing=0 local audio_deps=(opusenc opusdec) if [ $IS_TERMUX -eq 1 ]; then audio_deps+=(termux-microphone-record ffmpeg) else audio_deps+=(arecord aplay) fi for dep in "${audio_deps[@]}"; do if ! check_dep "$dep"; then log_err "$dep not found — run option 7 to install dependencies first" missing=1 fi done if [ $missing -eq 1 ]; then return 1 fi echo -e " ${DIM}This will record 3 seconds of audio, encode it with Opus,${NC}" echo -e " ${DIM}and play it back to verify your audio pipeline works.${NC}\n" mkdir -p "$AUDIO_DIR" # Step 1: Record echo -ne " ${YELLOW}● Recording for 3 seconds... speak now!${NC} " local _tid=$(uid) local raw_file="$AUDIO_DIR/test_${_tid}.raw" audio_record "$raw_file" 3 echo -e "${GREEN}done${NC}" if [ ! -s "$raw_file" ]; then log_err "Recording failed — check your microphone" return 1 fi local raw_size raw_size=$(stat -c%s "$raw_file") echo -e " ${DIM}Recorded $raw_size bytes of raw audio${NC}" # Step 2: Encode with Opus echo -ne " ${YELLOW}● Encoding with Opus at ${OPUS_BITRATE}kbps...${NC} " local opus_file="$AUDIO_DIR/test_${_tid}.opus" opusenc --raw --raw-rate "$SAMPLE_RATE" --raw-chan 1 \ --bitrate "$OPUS_BITRATE" --framesize "$OPUS_FRAMESIZE" \ --speech --quiet \ "$raw_file" "$opus_file" 2>/dev/null echo -e "${GREEN}done${NC}" if [ ! -s "$opus_file" ]; then log_err "Opus encoding failed" rm -f "$raw_file" return 1 fi local opus_size opus_size=$(stat -c%s "$opus_file") echo -e " ${DIM}Opus size: $opus_size bytes (compression ratio: $((raw_size / opus_size))x)${NC}" # Step 3: Encrypt + Decrypt round-trip (if secret is set) if [ -n "$SHARED_SECRET" ]; then echo -ne " ${YELLOW}● Encrypting and decrypting...${NC} " local enc_file="$AUDIO_DIR/test_enc_${_tid}.opus" local dec_file="$AUDIO_DIR/test_dec_${_tid}.opus" encrypt_file "$opus_file" "$enc_file" decrypt_file "$enc_file" "$dec_file" if cmp -s "$opus_file" "$dec_file"; then echo -e "${GREEN}encryption round-trip OK${NC}" else echo -e "${RED}encryption round-trip FAILED${NC}" fi rm -f "$enc_file" opus_file="$dec_file" fi # Step 4: Decode and play echo -ne " ${YELLOW}● Playing back...${NC} " play_chunk "$opus_file" echo -e "${GREEN}done${NC}" rm -f "$raw_file" "$opus_file" "$AUDIO_DIR/test_dec_${_tid}.opus" 2>/dev/null echo -e "\n ${GREEN}${BOLD}Audio test complete!${NC}" echo -e " ${DIM}If you heard your voice, the pipeline is working.${NC}\n" } #============================================================================= # SHOW STATUS #============================================================================= show_status() { echo -e "\n${BOLD}${CYAN}═══ Status ═══${NC}\n" # Tor status if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then echo -e " ${GREEN}●${NC} Tor running (PID $TOR_PID)" local onion onion=$(get_onion) if [ -n "$onion" ]; then echo -e " ${BOLD}${WHITE} Address: ${onion}${NC}" fi else echo -e " ${RED}●${NC} Tor not running" fi # Secret if [ -n "$SHARED_SECRET" ]; then echo -e " ${GREEN}●${NC} Shared secret set" else echo -e " ${RED}●${NC} No shared secret (set one before calling)" fi # Audio if check_dep arecord && check_dep opusenc; then echo -e " ${GREEN}●${NC} Audio pipeline ready" else echo -e " ${RED}●${NC} Audio dependencies missing" fi # Snowflake if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then if check_dep snowflake-client; then echo -e " ${GREEN}●${NC} Snowflake bridge enabled" else echo -e " ${YELLOW}●${NC} Snowflake enabled (client not installed)" fi else echo -e " ${DIM}●${NC} Snowflake bridge disabled" fi # Config echo -e "\n ${DIM}Listen port: $LISTEN_PORT${NC}" echo -e " ${DIM}SOCKS port: $TOR_SOCKS_PORT${NC}" echo -e " ${DIM}Cipher: $CIPHER${NC}" echo -e " ${DIM}Opus bitrate: ${OPUS_BITRATE}kbps${NC}" echo -e " ${DIM}Opus frame: ${OPUS_FRAMESIZE}ms${NC}" echo -e " ${DIM}PTT key: [SPACEBAR]${NC}" echo "" } #============================================================================= # SETTINGS MENU #============================================================================= settings_menu() { while true; do clear echo -e "\n${BOLD}${CYAN}═══ Settings ═══${NC}\n" echo -e " ${DIM}Current cipher: ${NC}${WHITE}${CIPHER}${NC}" echo -e " ${DIM}Current Opus bitrate: ${NC}${WHITE}${OPUS_BITRATE} kbps${NC}" echo -e " ${DIM}Current Opus frame: ${NC}${WHITE}${OPUS_FRAMESIZE} ms${NC}" local sf_label="${RED}disabled${NC}" if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then sf_label="${GREEN}enabled${NC}" fi echo -e " ${DIM}Snowflake bridge: ${NC}${sf_label}" local al_label="${RED}disabled${NC}" if [ "$AUTO_LISTEN" -eq 1 ]; then al_label="${GREEN}enabled${NC}" fi echo -e " ${DIM}Auto-listen: ${NC}${al_label}" local ptt_display="SPACE" [ "$PTT_KEY" != " " ] && ptt_display="$PTT_KEY" echo -e " ${DIM}PTT key: ${NC}${WHITE}${ptt_display}${NC}\n" echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}│${NC} Change encryption cipher" echo -e " ${BOLD}${WHITE}2${NC} ${CYAN}│${NC} Change Opus encoding quality" echo -e " ${BOLD}${WHITE}3${NC} ${CYAN}│${NC} Snowflake bridge (censorship circumvention)" echo -e " ${BOLD}${WHITE}4${NC} ${CYAN}│${NC} Auto-listen (listen for calls automatically once Tor starts)" echo -e " ${BOLD}${WHITE}5${NC} ${CYAN}│${NC} Change PTT (push-to-talk) key" echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}│${NC} ${DIM}Back to main menu${NC}" echo "" echo -ne " ${BOLD}Select: ${NC}" read -r schoice case "$schoice" in 1) settings_cipher ;; 2) settings_opus ;; 3) settings_snowflake ;; 4) if [ "$AUTO_LISTEN" -eq 1 ]; then AUTO_LISTEN=0 stop_auto_listener log_ok "Auto-listen disabled" else AUTO_LISTEN=1 log_ok "Auto-listen enabled" start_auto_listener fi save_config sleep 1 ;; 5) local _pd="SPACE" [ "$PTT_KEY" != " " ] && _pd="$PTT_KEY" echo -e "\n ${DIM}Current PTT key: ${NC}${WHITE}${_pd}${NC}" echo -ne " ${BOLD}Press the key you want to use for PTT: ${NC}" # Read a single character in raw mode local _old_stty _old_stty=$(stty -g) stty raw -echo local _newkey _newkey=$(dd bs=1 count=1 2>/dev/null) || true stty "$_old_stty" if [ -n "$_newkey" ]; then PTT_KEY="$_newkey" save_config local _nd="SPACE" [ "$PTT_KEY" != " " ] && _nd="$PTT_KEY" log_ok "PTT key set to: ${_nd}" fi sleep 1 ;; 0|q|Q) return ;; *) echo -e "\n ${RED}Invalid choice${NC}" sleep 1 ;; esac done } settings_cipher() { echo -e "\n${BOLD}${CYAN}═══ Select Encryption Cipher ═══${NC}\n" echo -e " ${DIM}Current: ${NC}${GREEN}${CIPHER}${NC}\n" # Curated cipher list ranked from strongest to adequate # Only includes ciphers verified to work with openssl enc -pbkdf2 # Excludes: ECB modes (pattern leakage), DES/RC2/RC4/Blowfish (weak), aliases local ciphers=( # ── 256-bit (Strongest) ── "aes-256-ctr" "aes-256-cbc" "aes-256-cfb" "aes-256-ofb" "chacha20" "camellia-256-ctr" "camellia-256-cbc" "aria-256-ctr" "aria-256-cbc" # ── 192-bit (Strong) ── "aes-192-ctr" "aes-192-cbc" "camellia-192-ctr" "camellia-192-cbc" "aria-192-ctr" "aria-192-cbc" # ── 128-bit (Adequate) ── "aes-128-ctr" "aes-128-cbc" "camellia-128-ctr" "camellia-128-cbc" "aria-128-ctr" "aria-128-cbc" ) local total=${#ciphers[@]} while true; do clear echo -e "\n${BOLD}${CYAN}═══ Available Ciphers ═══${NC}" echo -e " ${DIM}Current: ${NC}${GREEN}${CIPHER}${NC}" echo -e " ${DIM}${total} ciphers, ranked strongest → adequate${NC}\n" local tier="" for ((i = 0; i < total; i++)); do local num=$((i + 1)) local c="${ciphers[$i]}" # Print tier headers if [ $i -eq 0 ]; then echo -e " ${GREEN}${BOLD}── 256-bit (Strongest) ──${NC}" elif [ $i -eq 9 ]; then echo -e " ${YELLOW}${BOLD}── 192-bit (Strong) ──${NC}" elif [ $i -eq 15 ]; then echo -e " ${WHITE}${BOLD}── 128-bit (Adequate) ──${NC}" fi if [ "$c" = "$CIPHER" ]; then printf " ${GREEN}${BOLD}%4d${NC} ${CYAN}│${NC} ${GREEN}%-30s ◄ current${NC}\n" "$num" "$c" else printf " ${WHITE}${BOLD}%4d${NC} ${CYAN}│${NC} %-30s\n" "$num" "$c" fi done echo "" echo -e " ${DIM}[0] cancel${NC}" echo -ne " ${BOLD}Enter cipher number: ${NC}" read -r cinput case "$cinput" in 0|q|Q) return ;; '') ;; *) if [[ "$cinput" =~ ^[0-9]+$ ]] && [ "$cinput" -ge 1 ] && [ "$cinput" -le "$total" ]; then local selected="${ciphers[$((cinput - 1))]}" # Validate that openssl can actually use this cipher if echo "test" | openssl enc -"${selected}" -pbkdf2 -pass pass:test 2>/dev/null | openssl enc -d -"${selected}" -pbkdf2 -pass pass:test &>/dev/null; then CIPHER="$selected" save_config # Update runtime file for live mid-call sync [ -f "$CIPHER_RUNTIME_FILE" ] && echo "$CIPHER" > "$CIPHER_RUNTIME_FILE" # Notify remote side if in a call if [ "$CALL_ACTIVE" -eq 1 ]; then echo "CIPHER:${CIPHER}" >&4 2>/dev/null || true fi echo -e "\n ${GREEN}${BOLD}✓${NC} Cipher set to ${WHITE}${BOLD}${CIPHER}${NC}" else echo -e "\n ${RED}${BOLD}✗${NC} Cipher '${selected}' failed validation — not compatible with stream encryption" fi echo -ne " ${DIM}Press Enter to continue...${NC}" read -r return else echo -e "\n ${RED}Invalid number${NC}" sleep 1 fi ;; esac done } settings_opus() { echo -e "\n${BOLD}${CYAN}═══ Opus Encoding Quality ═══${NC}\n" echo -e " ${DIM}Current bitrate: ${NC}${GREEN}${OPUS_BITRATE} kbps${NC}\n" local -a presets=(6 8 12 16 24 32 48 64) local -a labels=( "6 kbps — Minimum (very low bandwidth)" "8 kbps — Low (narrowband voice)" "12 kbps — Medium-Low (clear voice)" "16 kbps — Medium (recommended for Tor)" "24 kbps — Medium-High (good quality)" "32 kbps — High (wideband voice)" "48 kbps — Very High (near-studio)" "64 kbps — Maximum (best quality)" ) for ((i = 0; i < ${#presets[@]}; i++)); do local num=$((i + 1)) if [ "${presets[$i]}" = "$OPUS_BITRATE" ]; then echo -e " ${GREEN}${BOLD}${num}${NC} ${CYAN}│${NC} ${GREEN}${labels[$i]} ◄ current${NC}" else echo -e " ${BOLD}${WHITE}${num}${NC} ${CYAN}│${NC} ${labels[$i]}" fi done echo -e " ${BOLD}${WHITE}9${NC} ${CYAN}│${NC} Custom bitrate" echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}│${NC} ${DIM}Cancel${NC}" echo "" echo -ne " ${BOLD}Select: ${NC}" read -r oinput case "$oinput" in [1-8]) OPUS_BITRATE=${presets[$((oinput - 1))]} save_config echo -e "\n ${GREEN}${BOLD}✓${NC} Opus bitrate set to ${WHITE}${BOLD}${OPUS_BITRATE} kbps${NC}" ;; 9) echo -ne "\n ${BOLD}Enter bitrate (6-510 kbps): ${NC}" read -r custom_br if [[ "$custom_br" =~ ^[0-9]+$ ]] && [ "$custom_br" -ge 6 ] && [ "$custom_br" -le 510 ]; then OPUS_BITRATE=$custom_br save_config echo -e "\n ${GREEN}${BOLD}✓${NC} Opus bitrate set to ${WHITE}${BOLD}${OPUS_BITRATE} kbps${NC}" else echo -e "\n ${RED}Invalid bitrate. Must be 6–510.${NC}" fi ;; 0|q|Q) return ;; *) echo -e "\n ${RED}Invalid choice${NC}" ;; esac echo -ne " ${DIM}Press Enter to continue...${NC}" read -r } settings_snowflake() { clear echo -e "\n${BOLD}${CYAN}═══ Snowflake Bridge ═══${NC}\n" echo -e " ${DIM}Snowflake uses WebRTC proxies to help bypass Tor censorship.${NC}" echo -e " ${DIM}Enable this if Tor is blocked in your region.${NC}\n" if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then echo -e " Status: ${GREEN}${BOLD}ENABLED${NC}" else echo -e " Status: ${RED}${BOLD}DISABLED${NC}" fi if check_dep snowflake-client; then echo -e " Binary: ${GREEN}●${NC} snowflake-client installed" else echo -e " Binary: ${RED}●${NC} snowflake-client not installed" fi echo -e " ${DIM}Tor manages snowflake-client as a pluggable transport.${NC}" echo "" if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}│${NC} Disable Snowflake" else echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}│${NC} Enable Snowflake" fi echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}│${NC} ${DIM}Back${NC}" echo "" echo -ne " ${BOLD}Select: ${NC}" read -r sf_choice case "$sf_choice" in 1) if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then SNOWFLAKE_ENABLED=0 save_config echo -e "\n ${YELLOW}${BOLD}✓${NC} Snowflake disabled" echo -e " ${DIM}Restart Tor for changes to take effect.${NC}" else # Install if not present if ! check_dep snowflake-client; then echo "" echo -ne " ${BOLD}snowflake-client not installed. Install now? [Y/n]: ${NC}" read -r install_confirm if [ "$install_confirm" != "n" ] && [ "$install_confirm" != "N" ]; then install_snowflake || { echo -ne " ${DIM}Press Enter to continue...${NC}" read -r return } else echo -e "\n ${YELLOW}Snowflake not enabled (client not installed)${NC}" echo -ne " ${DIM}Press Enter to continue...${NC}" read -r return fi fi SNOWFLAKE_ENABLED=1 save_config echo -e "\n ${GREEN}${BOLD}✓${NC} Snowflake enabled" echo -e " ${DIM}Restart Tor for changes to take effect.${NC}" fi ;; 0|q|Q) return ;; *) echo -e "\n ${RED}Invalid choice${NC}" ;; esac echo -ne " ${DIM}Press Enter to continue...${NC}" read -r } #============================================================================= # MAIN MENU #============================================================================= show_banner() { clear echo "" echo -e "${BOLD}${TOR_PURPLE} ╔╦╗┌─┐┬─┐┌┬┐┬┌┐┌┌─┐┬ ╔═╗┬ ┬┌─┐┌┐┌┌─┐${NC}" echo -e "${BOLD}${TOR_PURPLE} ║ ├┤ ├┬┘│││││││├─┤│ ╠═╝├─┤│ ││││├┤ ${NC}" echo -e "${BOLD}${TOR_PURPLE} ╩ └─┘┴└─┴ ┴┴┘└┘┴ ┴┴─┘╩ ┴ ┴└─┘┘└┘└─┘${NC}" echo "" echo -e " ${TOR_PURPLE}───────────────────────────────────────${NC}" echo -e " ${TOR_PURPLE}${BOLD}Encrypted Voice & Chat${NC} ${DIM}over${NC} ${TOR_PURPLE}${BOLD}Tor${NC} ${DIM}Hidden Services${NC}" echo -e " ${TOR_PURPLE}───────────────────────────────────────${NC}" local cipher_display="${CIPHER^^}" echo -e " ${DIM}v${VERSION} | Push-to-Talk | End-to-End ${cipher_display}${NC}\n" } main_menu() { while true; do show_banner # Show quick status line local tor_status="${RED}●${NC}" if [ -n "$TOR_PID" ] && kill -0 "$TOR_PID" 2>/dev/null; then tor_status="${GREEN}●${NC}" fi local secret_status="${RED}●${NC}" if [ -n "$SHARED_SECRET" ]; then secret_status="${GREEN}●${NC}" fi local sf_status="${RED}●${NC}" if [ "$SNOWFLAKE_ENABLED" -eq 1 ]; then sf_status="${GREEN}●${NC}" fi local al_status="${RED}●${NC}" if [ "$AUTO_LISTEN" -eq 1 ]; then al_status="${GREEN}●${NC}" fi local _ptt_d="SPACE" [ "$PTT_KEY" != " " ] && _ptt_d="$PTT_KEY" echo -e " ${DIM}Tor:${NC} $tor_status ${DIM}Secret:${NC} $secret_status ${DIM}SF:${NC} $sf_status ${DIM}AL:${NC} $al_status ${DIM}PTT:${NC} ${GREEN}[${_ptt_d}]${NC}\n" echo -e " ${BOLD}${WHITE}1${NC} ${CYAN}│${NC} Listen for calls" echo -e " ${BOLD}${WHITE}2${NC} ${CYAN}│${NC} Call an onion address" echo -e " ${BOLD}${WHITE}3${NC} ${CYAN}│${NC} Show my onion address" echo -e " ${BOLD}${WHITE}4${NC} ${CYAN}│${NC} Set shared secret" echo -e " ${BOLD}${WHITE}5${NC} ${CYAN}│${NC} Test audio (loopback)" echo -e " ${BOLD}${WHITE}6${NC} ${CYAN}│${NC} Show status" echo -e " ${BOLD}${WHITE}7${NC} ${CYAN}│${NC} Install dependencies" echo -e " ${BOLD}${WHITE}8${NC} ${CYAN}│${NC} Start Tor" echo -e " ${BOLD}${WHITE}9${NC} ${CYAN}│${NC} Stop Tor" echo -e " ${BOLD}${WHITE}10${NC}${CYAN}│${NC} Restart Tor" echo -e " ${BOLD}${WHITE}11${NC}${CYAN}│${NC} Rotate onion address" echo -e " ${BOLD}${WHITE}12${NC}${CYAN}│${NC} Settings" echo -e " ${BOLD}${WHITE}0${NC} ${CYAN}│${NC} ${RED}Quit${NC}" echo "" echo -ne " ${BOLD}Select: ${NC}" # If auto-listen is active, poll for incoming calls without redrawing local choice="" if [ "$AUTO_LISTEN" -eq 1 ] && [ -n "$AUTO_LISTEN_PID" ]; then echo -ne "${DIM}[Auto-listening...]${NC} " >&2 while true; do # Check for incoming call if check_auto_listen; then choice="" break fi # Try to read user input with short timeout if read -r -t 1 choice 2>/dev/null; then break # user typed something fi done else read -r choice fi [ -z "$choice" ] && continue case "$choice" in 1) listen_for_call ;; 2) call_remote ;; 3) local onion onion=$(get_onion) if [ -n "$onion" ]; then echo -e "\n ${BOLD}${GREEN}Your address:${NC} ${WHITE}${BOLD}${onion}${NC}\n" else echo -e "\n ${YELLOW}Tor hidden service not running. Start Tor first (option 8).${NC}\n" fi echo -ne " ${DIM}Press Enter to continue...${NC}" read -r ;; 4) set_shared_secret ;; 5) test_audio echo -ne " ${DIM}Press Enter to continue...${NC}" read -r ;; 6) show_status echo -ne " ${DIM}Press Enter to continue...${NC}" read -r ;; 7) install_deps echo -ne "\n ${DIM}Press Enter to continue...${NC}" read -r ;; 8) start_tor start_auto_listener echo -ne "\n ${DIM}Press Enter to continue...${NC}" read -r ;; 9) stop_tor echo -ne "\n ${DIM}Press Enter to continue...${NC}" read -r ;; 10) stop_tor start_tor start_auto_listener echo -ne "\n ${DIM}Press Enter to continue...${NC}" read -r ;; 11) rotate_onion echo -ne "\n ${DIM}Press Enter to continue...${NC}" read -r ;; 12) settings_menu ;; 0|q|Q) echo -e "\n${GREEN}Goodbye!${NC}" stop_tor cleanup exit 0 ;; *) echo -e "\n ${RED}Invalid choice${NC}" sleep 1 ;; esac done } #============================================================================= # ENTRY POINT #============================================================================= trap cleanup EXIT INT TERM # Create data directories mkdir -p "$DATA_DIR" "$AUDIO_DIR" "$PID_DIR" "$DATA_DIR/run" # Clean any stale run files from previous sessions rm -f "$DATA_DIR/run/"* 2>/dev/null || true # Load saved config load_config # Handle command-line arguments case "${1:-}" in install) install_deps ;; test) test_audio ;; status) show_status ;; listen) load_config listen_for_call ;; call) load_config if [ -n "${2:-}" ]; then remote_onion="$2" if [[ "$remote_onion" != *.onion ]]; then remote_onion="${remote_onion}.onion" fi start_tor call_remote else echo "Usage: $0 call " fi ;; help|-h|--help) echo -e "${BOLD}${APP_NAME} v${VERSION}${NC}" echo "" echo "Usage: $0 [command]" echo "" echo "Commands:" echo " (none) Interactive menu" echo " install Install dependencies" echo " test Run audio loopback test" echo " status Show current status" echo " listen Start listening for calls" echo " call ADDR Call an onion address" echo " help Show this help" ;; *) main_menu ;; esac