terminalphone/terminalphone.sh
2026-02-18 20:10:06 +00:00

1577 lines
52 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.2"
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_$$"
# 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
# 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"
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
chmod 600 "$TOR_CONF"
}
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..."
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() {
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"
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 stdin to stdout
encrypt_stream() {
local c="$CIPHER"
[ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE")
openssl enc -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" -nosalt 2>/dev/null
}
# Decrypt stdin to stdout
decrypt_stream() {
local c="$CIPHER"
[ -f "$CIPHER_RUNTIME_FILE" ] && c=$(cat "$CIPHER_RUNTIME_FILE")
openssl enc -d -"${c}" -pbkdf2 -iter 10000 -pass "pass:${SHARED_SECRET}" -nosalt 2>/dev/null
}
# 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
echo -ne "\r ${DIM}Sending...${NC} " >&2
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 b64
b64=$(base64 -w 0 "$enc_file" 2>/dev/null)
echo "AUDIO:${b64}" >&4 2>/dev/null || true
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
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=""
}
# Start listening for incoming calls
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
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"
# Use socat to accept a TCP connection, then handle it
log_info "Waiting for incoming connection..."
# Create named pipes for bidirectional communication
rm -f "$RECV_PIPE" "$SEND_PIPE"
mkfifo "$RECV_PIPE" "$SEND_PIPE"
# Flag file that socat will create when a connection arrives
local incoming_flag="$DATA_DIR/run/incoming_$$"
rm -f "$incoming_flag"
# Start socat in background — it touches the flag when someone connects
socat "TCP-LISTEN:$LISTEN_PORT,reuseaddr" \
"SYSTEM:touch $incoming_flag; cat $SEND_PIPE & cat > $RECV_PIPE" &
local socat_pid=$!
save_pid "socat" "$socat_pid"
# Wait for an incoming connection (poll for the flag file)
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"
return 1
fi
sleep 0.5
done
touch "$CONNECTED_FLAG"
log_ok "Call connected!"
in_call_session "$RECV_PIPE" "$SEND_PIPE" ""
# Full cleanup after call ends
cleanup_call
}
# 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"
# Give socat a moment to connect
sleep 2
if kill -0 "$socat_pid" 2>/dev/null; then
log_ok "Connected to ${remote_onion}!"
in_call_session "$RECV_PIPE" "$SEND_PIPE" "$remote_onion"
else
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
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
# Show cipher info — always show both local and remote
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
echo "" >&2
echo -e " ${BOLD}Controls:${NC}" >&2
echo -e " ${GREEN}[SPACE]${NC} -- Push-to-Talk" >&2
echo -e " ${CYAN}[T]${NC} -- Send text message" >&2
echo -e " ${YELLOW}[S]${NC} -- Settings" >&2
echo -e " ${RED}[Q]${NC} -- Hang up" >&2
echo -e "" >&2
}
in_call_session() {
local recv_pipe="$1"
local send_pipe="$2"
local known_remote="${3:-}"
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
# Draw call header
draw_call_header "$remote_display" "$remote_cipher"
# Start receive handler in background
# Protocol: ID:<onion>, PTT_START, PTT_STOP, PING,
# or "AUDIO:<base64_encoded_encrypted_opus>"
(
while [ -f "$CONNECTED_FLAG" ]; do
local line=""
if read -r line <&3 2>/dev/null; then
case "$line" in
PTT_START)
echo -ne "\r ${BG_GREEN}${WHITE}${BOLD} REMOTE TALKING ${NC} "
;;
PTT_STOP)
echo -ne "\r ${DIM} Remote idle ${NC} "
;;
PING)
echo -ne "\r ${DIM} Ping received ${NC} "
;;
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[4;1H\033[K' >&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 "\r\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
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=""
local ptt_active=0
if [ $IS_TERMUX -eq 1 ]; then
echo -ne "\r ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2
else
echo -ne "\r ${GREEN}${BOLD} Ready ${NC} ${DIM}[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2
fi
while [ -f "$CONNECTED_FLAG" ]; do
local key=""
key=$(dd bs=1 count=1 2>/dev/null) || true
if [ "$key" = " " ]; then
if [ $IS_TERMUX -eq 1 ]; then
# TERMUX: Toggle mode
if [ $ptt_active -eq 0 ]; then
ptt_active=1
echo -ne "\r ${BG_RED}${WHITE}${BOLD} ● RECORDING ${NC} ${DIM}[SPACE]=Send${NC} " >&2
start_recording
else
ptt_active=0
stop_and_send
echo "PTT_STOP" >&4 2>/dev/null || true
echo -ne "\r ${GREEN}${BOLD} Sent! ${NC} ${DIM}[SPACE]=Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&2
fi
else
# LINUX: Hold-to-talk
if [ $ptt_active -eq 0 ]; then
ptt_active=1
echo -ne "\r ${BG_RED}${WHITE}${BOLD}${BLINK} ● RECORDING ${NC} " >&2
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
ptt_active=0
stop_and_send
echo "PTT_STOP" >&4 2>/dev/null || true
echo -ne "\r ${GREEN}${BOLD} Sent! ${NC} ${DIM}[SPACE]=Hold to Talk [T]=Chat [S]=Settings [Q]=Hang up${NC} " >&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 -e "\r " >&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
echo "" >&2
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
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
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
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
# 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}\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}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 ;;
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 -nosalt 2>/dev/null | openssl enc -d -"${selected}" -pbkdf2 -pass pass:test -nosalt &>/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 6510.${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
echo -e " ${DIM}Tor:${NC} $tor_status ${DIM}Secret:${NC} $secret_status ${DIM}PTT:${NC} ${GREEN}[SPACE]${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}"
read -r choice
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
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
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 <onion-address>"
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