#!/bin/bash # Interactive Kiosk Mumble Client Installer # With systemd service and fixed audio set -e echo "========================================" echo "Kiosk Mumble Client Installer" echo "Interactive Setup with Systemd Service" echo "========================================" echo "" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' print_success() { echo -e "${GREEN}✓${NC} $1"; } print_error() { echo -e "${RED}✗${NC} $1"; } print_info() { echo -e "${BLUE}ℹ${NC} $1"; } print_warning() { echo -e "${YELLOW}⚠${NC} $1"; } TARGET_USER="${1:-kiosk}" USER_HOME=$(eval echo "~$TARGET_USER") echo "" print_info "Target user: $TARGET_USER" print_info "Home directory: $USER_HOME" echo "" # Check if user exists if ! id "$TARGET_USER" &>/dev/null; then print_error "User $TARGET_USER does not exist" exit 1 fi # Interactive configuration echo "=========================================" echo "Mumble Server Configuration" echo "=========================================" echo "" read -p "Mumble server address [mumble.example.org]: " MUMBLE_SERVER MUMBLE_SERVER=${MUMBLE_SERVER:-mumble.example.org} read -p "Port [64738]: " MUMBLE_PORT MUMBLE_PORT=${MUMBLE_PORT:-64738} read -p "Username [$TARGET_USER]: " MUMBLE_USERNAME MUMBLE_USERNAME=${MUMBLE_USERNAME:-$TARGET_USER} read -sp "Password (leave empty if none): " MUMBLE_PASSWORD echo "" read -p "Channel to join (leave empty for root): " MUMBLE_CHANNEL read -p "Skip SSL verification? (y/N): " MUMBLE_INSECURE MUMBLE_INSECURE=${MUMBLE_INSECURE:-n} echo "" echo "Configuration Summary:" echo "----------------------" echo "Server: $MUMBLE_SERVER:$MUMBLE_PORT" echo "Username: $MUMBLE_USERNAME" echo "Password: $([ -n "$MUMBLE_PASSWORD" ] && echo '(set)' || echo '(none)')" echo "Channel: ${MUMBLE_CHANNEL:-(root)}" echo "Skip SSL: $MUMBLE_INSECURE" echo "" read -p "Continue with installation? (Y/n): " CONFIRM if [[ "$CONFIRM" =~ ^[Nn]$ ]]; then echo "Installation cancelled" exit 0 fi # Check prerequisites print_info "Checking prerequisites..." if ! command -v go &> /dev/null; then print_warning "Go not installed, installing..." sudo apt update sudo apt install -y golang-go fi print_success "Go installed: $(go version)" # Install system dependencies print_info "Installing system dependencies..." sudo apt install -y \ libopus-dev \ portaudio19-dev \ pkg-config \ build-essential \ git \ pulseaudio-utils print_success "System dependencies installed" # Create workspace WORK_DIR="$USER_HOME/kiosk-mumble" print_info "Creating workspace at $WORK_DIR..." sudo -u "$TARGET_USER" mkdir -p "$WORK_DIR" # Save the Go source code with FIXED audio callback print_info "Creating source files..." sudo -u "$TARGET_USER" bash << 'EOF' cat > "$HOME/kiosk-mumble/main.go" << 'GOSRC' package main import ( "crypto/tls" "encoding/binary" "flag" "fmt" "log" "net" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" "time" "github.com/gordonklaus/portaudio" "layeh.com/gumble/gumble" "layeh.com/gumble/gumbleutil" _ "layeh.com/gumble/opus" ) const ( AudioSampleRate = 48000 AudioFrameSize = 960 AudioChannels = 1 ) type MumbleClient struct { client *gumble.Client config *gumble.Config stream *portaudio.Stream transmitting bool targetChannel string audioOut chan<- gumble.AudioBuffer pttDevice string pttKeyCode int } func main() { server := flag.String("server", "localhost:64738", "Mumble server (host:port)") username := flag.String("username", "kiosk-client", "Username") password := flag.String("password", "", "Server password") channel := flag.String("channel", "", "Channel to join") insecure := flag.Bool("insecure", false, "Skip TLS verification") vad := flag.Bool("vad", false, "Voice activity detection") pttKey := flag.String("ptt-key", "", "PTT key: 'space', 'mute', 'f13', etc") pttDevice := flag.String("ptt-device", "", "PTT input device") listDevices := flag.Bool("list-devices", false, "List input devices and exit") flag.Parse() if *listDevices { listInputDevices() return } fmt.Println("╔════════════════════════════════╗") fmt.Println("║ Kiosk Mumble Client v1.0 ║") fmt.Println("╚════════════════════════════════╝") fmt.Println() if err := portaudio.Initialize(); err != nil { log.Fatalf("PortAudio init failed: %v", err) } defer portaudio.Terminate() config := gumble.NewConfig() config.Username = *username config.Password = *password tlsConfig := &tls.Config{InsecureSkipVerify: *insecure} mc := &MumbleClient{ config: config, transmitting: *vad, targetChannel: *channel, pttDevice: *pttDevice, } if *pttKey != "" { mc.pttKeyCode = parsePTTKey(*pttKey) if mc.pttDevice == "" { mc.pttDevice = findBestInputDevice() } } config.Attach(gumbleutil.Listener{ Connect: mc.onConnect, Disconnect: mc.onDisconnect, UserChange: mc.onUserChange, TextMessage: mc.onTextMessage, }) fmt.Printf("Connecting to %s as '%s'...\n", *server, *username) if *password != "" { fmt.Println("Using password authentication") } client, err := gumble.DialWithDialer(new(net.Dialer), *server, config, tlsConfig) if err != nil { log.Fatalf("Connection failed: %v", err) } mc.client = client defer client.Disconnect() fmt.Println("✓ Connected!") if err := mc.setupAudio(); err != nil { log.Fatalf("Audio setup failed: %v", err) } defer mc.stream.Close() if err := mc.stream.Start(); err != nil { log.Fatalf("Audio start failed: %v", err) } defer mc.stream.Stop() fmt.Println() fmt.Println("Controls:") if *vad { fmt.Println(" Mode: Voice Activity Detection (always transmitting)") } else if *pttKey != "" { fmt.Printf(" Mode: Push-to-Talk (key: %s)\n", *pttKey) if mc.pttDevice != "" { fmt.Printf(" Device: %s\n", mc.pttDevice) } } else { fmt.Println(" Mode: Manual (no transmission)") } fmt.Println(" Ctrl+C: Quit") fmt.Println() if *pttKey != "" && mc.pttDevice != "" { go mc.monitorPTT() } if mc.targetChannel != "" { time.Sleep(500 * time.Millisecond) mc.joinChannel(mc.targetChannel) } sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan fmt.Println("\nDisconnecting...") } func (mc *MumbleClient) setupAudio() error { mc.audioOut = mc.client.AudioOutgoing() h, err := portaudio.DefaultHostApi() if err != nil { return fmt.Errorf("failed to get default host API: %w", err) } p := portaudio.LowLatencyParameters(h.DefaultInputDevice, nil) p.Input.Channels = AudioChannels fmt.Printf("Audio: Using device '%s'\n", h.DefaultInputDevice.Name) stream, err := portaudio.OpenStream(p, func(in, out []int16) { if !mc.transmitting || mc.audioOut == nil { return } pcm := make([]int16, len(in)) copy(pcm, in) select { case mc.audioOut <- pcm: default: } }) if err != nil { return fmt.Errorf("failed to open audio stream: %w", err) } mc.stream = stream return nil } func (mc *MumbleClient) monitorPTT() { file, err := os.Open(mc.pttDevice) if err != nil { fmt.Printf("⚠ Failed to open PTT device %s: %v\n", mc.pttDevice, err) fmt.Println("⚠ PTT will not work. Try running with sudo or add user to 'input' group:") fmt.Printf(" sudo usermod -a -G input %s\n", os.Getenv("USER")) return } defer file.Close() fmt.Printf("✓ PTT monitoring started on %s\n", mc.pttDevice) type inputEvent struct { Time syscall.Timeval Type uint16 Code uint16 Value int32 } for { var event inputEvent err := binary.Read(file, binary.LittleEndian, &event) if err != nil { fmt.Printf("⚠ PTT monitoring error: %v\n", err) return } if event.Type == 1 && int(event.Code) == mc.pttKeyCode { if event.Value == 1 { mc.transmitting = true fmt.Println("🎤 Transmitting...") } else if event.Value == 0 { mc.transmitting = false fmt.Println("🔇 Not transmitting") } } } } func parsePTTKey(key string) int { keyMap := map[string]int{ "space": 57, "mute": 113, "volumeup": 115, "volumedown": 114, "playpause": 164, "previoussong": 165, "nextsong": 163, "f13": 183, "f14": 184, "f15": 185, "f16": 186, "f17": 187, "f18": 188, "f19": 189, "f20": 190, "leftctrl": 29, "rightctrl": 97, "leftalt": 56, "rightalt": 100, "capslock": 58, "numlock": 69, "scrolllock": 70, } key = strings.ToLower(strings.TrimSpace(key)) if code, ok := keyMap[key]; ok { return code } var code int if _, err := fmt.Sscanf(key, "%d", &code); err == nil { return code } fmt.Printf("⚠ Unknown key '%s', using Space\n", key) return 57 } func findBestInputDevice() string { devices, err := filepath.Glob("/dev/input/event*") if err != nil || len(devices) == 0 { return "" } for _, dev := range devices { name := getDeviceName(dev) if strings.Contains(strings.ToLower(name), "keyboard") { return dev } } if len(devices) > 0 { return devices[0] } return "" } func getDeviceName(devicePath string) string { sysPath := "/sys/class/input/" + filepath.Base(devicePath) + "/device/name" data, err := os.ReadFile(sysPath) if err == nil { return strings.TrimSpace(string(data)) } return devicePath } func listInputDevices() { fmt.Println("Available Input Devices:") fmt.Println("========================") fmt.Println() devices, err := filepath.Glob("/dev/input/event*") if err != nil { fmt.Println("Error listing devices:", err) return } if len(devices) == 0 { fmt.Println("No input devices found") fmt.Println("Note: You may need to run with sudo") return } re := regexp.MustCompile(`event(\d+)`) for _, dev := range devices { name := getDeviceName(dev) match := re.FindStringSubmatch(dev) eventNum := "" if len(match) > 1 { eventNum = match[1] } fmt.Printf(" %s\n", dev) fmt.Printf(" Name: %s\n", name) if eventNum != "" { fmt.Printf(" Event: %s\n", eventNum) } fmt.Println() } fmt.Println("Common PTT Keys:") fmt.Println(" -ptt-key mute # Mute media key") fmt.Println(" -ptt-key space # Spacebar") fmt.Println(" -ptt-key f13 # F13") fmt.Println(" -ptt-key capslock # Caps Lock") fmt.Println() } func (mc *MumbleClient) onConnect(e *gumble.ConnectEvent) { fmt.Println("✓ Connected to server") fmt.Printf(" Server: %s\n", e.Client.Conn.RemoteAddr()) fmt.Printf(" Users: %d online\n", len(e.Client.Users)) fmt.Printf(" Channels: %d\n", len(e.Client.Channels)) } func (mc *MumbleClient) onDisconnect(e *gumble.DisconnectEvent) { fmt.Println("✗ Disconnected from server") if e.String != "" { fmt.Printf(" Reason: %s\n", e.String) } } func (mc *MumbleClient) onUserChange(e *gumble.UserChangeEvent) { if e.Type.Has(gumble.UserChangeConnected) { fmt.Printf("→ %s joined\n", e.User.Name) } if e.Type.Has(gumble.UserChangeDisconnected) { fmt.Printf("← %s left\n", e.User.Name) } if e.Type.Has(gumble.UserChangeChannel) { if e.User.Channel != nil { fmt.Printf("↔ %s moved to %s\n", e.User.Name, e.User.Channel.Name) } } } func (mc *MumbleClient) onTextMessage(e *gumble.TextMessageEvent) { if e.Sender != nil { fmt.Printf("💬 %s: %s\n", e.Sender.Name, e.Message) } else { fmt.Printf("💬 Server: %s\n", e.Message) } } func (mc *MumbleClient) joinChannel(channelPath string) { if mc.client == nil || mc.client.Self == nil { fmt.Printf("⚠ Client not ready yet, retrying...\n") time.Sleep(1 * time.Second) if mc.client == nil || mc.client.Self == nil { fmt.Printf("⚠ Cannot join channel: client not initialized\n") return } } parts := strings.Split(channelPath, "/") var targetChannel *gumble.Channel for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } found := false for _, ch := range mc.client.Channels { if ch.Name == part { if targetChannel == nil { if ch.Parent == nil || ch.Parent.Parent == nil { targetChannel = ch found = true break } } else { if ch.Parent != nil && ch.Parent.ID == targetChannel.ID { targetChannel = ch found = true break } } } } if !found { fmt.Printf("⚠ Channel not found: %s\n", part) fmt.Println("Available channels:") mc.listChannels() return } } if targetChannel != nil { mc.client.Self.Move(targetChannel) fmt.Printf("✓ Joined channel: %s\n", channelPath) } } func (mc *MumbleClient) listChannels() { for _, ch := range mc.client.Channels { indent := strings.Repeat(" ", getChannelDepth(ch)) fmt.Printf("%s- %s\n", indent, ch.Name) } } func getChannelDepth(ch *gumble.Channel) int { depth := 0 current := ch for current.Parent != nil { depth++ current = current.Parent } return depth } GOSRC EOF print_success "Source code created" # Build print_info "Building client (this may take ~45 minutes)..." sudo -u "$TARGET_USER" bash << 'EOF' cd "$HOME/kiosk-mumble" go mod init kiosk-mumble 2>/dev/null || true echo "Downloading dependencies..." go get layeh.com/gumble/gumble@latest go get layeh.com/gumble/gumbleutil@latest go get layeh.com/gumble/opus@latest go get github.com/gordonklaus/portaudio@latest go mod tidy echo "Compiling..." CGO_ENABLED=1 go build -o kiosk-mumble main.go if [ -f kiosk-mumble ]; then echo "✓ Build successful!" mkdir -p "$HOME/bin" cp kiosk-mumble "$HOME/bin/" chmod +x "$HOME/bin/kiosk-mumble" echo "✓ Installed to $HOME/bin/kiosk-mumble" else echo "✗ Build failed" exit 1 fi EOF BUILD_RESULT=$? if [ $BUILD_RESULT -ne 0 ]; then print_error "Build failed!" exit 1 fi print_success "Build complete!" # Configure permissions print_info "Configuring permissions..." if ! groups "$TARGET_USER" | grep -q '\binput\b'; then sudo usermod -a -G input "$TARGET_USER" print_success "Added to input group" else print_success "Already in input group" fi if ! groups "$TARGET_USER" | grep -q '\baudio\b'; then sudo usermod -a -G audio "$TARGET_USER" print_success "Added to audio group" else print_success "Already in audio group" fi # Create systemd service print_info "Creating systemd service..." INSECURE_FLAG="" if [[ "$MUMBLE_INSECURE" =~ ^[Yy]$ ]]; then INSECURE_FLAG="-insecure" fi PASSWORD_FLAG="" if [ -n "$MUMBLE_PASSWORD" ]; then PASSWORD_FLAG="-password \"$MUMBLE_PASSWORD\"" fi CHANNEL_FLAG="" if [ -n "$MUMBLE_CHANNEL" ]; then CHANNEL_FLAG="-channel \"$MUMBLE_CHANNEL\"" fi sudo tee /etc/systemd/system/kiosk-mumble.service > /dev/null << SERVICEEOF [Unit] Description=Kiosk Mumble Client After=network-online.target sound.target Wants=network-online.target [Service] Type=simple User=$TARGET_USER Group=$TARGET_USER Environment="XDG_RUNTIME_DIR=/run/user/$(id -u $TARGET_USER)" Environment="PULSE_SERVER=/run/user/$(id -u $TARGET_USER)/pulse/native" ExecStart=$USER_HOME/bin/kiosk-mumble -server $MUMBLE_SERVER:$MUMBLE_PORT -username $MUMBLE_USERNAME $PASSWORD_FLAG $CHANNEL_FLAG $INSECURE_FLAG -vad Restart=always RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target SERVICEEOF sudo systemctl daemon-reload print_success "Systemd service created" echo "" echo "========================================" echo "Installation Complete!" echo "========================================" echo "" print_success "Kiosk Mumble Client installed" echo "" echo "Service Management:" echo "-------------------" echo "Start service: sudo systemctl start kiosk-mumble" echo "Stop service: sudo systemctl stop kiosk-mumble" echo "Enable on boot: sudo systemctl enable kiosk-mumble" echo "View logs: sudo journalctl -u kiosk-mumble -f" echo "Service status: sudo systemctl status kiosk-mumble" echo "" echo "Manual Testing:" echo "---------------" echo "Test audio:" if [ -n "$MUMBLE_CHANNEL" ]; then echo " sudo -u $TARGET_USER $USER_HOME/bin/kiosk-mumble \\" echo " -server $MUMBLE_SERVER:$MUMBLE_PORT \\" echo " -username $MUMBLE_USERNAME \\" echo " -channel \"$MUMBLE_CHANNEL\" \\" echo " -vad" else echo " sudo -u $TARGET_USER $USER_HOME/bin/kiosk-mumble \\" echo " -server $MUMBLE_SERVER:$MUMBLE_PORT \\" echo " -username $MUMBLE_USERNAME \\" echo " -vad" fi echo "" echo "Audio Troubleshooting:" echo "----------------------" echo "Check audio devices:" echo " pactl list short sinks" echo " pactl list short sources" echo "" echo "Test microphone:" echo " arecord -d 5 test.wav && aplay test.wav" echo "" print_warning "Note: User must log out and back in for group changes to take effect!" echo "" read -p "Enable and start service now? (Y/n): " START_NOW if [[ ! "$START_NOW" =~ ^[Nn]$ ]]; then sudo systemctl enable kiosk-mumble sudo systemctl start kiosk-mumble print_success "Service enabled and started" echo "" echo "Checking status..." sleep 2 sudo systemctl status kiosk-mumble --no-pager fi echo "" print_info "Installation complete! Check logs with: sudo journalctl -u kiosk-mumble -f" echo ""