From a31f1817fc01ae26afb3d616ae868124428a4d44 Mon Sep 17 00:00:00 2001 From: outis1one Date: Mon, 24 Nov 2025 00:22:24 -0500 Subject: [PATCH] Add files via upload --- install_mumble.sh | 704 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 install_mumble.sh diff --git a/install_mumble.sh b/install_mumble.sh new file mode 100644 index 0000000..1f9fe44 --- /dev/null +++ b/install_mumble.sh @@ -0,0 +1,704 @@ +#!/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 ""