Port persistence: - Add WEB_ADMIN_PORT to save_config() and load_config() - Save config when port is changed in web_admin_menu - Remove hardcoded initialization that overrode saved config Device listing fix: - Rewrite get_devices() parser to properly track state across sections - Fix regex to handle variable spacing in device comment lines - Continue tracking device through auth section to capture password - Devices now properly listed in web admin interface
5154 lines
181 KiB
Bash
5154 lines
181 KiB
Bash
#!/bin/bash
|
|
# ================================================================
|
|
# Easy Asterisk - Interactive Installer v0.10.0
|
|
#
|
|
# Copyright (C) 2025 Easy Asterisk Contributors
|
|
# Licensed under GNU General Public License v3.0
|
|
# See LICENSE file or https://www.gnu.org/licenses/gpl-3.0.html
|
|
#
|
|
# UPDATES in v0.10.0:
|
|
# - FIXED: Extension deletion now properly removes all sections (endpoint, auth, aor)
|
|
# - FIXED: Extension renaming now preserves AA tags correctly
|
|
# - FIXED: LAN/VPN devices now explicitly use UDP transport (prevents TLS fallback)
|
|
# - FIXED: LAN devices now have media_encryption=no to prevent SRTP issues
|
|
# - ADDED: Web Admin interface for browser-based client management
|
|
# - View device status (online/offline) in real-time
|
|
# - Add/delete devices via web interface
|
|
# - View rooms and categories
|
|
# - HTTP Basic authentication with SHA256 password hashing
|
|
# - Access at http://server:8080/clients
|
|
# - IMPROVED: Device deletion uses awk for reliable multi-section removal
|
|
# - IMPROVED: Device renaming uses awk to handle all edge cases
|
|
#
|
|
# PREVIOUS UPDATES (v0.9.9):
|
|
# - REMOVED: All COTURN/TURN relay server code (focus on direct connections)
|
|
# - ADDED: VLAN subnet configuration to prevent 30-second call drops
|
|
# - ADDED: Provisioning Manager (http.conf setup, symlinks, linphone.xml editor)
|
|
# - ADDED: Manual Update System for Asterisk with backup/rollback
|
|
# - ADDED: Room Directory (visual display of Ring Groups vs Page Groups)
|
|
# - ADDED: Split-horizon DNS documentation for VLAN environments
|
|
# - IMPROVED: Server IP address documented in transport configurations
|
|
# - IMPROVED: Multiple local_net entries for proper VLAN support
|
|
# ================================================================
|
|
|
|
set +e
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
# Defaults
|
|
DEFAULT_SIP_PORT="5060"
|
|
DEFAULT_SIPS_PORT="5061"
|
|
CONFIG_DIR="/etc/easy-asterisk"
|
|
CONFIG_FILE="${CONFIG_DIR}/config"
|
|
PTT_CONFIG_FILE="${CONFIG_DIR}/ptt-device"
|
|
CATEGORIES_FILE="${CONFIG_DIR}/categories.conf"
|
|
ROOMS_FILE="${CONFIG_DIR}/rooms.conf"
|
|
PROVISIONING_DIR="/var/lib/asterisk/static-http"
|
|
SCRIPT_VERSION="0.10.0"
|
|
|
|
# ================================================================
|
|
# 1. CORE HELPER FUNCTIONS
|
|
# ================================================================
|
|
|
|
print_header() {
|
|
echo -e "\n${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${CYAN} $1${NC}"
|
|
echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}\n"
|
|
}
|
|
|
|
print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
|
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
print_success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|
|
|
check_root() {
|
|
if [[ $EUID -ne 0 ]]; then
|
|
print_error "This script must be run as root (use sudo)"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
generate_password() {
|
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16
|
|
}
|
|
|
|
select_user() {
|
|
# Scan /home for real users (exclude system accounts)
|
|
local -a users=()
|
|
local -a user_ids=()
|
|
local count=0
|
|
|
|
echo "Scanning for users..."
|
|
echo ""
|
|
|
|
# Get users from /home with valid shells
|
|
while IFS=: read -r username _ uid _ _ homedir shell; do
|
|
# Only include users with UID >= 1000 and valid shell
|
|
if [[ $uid -ge 1000 && -d "$homedir" && "$shell" != "/usr/sbin/nologin" && "$shell" != "/bin/false" ]]; then
|
|
((count++))
|
|
users+=("$username")
|
|
user_ids+=("$uid")
|
|
echo " ${count}) ${username} (UID: ${uid}, Home: ${homedir})"
|
|
fi
|
|
done < /etc/passwd
|
|
|
|
# Add option to manually enter username
|
|
((count++))
|
|
echo " ${count}) Enter username manually"
|
|
echo ""
|
|
|
|
# Suggest default based on SUDO_USER or first user found
|
|
local default_choice=""
|
|
local default_user="${SUDO_USER:-}"
|
|
if [[ -z "$default_user" ]]; then
|
|
default_user="${users[0]:-}"
|
|
default_choice="1"
|
|
else
|
|
# Find index of SUDO_USER
|
|
for i in "${!users[@]}"; do
|
|
if [[ "${users[$i]}" == "$default_user" ]]; then
|
|
default_choice=$((i + 1))
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [[ -n "$default_choice" ]]; then
|
|
read -p "Select user [${default_choice}]: " choice
|
|
choice="${choice:-$default_choice}"
|
|
else
|
|
read -p "Select user: " choice
|
|
fi
|
|
|
|
# Validate choice
|
|
if [[ "$choice" =~ ^[0-9]+$ && "$choice" -le "${#users[@]}" && "$choice" -gt 0 ]]; then
|
|
local idx=$((choice - 1))
|
|
KIOSK_USER="${users[$idx]}"
|
|
KIOSK_UID="${user_ids[$idx]}"
|
|
echo ""
|
|
print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)"
|
|
return 0
|
|
elif [[ "$choice" == "$count" ]]; then
|
|
# Manual entry
|
|
echo ""
|
|
read -p "Enter username: " KIOSK_USER
|
|
if id "$KIOSK_USER" >/dev/null 2>&1; then
|
|
KIOSK_UID=$(id -u "$KIOSK_USER")
|
|
print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)"
|
|
return 0
|
|
else
|
|
print_error "User '$KIOSK_USER' not found"
|
|
return 1
|
|
fi
|
|
else
|
|
print_error "Invalid selection"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
load_config() {
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
source "$CONFIG_FILE" 2>/dev/null || true
|
|
fi
|
|
INSTALLED_SERVER="${INSTALLED_SERVER:-n}"
|
|
INSTALLED_CLIENT="${INSTALLED_CLIENT:-n}"
|
|
KIOSK_USER="${KIOSK_USER:-}"
|
|
KIOSK_UID="${KIOSK_UID:-}"
|
|
HAS_VLANS="${HAS_VLANS:-n}"
|
|
VLAN_SUBNETS="${VLAN_SUBNETS:-}"
|
|
WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}"
|
|
return 0
|
|
}
|
|
|
|
backup_config() {
|
|
local file=$1
|
|
if [[ -f "$file" ]]; then
|
|
cp "$file" "${file}.backup-$(date +%s)"
|
|
ls -tp "${file}.backup-"* 2>/dev/null | tail -n +6 | xargs -I {} rm -- {} 2>/dev/null
|
|
fi
|
|
}
|
|
|
|
save_config() {
|
|
mkdir -p "$CONFIG_DIR"
|
|
chmod 755 "$CONFIG_DIR"
|
|
|
|
cat > "$CONFIG_FILE" << EOF
|
|
# Easy Asterisk Configuration - $(date)
|
|
KIOSK_USER="$KIOSK_USER"
|
|
KIOSK_UID="$KIOSK_UID"
|
|
KIOSK_EXTENSION="$KIOSK_EXTENSION"
|
|
KIOSK_NAME="$KIOSK_NAME"
|
|
SIP_PASSWORD="$SIP_PASSWORD"
|
|
ASTERISK_HOST="$ASTERISK_HOST"
|
|
DOMAIN_NAME="$DOMAIN_NAME"
|
|
ENABLE_TLS="$ENABLE_TLS"
|
|
HAS_VLANS="$HAS_VLANS"
|
|
VLAN_SUBNETS="$VLAN_SUBNETS"
|
|
CERT_PATH="$CERT_PATH"
|
|
KEY_PATH="$KEY_PATH"
|
|
INSTALLED_SERVER="$INSTALLED_SERVER"
|
|
INSTALLED_CLIENT="$INSTALLED_CLIENT"
|
|
CURRENT_PUBLIC_IP="$CURRENT_PUBLIC_IP"
|
|
PTT_DEVICE="$PTT_DEVICE"
|
|
PTT_KEYCODE="$PTT_KEYCODE"
|
|
LOCAL_CIDR="$LOCAL_CIDR"
|
|
WEB_ADMIN_PORT="$WEB_ADMIN_PORT"
|
|
EOF
|
|
chmod 644 "$CONFIG_FILE"
|
|
|
|
# Save PTT config separately
|
|
if [[ -n "$PTT_DEVICE" ]]; then
|
|
cat > "$PTT_CONFIG_FILE" << EOF
|
|
PTT_DEVICE="$PTT_DEVICE"
|
|
PTT_KEYCODE="$PTT_KEYCODE"
|
|
EOF
|
|
chmod 644 "$PTT_CONFIG_FILE"
|
|
fi
|
|
}
|
|
|
|
open_firewall_ports() {
|
|
print_info "Configuring firewall ports..."
|
|
if command -v ufw &>/dev/null; then
|
|
if ufw status 2>/dev/null | grep -q "Status: active"; then
|
|
ufw allow 5060/udp comment "SIP UDP" 2>/dev/null || true
|
|
ufw allow 5061/tcp comment "SIP TLS" 2>/dev/null || true
|
|
ufw allow 10000:20000/udp comment "RTP Media" 2>/dev/null || true
|
|
ufw allow 8088/tcp comment "HTTP Provisioning" 2>/dev/null || true
|
|
ufw allow 8089/tcp comment "HTTPS Provisioning" 2>/dev/null || true
|
|
ufw reload 2>/dev/null || true
|
|
print_success "UFW firewall ports opened"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# 2. UTILITY FUNCTIONS
|
|
# ================================================================
|
|
|
|
get_public_ip() {
|
|
local ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
|
|
echo "$ip"
|
|
}
|
|
|
|
# ================================================================
|
|
# 3. DEVICE MANAGEMENT
|
|
# ================================================================
|
|
|
|
initialize_default_categories() {
|
|
mkdir -p "$CONFIG_DIR"
|
|
if [[ ! -f "$CATEGORIES_FILE" ]]; then
|
|
cat > "$CATEGORIES_FILE" << 'EOF'
|
|
# Format: id|name|auto_answer(yes/no)|description
|
|
kiosk|Kiosks|yes|Fixed auto-answer intercoms
|
|
mobile|Mobile Devices|no|Phones and mobile devices
|
|
EOF
|
|
chmod 600 "$CATEGORIES_FILE"
|
|
fi
|
|
if [[ ! -f "$ROOMS_FILE" ]]; then
|
|
cat > "$ROOMS_FILE" << 'EOF'
|
|
# Format: ext|name|members|timeout|type(ring/page)
|
|
199|All Kiosks|101,102,103,104,105|60|page
|
|
299|All Mobile|201,202,203,204,205|60|ring
|
|
EOF
|
|
chmod 600 "$ROOMS_FILE"
|
|
fi
|
|
}
|
|
|
|
list_categories() {
|
|
initialize_default_categories
|
|
local index=1
|
|
while IFS='|' read -r cat_id cat_name auto_answer description; do
|
|
[[ "$cat_id" =~ ^# ]] && continue
|
|
[[ -z "$cat_id" ]] && continue
|
|
local auto_text="${RED}Ring${NC}"
|
|
[[ "$auto_answer" == "yes" ]] && auto_text="${GREEN}Auto-answer${NC}"
|
|
echo -e " ${CYAN}$index)${NC} ${BOLD}$cat_name${NC} ($cat_id) - $auto_text"
|
|
((index++))
|
|
done < "$CATEGORIES_FILE"
|
|
}
|
|
|
|
get_category_by_index() {
|
|
local target_index=$1
|
|
local index=1
|
|
while IFS='|' read -r cat_id cat_name auto_answer description; do
|
|
[[ "$cat_id" =~ ^# ]] && continue
|
|
[[ -z "$cat_id" ]] && continue
|
|
if [[ $index -eq $target_index ]]; then
|
|
echo "$cat_id|$cat_name|$auto_answer"
|
|
return 0
|
|
fi
|
|
((index++))
|
|
done < "$CATEGORIES_FILE"
|
|
}
|
|
|
|
manage_categories() {
|
|
print_header "Manage Categories"
|
|
list_categories
|
|
echo ""
|
|
echo " 1) Add Category"
|
|
echo " 2) Rename Category"
|
|
echo " 3) Delete Category"
|
|
echo " 0) Back"
|
|
read -p "Select: " choice
|
|
case $choice in
|
|
1)
|
|
read -p "ID (lowercase): " cid
|
|
read -p "Display Name: " cname
|
|
read -p "Auto Answer? [y/N]: " ca
|
|
local ans="no"
|
|
[[ "$ca" =~ ^[Yy]$ ]] && ans="yes"
|
|
echo "${cid}|${cname}|${ans}|Custom category" >> "$CATEGORIES_FILE"
|
|
print_success "Category added"
|
|
rebuild_dialplan
|
|
;;
|
|
2)
|
|
read -p "Number to rename: " num
|
|
local data=$(get_category_by_index "$num")
|
|
if [[ -z "$data" ]]; then
|
|
print_error "Invalid selection"
|
|
return
|
|
fi
|
|
local old_id=$(echo "$data" | cut -d'|' -f1)
|
|
local old_name=$(echo "$data" | cut -d'|' -f2)
|
|
local auto_answer=$(echo "$data" | cut -d'|' -f3)
|
|
|
|
echo "Current: $old_name (ID: $old_id)"
|
|
read -p "New display name: " new_name
|
|
|
|
if [[ -z "$new_name" ]]; then
|
|
print_error "Name cannot be empty"
|
|
return
|
|
fi
|
|
|
|
# Backup
|
|
backup_config "$CATEGORIES_FILE"
|
|
|
|
# Update category file
|
|
sed -i "s/^${old_id}|${old_name}|/${old_id}|${new_name}|/" "$CATEGORIES_FILE"
|
|
|
|
print_success "Category renamed: ${old_name} → ${new_name}"
|
|
rebuild_dialplan
|
|
;;
|
|
3)
|
|
read -p "Number to delete: " num
|
|
local data=$(get_category_by_index "$num")
|
|
if [[ -z "$data" ]]; then
|
|
print_error "Invalid selection"
|
|
return
|
|
fi
|
|
local cid=$(echo "$data" | cut -d'|' -f1)
|
|
local cname=$(echo "$data" | cut -d'|' -f2)
|
|
|
|
# Count devices in this category
|
|
local device_count=$(grep -c "; === Device:.* (${cid})" /etc/asterisk/pjsip.conf 2>/dev/null || echo "0")
|
|
|
|
if [[ $device_count -gt 0 ]]; then
|
|
echo ""
|
|
echo -e "${YELLOW}Warning: This category has ${device_count} device(s)${NC}"
|
|
echo ""
|
|
echo " 1) Delete category only (reassign devices to 'uncategorized')"
|
|
echo " 2) Delete category AND all devices in it"
|
|
echo " 0) Cancel"
|
|
read -p "Select: " del_choice
|
|
|
|
case $del_choice in
|
|
1)
|
|
# Ensure uncategorized category exists
|
|
if ! grep -q "^uncategorized|" "$CATEGORIES_FILE" 2>/dev/null; then
|
|
echo "uncategorized|Uncategorized|no|Default category for orphaned devices" >> "$CATEGORIES_FILE"
|
|
fi
|
|
|
|
# Reassign all devices to uncategorized
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
sed -i "s/; === Device: \(.*\) (${cid})/; === Device: \1 (uncategorized)/" /etc/asterisk/pjsip.conf
|
|
|
|
# Delete the category
|
|
sed -i "/^${cid}|/d" "$CATEGORIES_FILE"
|
|
|
|
print_success "Category deleted, ${device_count} device(s) moved to 'uncategorized'"
|
|
rebuild_dialplan
|
|
;;
|
|
2)
|
|
echo ""
|
|
echo -e "${RED}WARNING: This will DELETE ${device_count} device(s)!${NC}"
|
|
read -p "Type 'DELETE ALL' to confirm: " confirm
|
|
|
|
if [[ "$confirm" == "DELETE ALL" ]]; then
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
|
|
# Get all extensions in this category
|
|
local exts_to_delete=""
|
|
local in_device=0
|
|
local current_ext=""
|
|
local current_cat=""
|
|
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == *"; === Device:"* ]]; then
|
|
local temp="${line#*; === Device: }"
|
|
temp="${temp% ===}"
|
|
[[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}"
|
|
current_cat="${temp##* (}"; current_cat="${current_cat%)}"
|
|
fi
|
|
if [[ "$line" =~ ^\[([0-9]+)\] ]]; then
|
|
current_ext="${BASH_REMATCH[1]}"
|
|
if [[ "$current_cat" == "$cid" ]]; then
|
|
exts_to_delete="${exts_to_delete} ${current_ext}"
|
|
fi
|
|
fi
|
|
done < /etc/asterisk/pjsip.conf
|
|
|
|
# Delete all device sections for this category
|
|
for ext in $exts_to_delete; do
|
|
sed -i "/^; === Device:.*${ext}.* (${cid})/,/^$/d" /etc/asterisk/pjsip.conf
|
|
sed -i "/^\[${ext}\]/,/^$/d" /etc/asterisk/pjsip.conf
|
|
done
|
|
|
|
# Delete the category
|
|
sed -i "/^${cid}|/d" "$CATEGORIES_FILE"
|
|
|
|
asterisk -rx "pjsip reload" 2>/dev/null
|
|
rebuild_dialplan
|
|
print_success "Category and ${device_count} device(s) deleted"
|
|
else
|
|
print_error "Cancelled"
|
|
fi
|
|
;;
|
|
0)
|
|
print_error "Cancelled"
|
|
return
|
|
;;
|
|
esac
|
|
else
|
|
# No devices, just delete the category
|
|
sed -i "/^${cid}|/d" "$CATEGORIES_FILE"
|
|
print_success "Category deleted (no devices affected)"
|
|
rebuild_dialplan
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
|
|
manage_rooms() {
|
|
print_header "Manage Rooms"
|
|
initialize_default_categories
|
|
echo "Current Rooms:"
|
|
local index=1
|
|
while IFS='|' read -r rext rname rmem rtime rtype; do
|
|
[[ "$rext" =~ ^# ]] && continue
|
|
[[ -z "$rext" ]] && continue
|
|
local type_text="Ring Group"
|
|
[[ "$rtype" == "page" ]] && type_text="${GREEN}PAGE/INTERCOM${NC}"
|
|
echo -e " ${CYAN}$index)${NC} ${BOLD}$rname${NC} ($rext) - $type_text"
|
|
echo -e " Members: $rmem"
|
|
((index++))
|
|
done < "$ROOMS_FILE"
|
|
echo ""
|
|
echo " 1) Add Room"
|
|
echo " 2) Rename Room"
|
|
echo " 3) Edit Room Members"
|
|
echo " 4) Delete Room"
|
|
echo " 0) Back"
|
|
read -p "Select: " choice
|
|
case $choice in
|
|
1)
|
|
read -p "Room Extension: " new_ext
|
|
read -p "Room Name: " new_name
|
|
echo " 1) Ring Group (Phones ring)"
|
|
echo " 2) Page/Intercom (Auto-answer)"
|
|
read -p "Select [1]: " type_sel
|
|
local rtype="ring"
|
|
[[ "$type_sel" == "2" ]] && rtype="page"
|
|
read -p "Members (e.g. 101,102): " members
|
|
echo "${new_ext}|${new_name}|${members}|60|${rtype}" >> "$ROOMS_FILE"
|
|
rebuild_dialplan
|
|
print_success "Room Created"
|
|
;;
|
|
2)
|
|
read -p "Select Room #: " rnum
|
|
local target_line=""
|
|
local count=0
|
|
while IFS= read -r line; do
|
|
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
|
|
((count++))
|
|
if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi
|
|
fi
|
|
done < "$ROOMS_FILE"
|
|
if [[ -n "$target_line" ]]; then
|
|
IFS='|' read -r rext old_name rmem rtime rtype <<< "$target_line"
|
|
echo "Current name: $old_name"
|
|
read -p "New name: " new_name
|
|
|
|
if [[ -z "$new_name" ]]; then
|
|
print_error "Name cannot be empty"
|
|
return
|
|
fi
|
|
|
|
backup_config "$ROOMS_FILE"
|
|
sed -i "/^${rext}|/d" "$ROOMS_FILE"
|
|
echo "${rext}|${new_name}|${rmem}|${rtime}|${rtype}" >> "$ROOMS_FILE"
|
|
rebuild_dialplan
|
|
print_success "Room renamed: ${old_name} → ${new_name}"
|
|
else
|
|
print_error "Invalid selection"
|
|
fi
|
|
;;
|
|
3)
|
|
read -p "Select Room #: " rnum
|
|
local target_line=""
|
|
local count=0
|
|
while IFS= read -r line; do
|
|
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
|
|
((count++))
|
|
if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi
|
|
fi
|
|
done < "$ROOMS_FILE"
|
|
if [[ -n "$target_line" ]]; then
|
|
IFS='|' read -r rext rname rmem rtime rtype <<< "$target_line"
|
|
echo "Current members: $rmem"
|
|
read -p "New members: " new_mem
|
|
sed -i "/^${rext}|/d" "$ROOMS_FILE"
|
|
echo "${rext}|${rname}|${new_mem}|${rtime}|${rtype}" >> "$ROOMS_FILE"
|
|
rebuild_dialplan
|
|
print_success "Room Updated"
|
|
fi
|
|
;;
|
|
4)
|
|
read -p "Select Room #: " rnum
|
|
local count=0
|
|
local target_ext=""
|
|
local target_name=""
|
|
while IFS='|' read -r rext rname rrest; do
|
|
if [[ ! "$rext" =~ ^# ]] && [[ -n "$rext" ]]; then
|
|
((count++))
|
|
if [[ $count -eq $rnum ]]; then
|
|
target_ext="$rext"
|
|
target_name="$rname"
|
|
break
|
|
fi
|
|
fi
|
|
done < "$ROOMS_FILE"
|
|
if [[ -n "$target_ext" ]]; then
|
|
echo ""
|
|
echo -e "${YELLOW}Note: Deleting a room only removes the group.${NC}"
|
|
echo -e "${YELLOW}Individual devices in this room are NOT deleted.${NC}"
|
|
echo ""
|
|
read -p "Delete room '${target_name}' (${target_ext})? [y/N]: " confirm
|
|
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
|
sed -i "/^${target_ext}|/d" "$ROOMS_FILE"
|
|
rebuild_dialplan
|
|
print_success "Room deleted (devices unaffected)"
|
|
else
|
|
print_error "Cancelled"
|
|
fi
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
add_device_menu() {
|
|
print_header "Add Device"
|
|
load_config # Load saved configuration to check ENABLE_TLS, DOMAIN_NAME, etc.
|
|
list_categories
|
|
read -p "Category number: " cat_num
|
|
local cat_data=$(get_category_by_index "$cat_num")
|
|
if [[ -z "$cat_data" ]]; then print_error "Invalid"; return; fi
|
|
local cat_id=$(echo "$cat_data" | cut -d'|' -f1)
|
|
local cat_name=$(echo "$cat_data" | cut -d'|' -f2)
|
|
local auto_answer=$(echo "$cat_data" | cut -d'|' -f3)
|
|
|
|
local start_range=101 end_range=199
|
|
case "$cat_id" in
|
|
kiosk) start_range=101; end_range=199 ;;
|
|
mobile) start_range=201; end_range=299 ;;
|
|
*) start_range=301; end_range=399 ;;
|
|
esac
|
|
|
|
local suggested_ext=""
|
|
for ext in $(seq $start_range $end_range); do
|
|
if ! grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then
|
|
suggested_ext=$ext; break
|
|
fi
|
|
done
|
|
|
|
read -p "Extension [$suggested_ext]: " ext
|
|
ext="${ext:-$suggested_ext}"
|
|
|
|
if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then
|
|
print_error "Extension exists!"; return
|
|
fi
|
|
|
|
read -p "Name: " name
|
|
name="${name:-Device $ext}"
|
|
local pass=$(generate_password)
|
|
|
|
local override_tag=""
|
|
if [[ "$auto_answer" == "no" ]]; then
|
|
read -p "Force AUTO-ANSWER? [y/N]: " force_aa
|
|
[[ "$force_aa" =~ ^[Yy]$ ]] && override_tag="[AA:yes]" && auto_answer="yes"
|
|
elif [[ "$auto_answer" == "yes" ]]; then
|
|
read -p "Force RING? [y/N]: " force_ring
|
|
[[ "$force_ring" =~ ^[Yy]$ ]] && override_tag="[AA:no]" && auto_answer="no"
|
|
fi
|
|
|
|
# CONNECTION TYPE SELECTION
|
|
local conn_type="lan"
|
|
local transport_block=""
|
|
local encryption_block=""
|
|
local ice_block=""
|
|
local display_server=""
|
|
local display_port="5060"
|
|
local display_transport="UDP"
|
|
local display_encryption="None"
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo -e " HOW WILL THIS DEVICE CONNECT?"
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo -e " 1) ${GREEN}LAN/VPN${NC} - Same network or VPN tunnel (UDP)"
|
|
if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then
|
|
echo -e " 2) ${CYAN}FQDN${NC} - Internet or cross-VLAN via ${DOMAIN_NAME} (TLS)"
|
|
else
|
|
echo -e " 2) ${YELLOW}FQDN${NC} - Not configured (run 'Setup Internet Access' first)"
|
|
fi
|
|
echo ""
|
|
read -p " Select [1]: " conn_choice
|
|
conn_choice="${conn_choice:-1}"
|
|
|
|
if [[ "$conn_choice" == "1" ]]; then
|
|
# LAN/VPN - UDP, no encryption (explicit transport prevents TLS fallback)
|
|
transport_block="transport=transport-udp"
|
|
encryption_block="media_encryption=no"
|
|
display_server="$(hostname -I | awk '{print $1}')"
|
|
display_port="5060"
|
|
display_transport="UDP"
|
|
display_encryption="None"
|
|
elif [[ "$conn_choice" == "2" ]]; then
|
|
if [[ "$ENABLE_TLS" != "y" || -z "$DOMAIN_NAME" ]]; then
|
|
print_error "FQDN access not configured. Run 'Setup Internet Access' first."
|
|
return
|
|
fi
|
|
conn_type="fqdn"
|
|
transport_block="transport=transport-tls"
|
|
encryption_block="media_encryption=sdes"
|
|
ice_block="ice_support=yes"
|
|
display_server="$DOMAIN_NAME"
|
|
display_port="5061"
|
|
display_transport="TLS"
|
|
display_encryption="SRTP (SDES)"
|
|
fi
|
|
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
|
|
cat >> /etc/asterisk/pjsip.conf << EOF
|
|
|
|
; === Device: $name ($cat_id) $override_tag ===
|
|
[${ext}]
|
|
type=endpoint
|
|
context=intercom
|
|
${transport_block}
|
|
disallow=all
|
|
allow=opus
|
|
allow=ulaw
|
|
allow=alaw
|
|
allow=g722
|
|
${encryption_block}
|
|
direct_media=no
|
|
rtp_symmetric=yes
|
|
force_rport=yes
|
|
rewrite_contact=yes
|
|
${ice_block}
|
|
auth=${ext}
|
|
aors=${ext}
|
|
callerid="${name}" <${ext}>
|
|
|
|
[${ext}]
|
|
type=auth
|
|
auth_type=userpass
|
|
username=${ext}
|
|
password=${pass}
|
|
|
|
[${ext}]
|
|
type=aor
|
|
max_contacts=5
|
|
remove_existing=yes
|
|
qualify_frequency=60
|
|
EOF
|
|
|
|
chown -R asterisk:asterisk /etc/asterisk 2>/dev/null || true
|
|
asterisk -rx "pjsip reload" >/dev/null 2>&1
|
|
rebuild_dialplan
|
|
|
|
# Prepare provisioning URLs if HTTP server is configured
|
|
local server_ip=$(hostname -I | awk '{print $1}')
|
|
local prov_url_http=""
|
|
local prov_url_https=""
|
|
if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then
|
|
prov_url_http="http://${server_ip}:8088/static/linphone.xml"
|
|
if [[ -n "$DOMAIN_NAME" ]]; then
|
|
prov_url_https="https://${DOMAIN_NAME}:8089/static/linphone.xml"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo " DEVICE ADDED: $name (Extension $ext)"
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo -e " ${BOLD}Server Details:${NC}"
|
|
echo " Server: ${display_server}"
|
|
echo " Port: ${display_port}"
|
|
echo " Transport: ${display_transport}"
|
|
echo " Extension: $ext"
|
|
echo " Password: $pass"
|
|
echo " Encryption: ${display_encryption}"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo -e " ${BOLD}LINPHONE SETUP${NC}"
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
if [[ -n "$prov_url_http" ]]; then
|
|
echo " Remote Provisioning (Recommended):"
|
|
echo " 1. In Linphone → Settings → Remote provisioning"
|
|
echo " 2. Enter URL:"
|
|
echo " ${prov_url_http}"
|
|
[[ -n "$prov_url_https" ]] && echo " OR ${prov_url_https}"
|
|
echo " 3. Tap 'Fetch' to apply configuration"
|
|
echo ""
|
|
echo " OR Manual Setup:"
|
|
else
|
|
echo " Manual Setup:"
|
|
fi
|
|
echo " 1. Add Account → Use SIP account"
|
|
echo " 2. Username: $ext"
|
|
echo " 3. Password: $pass"
|
|
echo " 4. Domain: ${display_server}"
|
|
echo " 5. Transport: ${display_transport}"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo -e " ${BOLD}BARESIP SETUP (if Linphone has audio issues)${NC}"
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Baresip often works better on privacy-focused Android ROMs."
|
|
echo " Two-step manual configuration required:"
|
|
echo ""
|
|
echo " Step 1: Add Account"
|
|
echo " Menu (☰) → Accounts → Add (+)"
|
|
echo " SIP URI: ${ext}@${display_server}"
|
|
echo " Save (✓)"
|
|
echo ""
|
|
echo " Step 2: Edit Account (Complete Config)"
|
|
echo " Tap account → Edit"
|
|
echo " Auth Username: $ext (JUST the number!)"
|
|
echo " Auth Password: $pass"
|
|
echo " Outbound Proxy: ${display_server} (JUST the domain!)"
|
|
echo " Media Encryption: srtp (select from dropdown)"
|
|
echo " Register: ✓ (check box)"
|
|
echo " Save (✓)"
|
|
echo ""
|
|
echo " Verify: Look for green dot or 'Registered' status"
|
|
echo " To call: Just dial extension (101, 202, etc.)"
|
|
echo ""
|
|
echo " For detailed Baresip instructions:"
|
|
echo " Server Settings → Provisioning Manager → Create Baresip Config"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " NOTE: These instructions work for most SIP apps (Zoiper,"
|
|
echo " sipnetic, etc.) - just use the same credentials."
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
}
|
|
|
|
remove_device() {
|
|
print_header "Remove Device"
|
|
declare -A REMOVE_MAP
|
|
declare -A NAME_MAP
|
|
local count=1
|
|
local current_name=""
|
|
echo "Select device to remove:"
|
|
echo ""
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == *"; === Device:"* ]]; then
|
|
local temp="${line#*; === Device: }"
|
|
temp="${temp% ===}"
|
|
temp="${temp% \[AA:*\]}"
|
|
current_name="${temp% (*)}"
|
|
fi
|
|
if [[ "$line" =~ ^\[([0-9]+)\]$ && "$current_name" != "" ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
echo " ${count}) Ext ${ext} - ${current_name}"
|
|
REMOVE_MAP[$count]=$ext
|
|
NAME_MAP[$count]="$current_name"
|
|
((count++))
|
|
current_name=""
|
|
fi
|
|
done < /etc/asterisk/pjsip.conf
|
|
echo ""
|
|
echo " 98) DELETE ALL DEVICES"
|
|
echo " 0) Cancel"
|
|
echo ""
|
|
read -p "Select: " choice
|
|
|
|
if [[ "$choice" == "98" ]]; then
|
|
echo ""
|
|
print_warn "This will DELETE ALL DEVICES!"
|
|
read -p "Type 'DELETE ALL' to confirm: " confirm
|
|
if [[ "$confirm" == "DELETE ALL" ]]; then
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
# Remove all device sections - use awk to properly handle all sections
|
|
awk '
|
|
/^; === Device:/ { skip = 1; next }
|
|
/^\[[0-9]{3}\]$/ { if (skip) next }
|
|
/^type=(endpoint|auth|aor)/ { if (skip) next }
|
|
/^$/ { if (skip) { skip = 0; next } }
|
|
!skip { print }
|
|
' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp
|
|
mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf
|
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
|
asterisk -rx "pjsip reload" 2>/dev/null
|
|
rebuild_dialplan
|
|
print_success "All devices deleted"
|
|
else
|
|
print_error "Cancelled"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
[[ "$choice" == "0" || -z "${REMOVE_MAP[$choice]}" ]] && return
|
|
|
|
local ext="${REMOVE_MAP[$choice]}"
|
|
local name="${NAME_MAP[$choice]}"
|
|
read -p "Confirm removal of $ext ($name)? [y/N]: " confirm
|
|
if [[ "$confirm" =~ ^[Yy]$ ]]; then
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
# Use awk to remove the device comment and ALL three sections for this extension
|
|
awk -v ext="$ext" '
|
|
BEGIN { skip = 0; found_ext = 0 }
|
|
# Match device comment line - start potential skip
|
|
/^; === Device:/ { pending_comment = $0; next }
|
|
# Check if this is the extension we want to delete
|
|
$0 ~ "^\\[" ext "\\]$" {
|
|
if (pending_comment != "") {
|
|
# This is our device - skip the comment and this section
|
|
skip = 1
|
|
found_ext = 1
|
|
pending_comment = ""
|
|
next
|
|
} else if (found_ext) {
|
|
# Additional sections for same extension (auth, aor)
|
|
skip = 1
|
|
next
|
|
}
|
|
}
|
|
# If we have a pending comment for a different extension, print it
|
|
pending_comment != "" && $0 !~ "^\\[" ext "\\]$" {
|
|
print pending_comment
|
|
pending_comment = ""
|
|
}
|
|
# Skip lines until empty line
|
|
skip && /^$/ { skip = 0; next }
|
|
skip { next }
|
|
{ print }
|
|
' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp
|
|
mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf
|
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
|
asterisk -rx "pjsip reload" 2>/dev/null
|
|
rebuild_dialplan
|
|
print_success "Removed extension $ext ($name)"
|
|
fi
|
|
}
|
|
|
|
rename_device() {
|
|
print_header "Rename Device"
|
|
declare -A DEVICE_MAP
|
|
declare -A NAME_MAP
|
|
declare -A AA_MAP
|
|
local count=1
|
|
echo "Select device to rename:"
|
|
echo ""
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == *"; === Device:"* ]]; then
|
|
local temp="${line#*; === Device: }"
|
|
temp="${temp% ===}"
|
|
local aa_tag=""
|
|
if [[ "$temp" == *"[AA:yes]"* ]]; then
|
|
aa_tag="[AA:yes]"
|
|
temp="${temp% \[AA:yes\]}"
|
|
elif [[ "$temp" == *"[AA:no]"* ]]; then
|
|
aa_tag="[AA:no]"
|
|
temp="${temp% \[AA:no\]}"
|
|
fi
|
|
local name="${temp% (*)}"
|
|
local cat="${temp##* (}"; cat="${cat%)}"
|
|
fi
|
|
if [[ "$line" =~ ^\[([0-9]+)\]$ && -n "$name" ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
echo " ${count}) Ext ${ext} - ${name} (${cat})"
|
|
DEVICE_MAP[$count]=$ext
|
|
NAME_MAP[$count]="${name}|${cat}"
|
|
AA_MAP[$count]="${aa_tag}"
|
|
((count++))
|
|
name=""
|
|
fi
|
|
done < /etc/asterisk/pjsip.conf
|
|
echo ""
|
|
echo " 0) Cancel"
|
|
echo ""
|
|
read -p "Select: " choice
|
|
|
|
[[ "$choice" == "0" || -z "${DEVICE_MAP[$choice]}" ]] && return
|
|
|
|
local ext="${DEVICE_MAP[$choice]}"
|
|
local info="${NAME_MAP[$choice]}"
|
|
local aa_tag="${AA_MAP[$choice]}"
|
|
local old_name="${info%|*}"
|
|
local cat="${info##*|}"
|
|
|
|
echo ""
|
|
echo "Current name: ${old_name}"
|
|
read -p "New name: " new_name
|
|
|
|
if [[ -z "$new_name" ]]; then
|
|
print_error "Name cannot be empty"
|
|
return
|
|
fi
|
|
|
|
# Backup config
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
|
|
# Use awk to properly update both the comment line (preserving AA tag) and callerid
|
|
awk -v ext="$ext" -v old_name="$old_name" -v new_name="$new_name" -v cat="$cat" -v aa_tag="$aa_tag" '
|
|
# Update device comment line
|
|
/^; === Device:/ && $0 ~ old_name && $0 ~ cat {
|
|
if (aa_tag != "") {
|
|
print "; === Device: " new_name " (" cat ") " aa_tag " ==="
|
|
} else {
|
|
print "; === Device: " new_name " (" cat ") ==="
|
|
}
|
|
next
|
|
}
|
|
# Track when we are in the correct extension section
|
|
$0 ~ "^\\[" ext "\\]$" { in_ext = 1 }
|
|
/^$/ { in_ext = 0 }
|
|
# Update callerid in the extension section
|
|
in_ext && /^callerid=/ {
|
|
print "callerid=\"" new_name "\" <" ext ">"
|
|
next
|
|
}
|
|
{ print }
|
|
' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp
|
|
mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf
|
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
|
|
|
# Reload Asterisk
|
|
asterisk -rx "pjsip reload" 2>/dev/null
|
|
rebuild_dialplan quiet
|
|
|
|
print_success "Device renamed: ${old_name} → ${new_name}"
|
|
}
|
|
|
|
|
|
show_registered_devices() {
|
|
# Collect all device data
|
|
declare -A device_data
|
|
local dev_name="" dev_cat=""
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == *"; === Device:"* ]]; then
|
|
# Remove the prefix and suffix, handling variable whitespace
|
|
local temp="${line#*; === Device: }"
|
|
temp="${temp%% ===}" # Use %% to handle multiple spaces before ===
|
|
temp="${temp## }" # Trim leading spaces
|
|
temp="${temp%% }" # Trim trailing spaces
|
|
|
|
[[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}"
|
|
|
|
# Extract category - everything inside the last (...)
|
|
dev_cat="${temp##*\(}"
|
|
dev_cat="${dev_cat%\)}"
|
|
dev_cat="${dev_cat## }" # Trim any leading spaces
|
|
dev_cat="${dev_cat%% }" # Trim any trailing spaces
|
|
|
|
# Extract name - everything before the last (
|
|
dev_name="${temp%% \(*}"
|
|
fi
|
|
if [[ "$line" =~ ^\[([0-9]+)\] ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
if [[ -n "$dev_name" ]]; then
|
|
device_data[$ext]="${dev_name}|${dev_cat}"
|
|
dev_name=""
|
|
dev_cat=""
|
|
fi
|
|
fi
|
|
done < /etc/asterisk/pjsip.conf
|
|
|
|
# Interactive loop
|
|
while true; do
|
|
# Group by category
|
|
declare -A categories
|
|
declare -A category_names
|
|
for ext in "${!device_data[@]}"; do
|
|
local info="${device_data[$ext]}"
|
|
local cat="${info##*|}"
|
|
categories[$cat]="${categories[$cat]} $ext"
|
|
done
|
|
|
|
# Get full category names from categories file
|
|
while IFS='|' read -r cat_id cat_name auto_answer description; do
|
|
[[ "$cat_id" =~ ^# ]] && continue
|
|
[[ -z "$cat_id" ]] && continue
|
|
category_names[$cat_id]="$cat_name"
|
|
done < "$CATEGORIES_FILE"
|
|
|
|
clear
|
|
print_header "Device Status"
|
|
|
|
echo "Select category to view:"
|
|
echo " 1) All devices"
|
|
local i=2
|
|
declare -A cat_menu
|
|
for cat in $(echo "${!categories[@]}" | tr ' ' '\n' | sort); do
|
|
local display_name="${category_names[$cat]:-$cat}"
|
|
echo " ${i}) ${display_name}"
|
|
cat_menu[$i]="$cat"
|
|
((i++))
|
|
done
|
|
echo " 0) Back to menu"
|
|
echo ""
|
|
read -p "Select [1]: " cat_choice
|
|
|
|
[[ "$cat_choice" == "0" ]] && return
|
|
cat_choice="${cat_choice:-1}"
|
|
|
|
clear
|
|
print_header "Device Status"
|
|
printf "${CYAN}%-6s %-25s %-15s %-15s %-15s${NC}\n" "Ext" "Name" "Category" "Status" "Password"
|
|
echo "--------------------------------------------------------------------------------------------"
|
|
|
|
if [[ "$cat_choice" == "1" ]]; then
|
|
# Show all devices
|
|
for ext in $(echo "${!device_data[@]}" | tr ' ' '\n' | sort -n); do
|
|
local info="${device_data[$ext]}"
|
|
local name="${info%|*}"
|
|
local cat="${info##*|}"
|
|
local cat_display="${category_names[$cat]:-$cat}"
|
|
local status="${RED}Offline${NC}"
|
|
local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true)
|
|
[[ -n "$avail" ]] && status="${GREEN}Online${NC}"
|
|
local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2)
|
|
printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password"
|
|
done
|
|
else
|
|
# Show specific category
|
|
local selected_cat="${cat_menu[$cat_choice]}"
|
|
if [[ -n "$selected_cat" ]]; then
|
|
local cat_display="${category_names[$selected_cat]:-$selected_cat}"
|
|
echo -e "${BOLD}Showing: ${cat_display}${NC}"
|
|
echo ""
|
|
for ext in $(echo "${categories[$selected_cat]}" | tr ' ' '\n' | sort -n); do
|
|
local info="${device_data[$ext]}"
|
|
local name="${info%|*}"
|
|
local cat="${info##*|}"
|
|
local cat_display="${category_names[$cat]:-$cat}"
|
|
local status="${RED}Offline${NC}"
|
|
local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true)
|
|
[[ -n "$avail" ]] && status="${GREEN}Online${NC}"
|
|
local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2)
|
|
printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password"
|
|
done
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "Connection Details:"
|
|
echo " Domain: ${DOMAIN_NAME:-$(hostname -I | awk '{print $1}')}"
|
|
echo " Port: ${DEFAULT_SIP_PORT}/udp (LAN) or ${DEFAULT_SIPS_PORT}/tcp (TLS)"
|
|
echo ""
|
|
read -p "Press Enter to select another category (or 0 to exit)... "
|
|
done
|
|
}
|
|
|
|
|
|
# ================================================================
|
|
# 4. PTT WIZARD (Fixed: Mute by default)
|
|
# ================================================================
|
|
|
|
configure_ptt_menu() {
|
|
print_header "Configure PTT Button"
|
|
detect_ptt_button
|
|
}
|
|
|
|
detect_ptt_button() {
|
|
# Ensure evtest is installed
|
|
if ! command -v evtest &>/dev/null; then
|
|
apt install -y evtest >/dev/null 2>&1
|
|
fi
|
|
|
|
# Add user to input group
|
|
[[ -n "$KIOSK_USER" ]] && usermod -aG input "$KIOSK_USER" 2>/dev/null || true
|
|
|
|
print_info "Scanning input devices..."
|
|
echo ""
|
|
|
|
declare -a SUGGESTED_DEVICES SUGGESTED_NAMES OTHER_DEVICES OTHER_NAMES
|
|
|
|
for dev in /dev/input/event*; do
|
|
[[ -e "$dev" ]] || continue
|
|
local name=$(cat "/sys/class/input/$(basename $dev)/device/name" 2>/dev/null || echo "Unknown")
|
|
local lname=$(echo "$name" | tr '[:upper:]' '[:lower:]')
|
|
|
|
# Filter out system devices that aren't PTT candidates
|
|
if [[ "$lname" =~ (power.button|sleep.button|lid.switch|virtual|video.bus|hdmi|dp,pcm|hotkey|touchpad|touchscreen) ]]; then
|
|
OTHER_DEVICES+=("$dev")
|
|
OTHER_NAMES+=("$name")
|
|
# Prioritize keyboards, USB HID devices, pedals
|
|
elif [[ "$lname" =~ (keyboard|sayo.*nano$|pedal|foot|^hid) ]]; then
|
|
SUGGESTED_DEVICES+=("$dev")
|
|
SUGGESTED_NAMES+=("$name")
|
|
else
|
|
OTHER_DEVICES+=("$dev")
|
|
OTHER_NAMES+=("$name")
|
|
fi
|
|
done
|
|
|
|
# Display suggested devices first
|
|
if [[ ${#SUGGESTED_DEVICES[@]} -gt 0 ]]; then
|
|
echo -e "${GREEN}Keyboards and USB buttons:${NC}"
|
|
for i in "${!SUGGESTED_DEVICES[@]}"; do
|
|
printf " ${CYAN}%2d)${NC} %s - %s\n" "$((i+1))" "$(basename ${SUGGESTED_DEVICES[$i]})" "${SUGGESTED_NAMES[$i]}"
|
|
done
|
|
echo ""
|
|
fi
|
|
|
|
# Display other devices
|
|
if [[ ${#OTHER_DEVICES[@]} -gt 0 ]]; then
|
|
echo -e "${YELLOW}Other devices:${NC}"
|
|
local offset=${#SUGGESTED_DEVICES[@]}
|
|
for i in "${!OTHER_DEVICES[@]}"; do
|
|
printf " ${CYAN}%2d)${NC} %s - %s\n" "$((offset+i+1))" "$(basename ${OTHER_DEVICES[$i]})" "${OTHER_NAMES[$i]}"
|
|
done
|
|
echo ""
|
|
fi
|
|
|
|
local ALL_DEVICES=("${SUGGESTED_DEVICES[@]}" "${OTHER_DEVICES[@]}")
|
|
local total=${#ALL_DEVICES[@]}
|
|
|
|
if [[ $total -eq 0 ]]; then
|
|
print_error "No input devices found"
|
|
return 1
|
|
fi
|
|
|
|
echo " 0) Back"
|
|
echo ""
|
|
read -p "Select device [1]: " selection
|
|
selection="${selection:-1}"
|
|
|
|
[[ "$selection" == "0" ]] && return 0
|
|
[[ "$selection" -lt 1 || "$selection" -gt "$total" ]] && { print_error "Invalid selection"; return 1; }
|
|
|
|
PTT_DEVICE="${ALL_DEVICES[$((selection-1))]}"
|
|
local dev_name=$(cat "/sys/class/input/$(basename $PTT_DEVICE)/device/name" 2>/dev/null || echo "Unknown")
|
|
echo ""
|
|
print_success "Selected: $dev_name"
|
|
echo " ($PTT_DEVICE)"
|
|
echo ""
|
|
|
|
# Key detection loop
|
|
while true; do
|
|
echo -e "${YELLOW}══════════════════════════════════════════════════${NC}"
|
|
echo -e "${YELLOW} DO NOT PRESS YET - wait for countdown${NC}"
|
|
echo -e "${YELLOW}══════════════════════════════════════════════════${NC}"
|
|
|
|
for i in 5 4 3 2 1; do
|
|
echo -ne "\r Waiting... $i "
|
|
sleep 1
|
|
done
|
|
echo ""
|
|
echo ""
|
|
echo -e "${GREEN}>>> NOW PRESS YOUR PTT BUTTON <<<${NC}"
|
|
echo ""
|
|
|
|
local detected_code=$(timeout 10 evtest "$PTT_DEVICE" 2>/dev/null | grep -m1 "value 1$" | grep -oP 'code \K[0-9]+' || echo "")
|
|
|
|
if [[ -n "$detected_code" && "$detected_code" -gt 0 ]]; then
|
|
# Map common key codes to friendly names
|
|
local key_name="Key $detected_code"
|
|
case "$detected_code" in
|
|
1) key_name="Escape" ;;
|
|
28) key_name="Enter" ;;
|
|
57) key_name="Spacebar" ;;
|
|
69) key_name="Num Lock" ;;
|
|
113) key_name="Mute" ;;
|
|
114) key_name="Volume Down" ;;
|
|
115) key_name="Volume Up" ;;
|
|
116) key_name="Power" ;;
|
|
142) key_name="Sleep" ;;
|
|
272) key_name="Left Click" ;;
|
|
273) key_name="Right Click" ;;
|
|
esac
|
|
|
|
print_success "Detected: $key_name (code $detected_code)"
|
|
echo ""
|
|
read -p "Use this key? [Y/n]: " use_key
|
|
|
|
if [[ ! "$use_key" =~ ^[Nn]$ ]]; then
|
|
PTT_KEYCODE="$detected_code"
|
|
PTT_KEYNAME="$key_name"
|
|
break
|
|
fi
|
|
else
|
|
print_warn "No button press detected"
|
|
fi
|
|
|
|
echo ""
|
|
echo " 1) Try again"
|
|
echo " 2) Enter key code manually"
|
|
echo " 3) Cancel"
|
|
read -p "Select [1]: " retry
|
|
|
|
case "${retry:-1}" in
|
|
2)
|
|
read -p "Enter key code: " PTT_KEYCODE
|
|
PTT_KEYNAME="Manual"
|
|
break
|
|
;;
|
|
3)
|
|
return 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Ensure user is in input group (critical for PTT device access)
|
|
if [[ -n "$KIOSK_USER" ]]; then
|
|
if ! id -nG "$KIOSK_USER" | grep -qw "input"; then
|
|
print_info "Adding $KIOSK_USER to input group..."
|
|
usermod -aG input "$KIOSK_USER"
|
|
echo ""
|
|
print_error "IMPORTANT: User added to 'input' group"
|
|
echo " User must log out and log back in (or reboot) for group change to take effect."
|
|
echo " PTT will NOT work until then!"
|
|
echo ""
|
|
read -p "Press Enter to acknowledge..."
|
|
fi
|
|
fi
|
|
|
|
# Save configuration via save_config (will set proper permissions)
|
|
save_config
|
|
|
|
print_success "PTT configured: $PTT_KEYNAME on $(basename $PTT_DEVICE)"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " PTT Configuration Complete"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " Device: $PTT_DEVICE"
|
|
echo " Button: $PTT_KEYNAME"
|
|
echo " User: ${KIOSK_USER:-not set}"
|
|
echo ""
|
|
echo " Testing PTT:"
|
|
echo " 1. Check logs: journalctl -t kiosk-ptt -f"
|
|
echo " 2. Press PTT button"
|
|
echo " 3. You should see: 'PTT pressed - mic unmuted'"
|
|
echo ""
|
|
echo " If you see 'Permission denied' errors:"
|
|
echo " - User needs to be in 'input' group (already added above)"
|
|
echo " - Log out and log back in, or reboot"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
|
|
# Restart PTT service if client is installed
|
|
if [[ "$INSTALLED_CLIENT" == "y" && -n "$KIOSK_USER" ]]; then
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}"
|
|
echo ""
|
|
print_info "Restarting PTT service..."
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload 2>/dev/null
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart kiosk-ptt 2>/dev/null || true
|
|
sleep 2
|
|
echo ""
|
|
echo "Checking PTT status..."
|
|
journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null || echo " No logs yet (check after logging out/in if needed)"
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
create_ptt_handler() {
|
|
cat > /usr/local/bin/kiosk-ptt << 'PTTSCRIPT'
|
|
#!/bin/bash
|
|
CONFIG="/etc/easy-asterisk/config"
|
|
PTT_CONFIG="/etc/easy-asterisk/ptt-device"
|
|
[[ -f "$CONFIG" ]] && source "$CONFIG"
|
|
[[ -f "$PTT_CONFIG" ]] && source "$PTT_CONFIG"
|
|
|
|
# Exit if no PTT device configured - leave audio unmuted for normal kiosk operation
|
|
[[ -z "$PTT_DEVICE" ]] && exit 0
|
|
|
|
# Ensure we have the user's runtime directory
|
|
if [[ -z "$XDG_RUNTIME_DIR" ]]; then
|
|
# If running as systemd service, this should already be set
|
|
# But if not, try to detect it
|
|
if [[ -n "$KIOSK_UID" ]]; then
|
|
export XDG_RUNTIME_DIR="/run/user/${KIOSK_UID}"
|
|
else
|
|
# Fall back to current user
|
|
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
|
fi
|
|
fi
|
|
|
|
# Wait for PipeWire/PulseAudio to be ready
|
|
for i in {1..10}; do
|
|
if pactl info >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# PTT mode: Mute audio source on start, unmute only when button pressed
|
|
pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null || {
|
|
logger -t kiosk-ptt "ERROR: Failed to mute audio source"
|
|
exit 1
|
|
}
|
|
|
|
logger -t kiosk-ptt "PTT handler started, microphone muted, listening on $PTT_DEVICE"
|
|
|
|
# Unmute on press, mute on release
|
|
evtest --grab "$PTT_DEVICE" 2>/dev/null | while read -r line; do
|
|
if [[ "$line" =~ "value 1" ]]; then
|
|
pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null
|
|
logger -t kiosk-ptt "PTT pressed - mic unmuted"
|
|
fi
|
|
if [[ "$line" =~ "value 0" ]]; then
|
|
pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null
|
|
logger -t kiosk-ptt "PTT released - mic muted"
|
|
fi
|
|
done
|
|
PTTSCRIPT
|
|
chmod +x /usr/local/bin/kiosk-ptt
|
|
}
|
|
|
|
# ================================================================
|
|
# 5. AUDIO DUCKING
|
|
# ================================================================
|
|
|
|
configure_audio_ducking() {
|
|
[[ -z "$KIOSK_USER" ]] && return
|
|
local wp_dir="/home/${KIOSK_USER}/.config/wireplumber/wireplumber.conf.d"
|
|
mkdir -p "$wp_dir"
|
|
cat > "${wp_dir}/50-intercom-ducking.conf" << 'EOF'
|
|
wireplumber.settings = { linking.allow-moving-streams = true }
|
|
EOF
|
|
chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config"
|
|
}
|
|
|
|
ensure_audio_unmuted() {
|
|
[[ -z "$KIOSK_USER" ]] && return
|
|
[[ -z "$KIOSK_UID" ]] && return
|
|
|
|
# Only unmute if PTT is not configured
|
|
if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}"
|
|
|
|
# Wait a moment for PipeWire to initialize
|
|
sleep 2
|
|
|
|
# Unmute all sources and sinks
|
|
sudo -u "$KIOSK_USER" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null || true
|
|
sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || true
|
|
|
|
# Set reasonable volume levels if they're at 0
|
|
local source_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%')
|
|
local sink_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%')
|
|
|
|
[[ -n "$source_vol" && "$source_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null || true
|
|
[[ -n "$sink_vol" && "$sink_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# 6. DIAGNOSTICS & FIREWALL
|
|
# ================================================================
|
|
|
|
show_port_requirements() {
|
|
print_header "Port / Firewall Requirements"
|
|
echo "This server needs traffic to pass from your Clients (Kiosks/Phones)."
|
|
echo ""
|
|
echo "Does your Asterisk server have a PUBLIC IP (VPS/Cloud)?"
|
|
echo " -> YES: You must use 'Forwarding' (DNAT) rules on your router."
|
|
echo " -> NO: You must use 'Allow/Pass' rules on your VLAN interfaces."
|
|
echo ""
|
|
echo "Required Ports:"
|
|
echo "┌──────────────────┬──────────┬───────────────────────────────┐"
|
|
echo "│ Port │ Protocol │ Purpose │"
|
|
echo "├──────────────────┼──────────┼───────────────────────────────┤"
|
|
echo "│ 5060 │ UDP │ SIP Signaling (Registration) │"
|
|
echo "│ 5061 │ TCP │ SIP-TLS Signaling (Secure) │"
|
|
echo "│ 10000-20000 │ UDP │ RTP Media (Audio/Video) │"
|
|
if [[ "$USE_COTURN" == "y" ]]; then
|
|
echo "│ ${DEFAULT_TURN_PORT} │ UDP/TCP │ TURN Signaling (Handshake) │"
|
|
echo "│ 49152-65535 │ UDP │ TURN Relay (Actual Media Path)│"
|
|
fi
|
|
echo "└──────────────────┴──────────┴───────────────────────────────┘"
|
|
echo ""
|
|
echo "NOTE: VPN Users"
|
|
echo "If ALL clients and server are on a VPN (Tailscale/Wireguard), you DO NOT"
|
|
echo "need port forwarding or COTURN. Just bind Asterisk to the VPN IP."
|
|
}
|
|
|
|
show_firewall_guide() {
|
|
print_header "Interactive Firewall Guide (Hand-holding Mode)"
|
|
echo "For: Routers with VLAN support"
|
|
echo ""
|
|
echo "=== SCENARIO A: INTERNAL ONLY (VLAN to VLAN) ==="
|
|
echo "Example: Kiosks on VLAN 10, Server on VLAN 20"
|
|
echo "GOAL: Allow Kiosks to talk to Server."
|
|
echo ""
|
|
echo "STEP 1: Log in to Router. Go to Firewall > Rules > VLAN 10 Interface."
|
|
echo " (Do NOT use 'Port Forwarding' for internal VLANs!)"
|
|
echo ""
|
|
echo "STEP 2: Create Rule 1 (Signaling)"
|
|
echo " - Action: Pass (Allow)"
|
|
echo " - Protocol: UDP/TCP"
|
|
echo " - Source: VLAN 10 Net"
|
|
echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}"
|
|
echo " - Port: 3478"
|
|
echo ""
|
|
echo "STEP 3: Create Rule 2 (The Relay Range - CRITICAL)"
|
|
echo " - Action: Pass (Allow)"
|
|
echo " - Protocol: UDP"
|
|
echo " - Source: VLAN 10 Net"
|
|
echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}"
|
|
echo " - Port Range:"
|
|
echo " From: 49152"
|
|
echo " To: 65535"
|
|
echo " (Note: Type these numbers in the Start/End boxes)"
|
|
echo ""
|
|
echo "================================================"
|
|
echo ""
|
|
echo "=== SCENARIO B: EXTERNAL ACCESS (Internet to LAN) ==="
|
|
echo "Example: Remote phone connecting from a hotel."
|
|
echo "GOAL: Forward traffic from Internet to Server."
|
|
echo ""
|
|
echo "STEP 1: Go to Firewall > NAT > Port Forwarding."
|
|
echo "STEP 2: Create Rule."
|
|
echo " - Interface: WAN"
|
|
echo " - Protocol: UDP"
|
|
echo " - Dest. Port: 3478 (and 49152-65535)"
|
|
echo " - Redirect IP: ${CURRENT_PUBLIC_IP:-Server_IP}"
|
|
echo ""
|
|
read -p "Press Enter to return..."
|
|
}
|
|
|
|
show_preflight_check() {
|
|
print_header "Pre-Flight Requirements Check"
|
|
echo "Modern browsers (Chrome, Safari, Kiosk Mode) have strict security settings."
|
|
echo ""
|
|
echo "1. HTTPS / SSL Certificate (Required for Camera/Mic)"
|
|
echo " - Browsers block Mic/Cam on 'Insecure Origins' (HTTP)."
|
|
echo " - Exception: http://localhost is allowed."
|
|
echo " - Solution: You NEED a domain (FQDN) and SSL Cert (LetsEncrypt)."
|
|
echo " - Workaround: Use the 'Caddy Cert Sync' option in this script."
|
|
echo ""
|
|
echo "2. Static vs Dynamic IP"
|
|
echo " - If your Public IP changes, COTURN will break."
|
|
echo " - Solution: Use the 'Update IP manually' or auto-script in the menu."
|
|
echo ""
|
|
echo "3. VPN Alternative"
|
|
echo " - A VPN (Tailscale) negates the need for COTURN and Port Forwarding."
|
|
echo " - It treats all devices as if they are on the same flat network."
|
|
echo ""
|
|
read -p "Press Enter to return..."
|
|
}
|
|
|
|
test_sip_connectivity() {
|
|
print_header "SIP Connectivity Test"
|
|
if systemctl is-active asterisk >/dev/null; then
|
|
print_success "Asterisk Running"
|
|
else
|
|
print_error "Asterisk Down"
|
|
fi
|
|
echo ""
|
|
echo "Listening ports:"
|
|
ss -ulnp | grep 5060 || echo " UDP 5060: Not listening"
|
|
ss -tlnp | grep 5061 || echo " TCP 5061: Not listening"
|
|
if [[ -n "$DOMAIN_NAME" ]]; then
|
|
echo ""
|
|
echo "TLS Certificate check:"
|
|
timeout 5 openssl s_client -connect localhost:5061 -servername "$DOMAIN_NAME" 2>/dev/null | grep "Verify return code" || echo " TLS test failed"
|
|
fi
|
|
}
|
|
|
|
verify_cidr_config() {
|
|
print_header "CIDR Configuration"
|
|
local my_ip=$(hostname -I | cut -d' ' -f1)
|
|
echo "Server IP: $my_ip"
|
|
echo ""
|
|
echo "Current NAT settings in pjsip.conf:"
|
|
grep -E "external_|local_net" /etc/asterisk/pjsip.conf 2>/dev/null || echo " No NAT settings found"
|
|
}
|
|
|
|
configure_vlan_subnets() {
|
|
print_header "VLAN Configuration"
|
|
load_config
|
|
|
|
echo "VLAN Support for Asterisk Easy"
|
|
echo "================================================"
|
|
echo ""
|
|
echo "If your network uses VLANs (Virtual LANs), you need to"
|
|
echo "tell Asterisk about all the local subnets to prevent"
|
|
echo "calls from dropping after 30 seconds."
|
|
echo ""
|
|
echo "Example subnets:"
|
|
echo " 192.168.1.0/24 - Main network"
|
|
echo " 192.168.10.0/24 - IoT VLAN"
|
|
echo " 192.168.20.0/24 - Guest VLAN"
|
|
echo " 10.0.0.0/8 - Large private network"
|
|
echo ""
|
|
|
|
read -p "Does your network use VLANs? (y/n) [${HAS_VLANS}]: " has_vlans
|
|
has_vlans=${has_vlans:-$HAS_VLANS}
|
|
|
|
if [[ "$has_vlans" =~ ^[Yy] ]]; then
|
|
HAS_VLANS="y"
|
|
echo ""
|
|
echo "Current VLAN Subnets: ${VLAN_SUBNETS:-none}"
|
|
echo ""
|
|
echo "Enter VLAN subnets in CIDR notation, separated by spaces."
|
|
echo "Example: 192.168.1.0/24 192.168.10.0/24 192.168.20.0/24"
|
|
echo ""
|
|
read -p "VLAN Subnets: " vlan_input
|
|
|
|
if [[ -n "$vlan_input" ]]; then
|
|
VLAN_SUBNETS="$vlan_input"
|
|
save_config
|
|
print_success "VLAN configuration saved"
|
|
echo ""
|
|
echo "Rebuilding pjsip.conf to apply changes..."
|
|
generate_pjsip_conf
|
|
asterisk -rx "module reload res_pjsip.so" 2>/dev/null
|
|
print_success "Asterisk configuration updated"
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
echo " VLAN DNS SETUP GUIDE (Split-Horizon)"
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo "For proper VLAN operation with FQDNs, you need split-horizon DNS."
|
|
echo ""
|
|
read -p "Display DNS setup guide? (y/n) [y]: " show_dns
|
|
show_dns=${show_dns:-y}
|
|
|
|
if [[ "$show_dns" =~ ^[Yy]$ ]]; then
|
|
cat << 'DNSGUIDE'
|
|
|
|
WHAT YOU'RE ACHIEVING:
|
|
• Devices on VLANs use router for DNS (ctrld)
|
|
• ctrld split-horizon rules send FQDNs to the right LAN servers
|
|
• Only ctrld (router) can talk to servers' DNS (protected by UFW)
|
|
• No inter-VLAN routing is opened, just DNS and service ports
|
|
|
|
1. CTRLD.TOML (on OPNSense/Router):
|
|
|
|
[listener.0]
|
|
ip = '0.0.0.0'
|
|
port = 53
|
|
|
|
[listener.0.policy]
|
|
networks = [
|
|
{ 'network.0' = ['upstream.0'] },
|
|
{ 'network.1' = ['upstream.1'] }
|
|
]
|
|
rules = [
|
|
{ 'asterisk.mydomain.com' = ['upstream.4'] }
|
|
]
|
|
|
|
[network.0]
|
|
cidrs = ['192.168.1.0/24']
|
|
|
|
[network.1]
|
|
cidrs = ['192.168.200.0/24']
|
|
|
|
[upstream.0]
|
|
type = 'doh'
|
|
endpoint = 'https://dns.controld.com/your-profile'
|
|
timeout = 5000
|
|
|
|
[upstream.4]
|
|
type = 'legacy'
|
|
endpoint = '192.168.1.11' # This Asterisk server
|
|
timeout = 3000
|
|
|
|
2. DNSMASQ ON THIS SERVER:
|
|
|
|
sudo apt-get install dnsmasq
|
|
echo "listen-address=127.0.0.1" >> /etc/dnsmasq.conf
|
|
echo "listen-address=$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf
|
|
echo "bind-interfaces" >> /etc/dnsmasq.conf
|
|
echo "address=/asterisk.mydomain.com/$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf
|
|
sudo systemctl restart dnsmasq
|
|
|
|
3. UFW RULES ON THIS SERVER:
|
|
|
|
sudo ufw allow from 192.168.1.1 to any port 53 proto udp
|
|
sudo ufw allow from 192.168.1.1 to any port 53 proto tcp
|
|
sudo ufw deny 53
|
|
sudo ufw reload
|
|
|
|
Replace 192.168.1.1 with your router's LAN IP.
|
|
|
|
4. OPNSENSE FIREWALL RULES (for each VLAN):
|
|
|
|
Rule 1 - Allow DNS from VLAN to Router:
|
|
Action: Pass
|
|
Source: VLANxx net
|
|
Destination: This Firewall
|
|
Port: 53 (DNS)
|
|
Protocol: TCP/UDP
|
|
|
|
Rule 2 - Allow SIP/RTP from VLAN to Asterisk:
|
|
Source: VLANxx net
|
|
Destination: $(hostname -I | cut -d' ' -f1)
|
|
Ports: 5060/udp, 5061/tcp, 10000-20000/udp
|
|
|
|
5. DHCP SETTINGS (OPNSense):
|
|
|
|
For each VLAN, set DNS Servers to ONLY the router's VLAN IP.
|
|
Do NOT enter this server's IP as DNS.
|
|
|
|
═══════════════════════════════════════════════════════════
|
|
DNSGUIDE
|
|
fi
|
|
else
|
|
print_error "No subnets provided"
|
|
fi
|
|
else
|
|
HAS_VLANS="n"
|
|
VLAN_SUBNETS=""
|
|
save_config
|
|
print_success "VLAN support disabled"
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# PROVISIONING MANAGER
|
|
# ================================================================
|
|
|
|
setup_http_provisioning() {
|
|
print_header "HTTP Provisioning Setup"
|
|
|
|
echo "This will configure Asterisk's built-in HTTP server for"
|
|
echo "client provisioning (Linphone, etc.)."
|
|
echo ""
|
|
echo "Ports:"
|
|
echo " HTTP: 8088"
|
|
echo " HTTPS: 8089"
|
|
echo ""
|
|
|
|
# Create http.conf
|
|
backup_config "/etc/asterisk/http.conf" 2>/dev/null
|
|
cat > /etc/asterisk/http.conf << 'EOF'
|
|
[general]
|
|
enabled=yes
|
|
bindaddr=0.0.0.0
|
|
bindport=8088
|
|
|
|
tlsenable=yes
|
|
tlsbindaddr=0.0.0.0:8089
|
|
tlscertfile=/etc/asterisk/certs/server.crt
|
|
tlsprivatekey=/etc/asterisk/certs/server.key
|
|
|
|
; Serve static files from /var/lib/asterisk/static-http
|
|
enablestatic=yes
|
|
redirect=/static /var/lib/asterisk/static-http
|
|
|
|
; Security
|
|
session_limit=100
|
|
session_inactivity=30000
|
|
session_keep_alive=15000
|
|
EOF
|
|
|
|
chown asterisk:asterisk /etc/asterisk/http.conf
|
|
|
|
# Create provisioning directory
|
|
mkdir -p "$PROVISIONING_DIR"
|
|
chown asterisk:asterisk "$PROVISIONING_DIR"
|
|
|
|
# Create symlink if needed (Ubuntu/Debian fix)
|
|
if [[ ! -L /usr/share/asterisk/static-http ]]; then
|
|
mkdir -p /usr/share/asterisk
|
|
ln -sf "$PROVISIONING_DIR" /usr/share/asterisk/static-http
|
|
print_info "Created symlink: /usr/share/asterisk/static-http -> $PROVISIONING_DIR"
|
|
fi
|
|
|
|
# Reload Asterisk HTTP module
|
|
asterisk -rx "module reload res_http_post.so" 2>/dev/null || true
|
|
asterisk -rx "http show status" 2>/dev/null
|
|
|
|
print_success "HTTP provisioning configured"
|
|
echo ""
|
|
echo "Access provisioning files at:"
|
|
echo " HTTP: http://$(hostname -I | cut -d' ' -f1):8088/static/"
|
|
echo " HTTPS: https://$(hostname -I | cut -d' ' -f1):8089/static/"
|
|
}
|
|
|
|
create_linphone_xml() {
|
|
print_header "Create/Edit Linphone Provisioning XML"
|
|
load_config
|
|
|
|
local xml_file="$PROVISIONING_DIR/linphone.xml"
|
|
local server_ip=$(hostname -I | cut -d' ' -f1)
|
|
local domain="${DOMAIN_NAME:-$server_ip}"
|
|
local transport="tcp"
|
|
|
|
if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then
|
|
transport="tls"
|
|
fi
|
|
|
|
echo "Current Configuration:"
|
|
echo " Domain: $domain"
|
|
echo " Transport: $transport"
|
|
echo " Server IP: $server_ip"
|
|
echo ""
|
|
|
|
read -p "Create/Update linphone.xml? (y/n) [y]: " create_xml
|
|
create_xml=${create_xml:-y}
|
|
|
|
if [[ "$create_xml" =~ ^[Yy]$ ]]; then
|
|
mkdir -p "$PROVISIONING_DIR"
|
|
|
|
cat > "$xml_file" << EOF
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd"
|
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
xsi:schemaLocation="http://www.linphone.org/xsds/lpconfig.xsd lpconfig.xsd">
|
|
|
|
<!-- Easy Asterisk Provisioning -->
|
|
|
|
<section name="sip">
|
|
<entry name="default_proxy">0</entry>
|
|
<entry name="register_only_when_network_is_up">1</entry>
|
|
<entry name="ping_with_options">0</entry>
|
|
</section>
|
|
|
|
<section name="proxy_0">
|
|
<entry name="reg_proxy"><sip:${domain};transport=${transport}></entry>
|
|
<entry name="reg_identity">sip:USERNAME@${domain}</entry>
|
|
<entry name="reg_expires">3600</entry>
|
|
<entry name="publish">0</entry>
|
|
<entry name="dial_escape_plus">0</entry>
|
|
</section>
|
|
|
|
<section name="auth_info_0">
|
|
<entry name="username">USERNAME</entry>
|
|
<entry name="passwd">PASSWORD</entry>
|
|
<entry name="realm">${domain}</entry>
|
|
</section>
|
|
|
|
<section name="rtp">
|
|
<entry name="audio_rtp_port">7078</entry>
|
|
<entry name="audio_jitt_comp">60</entry>
|
|
</section>
|
|
|
|
<section name="sound">
|
|
<entry name="playback_dev_id">ANDROID SND: Android Sound card</entry>
|
|
<entry name="capture_dev_id">ANDROID SND: Android Sound card</entry>
|
|
<entry name="media_dev_id">ANDROID SND: Android Sound card</entry>
|
|
</section>
|
|
|
|
<section name="video">
|
|
<entry name="enabled">0</entry>
|
|
<entry name="automatically_initiate">0</entry>
|
|
<entry name="automatically_accept">0</entry>
|
|
</section>
|
|
|
|
<section name="app">
|
|
<entry name="auto_start">1</entry>
|
|
<entry name="show_contacts_emails_preference">0</entry>
|
|
<entry name="android_app_use_opensl">1</entry>
|
|
<entry name="android_push_notification">0</entry>
|
|
<!-- CRITICAL: Prevent audio pause when screen turns off -->
|
|
<entry name="android_pause_calls_when_audio_focus_lost">0</entry>
|
|
<!-- /e/OS and privacy ROMs: Keep service alive -->
|
|
<entry name="keep_service_alive">1</entry>
|
|
<entry name="start_at_boot">1</entry>
|
|
</section>
|
|
|
|
<section name="audio">
|
|
<!-- Force audio routing for background operation -->
|
|
<entry name="audio_route_earpiece">0</entry>
|
|
<entry name="audio_route_speaker">1</entry>
|
|
</section>
|
|
|
|
<section name="net">
|
|
<entry name="mtu">1300</entry>
|
|
</section>
|
|
|
|
</config>
|
|
EOF
|
|
|
|
chown asterisk:asterisk "$xml_file"
|
|
chmod 644 "$xml_file"
|
|
|
|
print_success "Created: $xml_file"
|
|
echo ""
|
|
echo "Provisioning URL:"
|
|
if [[ "$transport" == "tls" ]]; then
|
|
echo " https://${domain}:8089/static/linphone.xml"
|
|
else
|
|
echo " http://${server_ip}:8088/static/linphone.xml"
|
|
fi
|
|
echo ""
|
|
echo "IMPORTANT for Android:"
|
|
echo " 1. Use the URL above in Linphone's 'Remote provisioning'"
|
|
echo " 2. Replace USERNAME and PASSWORD in device-specific XML files"
|
|
echo " 3. Set Battery Optimization to 'Unrestricted' manually on phone"
|
|
echo " 4. The XML prevents audio pause when screen turns off"
|
|
echo ""
|
|
echo "FOR /e/OS (eFoundation) users:"
|
|
echo " See 'Troubleshoot /e/OS Audio' in Provisioning Manager menu"
|
|
fi
|
|
}
|
|
|
|
edit_linphone_xml() {
|
|
local xml_file="$PROVISIONING_DIR/linphone.xml"
|
|
|
|
if [[ ! -f "$xml_file" ]]; then
|
|
print_error "linphone.xml does not exist. Create it first."
|
|
return 1
|
|
fi
|
|
|
|
print_header "Edit Linphone XML"
|
|
echo "Opening in nano editor..."
|
|
echo "Press Ctrl+X to save and exit"
|
|
echo ""
|
|
read -p "Press Enter to continue..."
|
|
|
|
nano "$xml_file"
|
|
|
|
print_success "Changes saved"
|
|
}
|
|
|
|
show_provisioning_status() {
|
|
print_header "Provisioning Status"
|
|
|
|
# Check HTTP configuration
|
|
if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then
|
|
echo -e "HTTP Server: ${GREEN}Enabled${NC}"
|
|
asterisk -rx "http show status" 2>/dev/null | head -10
|
|
else
|
|
echo -e "HTTP Server: ${RED}Disabled${NC}"
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# Check provisioning directory
|
|
if [[ -d "$PROVISIONING_DIR" ]]; then
|
|
echo -e "Provisioning Dir: ${GREEN}$PROVISIONING_DIR${NC}"
|
|
echo "Files:"
|
|
ls -lh "$PROVISIONING_DIR" 2>/dev/null | tail -n +2 || echo " (empty)"
|
|
else
|
|
echo -e "Provisioning Dir: ${RED}Not created${NC}"
|
|
fi
|
|
|
|
echo ""
|
|
|
|
# Check symlink
|
|
if [[ -L /usr/share/asterisk/static-http ]]; then
|
|
echo -e "Symlink: ${GREEN}OK${NC} (/usr/share/asterisk/static-http)"
|
|
else
|
|
echo -e "Symlink: ${YELLOW}Not created${NC}"
|
|
fi
|
|
|
|
echo ""
|
|
local server_ip=$(hostname -I | cut -d' ' -f1)
|
|
echo "Provisioning URLs:"
|
|
echo " HTTP: http://${server_ip}:8088/static/"
|
|
echo " HTTPS: https://${server_ip}:8089/static/"
|
|
}
|
|
|
|
troubleshoot_eos_audio() {
|
|
print_header "/e/OS Audio Troubleshooting"
|
|
|
|
cat << 'EOSHELP'
|
|
PROBLEM: No audio sent by phone unless Linphone has focus
|
|
═══════════════════════════════════════════════════════════
|
|
|
|
This is a known issue with /e/OS (eFoundation OS) and privacy-focused
|
|
Android ROMs. /e/OS has stricter privacy controls that prevent apps
|
|
from accessing the microphone in the background.
|
|
|
|
SOLUTIONS (Try in order):
|
|
|
|
1. LINPHONE APP SETTINGS (In Linphone app itself):
|
|
────────────────────────────────────────────────────
|
|
a) Open Linphone → ☰ Menu → Settings → Audio
|
|
b) Change "Audio Route" to "Speaker" (not Earpiece)
|
|
c) Enable "Use Speaker for calls"
|
|
d) Disable "Echo Cancellation" (test if this helps)
|
|
e) Go to Settings → Network
|
|
f) Set "Media Encryption" to "None" (or match server)
|
|
|
|
2. /e/OS PRIVACY SETTINGS:
|
|
────────────────────────────────────────────────────
|
|
a) Settings → Apps → Linphone
|
|
b) Permissions → Microphone → "Allow all the time"
|
|
c) Permissions → Camera → "Don't allow" (if not using video)
|
|
d) "Remove permissions if app isn't used" → DISABLE
|
|
|
|
3. /e/OS ADVANCED PRIVACY SETTINGS:
|
|
────────────────────────────────────────────────────
|
|
a) Settings → Privacy (Advanced Privacy / Privacy Central)
|
|
b) Find Linphone in the list
|
|
c) Disable "Hide my IP" for Linphone
|
|
d) Set Location to "Real" (not fake location)
|
|
e) Disable any "Manage trackers" restrictions for Linphone
|
|
|
|
4. /e/OS NETWORK PERMISSIONS:
|
|
────────────────────────────────────────────────────
|
|
a) Settings → Apps → Linphone → Mobile data & Wi-Fi
|
|
b) Enable "Background data"
|
|
c) Enable "Unrestricted data usage"
|
|
d) Make sure "Allow network access" is ON
|
|
|
|
5. /e/OS AUTOSTART:
|
|
────────────────────────────────────────────────────
|
|
a) Settings → Apps → Linphone → Battery
|
|
b) Battery optimization → "Don't optimize" or "Unrestricted"
|
|
c) Settings → Apps → Linphone → Advanced
|
|
d) Enable "Autostart" if available
|
|
|
|
6. LINPHONE XML PROVISIONING (Server-side fix):
|
|
────────────────────────────────────────────────────
|
|
Your linphone.xml should already have these settings:
|
|
• android_pause_calls_when_audio_focus_lost=0
|
|
• keep_service_alive=1
|
|
• start_at_boot=1
|
|
• audio_route_speaker=1
|
|
|
|
To verify, check: $PROVISIONING_DIR/linphone.xml
|
|
|
|
7. ALTERNATIVE: USE SPEAKER MODE DURING CALL:
|
|
────────────────────────────────────────────────────
|
|
As a workaround, during an active call:
|
|
• Tap the speaker icon to enable speakerphone
|
|
• This often forces audio to work even in background
|
|
• Not ideal but proves the audio path works
|
|
|
|
8. NUCLEAR OPTION - DISABLE PRIVACY FEATURES:
|
|
────────────────────────────────────────────────────
|
|
If nothing works, temporarily disable /e/OS privacy features:
|
|
a) Settings → Privacy → Advanced Privacy
|
|
b) Toggle OFF "Advanced Privacy"
|
|
c) Test if Linphone audio works
|
|
d) If it works, re-enable and whitelist Linphone
|
|
|
|
9. ALTERNATIVE SIP APP:
|
|
────────────────────────────────────────────────────
|
|
If Linphone continues to have issues on /e/OS, try:
|
|
• Zoiper (better /e/OS compatibility)
|
|
• CSipSimple (older but reliable)
|
|
• Grandstream Wave (commercial but works well)
|
|
|
|
TESTING:
|
|
════════
|
|
1. Make a call with Linphone in foreground → audio works
|
|
2. Press Home button → does audio continue?
|
|
3. If audio stops, the issue is confirmed
|
|
|
|
WHAT'S HAPPENING:
|
|
═════════════════
|
|
/e/OS restricts background microphone access for privacy.
|
|
Even with permissions granted, the OS may suspend audio
|
|
capture when the app loses focus. The XML settings and
|
|
speaker mode help work around this limitation.
|
|
|
|
MORE HELP:
|
|
══════════
|
|
• /e/OS Community: https://community.e.foundation
|
|
• Linphone Forums: https://forum.linphone.org
|
|
• Issue: "Background microphone access on /e/OS"
|
|
|
|
═══════════════════════════════════════════════════════════
|
|
EOSHELP
|
|
}
|
|
|
|
create_baresip_config() {
|
|
print_header "Create Baresip Setup Instructions"
|
|
load_config
|
|
|
|
local server_ip=$(hostname -I | cut -d' ' -f1)
|
|
local domain="${DOMAIN_NAME:-$server_ip}"
|
|
|
|
echo "Baresip Setup Guide Generator"
|
|
echo "================================================"
|
|
echo ""
|
|
echo "Use Baresip if Linphone has audio issues (screen off, etc.)"
|
|
echo "Baresip often works better on privacy-focused Android ROMs."
|
|
echo ""
|
|
echo "NOTE: Baresip does NOT support remote provisioning."
|
|
echo " Manual configuration required."
|
|
echo ""
|
|
echo "Current Configuration:"
|
|
echo " Domain: $domain"
|
|
echo " Server IP: $server_ip"
|
|
echo ""
|
|
|
|
read -p "Enter extension number (e.g., 202): " extension
|
|
[[ -z "$extension" ]] && { print_error "Extension required"; return 1; }
|
|
|
|
read -p "Enter SIP password: " sip_password
|
|
[[ -z "$sip_password" ]] && { print_error "Password required"; return 1; }
|
|
|
|
read -p "Enter display name (e.g., Kitchen Phone): " display_name
|
|
display_name=${display_name:-Extension $extension}
|
|
|
|
local config_file="$PROVISIONING_DIR/baresip-${extension}.txt"
|
|
|
|
mkdir -p "$PROVISIONING_DIR"
|
|
|
|
cat > "$config_file" << BARESIPEOF
|
|
═══════════════════════════════════════════════════════════
|
|
BARESIP SETUP INSTRUCTIONS
|
|
Generated by Easy Asterisk v${SCRIPT_VERSION}
|
|
═══════════════════════════════════════════════════════════
|
|
|
|
IMPORTANT: Baresip does NOT support remote provisioning.
|
|
You must configure manually following these steps.
|
|
|
|
STEP 1: INSTALL BARESIP
|
|
════════════════════════════════════════════════════════════
|
|
• Download Baresip from F-Droid or Play Store
|
|
• Open the Baresip app
|
|
|
|
STEP 2: ADD ACCOUNT (Initial Entry)
|
|
════════════════════════════════════════════════════════════
|
|
1. Tap Menu (☰ hamburger icon) → Accounts
|
|
2. Tap the Add (+) button at the top
|
|
3. In "SIP URI" field, enter: BARESIPEOF
|
|
echo "${extension}@${domain}" >> "$config_file"
|
|
cat >> "$config_file" << 'BARESIPEOF'
|
|
4. Tap the Save (✓ checkmark) icon at the top
|
|
|
|
STEP 3: EDIT ACCOUNT (Complete Configuration)
|
|
════════════════════════════════════════════════════════════
|
|
Now go back and edit the account to add authentication:
|
|
|
|
1. Tap Menu (☰) → Accounts
|
|
2. Tap on the account you just created
|
|
3. Fill in the following fields:
|
|
|
|
BARESIPEOF
|
|
cat >> "$config_file" << EOF
|
|
Display Name: ${display_name}
|
|
|
|
Authentication Username: ${extension}
|
|
(CRITICAL: Just the extension number, NOT ${extension}@${domain})
|
|
|
|
Authentication Password: ${sip_password}
|
|
|
|
Outbound Proxy URI: ${domain}
|
|
(CRITICAL: Just the domain, NOT sip:${server_ip}:5060)
|
|
|
|
Media Encryption: srtp
|
|
(Select from dropdown menu)
|
|
|
|
Register: ✓ (Check this box)
|
|
|
|
4. Tap Save (✓ checkmark icon)
|
|
|
|
STEP 4: VERIFY REGISTRATION
|
|
════════════════════════════════════════════════════════════
|
|
• Wait a few seconds for registration
|
|
• You should see:
|
|
- Green dot next to account, OR
|
|
- "Registered" status text
|
|
|
|
If registration FAILS:
|
|
✗ Double-check "Authentication Username" is JUST "${extension}"
|
|
✗ Double-check "Outbound Proxy URI" is JUST "${domain}"
|
|
✗ Verify password is correct: ${sip_password}
|
|
|
|
STEP 5: SET CALLING AS DEFAULT (Optional)
|
|
════════════════════════════════════════════════════════════
|
|
To make tapping a contact initiate a call (not message):
|
|
|
|
1. Tap Menu (☰) → Settings (or Preferences)
|
|
2. Look for "Default Action" or "Contact Action"
|
|
3. If available, select: "Audio Call" or "Call"
|
|
4. Save
|
|
|
|
NOTE: This option may not exist in all Baresip versions.
|
|
If not available, you can still call by:
|
|
- Long-pressing a contact → Select "Call"
|
|
- Or using the phone icon during selection
|
|
|
|
STEP 6: AUDIO SETTINGS (Recommended)
|
|
════════════════════════════════════════════════════════════
|
|
1. Tap Menu (☰) → Settings → Audio
|
|
2. Configure:
|
|
Audio Module: opensles (or audiotrack if opensles doesn't work)
|
|
Echo Cancellation: ✓ Enabled
|
|
Noise Suppression: ✓ Enabled
|
|
|
|
STEP 7: ANDROID PERMISSIONS
|
|
════════════════════════════════════════════════════════════
|
|
Go to your phone's:
|
|
Settings → Apps → Baresip
|
|
|
|
Set the following:
|
|
• Permissions → Microphone: Allow while using app
|
|
• Permissions → Phone: Allow
|
|
• Battery: Unrestricted (or Not optimized)
|
|
• Mobile data & Wi-Fi → Background data: Enabled
|
|
|
|
DIALING EXTENSIONS
|
|
════════════════════════════════════════════════════════════
|
|
To call other extensions:
|
|
|
|
Method 1 (Try this first):
|
|
Just dial the extension number: 101, 202, etc.
|
|
|
|
Method 2 (If method 1 doesn't work):
|
|
Full format: 101@${domain}
|
|
|
|
Common Extensions:
|
|
• Individual devices: 101, 102, 201, 202, etc.
|
|
• Page groups (auto-answer broadcast): 199
|
|
• Ring groups (rings all phones): 299
|
|
|
|
TOP BAR ICONS IN BARESIP
|
|
════════════════════════════════════════════════════════════
|
|
☰ = Hamburger menu (Accounts, Settings, About, etc.)
|
|
✓ = Save/Confirm current action
|
|
⋮ = Additional options (context-dependent)
|
|
📞 = Answer incoming call / Place outgoing call
|
|
🔊 = Enable speakerphone (during active call)
|
|
🔇 = Mute microphone (during active call)
|
|
✕ = Hang up / End call
|
|
|
|
TROUBLESHOOTING
|
|
════════════════════════════════════════════════════════════
|
|
|
|
PROBLEM: Registration fails
|
|
SOLUTION:
|
|
• Verify "Authentication Username" is JUST: ${extension}
|
|
• Verify "Outbound Proxy URI" is JUST: ${domain}
|
|
• Check password is correct
|
|
• Check phone has network connectivity
|
|
• Check firewall allows SIP traffic
|
|
|
|
PROBLEM: Can't dial extensions
|
|
SOLUTION:
|
|
• Verify you're registered (green dot/status)
|
|
• Try dialing full format: ${extension}@${domain}
|
|
• Check extension exists on server
|
|
|
|
PROBLEM: No audio / Audio doesn't work
|
|
SOLUTION:
|
|
• Menu → Settings → Audio → Try different "Audio Module"
|
|
• Check microphone permissions in Android settings
|
|
• During call, try tapping speaker icon
|
|
|
|
PROBLEM: Audio cuts when screen turns off
|
|
SOLUTION:
|
|
• Settings → Apps → Baresip → Battery → Unrestricted
|
|
• Baresip handles this much better than Linphone!
|
|
• This issue is rare with Baresip
|
|
|
|
PROBLEM: Can't find "Default Action" setting
|
|
SOLUTION:
|
|
• Not all Baresip versions have this option
|
|
• Alternative: Long-press contact → Select "Call"
|
|
• Or tap contact then tap phone icon
|
|
|
|
═══════════════════════════════════════════════════════════
|
|
QUICK REFERENCE
|
|
═══════════════════════════════════════════════════════════
|
|
Display Name: ${display_name}
|
|
SIP URI (initial): ${extension}@${domain}
|
|
Auth Username: ${extension}
|
|
Auth Password: ${sip_password}
|
|
Outbound Proxy: ${domain}
|
|
Media Encryption: srtp
|
|
Register: ✓
|
|
═══════════════════════════════════════════════════════════
|
|
|
|
EOF
|
|
|
|
chown asterisk:asterisk "$config_file"
|
|
chmod 644 "$config_file"
|
|
|
|
print_success "Created: $config_file"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
echo "BARESIP SETUP - Extension ${extension}"
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo "Download instructions:"
|
|
echo " http://${server_ip}:8088/static/baresip-${extension}.txt"
|
|
echo ""
|
|
echo "QUICK SETUP SUMMARY:"
|
|
echo ""
|
|
echo "Step 1: Add Account"
|
|
echo " Menu → Accounts → Add (+)"
|
|
echo " SIP URI: ${extension}@${domain}"
|
|
echo " Save (✓)"
|
|
echo ""
|
|
echo "Step 2: Edit Account"
|
|
echo " Tap account → Edit"
|
|
echo " Auth Username: ${extension} (JUST the number!)"
|
|
echo " Auth Password: ${sip_password}"
|
|
echo " Outbound Proxy: ${domain} (JUST the domain!)"
|
|
echo " Media Encryption: srtp (select from dropdown)"
|
|
echo " Register: ✓"
|
|
echo " Save (✓)"
|
|
echo ""
|
|
echo "Step 3: Verify"
|
|
echo " Look for green dot or 'Registered' status"
|
|
echo ""
|
|
echo "Step 4: Dial Extensions"
|
|
echo " Just dial: 101, 202, etc."
|
|
echo ""
|
|
echo "Full details in the text file above."
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
}
|
|
|
|
provisioning_manager_menu() {
|
|
while true; do
|
|
clear
|
|
print_header "Provisioning Manager"
|
|
echo " 1) Setup HTTP Server (ports 8088/8089)"
|
|
echo " 2) Create/Update linphone.xml"
|
|
echo " 3) Edit linphone.xml"
|
|
echo " 4) Create Baresip Config"
|
|
echo " 5) Show Status"
|
|
echo " 6) Open Provisioning Directory"
|
|
echo " 7) Troubleshoot /e/OS Audio Issues"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
|
|
case $choice in
|
|
1) setup_http_provisioning ;;
|
|
2) create_linphone_xml ;;
|
|
3) edit_linphone_xml ;;
|
|
4) create_baresip_config ;;
|
|
5) show_provisioning_status ;;
|
|
6)
|
|
if command -v mc &>/dev/null; then
|
|
mc "$PROVISIONING_DIR"
|
|
else
|
|
print_info "Opening with ls..."
|
|
ls -lah "$PROVISIONING_DIR"
|
|
fi
|
|
;;
|
|
7) troubleshoot_eos_audio ;;
|
|
0) return ;;
|
|
esac
|
|
|
|
[[ "$choice" != "0" ]] && read -p "Press Enter..."
|
|
done
|
|
}
|
|
|
|
# ================================================================
|
|
# MANUAL UPDATE SYSTEM
|
|
# ================================================================
|
|
|
|
manual_update_asterisk() {
|
|
print_header "Manual Asterisk Update"
|
|
echo "WARNING: This will update Asterisk from the repository."
|
|
echo "A backup will be created automatically."
|
|
echo ""
|
|
asterisk -V 2>/dev/null || echo "Asterisk not currently running"
|
|
echo ""
|
|
read -p "Continue with update? (y/n) [n]: " confirm
|
|
confirm=${confirm:-n}
|
|
|
|
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|
print_info "Update cancelled"
|
|
return
|
|
fi
|
|
|
|
# Backup configurations
|
|
local backup_dir="/root/asterisk-backup-$(date +%Y%m%d_%H%M%S)"
|
|
mkdir -p "$backup_dir"
|
|
echo "Creating backup in $backup_dir..."
|
|
cp -r /etc/asterisk "$backup_dir/"
|
|
cp -r /var/lib/asterisk "$backup_dir/" 2>/dev/null || true
|
|
|
|
print_success "Backup created: $backup_dir"
|
|
|
|
# Update
|
|
echo ""
|
|
print_info "Updating Asterisk..."
|
|
apt update
|
|
apt install --only-upgrade asterisk asterisk-modules -y
|
|
|
|
# Restart
|
|
echo ""
|
|
print_info "Restarting Asterisk..."
|
|
systemctl restart asterisk
|
|
|
|
sleep 3
|
|
|
|
if systemctl is-active asterisk >/dev/null; then
|
|
print_success "Asterisk updated successfully"
|
|
asterisk -V
|
|
echo ""
|
|
echo "Backup location: $backup_dir"
|
|
echo ""
|
|
echo "To rollback if needed:"
|
|
echo " systemctl stop asterisk"
|
|
echo " cp -r $backup_dir/asterisk/* /etc/asterisk/"
|
|
echo " systemctl start asterisk"
|
|
else
|
|
print_error "Asterisk failed to start after update!"
|
|
echo ""
|
|
echo "Rolling back..."
|
|
cp -r "$backup_dir/asterisk/"* /etc/asterisk/
|
|
systemctl restart asterisk
|
|
print_info "Rollback complete"
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# ROOM DIRECTORY
|
|
# ================================================================
|
|
|
|
show_room_directory() {
|
|
print_header "Room Directory"
|
|
load_config
|
|
|
|
if [[ ! -f "$ROOMS_FILE" ]]; then
|
|
print_error "Rooms file not found: $ROOMS_FILE"
|
|
return
|
|
fi
|
|
|
|
echo "Ring Groups vs Page Groups:"
|
|
echo " • Ring Groups: Rings all members until one answers"
|
|
echo " • Page Groups: Auto-answer broadcast to all members"
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
|
|
local has_rooms=false
|
|
while IFS='|' read -r ext name members timeout type; do
|
|
# Skip comments and empty lines
|
|
[[ "$ext" =~ ^[[:space:]]*# ]] && continue
|
|
[[ -z "$ext" ]] && continue
|
|
|
|
has_rooms=true
|
|
|
|
# Determine icon based on type
|
|
local icon="📞"
|
|
local type_label="Ring Group"
|
|
if [[ "$type" == "page" ]]; then
|
|
icon="📢"
|
|
type_label="Page Group"
|
|
fi
|
|
|
|
echo ""
|
|
echo "$icon Extension: $ext - $name"
|
|
echo " Type: $type_label"
|
|
echo " Members: $members"
|
|
echo " Timeout: ${timeout}s"
|
|
done < "$ROOMS_FILE"
|
|
|
|
if [[ "$has_rooms" == "false" ]]; then
|
|
echo ""
|
|
echo "No rooms configured yet."
|
|
echo "Use 'Device Management → Manage rooms' to create rooms."
|
|
fi
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════"
|
|
}
|
|
|
|
watch_live_logs() {
|
|
print_header "Live Debugging"
|
|
echo "Enabling PJSIP Logger..."
|
|
asterisk -rx "module load res_pjsip_logger.so" 2>/dev/null || true
|
|
asterisk -rx "pjsip set logger on" 2>/dev/null
|
|
echo ""
|
|
echo "Options:"
|
|
echo " 1) Asterisk Console (verbose)"
|
|
echo " 2) Packet Capture (tcpdump)"
|
|
read -p "Select [1]: " pcap
|
|
if [[ "$pcap" == "2" ]]; then
|
|
echo "Starting tcpdump. Press CTRL+C to stop."
|
|
tcpdump -i any port 5060 or port 5061 -nn -v
|
|
else
|
|
echo "Starting Console. Press CTRL+C to exit."
|
|
asterisk -rvvv
|
|
fi
|
|
asterisk -rx "pjsip set logger off" 2>/dev/null
|
|
}
|
|
|
|
router_doctor() {
|
|
print_header "Router Traffic Doctor"
|
|
if ! systemctl is-active asterisk >/dev/null; then
|
|
print_error "Asterisk is NOT RUNNING"
|
|
restart_asterisk_safe
|
|
return
|
|
fi
|
|
|
|
print_success "Asterisk is UP"
|
|
echo ""
|
|
echo "Server Listening IPs:"
|
|
ip -o -4 addr show | awk '{print " " $2 ": " $4}'
|
|
echo ""
|
|
echo "Instructions:"
|
|
echo " 1. Take out your phone/laptop"
|
|
echo " 2. Attempt to REGISTER or CALL"
|
|
echo " 3. I will listen for 15 seconds"
|
|
echo ""
|
|
read -p "Press Enter to start listening..."
|
|
|
|
if timeout 15 tcpdump -i any -c 1 "port 5060 or port 5061" 2>/dev/null; then
|
|
echo ""
|
|
print_success "PACKET RECEIVED! Router forwarding is working."
|
|
else
|
|
echo ""
|
|
print_error "NO PACKETS RECEIVED."
|
|
echo "Your router or firewall is blocking the connection."
|
|
fi
|
|
}
|
|
|
|
configure_local_client() {
|
|
print_header "Configure Local Client"
|
|
load_config
|
|
|
|
# If KIOSK_USER already set from config, show and ask if want to change
|
|
if [[ -n "$KIOSK_USER" ]]; then
|
|
echo "Current configured user: $KIOSK_USER"
|
|
read -p "Change user? [y/N]: " change_user
|
|
if [[ "$change_user" =~ ^[Yy]$ ]]; then
|
|
KIOSK_USER=""
|
|
KIOSK_UID=""
|
|
fi
|
|
fi
|
|
|
|
# If still no user, select one
|
|
if [[ -z "$KIOSK_USER" ]]; then
|
|
echo ""
|
|
echo "Select the user to configure:"
|
|
echo ""
|
|
if ! select_user; then
|
|
print_error "User selection failed"
|
|
return 1
|
|
fi
|
|
else
|
|
# Ensure KIOSK_UID is set
|
|
KIOSK_UID=$(id -u "$KIOSK_USER" 2>/dev/null)
|
|
fi
|
|
|
|
echo ""
|
|
|
|
if [[ ! -d "/home/${KIOSK_USER}/.baresip" ]]; then
|
|
print_error "Baresip not installed for $KIOSK_USER"
|
|
echo ""
|
|
read -p "Install Baresip client now? [Y/n]: " install_it
|
|
if [[ ! "$install_it" =~ ^[Nn]$ ]]; then
|
|
install_baresip_packages
|
|
configure_baresip
|
|
enable_client_services
|
|
INSTALLED_CLIENT="y"
|
|
save_config
|
|
print_success "Baresip installed"
|
|
echo ""
|
|
echo "Audio configured for $KIOSK_USER"
|
|
echo "If audio doesn't work, log out and back in or reboot."
|
|
echo ""
|
|
else
|
|
return
|
|
fi
|
|
fi
|
|
|
|
read -p "Extension: " ext
|
|
read -p "Password: " pass
|
|
read -p "Server Domain/IP: " server
|
|
|
|
local transport_str="udp"
|
|
local media_enc=""
|
|
|
|
if [[ "$server" =~ [a-zA-Z] ]]; then
|
|
print_info "Domain detected. Using TLS."
|
|
transport_str="tls"
|
|
media_enc=";mediaenc=srtp"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Answer Mode:"
|
|
echo " 1) Manual (ring on incoming)"
|
|
echo " 2) Auto (auto-answer)"
|
|
read -p "Select [1]: " amode
|
|
local answermode="manual"
|
|
[[ "$amode" == "2" ]] && answermode="auto"
|
|
|
|
echo ""
|
|
echo "Enable TURN? (Required if behind NAT/VLAN without VPN)"
|
|
read -p "Use TURN server? [y/N]: " use_turn
|
|
local turn_config=""
|
|
if [[ "$use_turn" =~ ^[Yy]$ ]]; then
|
|
read -p "TURN User [${TURN_USER}]: " t_user
|
|
t_user="${t_user:-$TURN_USER}"
|
|
read -p "TURN Pass [${TURN_PASS}]: " t_pass
|
|
t_pass="${t_pass:-$TURN_PASS}"
|
|
local turn_host="${server}"
|
|
if [[ ! "$turn_host" =~ [a-zA-Z] ]]; then
|
|
# If server is IP, ask if TURN host is different
|
|
read -p "TURN Host [${server}]: " th
|
|
turn_host="${th:-$server}"
|
|
fi
|
|
turn_config="turn_server turn:${t_user}:${t_pass}@${turn_host}:3478"
|
|
fi
|
|
|
|
# Update config file for TURN
|
|
local conf_file="/home/${KIOSK_USER}/.baresip/config"
|
|
if [[ -f "$conf_file" ]]; then
|
|
sed -i '/^turn_server/d' "$conf_file"
|
|
if [[ -n "$turn_config" ]]; then
|
|
echo "$turn_config" >> "$conf_file"
|
|
print_success "TURN configuration added"
|
|
fi
|
|
fi
|
|
|
|
cat > "/home/${KIOSK_USER}/.baresip/accounts" << EOF
|
|
<sip:${ext}@${server};transport=${transport_str}>;auth_pass=${pass};answermode=${answermode}${media_enc}
|
|
EOF
|
|
chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/accounts"
|
|
chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/config"
|
|
|
|
# Update main config
|
|
ASTERISK_HOST="$server"
|
|
KIOSK_EXTENSION="$ext"
|
|
CLIENT_ANSWERMODE="$answermode"
|
|
save_config
|
|
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}"
|
|
|
|
# Reload systemd daemon in case services changed
|
|
sudo -u "${KIOSK_USER}" $user_dbus systemctl --user daemon-reload 2>/dev/null
|
|
|
|
# Restart audio and client services
|
|
print_info "Restarting services..."
|
|
sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true
|
|
sleep 2
|
|
sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart baresip 2>/dev/null
|
|
|
|
# Ensure audio is unmuted if not in PTT mode
|
|
if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then
|
|
sleep 1
|
|
ensure_audio_unmuted
|
|
fi
|
|
|
|
print_success "Client Reconfigured & Services Restarted"
|
|
echo ""
|
|
echo "Run Diagnostics to verify connection status."
|
|
}
|
|
|
|
run_client_diagnostics() {
|
|
print_header "Client Diagnostics"
|
|
load_config
|
|
local t_user="${KIOSK_USER:-$SUDO_USER}"
|
|
t_user="${t_user:-$USER}"
|
|
local t_uid=$(id -u "$t_user" 2>/dev/null)
|
|
|
|
echo -e "User: ${BOLD}$t_user${NC}"
|
|
echo "---------------------------------------------------"
|
|
if sudo -u "$t_user" XDG_RUNTIME_DIR=/run/user/$t_uid systemctl --user is-active baresip >/dev/null 2>&1; then
|
|
print_success "Baresip RUNNING"
|
|
else
|
|
print_error "Baresip STOPPED/FAILED"
|
|
fi
|
|
echo "---------------------------------------------------"
|
|
|
|
echo "Audio Services:"
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid"
|
|
if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire >/dev/null 2>&1; then
|
|
print_success "PipeWire RUNNING"
|
|
else
|
|
print_error "PipeWire STOPPED"
|
|
fi
|
|
if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire-pulse >/dev/null 2>&1; then
|
|
print_success "PipeWire-Pulse RUNNING"
|
|
else
|
|
print_error "PipeWire-Pulse STOPPED"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Audio Status:"
|
|
local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}')
|
|
local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}')
|
|
local src_vol=$(sudo -u "$t_user" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1)
|
|
local sink_vol=$(sudo -u "$t_user" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1)
|
|
|
|
echo " Microphone: ${src_mute:-unknown} (Volume: ${src_vol:-unknown})"
|
|
echo " Speaker: ${sink_mute:-unknown} (Volume: ${sink_vol:-unknown})"
|
|
|
|
if [[ "$src_mute" == "yes" ]]; then
|
|
echo ""
|
|
print_error "MICROPHONE IS MUTED - No audio will be sent!"
|
|
echo " To fix: pactl set-source-mute @DEFAULT_SOURCE@ 0"
|
|
fi
|
|
|
|
echo ""
|
|
echo "PTT Configuration:"
|
|
if [[ -f /etc/easy-asterisk/ptt-device ]]; then
|
|
echo " PTT Mode: ENABLED"
|
|
source /etc/easy-asterisk/ptt-device 2>/dev/null
|
|
echo " Device: ${PTT_DEVICE:-not set}"
|
|
else
|
|
echo " PTT Mode: DISABLED (normal intercom mode)"
|
|
fi
|
|
|
|
echo "---------------------------------------------------"
|
|
|
|
echo "Network Interface:"
|
|
grep "^net_interface" "/home/$t_user/.baresip/config" 2>/dev/null || echo " Not set"
|
|
|
|
echo "---------------------------------------------------"
|
|
echo "Account Config:"
|
|
cat "/home/$t_user/.baresip/accounts" 2>/dev/null | sed 's/auth_pass=[^;]*/auth_pass=***/' || echo " Not found"
|
|
|
|
echo "---------------------------------------------------"
|
|
if [[ -n "$ASTERISK_HOST" ]]; then
|
|
echo -n "Server ($ASTERISK_HOST): "
|
|
if ping -c 1 -W 2 "$ASTERISK_HOST" >/dev/null 2>&1; then
|
|
print_success "Reachable"
|
|
else
|
|
print_error "Unreachable"
|
|
fi
|
|
fi
|
|
echo "---------------------------------------------------"
|
|
echo "System Logs (launcher):"
|
|
journalctl -t baresip-launcher -n 10 --no-pager 2>/dev/null | tail -10 || echo " No launcher logs"
|
|
echo ""
|
|
echo "System Logs (PTT):"
|
|
journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null | tail -5 || echo " No PTT logs"
|
|
echo ""
|
|
echo "Baresip Service Log:"
|
|
sudo -u "$t_user" journalctl --user -u baresip -n 10 --no-pager 2>/dev/null || echo " No logs"
|
|
echo "---------------------------------------------------"
|
|
echo ""
|
|
echo "To see live logs, run:"
|
|
echo " journalctl -t baresip-launcher -f # Launcher logs"
|
|
echo " journalctl -t kiosk-ptt -f # PTT logs"
|
|
echo " sudo -u $t_user journalctl --user -u baresip -f # Baresip logs"
|
|
echo "---------------------------------------------------"
|
|
}
|
|
|
|
run_audio_test() {
|
|
print_header "Audio Test"
|
|
echo "Playing test tone..."
|
|
speaker-test -t sine -f 440 -c 2 -l 1 >/dev/null 2>&1
|
|
echo ""
|
|
read -p "Did you hear audio? [y/N]: " res
|
|
if [[ "$res" =~ ^[Yy]$ ]]; then
|
|
print_success "Audio OK"
|
|
else
|
|
print_error "Check volume/connections"
|
|
fi
|
|
}
|
|
|
|
verify_audio_setup() {
|
|
print_header "Audio Verification"
|
|
echo "=== Codecs ==="
|
|
asterisk -rx "core show codecs" 2>/dev/null | grep -E "(opus|ulaw|alaw|g722)" || echo " N/A"
|
|
echo ""
|
|
echo "=== PJSIP Modules ==="
|
|
asterisk -rx "module show like pjsip" 2>/dev/null | head -10 || echo " N/A"
|
|
echo ""
|
|
echo "=== Certificate ==="
|
|
if [[ -f /etc/asterisk/certs/server.crt ]]; then
|
|
openssl x509 -in /etc/asterisk/certs/server.crt -noout -subject -dates 2>/dev/null
|
|
else
|
|
echo " None"
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# 7. ASTERISK CONFIG
|
|
# ================================================================
|
|
|
|
fix_asterisk_systemd() {
|
|
print_info "Configuring systemd..."
|
|
mkdir -p /etc/systemd/system/asterisk.service.d/
|
|
cat > /etc/systemd/system/asterisk.service.d/override.conf << 'SVCEOF'
|
|
[Unit]
|
|
Wants=network-online.target
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk
|
|
RuntimeDirectory=asterisk
|
|
RuntimeDirectoryMode=0750
|
|
MemoryMax=infinity
|
|
TasksMax=infinity
|
|
KillMode=mixed
|
|
KillSignal=SIGTERM
|
|
TimeoutStartSec=60
|
|
TimeoutStopSec=30
|
|
SendSIGKILL=no
|
|
Restart=always
|
|
RestartSec=10
|
|
Type=simple
|
|
SVCEOF
|
|
systemctl daemon-reload
|
|
}
|
|
|
|
recover_xml_docs() {
|
|
mkdir -p /var/lib/asterisk/documentation/thirdparty
|
|
chown -R asterisk:asterisk /var/lib/asterisk/documentation 2>/dev/null
|
|
}
|
|
|
|
repair_core_configs() {
|
|
print_info "Repairing configs..."
|
|
|
|
# Copy modules (not symlink - AppArmor blocks symlinks)
|
|
if [[ -d "/usr/lib/x86_64-linux-gnu/asterisk/modules" ]]; then
|
|
mkdir -p /usr/lib/asterisk/modules
|
|
cp -rn /usr/lib/x86_64-linux-gnu/asterisk/modules/* /usr/lib/asterisk/modules/ 2>/dev/null || true
|
|
fi
|
|
|
|
mkdir -p /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk
|
|
recover_xml_docs
|
|
|
|
if [[ ! -f /etc/asterisk/asterisk.conf ]]; then
|
|
cat > /etc/asterisk/asterisk.conf << EOF
|
|
[directories]
|
|
astetcdir => /etc/asterisk
|
|
astmoddir => /usr/lib/asterisk/modules
|
|
astvarlibdir => /var/lib/asterisk
|
|
astdbdir => /var/lib/asterisk
|
|
astkeydir => /var/lib/asterisk
|
|
astdatadir => /var/lib/asterisk
|
|
astagidir => /var/lib/asterisk/agi-bin
|
|
astspooldir => /var/spool/asterisk
|
|
astrundir => /var/run/asterisk
|
|
astlogdir => /var/log/asterisk
|
|
EOF
|
|
fi
|
|
|
|
cat > /etc/asterisk/modules.conf << EOF
|
|
[modules]
|
|
autoload=yes
|
|
noload => chan_sip.so
|
|
noload => chan_iax2.so
|
|
load => res_pjsip.so
|
|
load => res_pjsip_session.so
|
|
load => res_pjsip_logger.so
|
|
load => chan_pjsip.so
|
|
load => codec_ulaw.so
|
|
load => codec_alaw.so
|
|
load => codec_g722.so
|
|
load => codec_opus.so
|
|
load => res_rtp_asterisk.so
|
|
load => app_dial.so
|
|
load => app_page.so
|
|
load => pbx_config.so
|
|
EOF
|
|
|
|
# Disable optional modules (NOT stasis - required in Asterisk 20.x)
|
|
for conf in ari http manager geolocation; do
|
|
cat > "/etc/asterisk/${conf}.conf" << EOF
|
|
[general]
|
|
enabled = no
|
|
EOF
|
|
done
|
|
|
|
# Configure Stasis properly (required core module)
|
|
cat > /etc/asterisk/stasis.conf << EOF
|
|
[general]
|
|
; Stasis is required for Asterisk 20.x core functionality
|
|
EOF
|
|
|
|
if [[ ! -f /etc/asterisk/sorcery.conf ]]; then
|
|
cat > /etc/asterisk/sorcery.conf << EOF
|
|
[res_pjsip]
|
|
endpoint=config,pjsip.conf,criteria=type=endpoint
|
|
auth=config,pjsip.conf,criteria=type=auth
|
|
aor=config,pjsip.conf,criteria=type=aor
|
|
transport=config,pjsip.conf,criteria=type=transport
|
|
EOF
|
|
fi
|
|
|
|
# ICE and STUN only for FQDN/internet calling
|
|
load_config
|
|
local ice_stun_config=""
|
|
if [[ -n "$DOMAIN_NAME" ]]; then
|
|
ice_stun_config="icesupport=yes
|
|
stunaddr=stun.l.google.com:19302"
|
|
else
|
|
ice_stun_config="# icesupport disabled - LAN only mode"
|
|
fi
|
|
|
|
cat > /etc/asterisk/rtp.conf << EOF
|
|
[general]
|
|
rtpstart=10000
|
|
rtpend=20000
|
|
strictrtp=yes
|
|
${ice_stun_config}
|
|
EOF
|
|
|
|
cat > /etc/asterisk/logger.conf << EOF
|
|
[general]
|
|
[logfiles]
|
|
console => notice,warning,error
|
|
EOF
|
|
|
|
rm -f /var/lib/asterisk/.asterisk_history
|
|
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk 2>/dev/null || true
|
|
chown -R asterisk:asterisk /usr/lib/asterisk/modules 2>/dev/null || true
|
|
}
|
|
|
|
generate_pjsip_conf() {
|
|
print_info "Generating PJSIP..."
|
|
load_config
|
|
local conf_file="/etc/asterisk/pjsip.conf"
|
|
backup_config "$conf_file"
|
|
|
|
# Prioritize CURRENT_PUBLIC_IP from coturn/updater if available, else detect
|
|
local public_ip="${CURRENT_PUBLIC_IP}"
|
|
if [[ -z "$public_ip" ]]; then
|
|
public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "")
|
|
fi
|
|
|
|
# Get server IP for transport binding info
|
|
local server_ip=$(hostname -I | cut -d' ' -f1)
|
|
|
|
local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1)
|
|
local default_cidr="$raw_cidr"
|
|
if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi
|
|
|
|
# Use stored CIDR if available
|
|
local local_net="${LOCAL_CIDR:-$default_cidr}"
|
|
|
|
# Build local_net entries (main network + VLANs)
|
|
local all_local_nets="local_net=$local_net"
|
|
if [[ "$HAS_VLANS" == "y" && -n "$VLAN_SUBNETS" ]]; then
|
|
for vlan_subnet in $VLAN_SUBNETS; do
|
|
all_local_nets="${all_local_nets}
|
|
local_net=${vlan_subnet}"
|
|
done
|
|
print_info "VLAN subnets configured: $VLAN_SUBNETS"
|
|
fi
|
|
|
|
local nat_settings=""
|
|
if [[ -n "$public_ip" && -n "$DOMAIN_NAME" ]]; then
|
|
nat_settings="external_media_address=$public_ip
|
|
external_signaling_address=$public_ip
|
|
${all_local_nets}"
|
|
print_info "NAT: Public IP=$public_ip, Server IP=$server_ip"
|
|
fi
|
|
|
|
cat > "$conf_file" << EOF
|
|
; Easy Asterisk v${SCRIPT_VERSION}
|
|
[global]
|
|
type=global
|
|
user_agent=EasyAsterisk
|
|
|
|
[transport-udp]
|
|
type=transport
|
|
protocol=udp
|
|
bind=0.0.0.0:${DEFAULT_SIP_PORT}
|
|
; Server IP: ${server_ip}
|
|
${nat_settings}
|
|
|
|
[transport-tcp]
|
|
type=transport
|
|
protocol=tcp
|
|
bind=0.0.0.0:${DEFAULT_SIP_PORT}
|
|
; Server IP: ${server_ip}
|
|
${nat_settings}
|
|
|
|
[transport-tls]
|
|
type=transport
|
|
protocol=tls
|
|
bind=0.0.0.0:${DEFAULT_SIPS_PORT}
|
|
; Server IP: ${server_ip}
|
|
cert_file=/etc/asterisk/certs/server.crt
|
|
priv_key_file=/etc/asterisk/certs/server.key
|
|
ca_list_file=/etc/ssl/certs/ca-certificates.crt
|
|
method=tlsv1_2
|
|
${nat_settings}
|
|
|
|
EOF
|
|
|
|
local backup_file=$(ls -t "${conf_file}.backup-"* 2>/dev/null | head -1)
|
|
if [[ -f "$backup_file" ]]; then
|
|
awk '/^; === Device:/{flag=1} flag' "$backup_file" >> "$conf_file"
|
|
print_success "Restored devices from backup"
|
|
fi
|
|
chown asterisk:asterisk "$conf_file"
|
|
}
|
|
|
|
rebuild_dialplan() {
|
|
local quiet=$1
|
|
[[ "$quiet" != "quiet" ]] && print_info "Rebuilding dialplan..."
|
|
local conf_file="/etc/asterisk/extensions.conf"
|
|
backup_config "$conf_file"
|
|
|
|
cat > "$conf_file" << EOF
|
|
[general]
|
|
static=yes
|
|
writeprotect=no
|
|
[default]
|
|
exten => _X.,1,Hangup()
|
|
[intercom]
|
|
EOF
|
|
|
|
local dev_name="" dev_cat="" dev_auto="" dev_aa_override=""
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == *"; === Device:"* ]]; then
|
|
dev_aa_override=""
|
|
local temp="${line#*; === Device: }"
|
|
temp="${temp% ===}"
|
|
if [[ "$temp" == *"[AA:yes]"* ]]; then
|
|
dev_aa_override="yes"; temp="${temp% [AA:yes]}"
|
|
elif [[ "$temp" == *"[AA:no]"* ]]; then
|
|
dev_aa_override="no"; temp="${temp% [AA:no]}"
|
|
fi
|
|
dev_cat="${temp##* (}"; dev_cat="${dev_cat%)}"
|
|
dev_name="${temp% (*)}"
|
|
dev_auto="no"
|
|
local cat_data=$(grep "^${dev_cat}|" "$CATEGORIES_FILE" 2>/dev/null || true)
|
|
if [[ -n "$cat_data" ]]; then
|
|
local is_auto=$(echo "$cat_data" | cut -d'|' -f3)
|
|
[[ "$is_auto" == "yes" ]] && dev_auto="yes"
|
|
fi
|
|
[[ "$dev_aa_override" == "yes" ]] && dev_auto="yes"
|
|
[[ "$dev_aa_override" == "no" ]] && dev_auto="no"
|
|
fi
|
|
if [[ "$line" =~ ^\[([0-9]+)\] ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
if [[ -n "$dev_name" ]]; then
|
|
if [[ "$dev_auto" == "yes" ]]; then
|
|
cat >> "$conf_file" << EOF
|
|
exten => ${ext},1,NoOp(Auto-Answer ${ext})
|
|
same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0)
|
|
same => n,Set(PJSIP_HEADER(add,Alert-Info)=auto-answer)
|
|
same => n,Dial(PJSIP/${ext},60)
|
|
same => n,Hangup()
|
|
|
|
EOF
|
|
else
|
|
cat >> "$conf_file" << EOF
|
|
exten => ${ext},1,NoOp(Call ${ext})
|
|
same => n,Dial(PJSIP/${ext},60)
|
|
same => n,Hangup()
|
|
|
|
EOF
|
|
fi
|
|
dev_name=""
|
|
fi
|
|
fi
|
|
done < /etc/asterisk/pjsip.conf
|
|
|
|
# Add rooms
|
|
if [[ -f "$ROOMS_FILE" ]]; then
|
|
while IFS='|' read -r rext rname rmem rtime rtype; do
|
|
[[ "$rext" =~ ^# ]] && continue
|
|
[[ -z "$rext" ]] && continue
|
|
local dial_list=""
|
|
IFS=',' read -ra EXTS <<< "$rmem"
|
|
for ext in "${EXTS[@]}"; do
|
|
ext=$(echo "$ext" | tr -d ' ')
|
|
[[ -n "$dial_list" ]] && dial_list="${dial_list}&"
|
|
dial_list="${dial_list}PJSIP/${ext}"
|
|
done
|
|
if [[ "$rtype" == "page" ]]; then
|
|
cat >> "$conf_file" << EOF
|
|
; Room: ${rname} (Page)
|
|
exten => ${rext},1,NoOp(Page ${rname})
|
|
same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0)
|
|
same => n,Page(${dial_list},i,${rtime})
|
|
same => n,Hangup()
|
|
|
|
EOF
|
|
else
|
|
cat >> "$conf_file" << EOF
|
|
; Room: ${rname} (Ring)
|
|
exten => ${rext},1,NoOp(Call ${rname})
|
|
same => n,Dial(${dial_list},${rtime})
|
|
same => n,Hangup()
|
|
|
|
EOF
|
|
fi
|
|
done < "$ROOMS_FILE"
|
|
fi
|
|
|
|
chown -R asterisk:asterisk /etc/asterisk
|
|
asterisk -rx "dialplan reload" &>/dev/null || true
|
|
}
|
|
|
|
configure_asterisk() {
|
|
if ! id asterisk >/dev/null 2>&1; then
|
|
useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true
|
|
fi
|
|
|
|
print_info "Configuring Asterisk..."
|
|
fix_asterisk_systemd
|
|
initialize_default_categories
|
|
repair_core_configs
|
|
|
|
mkdir -p /etc/asterisk/certs
|
|
if [[ ! -f /etc/asterisk/certs/server.crt ]]; then
|
|
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
|
-keyout /etc/asterisk/certs/server.key \
|
|
-out /etc/asterisk/certs/server.crt \
|
|
-subj "/CN=asterisk-local" 2>/dev/null
|
|
fi
|
|
|
|
chown asterisk:asterisk /etc/asterisk/certs/server.* 2>/dev/null || true
|
|
chmod 644 /etc/asterisk/certs/server.crt 2>/dev/null || true
|
|
chmod 600 /etc/asterisk/certs/server.key 2>/dev/null || true
|
|
|
|
generate_pjsip_conf
|
|
rebuild_dialplan "quiet"
|
|
|
|
restart_asterisk_safe
|
|
systemctl enable asterisk
|
|
}
|
|
|
|
restart_asterisk_safe() {
|
|
print_info "Restarting Asterisk..."
|
|
systemctl stop asterisk 2>/dev/null || true
|
|
sleep 2
|
|
# Use -x for exact match to avoid killing this script
|
|
pkill -9 -x asterisk 2>/dev/null || true
|
|
rm -f /var/run/asterisk/asterisk.pid 2>/dev/null || true
|
|
rm -f /var/lib/asterisk/.asterisk_history 2>/dev/null || true
|
|
systemctl start asterisk
|
|
sleep 3
|
|
|
|
if systemctl is-active asterisk >/dev/null; then
|
|
print_success "Asterisk running"
|
|
else
|
|
print_error "Asterisk failed to start"
|
|
journalctl -u asterisk -n 15 --no-pager
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# 8. CLIENT CONFIG
|
|
# ================================================================
|
|
|
|
configure_baresip() {
|
|
local baresip_dir="/home/${KIOSK_USER}/.baresip"
|
|
mkdir -p "$baresip_dir"
|
|
|
|
# Detect network interface
|
|
local found_iface=""
|
|
for target in 8.8.8.8 1.1.1.1 9.9.9.9; do
|
|
local iface=$(ip route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1)
|
|
if [[ -n "$iface" ]]; then
|
|
found_iface="$iface"
|
|
print_success "Network interface: $found_iface"
|
|
break
|
|
fi
|
|
done
|
|
|
|
cat > "${baresip_dir}/config" << EOF
|
|
poll_method epoll
|
|
audio_player pulse
|
|
audio_source pulse
|
|
audio_alert pulse
|
|
sip_autoanswer yes
|
|
sip_cafile /etc/ssl/certs/ca-certificates.crt
|
|
rtp_timeout 0
|
|
net_af ipv4
|
|
module_path /usr/lib/baresip/modules
|
|
module srtp.so
|
|
module stdio.so
|
|
module pulse.so
|
|
module g711.so
|
|
module opus.so
|
|
module account.so
|
|
module stun.so
|
|
module ice.so
|
|
module turn.so
|
|
EOF
|
|
|
|
[[ -n "$found_iface" ]] && echo "net_interface $found_iface" >> "${baresip_dir}/config"
|
|
|
|
local transport="udp"
|
|
local mediaenc=""
|
|
if [[ "$ENABLE_TLS" == "y" ]]; then
|
|
transport="tls"
|
|
mediaenc=";mediaenc=srtp"
|
|
fi
|
|
|
|
local amode="${CLIENT_ANSWERMODE:-auto}"
|
|
|
|
cat > "${baresip_dir}/accounts" << EOF
|
|
<sip:${KIOSK_EXTENSION}@${ASTERISK_HOST};transport=${transport}>;auth_pass=${SIP_PASSWORD};answermode=${amode}${mediaenc}
|
|
EOF
|
|
chown -R ${KIOSK_USER}:${KIOSK_USER} "$baresip_dir"
|
|
chmod 700 "$baresip_dir"
|
|
|
|
configure_audio_ducking
|
|
create_ptt_handler
|
|
create_baresip_launcher
|
|
}
|
|
|
|
create_baresip_launcher() {
|
|
local launcher_user="${KIOSK_USER}"
|
|
cat > /usr/local/bin/easy-asterisk-launcher << LAUNCHER
|
|
#!/bin/bash
|
|
CONFIG_FILE="/home/${launcher_user}/.baresip/config"
|
|
ACCOUNTS_FILE="/home/${launcher_user}/.baresip/accounts"
|
|
TARGETS=("8.8.8.8" "1.1.1.1" "9.9.9.9")
|
|
FOUND_IFACE=""
|
|
|
|
logger -t baresip-launcher "Starting Baresip launcher for user ${launcher_user}"
|
|
|
|
# Wait for network
|
|
for i in {1..6}; do
|
|
for target in "\${TARGETS[@]}"; do
|
|
IFACE=\$(ip route get "\$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i=="dev") print \$(i+1)}' | head -1)
|
|
if [[ -n "\$IFACE" ]]; then
|
|
FOUND_IFACE="\$IFACE"
|
|
logger -t baresip-launcher "Network found on interface: \$IFACE"
|
|
break 2
|
|
fi
|
|
done
|
|
logger -t baresip-launcher "Waiting for network... (attempt \$i/6)"
|
|
sleep 5
|
|
done
|
|
|
|
if [[ -z "\$FOUND_IFACE" ]]; then
|
|
logger -t baresip-launcher "ERROR: No network interface found after 30 seconds"
|
|
fi
|
|
|
|
# Update network interface in config
|
|
if [[ -f "\$CONFIG_FILE" && -n "\$FOUND_IFACE" ]]; then
|
|
sed -i '/^#*net_interface/d' "\$CONFIG_FILE"
|
|
echo "net_interface \${FOUND_IFACE}" >> "\$CONFIG_FILE"
|
|
logger -t baresip-launcher "Updated config with interface: \$FOUND_IFACE"
|
|
fi
|
|
|
|
# Verify config files exist
|
|
if [[ ! -f "\$CONFIG_FILE" ]]; then
|
|
logger -t baresip-launcher "ERROR: Config file not found: \$CONFIG_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "\$ACCOUNTS_FILE" ]]; then
|
|
logger -t baresip-launcher "ERROR: Accounts file not found: \$ACCOUNTS_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
logger -t baresip-launcher "Starting Baresip client..."
|
|
exec /usr/bin/baresip -f "/home/${launcher_user}/.baresip"
|
|
LAUNCHER
|
|
chmod +x /usr/local/bin/easy-asterisk-launcher
|
|
}
|
|
|
|
enable_client_services() {
|
|
local systemd_dir="/home/${KIOSK_USER}/.config/systemd/user"
|
|
mkdir -p "$systemd_dir"
|
|
|
|
# Ensure audio group membership
|
|
if ! id -nG "$KIOSK_USER" | grep -qw "audio"; then
|
|
usermod -aG audio "$KIOSK_USER"
|
|
fi
|
|
|
|
# Ensure input group membership (for PTT device access)
|
|
if ! id -nG "$KIOSK_USER" | grep -qw "input"; then
|
|
usermod -aG input "$KIOSK_USER"
|
|
fi
|
|
|
|
# Baresip service
|
|
cat > "${systemd_dir}/baresip.service" << EOF
|
|
[Unit]
|
|
Description=Baresip SIP Client
|
|
After=pipewire.service pipewire-pulse.service network-online.target
|
|
Wants=network-online.target pipewire.service pipewire-pulse.service
|
|
Requires=pipewire-pulse.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStartPre=/bin/sleep 5
|
|
ExecStart=/usr/local/bin/easy-asterisk-launcher
|
|
Restart=always
|
|
RestartSec=10
|
|
Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}
|
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
|
|
# PTT service - only create if PTT is configured
|
|
cat > "${systemd_dir}/kiosk-ptt.service" << EOF
|
|
[Unit]
|
|
Description=PTT Button Handler
|
|
After=pipewire.service pipewire-pulse.service baresip.service
|
|
Requires=pipewire-pulse.service
|
|
ConditionPathExists=/etc/easy-asterisk/ptt-device
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStartPre=/bin/sleep 8
|
|
ExecStart=/usr/local/bin/kiosk-ptt
|
|
Restart=always
|
|
RestartSec=10
|
|
Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}
|
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus
|
|
Environment=KIOSK_UID=${KIOSK_UID}
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
|
|
chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config"
|
|
|
|
if [[ -n "$KIOSK_USER" ]]; then
|
|
loginctl enable-linger $KIOSK_USER 2>/dev/null || true
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}"
|
|
|
|
# Enable and start PipeWire services for the user
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable pipewire pipewire-pulse 2>/dev/null || true
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true
|
|
|
|
# Enable baresip
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable baresip
|
|
|
|
# Only enable PTT if configured
|
|
if [[ -f /etc/easy-asterisk/ptt-device ]]; then
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable kiosk-ptt
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip kiosk-ptt
|
|
else
|
|
sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip
|
|
# Ensure audio is unmuted for normal kiosk operation
|
|
ensure_audio_unmuted
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ================================================================
|
|
# 9. CERTIFICATE HANDLING
|
|
# ================================================================
|
|
|
|
check_cert_coverage() {
|
|
local cert_file=$1 target_domain=$2 base_domain=$3
|
|
[[ ! -f "$cert_file" ]] && return 1
|
|
local sans=$(openssl x509 -in "$cert_file" -text -noout 2>/dev/null | grep -A1 "Subject Alternative Name" | tail -1)
|
|
echo "$sans" | grep -q "DNS:${target_domain}" && return 0
|
|
echo "$sans" | grep -q "DNS:\*.${base_domain}" && return 0
|
|
return 1
|
|
}
|
|
|
|
setup_caddy_cert_sync() {
|
|
local mode=$1
|
|
[[ "$mode" == "force" ]] && print_header "Caddy Cert Sync"
|
|
|
|
load_config
|
|
local domain=${DOMAIN_NAME:-sip.example.com}
|
|
if [[ "$mode" == "force" ]]; then
|
|
read -p "Domain [$domain]: " input_domain
|
|
domain="${input_domain:-$domain}"
|
|
fi
|
|
|
|
local actual_user="${SUDO_USER:-$USER}"
|
|
local actual_home=$(eval echo ~"$actual_user")
|
|
local base_domain=$(echo "$domain" | awk -F. '{print $(NF-1)"."$NF}')
|
|
|
|
local search_paths=(
|
|
"${actual_home}/docker/caddy/ssl"
|
|
"${actual_home}/docker/caddy/caddy_data"
|
|
"${actual_home}/docker/caddy/caddy_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory"
|
|
"/var/lib/caddy"
|
|
"/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory"
|
|
"/data/caddy"
|
|
"/root/.local/share/caddy/certificates"
|
|
)
|
|
local caddy_cert="" caddy_key=""
|
|
|
|
[[ "$mode" == "force" ]] && echo "Searching for certificates..."
|
|
|
|
for base_path in "${search_paths[@]}"; do
|
|
if ! sudo test -d "$base_path" 2>/dev/null; then continue; fi
|
|
[[ "$mode" == "force" ]] && echo " Checking: $base_path"
|
|
|
|
local candidates=$(sudo find "$base_path" -maxdepth 5 -type f \( -name "fullchain.pem" -o -name "*.crt" \) 2>/dev/null)
|
|
|
|
for cert in $candidates; do
|
|
sudo cp "$cert" /tmp/cert_check.pem 2>/dev/null || continue
|
|
if check_cert_coverage "/tmp/cert_check.pem" "$domain" "$base_domain"; then
|
|
[[ "$mode" == "force" ]] && print_success "Found matching cert: $cert"
|
|
caddy_cert="$cert"
|
|
local dir=$(dirname "$cert")
|
|
local name=$(basename "$cert")
|
|
if [[ "$name" == "fullchain.pem" ]]; then
|
|
caddy_key="${dir}/privkey.pem"
|
|
else
|
|
caddy_key=$(echo "$cert" | sed 's/\.crt/\.key/')
|
|
fi
|
|
if sudo test -f "$caddy_key"; then
|
|
rm -f /tmp/cert_check.pem
|
|
break 2
|
|
fi
|
|
fi
|
|
rm -f /tmp/cert_check.pem
|
|
done
|
|
done
|
|
|
|
if [[ -n "$caddy_cert" && -n "$caddy_key" ]]; then
|
|
mkdir -p /etc/asterisk/certs
|
|
sudo cat "$caddy_cert" > /etc/asterisk/certs/server.crt
|
|
sudo cat "$caddy_key" > /etc/asterisk/certs/server.key
|
|
|
|
chown asterisk:asterisk /etc/asterisk/certs/server.*
|
|
chmod 644 /etc/asterisk/certs/server.crt
|
|
chmod 600 /etc/asterisk/certs/server.key
|
|
|
|
DOMAIN_NAME="$domain"
|
|
ENABLE_TLS="y"
|
|
ASTERISK_HOST="$domain"
|
|
save_config
|
|
|
|
generate_pjsip_conf
|
|
restart_asterisk_safe
|
|
|
|
[[ "$mode" == "force" ]] && print_success "Certificates installed for $domain"
|
|
return 0
|
|
else
|
|
[[ "$mode" == "force" ]] && print_warn "No matching certificates found"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
setup_internet_access() {
|
|
print_header "Setup Internet Access"
|
|
|
|
echo "Select Certificate Source:"
|
|
echo " 1) Auto-Sync from Caddy (Docker/Native)"
|
|
echo " 2) Standalone Certbot (Requires Port 80 open)"
|
|
echo " 3) Self-Signed (Internal testing only)"
|
|
echo " 4) Manual Path"
|
|
echo " 0) Cancel"
|
|
read -p "Select: " cert_opt
|
|
|
|
[[ "$cert_opt" == "0" ]] && return
|
|
|
|
# Show port requirements
|
|
show_preflight_check
|
|
show_port_requirements
|
|
echo ""
|
|
read -p "Continue? [Y/n]: " cont
|
|
[[ "$cont" =~ ^[Nn]$ ]] && return
|
|
|
|
load_config
|
|
read -p "FQDN [${DOMAIN_NAME:-sip.example.com}]: " fqdn
|
|
DOMAIN_NAME="${fqdn:-${DOMAIN_NAME:-sip.example.com}}"
|
|
ASTERISK_HOST="$DOMAIN_NAME"
|
|
|
|
echo ""
|
|
echo "Do you have a separate domain for TURN? (e.g., turn.example.com)"
|
|
read -p "Enter TURN domain (leave empty to use $DOMAIN_NAME): " t_dom
|
|
TURN_DOMAIN="${t_dom:-$DOMAIN_NAME}"
|
|
|
|
# CIDR Prompt
|
|
echo ""
|
|
print_header "Local Network CIDR"
|
|
local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1)
|
|
local default_cidr="$raw_cidr"
|
|
if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi
|
|
echo "This helps Asterisk distinguish local vs external traffic."
|
|
read -p "Local network CIDR [$default_cidr]: " local_net
|
|
LOCAL_CIDR="${local_net:-$default_cidr}"
|
|
|
|
save_config
|
|
|
|
case "$cert_opt" in
|
|
1) # Caddy
|
|
# Show Caddy Helper text
|
|
echo "---------------------------------------------------------"
|
|
echo "CADDY HELPER: Ensure these are in your Caddyfile to get certs:"
|
|
echo ""
|
|
echo "${DOMAIN_NAME} {"
|
|
echo " respond \"Asterisk Cert Placeholder\" 200"
|
|
echo "}"
|
|
if [[ "$TURN_DOMAIN" != "$DOMAIN_NAME" ]]; then
|
|
echo ""
|
|
echo "${TURN_DOMAIN} {"
|
|
echo " respond \"TURN Cert Placeholder\" 200"
|
|
echo "}"
|
|
fi
|
|
echo ""
|
|
echo "Restart Caddy, wait 30s, then press Enter."
|
|
echo "---------------------------------------------------------"
|
|
read -p "Press Enter to sync..."
|
|
if setup_caddy_cert_sync "auto"; then
|
|
print_success "Setup complete using Caddy certificates!"
|
|
else
|
|
print_error "Caddy sync failed. Ensure Caddy is running."
|
|
return
|
|
fi
|
|
;;
|
|
2) # Certbot
|
|
print_info "Installing Certbot..."
|
|
apt install -y certbot
|
|
certbot certonly --standalone -d "$DOMAIN_NAME" --non-interactive --agree-tos --register-unsafely-without-email
|
|
if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]]; then
|
|
mkdir -p /etc/asterisk/certs
|
|
cat "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" > /etc/asterisk/certs/server.crt
|
|
cat "/etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem" > /etc/asterisk/certs/server.key
|
|
chown asterisk:asterisk /etc/asterisk/certs/server.*
|
|
print_success "Certbot Success"
|
|
else
|
|
print_error "Certbot failed"
|
|
return
|
|
fi
|
|
;;
|
|
3) # Self-Signed
|
|
mkdir -p /etc/asterisk/certs
|
|
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
|
-keyout /etc/asterisk/certs/server.key \
|
|
-out /etc/asterisk/certs/server.crt \
|
|
-subj "/CN=$DOMAIN_NAME" 2>/dev/null
|
|
chown asterisk:asterisk /etc/asterisk/certs/server.*
|
|
chmod 644 /etc/asterisk/certs/server.crt
|
|
chmod 600 /etc/asterisk/certs/server.key
|
|
print_success "Self-signed certificate generated"
|
|
print_warn "Clients will need to trust this certificate"
|
|
;;
|
|
4) # Manual
|
|
read -p "Certificate Path: " cp
|
|
read -p "Private Key Path: " kp
|
|
if [[ -f "$cp" && -f "$kp" ]]; then
|
|
mkdir -p /etc/asterisk/certs
|
|
cat "$cp" > /etc/asterisk/certs/server.crt
|
|
cat "$kp" > /etc/asterisk/certs/server.key
|
|
chown asterisk:asterisk /etc/asterisk/certs/server.*
|
|
print_success "Certificates installed"
|
|
else
|
|
print_error "Files not found!"
|
|
return
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
ENABLE_TLS="y"
|
|
save_config
|
|
generate_pjsip_conf
|
|
restart_asterisk_safe
|
|
print_success "Internet access configuration complete"
|
|
}
|
|
|
|
# ================================================================
|
|
# 10. INSTALLATION
|
|
# ================================================================
|
|
|
|
install_full() {
|
|
print_header "Full Installation"
|
|
local default_user="${SUDO_USER:-$USER}"
|
|
read -p "Client User [$default_user]: " target_user
|
|
KIOSK_USER="${target_user:-$default_user}"
|
|
KIOSK_UID=$(id -u "$KIOSK_USER")
|
|
|
|
if ! collect_common_config; then return; fi
|
|
collect_client_config
|
|
install_dependencies
|
|
INSTALLED_SERVER="y"
|
|
INSTALLED_CLIENT="y"
|
|
ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run
|
|
configure_asterisk
|
|
configure_baresip
|
|
enable_client_services
|
|
open_firewall_ports
|
|
save_config
|
|
|
|
echo ""
|
|
echo "════════════════════════════════════════════════════════"
|
|
print_success "Local network install complete"
|
|
echo ""
|
|
echo "Server and devices are reachable over internal LAN network only."
|
|
echo "To add internet calling capability, continue with the setup below."
|
|
echo "════════════════════════════════════════════════════════"
|
|
echo ""
|
|
read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup
|
|
[[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access
|
|
|
|
print_success "Installation complete"
|
|
}
|
|
|
|
install_server_only() {
|
|
print_header "Server Installation"
|
|
ASTERISK_HOST="127.0.0.1"
|
|
ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run
|
|
install_asterisk_packages
|
|
configure_asterisk
|
|
open_firewall_ports
|
|
INSTALLED_SERVER="y"
|
|
save_config
|
|
|
|
echo ""
|
|
echo "════════════════════════════════════════════════════════"
|
|
print_success "Local network install complete"
|
|
echo ""
|
|
echo "Server and devices are reachable over internal LAN network only."
|
|
echo "To add internet calling capability, continue with the setup below."
|
|
echo "════════════════════════════════════════════════════════"
|
|
echo ""
|
|
read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup
|
|
[[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access
|
|
|
|
print_success "Server installed"
|
|
}
|
|
|
|
install_client_only() {
|
|
print_header "Client Installation"
|
|
echo "Select the user to install the kiosk client for:"
|
|
echo ""
|
|
|
|
if ! select_user; then
|
|
print_error "User selection failed"
|
|
return 1
|
|
fi
|
|
|
|
echo ""
|
|
read -p "Server (IP or domain): " ASTERISK_HOST
|
|
read -p "SIP Password: " SIP_PASSWORD
|
|
|
|
if [[ "$ASTERISK_HOST" =~ [a-zA-Z] ]]; then
|
|
ENABLE_TLS="y"
|
|
else
|
|
ENABLE_TLS="n"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Answer Mode:"
|
|
echo " 1) Auto (auto-answer incoming calls)"
|
|
echo " 2) Manual (ring on incoming)"
|
|
read -p "Select [1]: " aa_sel
|
|
CLIENT_ANSWERMODE="auto"
|
|
[[ "$aa_sel" == "2" ]] && CLIENT_ANSWERMODE="manual"
|
|
|
|
collect_client_config
|
|
install_baresip_packages
|
|
INSTALLED_CLIENT="y"
|
|
configure_baresip
|
|
enable_client_services
|
|
save_config
|
|
|
|
print_success "Client installed"
|
|
echo ""
|
|
echo "════════════════════════════════════════════════════════"
|
|
echo " IMPORTANT: Audio Configuration"
|
|
echo "════════════════════════════════════════════════════════"
|
|
echo " User: $KIOSK_USER"
|
|
echo " - Audio group: Added"
|
|
echo " - PipeWire services: Enabled"
|
|
echo " - Microphone: Unmuted (for intercom mode)"
|
|
echo ""
|
|
echo " If audio doesn't work immediately:"
|
|
echo " 1. Log out and log back in as '$KIOSK_USER'"
|
|
echo " 2. Or reboot the system"
|
|
echo " 3. Check audio with: pactl list sources short"
|
|
echo ""
|
|
echo " PTT Mode: Not configured (normal intercom operation)"
|
|
echo " To configure PTT: Main Menu > Client Management > Configure PTT Button"
|
|
echo "════════════════════════════════════════════════════════"
|
|
}
|
|
|
|
collect_common_config() {
|
|
SIP_PASSWORD="${SIP_PASSWORD:-$(generate_password)}"
|
|
ASTERISK_HOST="127.0.0.1"
|
|
return 0
|
|
}
|
|
|
|
collect_client_config() {
|
|
read -p "Extension [101]: " KIOSK_EXTENSION
|
|
KIOSK_EXTENSION="${KIOSK_EXTENSION:-101}"
|
|
KIOSK_NAME="kiosk-${KIOSK_EXTENSION}"
|
|
}
|
|
|
|
install_dependencies() {
|
|
install_asterisk_packages
|
|
install_baresip_packages
|
|
}
|
|
|
|
install_asterisk_packages() {
|
|
echo "exit 101" > /usr/sbin/policy-rc.d
|
|
chmod +x /usr/sbin/policy-rc.d
|
|
apt update
|
|
# asterisk-opus removed (included in asterisk-modules on Ubuntu 24.04+)
|
|
apt install -y asterisk asterisk-core-sounds-en-gsm asterisk-modules openssl curl tcpdump sngrep || true
|
|
mkdir -p /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk
|
|
ldconfig
|
|
update-ca-certificates 2>/dev/null || true
|
|
rm -f /usr/sbin/policy-rc.d
|
|
fix_asterisk_systemd
|
|
}
|
|
|
|
install_baresip_packages() {
|
|
apt update
|
|
apt install -y baresip baresip-core pipewire pipewire-alsa pipewire-pulse wireplumber alsa-utils evtest || true
|
|
}
|
|
|
|
uninstall_menu() {
|
|
print_header "Uninstall"
|
|
echo " 1) Remove Everything"
|
|
echo " 2) Asterisk Only"
|
|
echo " 3) Baresip Only"
|
|
echo " 0) Cancel"
|
|
read -p "Select: " ch
|
|
case $ch in
|
|
1)
|
|
systemctl stop asterisk 2>/dev/null || true
|
|
apt purge -y asterisk* baresip baresip-core 2>/dev/null || true
|
|
rm -rf /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /usr/lib/asterisk
|
|
rm -rf /etc/systemd/system/asterisk.service.d /etc/easy-asterisk
|
|
[[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip"
|
|
systemctl daemon-reload
|
|
INSTALLED_SERVER="n"
|
|
INSTALLED_CLIENT="n"
|
|
rm -f "$CONFIG_FILE"
|
|
print_success "Removed all"
|
|
;;
|
|
2)
|
|
systemctl stop asterisk 2>/dev/null || true
|
|
apt purge -y asterisk* 2>/dev/null || true
|
|
rm -rf /etc/asterisk /var/lib/asterisk
|
|
INSTALLED_SERVER="n"
|
|
save_config
|
|
print_success "Removed Asterisk"
|
|
;;
|
|
3)
|
|
apt purge -y baresip baresip-core 2>/dev/null || true
|
|
[[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip"
|
|
INSTALLED_CLIENT="n"
|
|
save_config
|
|
print_success "Removed Baresip"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ================================================================
|
|
# 11. MENU SYSTEM (Reordered: Server #2, Devices #3)
|
|
# ================================================================
|
|
|
|
show_main_menu() {
|
|
clear
|
|
print_header "Easy Asterisk v${SCRIPT_VERSION}"
|
|
|
|
load_config
|
|
echo " Status:"
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
[[ "$INSTALLED_SERVER" == "y" ]] && echo -e " Server: ${GREEN}Installed${NC}" || echo -e " Server: ${YELLOW}Not installed${NC}"
|
|
[[ "$INSTALLED_CLIENT" == "y" ]] && echo -e " Client: ${GREEN}Installed${NC}" || echo -e " Client: ${YELLOW}Not installed${NC}"
|
|
[[ -n "$DOMAIN_NAME" ]] && echo -e " Domain: ${DOMAIN_NAME}"
|
|
else
|
|
echo -e " ${YELLOW}Not configured${NC}"
|
|
fi
|
|
echo ""
|
|
|
|
declare -A menu_map
|
|
local count=1
|
|
|
|
echo " ${count}) Install/Configure"; menu_map[$count]="submenu_install"; ((count++))
|
|
if [[ "$INSTALLED_SERVER" == "y" ]]; then
|
|
echo " ${count}) Server Settings"; menu_map[$count]="submenu_server"; ((count++))
|
|
echo " ${count}) Device Management"; menu_map[$count]="submenu_devices"; ((count++))
|
|
fi
|
|
echo " ${count}) Client Settings"; menu_map[$count]="submenu_client"; ((count++))
|
|
echo " ${count}) Tools"; menu_map[$count]="submenu_tools"; ((count++))
|
|
echo " 0) Exit"
|
|
echo ""
|
|
|
|
read -p " Select: " choice
|
|
[[ "$choice" == "0" ]] && exit 0
|
|
local action=${menu_map[$choice]}
|
|
[[ -n "$action" ]] && $action
|
|
show_main_menu
|
|
}
|
|
|
|
submenu_install() {
|
|
clear
|
|
print_header "Install"
|
|
echo " 1) Full (server + client)"
|
|
echo " 2) Server only"
|
|
echo " 3) Client only"
|
|
echo " 4) Uninstall"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
case $choice in
|
|
1) install_full; read -p "Press Enter..." ;;
|
|
2) install_server_only; read -p "Press Enter..." ;;
|
|
3) install_client_only; read -p "Press Enter..." ;;
|
|
4) uninstall_menu; read -p "Press Enter..." ;;
|
|
esac
|
|
}
|
|
|
|
# ================================================================
|
|
# WEB ADMIN INTERFACE
|
|
# ================================================================
|
|
|
|
# WEB_ADMIN_PORT is set in load_config (default: 8080)
|
|
WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin"
|
|
WEB_ADMIN_SERVICE="/etc/systemd/system/easy-asterisk-webadmin.service"
|
|
WEB_ADMIN_HTPASSWD="/etc/easy-asterisk/webadmin.htpasswd"
|
|
|
|
create_web_admin_script() {
|
|
cat > "$WEB_ADMIN_SCRIPT" << 'WEBADMIN'
|
|
#!/usr/bin/env python3
|
|
"""
|
|
Easy Asterisk Web Admin - Simple web interface for client management
|
|
"""
|
|
|
|
import http.server
|
|
import socketserver
|
|
import json
|
|
import subprocess
|
|
import os
|
|
import re
|
|
import base64
|
|
import hashlib
|
|
import html
|
|
from urllib.parse import parse_qs, urlparse
|
|
from functools import partial
|
|
|
|
PORT = int(os.environ.get('WEBADMIN_PORT', 8080))
|
|
HTPASSWD_FILE = "/etc/easy-asterisk/webadmin.htpasswd"
|
|
PJSIP_CONF = "/etc/asterisk/pjsip.conf"
|
|
CATEGORIES_FILE = "/etc/easy-asterisk/categories.conf"
|
|
ROOMS_FILE = "/etc/easy-asterisk/rooms.conf"
|
|
CONFIG_FILE = "/etc/easy-asterisk/config"
|
|
|
|
def check_auth(headers):
|
|
"""Verify HTTP Basic Auth against htpasswd file"""
|
|
if not os.path.exists(HTPASSWD_FILE):
|
|
return True # No auth required if no htpasswd file
|
|
|
|
auth_header = headers.get('Authorization', '')
|
|
if not auth_header.startswith('Basic '):
|
|
return False
|
|
|
|
try:
|
|
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
|
|
username, password = credentials.split(':', 1)
|
|
|
|
with open(HTPASSWD_FILE, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if ':' in line:
|
|
stored_user, stored_hash = line.split(':', 1)
|
|
if stored_user == username:
|
|
# Support plain text (for simplicity) or SHA256
|
|
if stored_hash.startswith('{SHA256}'):
|
|
expected = '{SHA256}' + hashlib.sha256(password.encode()).hexdigest()
|
|
return stored_hash == expected
|
|
else:
|
|
return stored_hash == password
|
|
return False
|
|
except:
|
|
return False
|
|
|
|
def get_registered_endpoints():
|
|
"""Get list of registered endpoints from Asterisk"""
|
|
try:
|
|
result = subprocess.run(
|
|
['asterisk', '-rx', 'pjsip show endpoints'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
endpoints = {}
|
|
for line in result.stdout.split('\n'):
|
|
match = re.match(r'\s*(\d{3})/\S+\s+(\S+)\s+', line)
|
|
if match:
|
|
ext, state = match.groups()
|
|
endpoints[ext] = 'online' if 'Avail' in state else 'offline'
|
|
return endpoints
|
|
except:
|
|
return {}
|
|
|
|
def get_devices():
|
|
"""Parse pjsip.conf to get device information"""
|
|
devices = []
|
|
if not os.path.exists(PJSIP_CONF):
|
|
return devices
|
|
|
|
with open(PJSIP_CONF, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Parse devices using a state machine approach
|
|
current_device = None
|
|
current_ext = None
|
|
|
|
for line in content.split('\n'):
|
|
line = line.strip()
|
|
|
|
# Match device comment line (handles variable spacing before ===)
|
|
if line.startswith('; === Device:'):
|
|
match = re.match(r'; === Device:\s*(.+?)\s*\((\w+)\)\s*(\[AA:(yes|no)\])?\s*===', line)
|
|
if match:
|
|
current_device = {
|
|
'name': match.group(1).strip(),
|
|
'category': match.group(2),
|
|
'auto_answer': match.group(4) if match.group(3) else None,
|
|
'extension': None,
|
|
'password': None,
|
|
'transport': 'udp',
|
|
'encryption': 'no'
|
|
}
|
|
current_ext = None
|
|
|
|
# Match extension section header
|
|
elif current_device and re.match(r'^\[(\d{3})\]$', line):
|
|
ext = re.match(r'^\[(\d{3})\]$', line).group(1)
|
|
if current_device['extension'] is None:
|
|
current_device['extension'] = ext
|
|
current_ext = ext
|
|
elif ext == current_ext:
|
|
# Same extension, could be auth or aor section
|
|
pass
|
|
|
|
# Parse properties within sections
|
|
elif current_device and current_ext:
|
|
if line.startswith('transport=transport-'):
|
|
current_device['transport'] = line.split('-')[1]
|
|
elif line.startswith('media_encryption='):
|
|
current_device['encryption'] = line.split('=')[1]
|
|
elif line.startswith('password='):
|
|
current_device['password'] = line.split('=')[1]
|
|
# Found password, device is complete
|
|
devices.append(current_device)
|
|
current_device = None
|
|
current_ext = None
|
|
|
|
return devices
|
|
|
|
def get_categories():
|
|
"""Get categories from config file"""
|
|
categories = []
|
|
if os.path.exists(CATEGORIES_FILE):
|
|
with open(CATEGORIES_FILE, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith('#'):
|
|
parts = line.split('|')
|
|
if len(parts) >= 3:
|
|
categories.append({
|
|
'id': parts[0],
|
|
'name': parts[1],
|
|
'auto_answer': parts[2],
|
|
'description': parts[3] if len(parts) > 3 else ''
|
|
})
|
|
return categories
|
|
|
|
def get_rooms():
|
|
"""Get rooms from config file"""
|
|
rooms = []
|
|
if os.path.exists(ROOMS_FILE):
|
|
with open(ROOMS_FILE, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith('#'):
|
|
parts = line.split('|')
|
|
if len(parts) >= 5:
|
|
rooms.append({
|
|
'extension': parts[0],
|
|
'name': parts[1],
|
|
'members': parts[2],
|
|
'timeout': parts[3],
|
|
'type': parts[4]
|
|
})
|
|
return rooms
|
|
|
|
def delete_device(extension):
|
|
"""Delete a device from pjsip.conf"""
|
|
if not os.path.exists(PJSIP_CONF):
|
|
return False, "Config file not found"
|
|
|
|
with open(PJSIP_CONF, 'r') as f:
|
|
lines = f.readlines()
|
|
|
|
new_lines = []
|
|
skip = False
|
|
found = False
|
|
pending_comment = None
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
if stripped.startswith('; === Device:'):
|
|
pending_comment = line
|
|
continue
|
|
|
|
if re.match(rf'^\[{extension}\]$', stripped):
|
|
if pending_comment:
|
|
found = True
|
|
skip = True
|
|
pending_comment = None
|
|
continue
|
|
elif found:
|
|
skip = True
|
|
continue
|
|
|
|
if pending_comment:
|
|
new_lines.append(pending_comment)
|
|
pending_comment = None
|
|
|
|
if skip and stripped == '':
|
|
skip = False
|
|
continue
|
|
|
|
if not skip:
|
|
new_lines.append(line)
|
|
|
|
if found:
|
|
with open(PJSIP_CONF, 'w') as f:
|
|
f.writelines(new_lines)
|
|
subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True)
|
|
return True, "Device deleted"
|
|
return False, "Device not found"
|
|
|
|
def generate_password(length=16):
|
|
"""Generate a random password"""
|
|
import secrets
|
|
import string
|
|
chars = string.ascii_letters + string.digits
|
|
return ''.join(secrets.choice(chars) for _ in range(length))
|
|
|
|
def add_device(name, category, extension, conn_type='lan', auto_answer=None):
|
|
"""Add a new device to pjsip.conf"""
|
|
if not os.path.exists(PJSIP_CONF):
|
|
return False, "Config file not found"
|
|
|
|
# Check if extension exists
|
|
with open(PJSIP_CONF, 'r') as f:
|
|
if f'[{extension}]' in f.read():
|
|
return False, "Extension already exists"
|
|
|
|
password = generate_password()
|
|
|
|
# Determine transport and encryption
|
|
if conn_type == 'fqdn':
|
|
transport = 'transport=transport-tls'
|
|
encryption = 'media_encryption=sdes'
|
|
ice = 'ice_support=yes'
|
|
else:
|
|
transport = 'transport=transport-udp'
|
|
encryption = 'media_encryption=no'
|
|
ice = ''
|
|
|
|
aa_tag = ''
|
|
if auto_answer == 'yes':
|
|
aa_tag = '[AA:yes] '
|
|
elif auto_answer == 'no':
|
|
aa_tag = '[AA:no] '
|
|
|
|
device_config = f'''
|
|
; === Device: {name} ({category}) {aa_tag}===
|
|
[{extension}]
|
|
type=endpoint
|
|
context=intercom
|
|
{transport}
|
|
disallow=all
|
|
allow=opus
|
|
allow=ulaw
|
|
allow=alaw
|
|
allow=g722
|
|
{encryption}
|
|
direct_media=no
|
|
rtp_symmetric=yes
|
|
force_rport=yes
|
|
rewrite_contact=yes
|
|
{ice}
|
|
auth={extension}
|
|
aors={extension}
|
|
callerid="{name}" <{extension}>
|
|
|
|
[{extension}]
|
|
type=auth
|
|
auth_type=userpass
|
|
username={extension}
|
|
password={password}
|
|
|
|
[{extension}]
|
|
type=aor
|
|
max_contacts=5
|
|
remove_existing=yes
|
|
qualify_frequency=60
|
|
'''
|
|
|
|
with open(PJSIP_CONF, 'a') as f:
|
|
f.write(device_config)
|
|
|
|
subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True)
|
|
subprocess.run(['chown', 'asterisk:asterisk', PJSIP_CONF], capture_output=True)
|
|
|
|
return True, {'extension': extension, 'password': password, 'name': name}
|
|
|
|
def get_server_info():
|
|
"""Get server configuration info"""
|
|
info = {
|
|
'domain': '',
|
|
'tls_enabled': False,
|
|
'server_ip': ''
|
|
}
|
|
|
|
if os.path.exists(CONFIG_FILE):
|
|
with open(CONFIG_FILE, 'r') as f:
|
|
for line in f:
|
|
if line.startswith('DOMAIN_NAME='):
|
|
info['domain'] = line.split('=', 1)[1].strip().strip('"')
|
|
elif line.startswith('ENABLE_TLS='):
|
|
info['tls_enabled'] = 'y' in line.lower()
|
|
|
|
try:
|
|
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
|
|
info['server_ip'] = result.stdout.split()[0] if result.stdout else ''
|
|
except:
|
|
pass
|
|
|
|
return info
|
|
|
|
HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Easy Asterisk - Client Admin</title>
|
|
<style>
|
|
:root {
|
|
--primary: #2563eb;
|
|
--success: #16a34a;
|
|
--danger: #dc2626;
|
|
--warning: #d97706;
|
|
--bg: #f8fafc;
|
|
--card-bg: #ffffff;
|
|
--text: #1e293b;
|
|
--text-muted: #64748b;
|
|
--border: #e2e8f0;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
}
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
header {
|
|
background: var(--primary);
|
|
color: white;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
border-radius: 8px;
|
|
}
|
|
header h1 { font-size: 1.5rem; }
|
|
header p { opacity: 0.9; font-size: 0.9rem; }
|
|
.card {
|
|
background: var(--card-bg);
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
margin-bottom: 20px;
|
|
overflow: hidden;
|
|
}
|
|
.card-header {
|
|
background: #f1f5f9;
|
|
padding: 15px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.card-header h2 { font-size: 1.1rem; }
|
|
.card-body { padding: 20px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
|
th { background: #f8fafc; font-weight: 600; }
|
|
.status {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
}
|
|
.status-online { background: #dcfce7; color: #166534; }
|
|
.status-offline { background: #fee2e2; color: #991b1b; }
|
|
.btn {
|
|
display: inline-block;
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
text-decoration: none;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.btn:hover { opacity: 0.9; }
|
|
.btn-primary { background: var(--primary); color: white; }
|
|
.btn-danger { background: var(--danger); color: white; }
|
|
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
|
.form-group { margin-bottom: 15px; }
|
|
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; }
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 1rem;
|
|
}
|
|
.form-row { display: flex; gap: 15px; flex-wrap: wrap; }
|
|
.form-row .form-group { flex: 1; min-width: 200px; }
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
.modal.active { display: flex; }
|
|
.modal-content {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 12px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-header { margin-bottom: 20px; }
|
|
.modal-header h3 { margin-bottom: 5px; }
|
|
.tabs { display: flex; border-bottom: 2px solid var(--border); margin-bottom: 20px; }
|
|
.tab {
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -2px;
|
|
}
|
|
.tab.active { border-color: var(--primary); color: var(--primary); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.alert {
|
|
padding: 12px 16px;
|
|
border-radius: 6px;
|
|
margin-bottom: 15px;
|
|
}
|
|
.alert-success { background: #dcfce7; color: #166534; }
|
|
.alert-error { background: #fee2e2; color: #991b1b; }
|
|
.credentials {
|
|
background: #f1f5f9;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
font-family: monospace;
|
|
}
|
|
.credentials p { margin: 5px 0; }
|
|
.refresh-btn { background: none; border: none; cursor: pointer; font-size: 1.2rem; }
|
|
@media (max-width: 768px) {
|
|
.form-row { flex-direction: column; }
|
|
.form-row .form-group { min-width: 100%; }
|
|
th, td { padding: 8px; font-size: 0.9rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>Easy Asterisk - Client Admin</h1>
|
|
<p>Manage SIP clients and extensions</p>
|
|
</header>
|
|
|
|
<div id="alert-container"></div>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="devices">Devices</div>
|
|
<div class="tab" data-tab="rooms">Rooms</div>
|
|
<div class="tab" data-tab="categories">Categories</div>
|
|
</div>
|
|
|
|
<div id="devices" class="tab-content active">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2>Registered Devices</h2>
|
|
<div>
|
|
<button class="refresh-btn" onclick="loadDevices()" title="Refresh">↻</button>
|
|
<button class="btn btn-primary" onclick="showAddModal()">+ Add Device</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Extension</th>
|
|
<th>Name</th>
|
|
<th>Category</th>
|
|
<th>Transport</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="devices-table"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="rooms" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2>Rooms (Ring/Page Groups)</h2>
|
|
<button class="refresh-btn" onclick="loadRooms()" title="Refresh">↻</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Extension</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Members</th>
|
|
<th>Timeout</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rooms-table"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="categories" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2>Device Categories</h2>
|
|
<button class="refresh-btn" onclick="loadCategories()" title="Refresh">↻</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Name</th>
|
|
<th>Auto-Answer</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="categories-table"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Device Modal -->
|
|
<div id="add-modal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Add New Device</h3>
|
|
<p>Configure a new SIP client</p>
|
|
</div>
|
|
<form id="add-form" onsubmit="addDevice(event)">
|
|
<div class="form-group">
|
|
<label>Device Name</label>
|
|
<input type="text" name="name" class="form-control" required placeholder="e.g., Kitchen Phone">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Category</label>
|
|
<select name="category" class="form-control" id="category-select"></select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Extension</label>
|
|
<input type="number" name="extension" class="form-control" required min="100" max="999">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Connection Type</label>
|
|
<select name="conn_type" class="form-control">
|
|
<option value="lan">LAN/VPN (UDP)</option>
|
|
<option value="fqdn">FQDN/Internet (TLS)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Auto-Answer Override</label>
|
|
<select name="auto_answer" class="form-control">
|
|
<option value="">Use Category Default</option>
|
|
<option value="yes">Force Auto-Answer</option>
|
|
<option value="no">Force Ring</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
|
<button type="button" class="btn" onclick="closeModal()" style="background: #e2e8f0;">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Add Device</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credentials Modal -->
|
|
<div id="credentials-modal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Device Created Successfully</h3>
|
|
<p>Save these credentials - the password cannot be retrieved later</p>
|
|
</div>
|
|
<div class="credentials" id="credentials-display"></div>
|
|
<div style="margin-top: 20px; text-align: right;">
|
|
<button class="btn btn-primary" onclick="closeCredentialsModal()">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = '/api';
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(tab.dataset.tab).classList.add('active');
|
|
});
|
|
});
|
|
|
|
function showAlert(message, type = 'success') {
|
|
const container = document.getElementById('alert-container');
|
|
container.innerHTML = `<div class="alert alert-${type}">${message}</div>`;
|
|
setTimeout(() => container.innerHTML = '', 5000);
|
|
}
|
|
|
|
async function loadDevices() {
|
|
try {
|
|
const [devicesRes, statusRes] = await Promise.all([
|
|
fetch(API_BASE + '/devices'),
|
|
fetch(API_BASE + '/status')
|
|
]);
|
|
const devices = await devicesRes.json();
|
|
const status = await statusRes.json();
|
|
|
|
const tbody = document.getElementById('devices-table');
|
|
tbody.innerHTML = devices.map(d => `
|
|
<tr>
|
|
<td><strong>${d.extension}</strong></td>
|
|
<td>${d.name}</td>
|
|
<td>${d.category}</td>
|
|
<td>${d.transport.toUpperCase()}</td>
|
|
<td><span class="status status-${status[d.extension] || 'offline'}">${status[d.extension] || 'offline'}</span></td>
|
|
<td>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteDevice('${d.extension}', '${d.name}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (e) {
|
|
showAlert('Failed to load devices', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadCategories() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/categories');
|
|
const categories = await res.json();
|
|
|
|
document.getElementById('categories-table').innerHTML = categories.map(c => `
|
|
<tr>
|
|
<td>${c.id}</td>
|
|
<td>${c.name}</td>
|
|
<td>${c.auto_answer}</td>
|
|
<td>${c.description}</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
document.getElementById('category-select').innerHTML = categories.map(c =>
|
|
`<option value="${c.id}">${c.name}</option>`
|
|
).join('');
|
|
} catch (e) {
|
|
showAlert('Failed to load categories', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadRooms() {
|
|
try {
|
|
const res = await fetch(API_BASE + '/rooms');
|
|
const rooms = await res.json();
|
|
|
|
document.getElementById('rooms-table').innerHTML = rooms.map(r => `
|
|
<tr>
|
|
<td>${r.extension}</td>
|
|
<td>${r.name}</td>
|
|
<td>${r.type}</td>
|
|
<td>${r.members}</td>
|
|
<td>${r.timeout}s</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (e) {
|
|
showAlert('Failed to load rooms', 'error');
|
|
}
|
|
}
|
|
|
|
function showAddModal() {
|
|
document.getElementById('add-modal').classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('add-modal').classList.remove('active');
|
|
document.getElementById('add-form').reset();
|
|
}
|
|
|
|
function closeCredentialsModal() {
|
|
document.getElementById('credentials-modal').classList.remove('active');
|
|
loadDevices();
|
|
}
|
|
|
|
async function addDevice(e) {
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const data = {
|
|
name: form.name.value,
|
|
category: form.category.value,
|
|
extension: form.extension.value,
|
|
conn_type: form.conn_type.value,
|
|
auto_answer: form.auto_answer.value || null
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(API_BASE + '/devices', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(data)
|
|
});
|
|
const result = await res.json();
|
|
|
|
if (result.success) {
|
|
closeModal();
|
|
document.getElementById('credentials-display').innerHTML = `
|
|
<p><strong>Extension:</strong> ${result.data.extension}</p>
|
|
<p><strong>Password:</strong> ${result.data.password}</p>
|
|
<p><strong>Name:</strong> ${result.data.name}</p>
|
|
`;
|
|
document.getElementById('credentials-modal').classList.add('active');
|
|
} else {
|
|
showAlert(result.error || 'Failed to add device', 'error');
|
|
}
|
|
} catch (e) {
|
|
showAlert('Failed to add device', 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteDevice(ext, name) {
|
|
if (!confirm(`Delete device ${ext} (${name})?`)) return;
|
|
|
|
try {
|
|
const res = await fetch(API_BASE + '/devices/' + ext, { method: 'DELETE' });
|
|
const result = await res.json();
|
|
|
|
if (result.success) {
|
|
showAlert('Device deleted');
|
|
loadDevices();
|
|
} else {
|
|
showAlert(result.error || 'Failed to delete', 'error');
|
|
}
|
|
} catch (e) {
|
|
showAlert('Failed to delete device', 'error');
|
|
}
|
|
}
|
|
|
|
// Initial load
|
|
loadDevices();
|
|
loadCategories();
|
|
loadRooms();
|
|
|
|
// Auto-refresh status every 30 seconds
|
|
setInterval(loadDevices, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
class WebAdminHandler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
pass # Suppress default logging
|
|
|
|
def send_auth_required(self):
|
|
self.send_response(401)
|
|
self.send_header('WWW-Authenticate', 'Basic realm="Easy Asterisk Admin"')
|
|
self.send_header('Content-type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(b'<h1>Authentication Required</h1>')
|
|
|
|
def do_GET(self):
|
|
if not check_auth(self.headers):
|
|
self.send_auth_required()
|
|
return
|
|
|
|
path = urlparse(self.path).path
|
|
|
|
if path == '/' or path == '/clients':
|
|
self.send_response(200)
|
|
self.send_header('Content-type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(HTML_TEMPLATE.encode())
|
|
|
|
elif path == '/api/devices':
|
|
devices = get_devices()
|
|
self.send_json(devices)
|
|
|
|
elif path == '/api/status':
|
|
status = get_registered_endpoints()
|
|
self.send_json(status)
|
|
|
|
elif path == '/api/categories':
|
|
categories = get_categories()
|
|
self.send_json(categories)
|
|
|
|
elif path == '/api/rooms':
|
|
rooms = get_rooms()
|
|
self.send_json(rooms)
|
|
|
|
elif path == '/api/server':
|
|
info = get_server_info()
|
|
self.send_json(info)
|
|
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_POST(self):
|
|
if not check_auth(self.headers):
|
|
self.send_auth_required()
|
|
return
|
|
|
|
path = urlparse(self.path).path
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
body = self.rfile.read(content_length).decode('utf-8')
|
|
|
|
if path == '/api/devices':
|
|
try:
|
|
data = json.loads(body)
|
|
success, result = add_device(
|
|
data['name'],
|
|
data['category'],
|
|
data['extension'],
|
|
data.get('conn_type', 'lan'),
|
|
data.get('auto_answer')
|
|
)
|
|
if success:
|
|
self.send_json({'success': True, 'data': result})
|
|
else:
|
|
self.send_json({'success': False, 'error': result}, 400)
|
|
except Exception as e:
|
|
self.send_json({'success': False, 'error': str(e)}, 400)
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def do_DELETE(self):
|
|
if not check_auth(self.headers):
|
|
self.send_auth_required()
|
|
return
|
|
|
|
path = urlparse(self.path).path
|
|
match = re.match(r'/api/devices/(\d+)', path)
|
|
|
|
if match:
|
|
ext = match.group(1)
|
|
success, msg = delete_device(ext)
|
|
self.send_json({'success': success, 'message': msg})
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def send_json(self, data, status=200):
|
|
self.send_response(status)
|
|
self.send_header('Content-type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
def main():
|
|
with socketserver.TCPServer(("", PORT), WebAdminHandler) as httpd:
|
|
print(f"Easy Asterisk Web Admin running on port {PORT}")
|
|
httpd.serve_forever()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
WEBADMIN
|
|
chmod +x "$WEB_ADMIN_SCRIPT"
|
|
print_success "Web admin script created"
|
|
}
|
|
|
|
create_web_admin_service() {
|
|
cat > "$WEB_ADMIN_SERVICE" << EOF
|
|
[Unit]
|
|
Description=Easy Asterisk Web Admin
|
|
After=network.target asterisk.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
Environment=WEBADMIN_PORT=${WEB_ADMIN_PORT}
|
|
ExecStart=/usr/bin/python3 ${WEB_ADMIN_SCRIPT}
|
|
Restart=always
|
|
RestartSec=5
|
|
User=root
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
print_success "Web admin service created"
|
|
}
|
|
|
|
setup_web_admin_auth() {
|
|
print_header "Web Admin Authentication"
|
|
echo "Set up login credentials for the web admin interface."
|
|
echo ""
|
|
|
|
read -p "Username [admin]: " wa_user
|
|
wa_user="${wa_user:-admin}"
|
|
|
|
while true; do
|
|
read -s -p "Password: " wa_pass
|
|
echo ""
|
|
if [[ ${#wa_pass} -lt 6 ]]; then
|
|
print_error "Password must be at least 6 characters"
|
|
continue
|
|
fi
|
|
read -s -p "Confirm password: " wa_pass2
|
|
echo ""
|
|
if [[ "$wa_pass" != "$wa_pass2" ]]; then
|
|
print_error "Passwords don't match"
|
|
continue
|
|
fi
|
|
break
|
|
done
|
|
|
|
# Store with SHA256 hash
|
|
local hash=$(echo -n "$wa_pass" | sha256sum | awk '{print $1}')
|
|
echo "${wa_user}:{SHA256}${hash}" > "$WEB_ADMIN_HTPASSWD"
|
|
chmod 600 "$WEB_ADMIN_HTPASSWD"
|
|
print_success "Authentication configured for user: $wa_user"
|
|
}
|
|
|
|
web_admin_menu() {
|
|
load_config
|
|
local server_ip=$(hostname -I | awk '{print $1}')
|
|
|
|
print_header "Web Admin Management"
|
|
|
|
# Check current status
|
|
local status="stopped"
|
|
if systemctl is-active --quiet easy-asterisk-webadmin 2>/dev/null; then
|
|
status="running"
|
|
fi
|
|
|
|
echo " Status: ${status^^}"
|
|
if [[ "$status" == "running" ]]; then
|
|
echo " URL: http://${server_ip}:${WEB_ADMIN_PORT}/clients"
|
|
[[ -n "$DOMAIN_NAME" ]] && echo " URL: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients"
|
|
fi
|
|
echo ""
|
|
echo " 1) Start Web Admin"
|
|
echo " 2) Stop Web Admin"
|
|
echo " 3) Restart Web Admin"
|
|
echo " 4) Configure Authentication"
|
|
echo " 5) Change Port (current: ${WEB_ADMIN_PORT})"
|
|
echo " 6) View Logs"
|
|
echo " 0) Back"
|
|
echo ""
|
|
read -p " Select: " choice
|
|
|
|
case $choice in
|
|
1)
|
|
if [[ ! -f "$WEB_ADMIN_SCRIPT" ]]; then
|
|
print_info "Installing web admin..."
|
|
create_web_admin_script
|
|
create_web_admin_service
|
|
fi
|
|
if [[ ! -f "$WEB_ADMIN_HTPASSWD" ]]; then
|
|
setup_web_admin_auth
|
|
fi
|
|
systemctl enable easy-asterisk-webadmin
|
|
systemctl start easy-asterisk-webadmin
|
|
sleep 2
|
|
if systemctl is-active --quiet easy-asterisk-webadmin; then
|
|
print_success "Web Admin started"
|
|
echo ""
|
|
echo " Access at: http://${server_ip}:${WEB_ADMIN_PORT}/clients"
|
|
[[ -n "$DOMAIN_NAME" ]] && echo " Or: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients"
|
|
else
|
|
print_error "Failed to start. Check: journalctl -u easy-asterisk-webadmin"
|
|
fi
|
|
;;
|
|
2)
|
|
systemctl stop easy-asterisk-webadmin
|
|
systemctl disable easy-asterisk-webadmin 2>/dev/null
|
|
print_success "Web Admin stopped"
|
|
;;
|
|
3)
|
|
systemctl restart easy-asterisk-webadmin
|
|
print_success "Web Admin restarted"
|
|
;;
|
|
4)
|
|
setup_web_admin_auth
|
|
systemctl restart easy-asterisk-webadmin 2>/dev/null
|
|
;;
|
|
5)
|
|
read -p "New port [${WEB_ADMIN_PORT}]: " new_port
|
|
new_port="${new_port:-$WEB_ADMIN_PORT}"
|
|
if [[ "$new_port" =~ ^[0-9]+$ ]] && [[ "$new_port" -ge 1024 ]] && [[ "$new_port" -le 65535 ]]; then
|
|
WEB_ADMIN_PORT="$new_port"
|
|
save_config
|
|
create_web_admin_service
|
|
systemctl restart easy-asterisk-webadmin 2>/dev/null
|
|
print_success "Port changed to $new_port"
|
|
else
|
|
print_error "Invalid port (must be 1024-65535)"
|
|
fi
|
|
;;
|
|
6)
|
|
journalctl -u easy-asterisk-webadmin -n 50 --no-pager
|
|
;;
|
|
0) return ;;
|
|
esac
|
|
}
|
|
|
|
submenu_server() {
|
|
clear
|
|
print_header "Server Settings"
|
|
echo " 1) Setup Internet Access (TLS/Certs/NAT)"
|
|
echo " 2) Force re-sync Caddy certs"
|
|
echo " 3) Show port/firewall requirements"
|
|
echo " 4) Interactive Firewall Guide"
|
|
echo " 5) Test SIP connectivity"
|
|
echo " 6) Verify CIDR/NAT config"
|
|
echo " 7) Watch Live Logs"
|
|
echo " 8) Router Doctor"
|
|
echo " 9) Configure VLAN Subnets"
|
|
echo " 10) Provisioning Manager"
|
|
echo " 11) Web Admin (Client Management)"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
case $choice in
|
|
1) setup_internet_access ;;
|
|
2) setup_caddy_cert_sync "force" ;;
|
|
3) show_port_requirements ;;
|
|
4) show_firewall_guide ;;
|
|
5) test_sip_connectivity ;;
|
|
6) verify_cidr_config ;;
|
|
7) watch_live_logs ;;
|
|
8) router_doctor ;;
|
|
9) configure_vlan_subnets ;;
|
|
10) provisioning_manager_menu ;;
|
|
11) web_admin_menu ;;
|
|
0) return ;;
|
|
esac
|
|
[[ "$choice" != "0" ]] && read -p "Press Enter..."
|
|
[[ "$choice" != "0" ]] && submenu_server
|
|
}
|
|
|
|
submenu_devices() {
|
|
clear
|
|
print_header "Device Management"
|
|
echo " 1) Add device"
|
|
echo " 2) Remove device"
|
|
echo " 3) Rename device"
|
|
echo " 4) List devices"
|
|
echo " 5) Manage categories"
|
|
echo " 6) Manage rooms"
|
|
echo " 7) Export Clients"
|
|
echo " 8) Import Clients"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
case $choice in
|
|
1) add_device_menu ;;
|
|
2) remove_device ;;
|
|
3) rename_device ;;
|
|
4) show_registered_devices ;;
|
|
5) manage_categories ;;
|
|
6) manage_rooms ;;
|
|
7) export_clients ;;
|
|
8) import_clients ;;
|
|
0) return ;;
|
|
esac
|
|
[[ "$choice" != "0" ]] && read -p "Press Enter..."
|
|
[[ "$choice" != "0" ]] && submenu_devices
|
|
}
|
|
|
|
export_clients() {
|
|
print_header "Export Client Configurations"
|
|
load_config
|
|
initialize_default_categories
|
|
|
|
# Create export directory
|
|
local timestamp=$(date +%Y%m%d_%H%M%S)
|
|
local export_dir="/tmp/asterisk_export_${timestamp}"
|
|
local export_file="/root/asterisk-clients-${timestamp}.tar.gz"
|
|
|
|
mkdir -p "$export_dir"
|
|
|
|
# Check if there are any devices to export
|
|
if ! grep -q "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null; then
|
|
print_error "No client devices found to export"
|
|
rm -rf "$export_dir"
|
|
return
|
|
fi
|
|
|
|
# Export devices from pjsip.conf (everything after transport definitions)
|
|
echo "Extracting client devices..."
|
|
awk '/^; === Device:/{flag=1} flag' /etc/asterisk/pjsip.conf > "$export_dir/devices.conf"
|
|
|
|
# Count devices
|
|
local device_count=$(grep -c "^; === Device:" "$export_dir/devices.conf")
|
|
|
|
# Export categories
|
|
if [[ -f "$CATEGORIES_FILE" ]]; then
|
|
echo "Exporting categories..."
|
|
cp "$CATEGORIES_FILE" "$export_dir/categories.conf"
|
|
fi
|
|
|
|
# Export rooms
|
|
if [[ -f "$ROOMS_FILE" ]]; then
|
|
echo "Exporting rooms..."
|
|
cp "$ROOMS_FILE" "$export_dir/rooms.conf"
|
|
fi
|
|
|
|
# Create metadata file
|
|
cat > "$export_dir/export_info.txt" << EOF
|
|
Easy Asterisk Client Export
|
|
Export Date: $(date)
|
|
Device Count: $device_count
|
|
Domain: ${DOMAIN_NAME:-Not configured}
|
|
TLS Enabled: ${ENABLE_TLS:-no}
|
|
Exported by: $(whoami)
|
|
Hostname: $(hostname)
|
|
EOF
|
|
|
|
# Create tar.gz archive
|
|
echo "Creating archive..."
|
|
tar -czf "$export_file" -C /tmp "asterisk_export_${timestamp}" 2>/dev/null
|
|
|
|
# Cleanup temp directory
|
|
rm -rf "$export_dir"
|
|
|
|
if [[ -f "$export_file" ]]; then
|
|
print_success "Export completed successfully!"
|
|
echo ""
|
|
echo " Exported: $device_count devices"
|
|
echo " File: $export_file"
|
|
echo " Size: $(du -h "$export_file" | cut -f1)"
|
|
echo ""
|
|
echo " To import on another system:"
|
|
echo " 1) Copy file to the target server"
|
|
echo " 2) Run Easy Asterisk"
|
|
echo " 3) Select 'Client Settings' -> 'Import Clients'"
|
|
else
|
|
print_error "Export failed"
|
|
fi
|
|
}
|
|
|
|
import_clients() {
|
|
print_header "Import Client Configurations"
|
|
load_config
|
|
initialize_default_categories
|
|
|
|
echo "Available export files in /root:"
|
|
local files=($(ls -t /root/asterisk-clients-*.tar.gz 2>/dev/null))
|
|
|
|
if [[ ${#files[@]} -eq 0 ]]; then
|
|
echo ""
|
|
read -p "Enter full path to export file: " import_file
|
|
else
|
|
echo ""
|
|
local i=1
|
|
for f in "${files[@]}"; do
|
|
echo " $i) $(basename "$f") - $(du -h "$f" | cut -f1) - $(date -r "$f" '+%Y-%m-%d %H:%M')"
|
|
((i++))
|
|
done
|
|
echo " 0) Enter custom path"
|
|
echo ""
|
|
read -p "Select file [1]: " file_choice
|
|
file_choice="${file_choice:-1}"
|
|
|
|
if [[ "$file_choice" == "0" ]]; then
|
|
read -p "Enter full path to export file: " import_file
|
|
elif [[ "$file_choice" -ge 1 && "$file_choice" -le ${#files[@]} ]]; then
|
|
import_file="${files[$((file_choice-1))]}"
|
|
else
|
|
print_error "Invalid selection"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -f "$import_file" ]]; then
|
|
print_error "File not found: $import_file"
|
|
return
|
|
fi
|
|
|
|
# Extract to temp directory
|
|
local timestamp=$(date +%Y%m%d_%H%M%S)
|
|
local import_dir="/tmp/asterisk_import_${timestamp}"
|
|
mkdir -p "$import_dir"
|
|
|
|
echo "Extracting archive..."
|
|
tar -xzf "$import_file" -C "$import_dir" 2>/dev/null
|
|
|
|
# Find the extracted directory
|
|
local extract_dir=$(find "$import_dir" -type d -name "asterisk_export_*" | head -1)
|
|
if [[ ! -d "$extract_dir" ]]; then
|
|
print_error "Invalid export file format"
|
|
rm -rf "$import_dir"
|
|
return
|
|
fi
|
|
|
|
# Show export info
|
|
if [[ -f "$extract_dir/export_info.txt" ]]; then
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
cat "$extract_dir/export_info.txt"
|
|
echo "═══════════════════════════════════════════════════════════════"
|
|
echo ""
|
|
fi
|
|
|
|
# Count devices to import
|
|
local device_count=0
|
|
if [[ -f "$extract_dir/devices.conf" ]]; then
|
|
device_count=$(grep -c "^; === Device:" "$extract_dir/devices.conf")
|
|
fi
|
|
|
|
if [[ $device_count -eq 0 ]]; then
|
|
print_error "No devices found in export file"
|
|
rm -rf "$import_dir"
|
|
return
|
|
fi
|
|
|
|
echo "This will import $device_count device(s)."
|
|
echo ""
|
|
read -p "Import mode [1=Merge, 2=Replace All]: " import_mode
|
|
import_mode="${import_mode:-1}"
|
|
|
|
if [[ "$import_mode" == "2" ]]; then
|
|
echo ""
|
|
echo "${RED}WARNING: This will DELETE ALL existing devices!${NC}"
|
|
read -p "Type 'DELETE ALL' to confirm: " confirm
|
|
if [[ "$confirm" != "DELETE ALL" ]]; then
|
|
print_error "Import cancelled"
|
|
rm -rf "$import_dir"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# Backup existing configurations
|
|
echo "Backing up current configuration..."
|
|
backup_config "/etc/asterisk/pjsip.conf"
|
|
backup_config "$CATEGORIES_FILE"
|
|
backup_config "$ROOMS_FILE"
|
|
|
|
# Import devices
|
|
if [[ "$import_mode" == "2" ]]; then
|
|
# Replace mode - remove all existing devices
|
|
echo "Removing existing devices..."
|
|
local temp_pjsip="/tmp/pjsip_base_${timestamp}.conf"
|
|
awk '/^; === Device:/{exit} {print}' /etc/asterisk/pjsip.conf > "$temp_pjsip"
|
|
cat "$temp_pjsip" "$extract_dir/devices.conf" > /etc/asterisk/pjsip.conf
|
|
rm -f "$temp_pjsip"
|
|
print_success "Replaced all devices with imported devices"
|
|
else
|
|
# Merge mode - check for conflicts
|
|
echo "Checking for extension conflicts..."
|
|
local conflicts=0
|
|
local conflict_list=""
|
|
|
|
while IFS= read -r line; do
|
|
if [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then
|
|
conflicts=$((conflicts + 1))
|
|
conflict_list="${conflict_list}${ext} "
|
|
fi
|
|
fi
|
|
done < "$extract_dir/devices.conf"
|
|
|
|
if [[ $conflicts -gt 0 ]]; then
|
|
echo ""
|
|
echo "${YELLOW}Warning: Found $conflicts conflicting extension(s): $conflict_list${NC}"
|
|
read -p "Skip conflicting devices? [Y/n]: " skip_conflicts
|
|
skip_conflicts="${skip_conflicts:-Y}"
|
|
|
|
if [[ ! "$skip_conflicts" =~ ^[Yy]$ ]]; then
|
|
print_error "Import cancelled"
|
|
rm -rf "$import_dir"
|
|
return
|
|
fi
|
|
|
|
# Import only non-conflicting devices
|
|
echo "Importing non-conflicting devices..."
|
|
local temp_import="/tmp/import_filtered_${timestamp}.conf"
|
|
local skip_device=0
|
|
|
|
while IFS= read -r line; do
|
|
if [[ "$line" == "; === Device:"* ]]; then
|
|
skip_device=0
|
|
echo "$line" >> "$temp_import"
|
|
elif [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then
|
|
local ext="${BASH_REMATCH[1]}"
|
|
if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then
|
|
skip_device=1
|
|
echo " Skipping extension $ext (already exists)"
|
|
else
|
|
echo "$line" >> "$temp_import"
|
|
fi
|
|
elif [[ $skip_device -eq 0 ]]; then
|
|
echo "$line" >> "$temp_import"
|
|
fi
|
|
done < "$extract_dir/devices.conf"
|
|
|
|
cat "$temp_import" >> /etc/asterisk/pjsip.conf
|
|
rm -f "$temp_import"
|
|
else
|
|
# No conflicts, import all
|
|
echo "No conflicts found, importing all devices..."
|
|
cat "$extract_dir/devices.conf" >> /etc/asterisk/pjsip.conf
|
|
fi
|
|
|
|
print_success "Devices imported successfully"
|
|
fi
|
|
|
|
# Import categories (merge, skip duplicates)
|
|
if [[ -f "$extract_dir/categories.conf" ]]; then
|
|
echo "Importing categories..."
|
|
while IFS='|' read -r cat_id cat_name auto_answer description; do
|
|
[[ "$cat_id" =~ ^# ]] && continue
|
|
[[ -z "$cat_id" ]] && continue
|
|
|
|
# Skip if already exists
|
|
if grep -q "^${cat_id}|" "$CATEGORIES_FILE" 2>/dev/null; then
|
|
echo " Skipping category '$cat_id' (already exists)"
|
|
else
|
|
echo "${cat_id}|${cat_name}|${auto_answer}|${description}" >> "$CATEGORIES_FILE"
|
|
echo " Imported category: $cat_name"
|
|
fi
|
|
done < "$extract_dir/categories.conf"
|
|
fi
|
|
|
|
# Import rooms (merge, skip duplicates)
|
|
if [[ -f "$extract_dir/rooms.conf" ]]; then
|
|
echo "Importing rooms..."
|
|
while IFS='|' read -r ext name members timeout type; do
|
|
[[ "$ext" =~ ^# ]] && continue
|
|
[[ -z "$ext" ]] && continue
|
|
|
|
# Skip if already exists
|
|
if grep -q "^${ext}|" "$ROOMS_FILE" 2>/dev/null; then
|
|
echo " Skipping room '$name' (extension $ext already exists)"
|
|
else
|
|
echo "${ext}|${name}|${members}|${timeout}|${type}" >> "$ROOMS_FILE"
|
|
echo " Imported room: $name (ext $ext)"
|
|
fi
|
|
done < "$extract_dir/rooms.conf"
|
|
fi
|
|
|
|
# Cleanup
|
|
rm -rf "$import_dir"
|
|
|
|
# Reload Asterisk
|
|
echo ""
|
|
echo "Reloading Asterisk configuration..."
|
|
asterisk -rx "pjsip reload" >/dev/null 2>&1
|
|
rebuild_dialplan quiet
|
|
|
|
print_success "Import completed successfully!"
|
|
echo ""
|
|
echo " Run 'List devices' to verify imported clients"
|
|
}
|
|
|
|
submenu_client() {
|
|
clear
|
|
print_header "Client Settings"
|
|
echo " 1) Configure Local Client"
|
|
echo " 2) Configure PTT Button"
|
|
echo " 3) Run Diagnostics"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
case $choice in
|
|
1) configure_local_client ;;
|
|
2) configure_ptt_menu ;;
|
|
3) run_client_diagnostics ;;
|
|
0) return ;;
|
|
esac
|
|
[[ "$choice" != "0" ]] && read -p "Press Enter..."
|
|
[[ "$choice" != "0" ]] && submenu_client
|
|
}
|
|
|
|
fix_audio_manually() {
|
|
print_header "Manual Audio Fix"
|
|
load_config
|
|
local t_user="${KIOSK_USER:-$SUDO_USER}"
|
|
t_user="${t_user:-$USER}"
|
|
local t_uid=$(id -u "$t_user" 2>/dev/null)
|
|
local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid"
|
|
|
|
echo "Fixing audio for user: $t_user"
|
|
echo ""
|
|
|
|
# Restart PipeWire services
|
|
echo "Restarting PipeWire services..."
|
|
sudo -u "$t_user" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true
|
|
sleep 2
|
|
|
|
# Unmute audio
|
|
echo "Unmuting audio sources and sinks..."
|
|
sudo -u "$t_user" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null && echo " ✓ Microphone unmuted" || echo " ✗ Failed to unmute microphone"
|
|
sudo -u "$t_user" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null && echo " ✓ Speaker unmuted" || echo " ✗ Failed to unmute speaker"
|
|
|
|
# Set volume
|
|
echo "Setting volume levels to 75%..."
|
|
sudo -u "$t_user" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null && echo " ✓ Microphone volume set" || echo " ✗ Failed to set microphone volume"
|
|
sudo -u "$t_user" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null && echo " ✓ Speaker volume set" || echo " ✗ Failed to set speaker volume"
|
|
|
|
echo ""
|
|
echo "Current audio status:"
|
|
local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}')
|
|
local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}')
|
|
echo " Microphone: ${src_mute:-unknown}"
|
|
echo " Speaker: ${sink_mute:-unknown}"
|
|
|
|
echo ""
|
|
echo "Restarting Baresip..."
|
|
sudo -u "$t_user" $user_dbus systemctl --user restart baresip 2>/dev/null && echo " ✓ Baresip restarted" || echo " ✗ Failed to restart Baresip"
|
|
}
|
|
|
|
submenu_tools() {
|
|
clear
|
|
print_header "Tools"
|
|
echo " 1) Audio Test"
|
|
echo " 2) Verify Audio/Codec Setup"
|
|
echo " 3) Fix Audio (Unmute & Restart)"
|
|
echo " 4) Room Directory"
|
|
echo " 5) Manual Update Asterisk"
|
|
echo " 0) Back"
|
|
read -p " Select: " choice
|
|
case $choice in
|
|
1) run_audio_test ;;
|
|
2) verify_audio_setup ;;
|
|
3) fix_audio_manually ;;
|
|
4) show_room_directory ;;
|
|
5) manual_update_asterisk ;;
|
|
0) return ;;
|
|
esac
|
|
[[ "$choice" != "0" ]] && read -p "Press Enter..."
|
|
[[ "$choice" != "0" ]] && submenu_tools
|
|
}
|
|
|
|
main() {
|
|
check_root
|
|
load_config
|
|
show_main_menu
|
|
}
|
|
|
|
main "$@"
|