diff --git a/Dockerfile b/Dockerfile index f6f2c43..7e9f850 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,13 @@ # ------------------------- # 1) Builder stage # ------------------------- -FROM golang:1.20-alpine AS builder +FROM golang:1.24-alpine AS builder -# Install CA certificates required for building -RUN apk add --no-cache ca-certificates +ENV GOTOOLCHAIN=auto WORKDIR /app - -# Copy local repository into the build image (portable across OSes) COPY . /app -# Ensure config.example exists; keep a fallback copy -RUN cp cmd/server/config.example.json cmd/server/config.json || true - -# Normalize go.mod 'go' directive to a valid major.minor form to avoid build errors -RUN if [ -f go.mod ]; then \ - sed -E -i 's/^go[[:space:]]+[0-9]+(\.[0-9]+){1,2}$/go 1.20/' go.mod || true; \ - fi - -# Build a static Linux binary for the server RUN cd cmd/server && CGO_ENABLED=0 GOOS=linux go build -o ts6viewer . # ------------------------- @@ -27,22 +15,16 @@ RUN cd cmd/server && CGO_ENABLED=0 GOOS=linux go build -o ts6viewer . # ------------------------- FROM alpine:latest -# Install CA certificates and gettext for envsubst -RUN apk add --no-cache ca-certificates gettext +RUN apk add --no-cache ca-certificates gettext openssh-client -# Set working directory to where the server expects config.json when using relative path WORKDIR /app/cmd/server -# Copy the built app and assets from builder COPY --from=builder /app /app -# Copy entrypoint script and ensure it's executable COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh || true -RUN chmod +x /app/cmd/server/ts6viewer || true +RUN chmod +x /app/entrypoint.sh +RUN chmod +x /app/cmd/server/ts6viewer -# Expose the internal port the app listens on EXPOSE 8080 -# Use the entrypoint to generate config and start the app ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 977098a..33a4c2a 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ -# TS6 Viewer +TS6 Viewer

Dark Theme Light Theme

A lightweight, fast, and modern web viewer for **TeamSpeak 6** servers. -TS6 Viewer connects to the TeamSpeak 6 WebQuery API, retrieves live server and channel data, and displays it in a clean, responsive web interface with optional dark/light themes. +TS6 Viewer connects to **ServerQuery (SSH, Port 10022)** and displays live server, channel, and client information. The viewer is designed to be: - Simple to deploy - Fast and lightweight - Fully client‑side auto‑refreshing -- Compatible with any TS6 server exposing WebQuery +- Compatible with any TS6 server - Customizable via a single `config.json` file --- + ## Features - Live TeamSpeak 6 server viewer @@ -25,103 +26,157 @@ The viewer is designed to be: - Channel tree rendering with clients - Spacer and full‑width channel support - Caching + rate‑limit protection -- Works on Windows, Linux, and Docker‑friendly environments +- Pure **ServerQuery (SSH)** backend +- Optional **voice status** (mute status, audio status, talking) --- -## Configuration +# ServerQuery (SSH) -The application uses a `config.json` file located in the same directory as the executable. +TS6 Viewer communicates exclusively via ServerQuery over SSH. -### Example `config.json` +Advantages: +- Persistent SSH connection +- Very fast, even with many clients +- clientlist -voice provides all audio/mute/talker info in a single call +- No REST overhead +- No per-client requests +- No rate limits -```json -{ - "_comment": "TS6 Viewer Configuration File", - "_comment2": "Rename this file to config.json and adjust the values to your setup.", +--- - "server_port": "8080", - "_comment_server_port": "The port on which the TS6 Viewer web interface will be available.", +# Configuration Files in the Project - "theme": "dark", - "_comment_theme": "Choose between 'light' or 'dark' for the viewer theme.", +The repository includes two important configuration templates: - "refresh_interval": 60, - "_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds).", +### `config.example.json` +This file is included in the project and serves as the template for your actual configuration. +You can: - "teamspeak6": { - "base_url": "http://192.168.178.195:57007", - "_comment_base_url": "The WebQuery HTTP endpoint of your TeamSpeak 6 server. Usually http://:10080 or your mapped port.", +- rename it manually to `config.json`, or +- let Docker generate `config.json` automatically using environment variables. - "api_key": "geheim", - "_comment_api_key": "Your TeamSpeak 6 ServerQuery API key. It is shown ONCE in the server logs on first startup. If lost, create a new one.", +--- - "server_id": 1, - "_comment_server_id": "The ID of the virtual server you want to display. Default is usually 1." - } -} +# Docker Support + +The repository includes: + +- Dockerfile.sh — multi‑stage build for Go + Alpine +- entrypoint.sh — generates config.json dynamically using environment variables +- docker-compose.yml — ready to run the viewer with one command + +This allows you to run TS6 Viewer fully containerized. + +--- + +## Dockerfile.sh explained + +The Dockerfile uses two stages: + +### 1) Builder Stage +- Based on golang:1.20-alpine +- Copies the entire repository into /app +- Ensures config.example.json exists +- Normalizes go.mod to avoid Go version parsing issues +- Builds a static Linux binary: cmd/server/ts6viewer + +### 2) Runtime Stage +- Based on alpine:latest +- Installs CA certificates + gettext (envsubst) +- Copies the built binary and assets from the builder +- Copies entrypoint.sh +- Exposes port 8080 +- Starts the viewer via the entrypoint script + +--- + +## entrypoint.sh explained + +The entrypoint script: + +1. Loads environment variables +2. Applies defaults if variables are missing +3. Uses envsubst to generate config.json from config.example.json +4. Starts the TS6 Viewer binary + +Environment variables include: + +- SERVER_PORT +- THEME +- REFRESH_INTERVAL +- HOST +- PORT +- USER +- PASSWORD +- ENABLE_VOICE_STATUS +- SERVER_ID + +This makes the Docker container fully configurable without editing files. + +# docker-compose.yml + +A ready‑to‑use compose file is included in the project. +You can start the viewer with: + +``` +docker compose up -d ``` --- -## Building the Program +# Building the Program -The project is written in **Go**, so building is extremely simple. +The project is written in Go. -### Prerequisites +### Build on Linux -- Go 1.20 or newer -- Git (optional) - ---- - -## Build on Linux -```bash +```sh git clone https://github.com/Maxallica/ts6-viewer.git cd ts6viewer - go build -o ts6viewer ``` -## Build Windows binary on Linux -```bash +### Build Windows binary on Linux +```sh cd "*/ts6-viewer/cmd/server" - GOOS=windows GOARCH=amd64 go build -o ts6viewer.exe ``` -## Running on Linux -```bash +### Running on Linux + +```sh ./ts6viewer ``` -## Build on Windows -```bash +### Build on Windows + +```sh git clone https://github.com/Maxallica/ts6-viewer.git cd ts6viewer - go build -o ts6viewer.exe ``` -## Build Linux binary on Windows -```bash -cd "*/ts6-viewer/cmd/server" +### Build Linux binary on Windows +```sh +cd "*/ts6-viewer/cmd/server" $env:GOOS="linux" $env:GOARCH="amd64" go build -o ts6viewer ``` -## Running on Windows -```bash +### Running on Windows +```sh .\ts6viewer.exe ``` --- -## Navigate to the ts6viewer page -http(s)\://\:\/ts6viewer +## Navigate to the TS6 Viewer page + +http(s)\/\/\:\/ts6viewer --- -## ❤️ Made with love in Germany \ No newline at end of file +## ❤️ Made with love in Germany diff --git a/cmd/server/config.example.json b/cmd/server/config.example.json index 899a462..c122e4f 100644 --- a/cmd/server/config.example.json +++ b/cmd/server/config.example.json @@ -2,23 +2,36 @@ "_comment": "TS6 Viewer Configuration File", "_comment2": "Rename this file to config.json and adjust the values to your setup.", - "server_port": "${SERVER_PORT}", - "_comment_server_port": "The port on which the TS6 Viewer web interface will be available. Usually '8080'", + "server_port": "${SERVER_PORT}", + "_comment_server_port": "The port on which the TS6 Viewer web interface will be available.", - "theme": "${THEME}", - "_comment_theme": "Choose between 'light' or 'dark' for the viewer theme. Usually 'dark'", + "theme": "${THEME}", + "_comment_theme": "Choose between 'light' or 'dark' for the viewer theme.", "refresh_interval": "${REFRESH_INTERVAL}", - "_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds). Usually '60'", + "_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds).", - "teamspeak6": { - "base_url": "${BASE_URL}", - "_comment_base_url": "The WebQuery HTTP endpoint of your TeamSpeak 6 server. Usually http://192.168.178.2:10080.", + "host_connection_link": "${HOST_CONNECTION_LINK}", + "_comment_host_connection_link": "The URL or IP address of your TeamSpeak 6 server. This is used for display purposes and should match the actual server address.", - "api_key": "${API_KEY}", - "_comment_api_key": "Your TeamSpeak 6 ServerQuery API key. It is shown ONCE in the server logs on first startup. If lost, you have to create a whole new server.", + "teamspeak6": { + "host": "${HOST}", + "_comment_host": "The ServerQuery [ssh] host. Usually the IP / domain of your TeamSpeak 6 server or 'localhost' if TS6 Viewer runs on the same machine.", + + "port": "${PORT}", + "_comment_port": "The ServerQuery [ssh] port. Usually 10022 for SSH.", + + "user": "${USER}", + "_comment_user": "The ServerQuery [ssh] user. Usually 'serveradmin' by default.", + + "password": "${PASSWORD}", + "_comment_password": "The ServerQuery [ssh] password. It is shown ONCE in the server logs on first startup.", + + "enable_voice_status": "${ENABLE_VOICE_STATUS}", + "_comment_enable_voice_status": "Fetch microphone and audio output status for each client (TS6 -voice).", "server_id": "${SERVER_ID}", "_comment_server_id": "The ID of the virtual server you want to display. Default is usually '1'." } } + diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4c81491 --- /dev/null +++ b/compose.yml @@ -0,0 +1,23 @@ +version: "3.9" + +services: + ts6viewer: + image: ts6viewer:latest + container_name: ts6viewer + ports: + - "9000:8080" + + environment: + SERVER_PORT: "8080" + THEME: "dark" + REFRESH_INTERVAL: "60" + HOST_CONNECTION_LINK: "" + + HOST: "192.168.178.2" + PORT: "10022" + USER: "serveradmin" + PASSWORD: "" + ENABLE_VOICE_STATUS: "true" + SERVER_ID: "1" + + restart: unless-stopped diff --git a/dark.png b/dark.png index 4ec32ff..984b8b5 100644 Binary files a/dark.png and b/dark.png differ diff --git a/entrypoint.sh b/entrypoint.sh index 52f8841..e7aa574 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,60 +1,53 @@ #!/bin/sh set -eu -# Paths EXAMPLE="/app/cmd/server/config.example.json" -TARGET_REL="config.json" # relative path in WORKDIR (/app/cmd/server) -TARGET_ABS="/app/cmd/server/config.json" # absolute path for clarity +TARGET_ABS="/app/cmd/server/config.json" BINARY="/app/cmd/server/ts6viewer" -# Export variables used in the template (use environment variables only) -export API_KEY="${API_KEY:-}" export SERVER_PORT="${SERVER_PORT:-8080}" -export BASE_URL="${BASE_URL:-http://127.0.0.1:10080}" -export SERVER_ID="${SERVER_ID:-1}" export THEME="${THEME:-dark}" export REFRESH_INTERVAL="${REFRESH_INTERVAL:-60}" +export HOST_CONNECTION_LINK="${HOST_CONNECTION_LINK:-}" -# Minimal, useful debug output -echo "=== STARTUP ===" -# Mask API key for logs: show first 4 chars if present -if [ -n "$API_KEY" ]; then - echo "API_KEY=****${API_KEY#????}" -else - echo "API_KEY=(empty)" -fi -echo "PORT=${SERVER_PORT} BASE_URL=${BASE_URL} THEME=${THEME}" -echo "Working dir: $(pwd)" -echo +export HOST="${HOST:-localhost}" +export PORT="${PORT:-10022}" +export USER="${USER:-serveradmin}" +export PASSWORD="${PASSWORD:-}" +export ENABLE_VOICE_STATUS="${ENABLE_VOICE_STATUS:-true}" +export SERVER_ID="${SERVER_ID:-1}" -# Quick binary check -if [ -x "$BINARY" ]; then - echo "Binary: OK -> $BINARY" -else - echo "Binary: MISSING or not executable -> $BINARY" >&2 +echo "[entrypoint] starting TS6 Viewer" + +if [ ! -x "$BINARY" ]; then + echo "[entrypoint] ERROR: binary not found or not executable: $BINARY" >&2 + exit 1 fi -# Ensure example exists; if not, start the binary directly if [ ! -f "$EXAMPLE" ]; then - echo "config.example.json not found at $EXAMPLE, starting binary directly" >&2 + echo "[entrypoint] WARNING: config.example.json not found, starting without generated config" >&2 + echo "[entrypoint] Starting server..." exec "$BINARY" fi -# Generate config.json from config.example.json using envsubst -envsubst < "$EXAMPLE" > "$TARGET_ABS" || { - echo "Failed to generate config.json" >&2 +if ! envsubst < "$EXAMPLE" > "$TARGET_ABS"; then + echo "[entrypoint] ERROR: failed to generate config.json" >&2 exit 1 -} +fi -# Ensure relative copy exists for relative loads -cp -f "$TARGET_ABS" "$TARGET_REL" || true +echo "[entrypoint] config.json generated" -# Show compact confirmation and a short preview of the generated config -size=$(wc -c < "$TARGET_ABS" 2>/dev/null || echo 0) -echo "Generated config.json (${size} bytes). Preview:" -echo "-----" -head -n 20 "$TARGET_ABS" || true -echo "-----" +echo "[entrypoint] Loaded configuration:" +echo " SERVER_PORT=$SERVER_PORT" +echo " THEME=$THEME" +echo " REFRESH_INTERVAL=$REFRESH_INTERVAL" +echo " HOST=$HOST" +echo " PORT=$PORT" +echo " USER=$USER" +echo " PASSWORD=*********" +echo " ENABLE_VOICE_STATUS=$ENABLE_VOICE_STATUS" +echo " SERVER_ID=$SERVER_ID" + +echo "[entrypoint] Starting server..." -echo "Starting server..." exec "$BINARY" diff --git a/go.mod b/go.mod index 804be69..6df341d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module ts6-viewer go 1.25.6 + +require golang.org/x/crypto v0.47.0 + +require golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index e69de29..d8a4490 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= diff --git a/http/http-helper.go b/http/http-helper.go index 82f1b70..9579e39 100644 --- a/http/http-helper.go +++ b/http/http-helper.go @@ -1,103 +1,83 @@ package http import ( - "net" + "log" "net/http" "time" "ts6-viewer/internal/config" - "ts6-viewer/internal/domain" - "ts6-viewer/internal/mapper" "ts6-viewer/internal/ts6" "ts6-viewer/internal/view" ) +// getIP extracts the IP address from the request. func getIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr + ip := r.RemoteAddr + if ipForwarded := r.Header.Get("X-Forwarded-For"); ipForwarded != "" { + ip = ipForwarded } - return host + return ip } +// allowRequest checks rate limiting per IP. func allowRequest(ip string) bool { mu.Lock() defer mu.Unlock() - last, exists := lastRequestTime[ip] - if exists && time.Since(last) < rateLimitWindow { + now := time.Now() + last, ok := lastRequestTime[ip] + if ok && now.Sub(last) < rateLimitWindow { return false } - lastRequestTime[ip] = time.Now() + lastRequestTime[ip] = now return true } -func getViewerData(cfg *config.Config, baseURL, apiKey, serverID string) (view.ViewerData, error) { +// getViewerData fetches or returns cached viewer data. +func getViewerData(cfg *config.Config, force bool) (view.VMTS6Viewer, error) { mu.Lock() defer mu.Unlock() - if time.Since(cacheTimestamp) < cacheTTL { + if !force && time.Since(cacheTimestamp) < cacheTTL { + log.Println("[HTTP] Returning cached viewer data") return cacheData, nil } - serverInfo, err := ts6.GetServerInfo(baseURL, apiKey, serverID) + log.Println("[HTTP] Fetching new viewer data from TS6 server") + sshClient, err := ts6.GetPersistentClient(cfg, cfg.Teamspeak6.ServerID) if err != nil { - return view.ViewerData{}, err + log.Printf("[HTTP] Failed to get SSH client: %v\n", err) + return view.VMTS6Viewer{}, err } - apiClients, err := ts6.GetClientList(baseURL, apiKey, serverID) + channels, err := ts6.GetChannelList(cfg, sshClient) if err != nil { - return view.ViewerData{}, err + log.Printf("[HTTP] Failed to get channels: %v\n", err) + return view.VMTS6Viewer{}, err } - apiChannels, err := ts6.GetChannelList(baseURL, apiKey, serverID) + clients, err := ts6.GetClientList(cfg, sshClient) if err != nil { - return view.ViewerData{}, err + log.Printf("[HTTP] Failed to get clients: %v\n", err) + return view.VMTS6Viewer{}, err } - // API → Domain - domainChannels := make([]*domain.Channel, 0, len(apiChannels)) - for _, ch := range apiChannels { - domainChannels = append(domainChannels, mapper.MapAPIChannel(ch)) + info, err := ts6.GetServerInfo(cfg, sshClient) + if err != nil { + log.Printf("[HTTP] Failed to get server info: %v\n", err) + return view.VMTS6Viewer{}, err } - fullClients := make([]*domain.FullClient, 0, len(apiClients)) - for _, c := range apiClients { - // info, err := ts6.GetClientInfo(baseURL, apiKey, serverID, c.CLID) - // if err != nil { - // fmt.Println("clientinfo error:", err) - // } - - domainInfo := &domain.ClientInfo{ - MicMuted: false, - OutputMuted: false, - IsTalking: false, - } - - domainClient := mapper.MapAPIClient(c) - // domainInfo := mapper.MapAPIClientInfo(info) - - fullClients = append(fullClients, &domain.FullClient{ - Client: *domainClient, - Info: domainInfo, - }) - } - - channelTree := domain.BuildChannelTree(domainChannels, fullClients) - - // ServerInfo → Domain - domainServer := mapper.MapAPIServer(serverInfo) - - // Domain → View - viewData := view.ViewerData{ - Server: mapper.MapServerToView(domainServer), - ChannelTree: mapper.MapChannelTreeToView(channelTree), + vmTS6Viewer := view.VMTS6Viewer{ + VMServer: view.BuildVMServer(cfg, info, clients), + VMChannels: view.BuildVMChannels(channels, clients), Theme: cfg.Theme, RefreshInterval: cfg.RefreshInterval, } - // Set cache - cacheData = viewData + cacheData = vmTS6Viewer cacheTimestamp = time.Now() - return viewData, nil + log.Println("[HTTP] Viewer data updated and cached") + + return vmTS6Viewer, nil } diff --git a/http/router.go b/http/router.go index 76ef17c..02eb271 100644 --- a/http/router.go +++ b/http/router.go @@ -16,7 +16,7 @@ import ( ) var ( - cacheData view.ViewerData + cacheData view.VMTS6Viewer cacheTimestamp time.Time cacheTTL = 5 * time.Second @@ -26,84 +26,101 @@ var ( mu sync.Mutex ) +// NewRouter sets up all HTTP routes and returns the router. func NewRouter(cfg config.Config) http.Handler { + // parse refresh interval if ttl, err := time.ParseDuration(cfg.RefreshInterval + "s"); err == nil { cacheTTL = ttl + log.Printf("[HTTP] Cache TTL set to %v\n", cacheTTL) } - baseURL := cfg.Teamspeak6.BaseURL - apiKey := cfg.Teamspeak6.ApiKey - serverID := cfg.Teamspeak6.ServerID refreshIntervalStr := cfg.RefreshInterval - refreshInterval, err := strconv.Atoi(refreshIntervalStr) if err != nil || refreshInterval <= 0 { refreshIntervalStr = "60" + refreshInterval = 60 } + log.Printf("[HTTP] Refresh interval: %d seconds\n", refreshInterval) + mux := http.NewServeMux() + // Load templates wd, err := os.Getwd() if err != nil { - log.Fatal("cannot get working directory:", err) + log.Fatal("[HTTP] Cannot get working directory:", err) } tmplPath := filepath.Join(wd, "..", "..", "internal", "web", "templates", "ts6viewer.html") tmpl := template.Must(template.ParseFiles(tmplPath)) + log.Printf("[HTTP] Loaded template: %s\n", tmplPath) + // Static assets staticPath := filepath.Join(wd, "..", "..", "internal", "web", "static") mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticPath)))) + log.Printf("[HTTP] Static files served from: %s\n", staticPath) // ----------------------------- - // JSON endpoint (/ts6viewer/data + /ts6viewer/data/) + // JSON data endpoint // ----------------------------- dataHandler := func(w http.ResponseWriter, r *http.Request) { ip := getIP(r) + log.Printf("[HTTP] /ts6viewer/data requested from IP: %s\n", ip) - var data view.ViewerData + var data view.VMTS6Viewer var err error + force := r.URL.Query().Get("force") == "1" + if force { + log.Printf("[HTTP] Force refresh requested by IP: %s\n", ip) + } if allowRequest(ip) { - data, err = getViewerData(&cfg, baseURL, apiKey, serverID) + data, err = getViewerData(&cfg, force) } else { + log.Printf("[HTTP] Rate limit hit for IP: %s\n", ip) data = cacheData } if err != nil { + log.Printf("[HTTP] Error getting viewer data: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(data) + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("[HTTP] Error encoding JSON response: %v\n", err) + } } mux.HandleFunc("/ts6viewer/data", dataHandler) mux.HandleFunc("/ts6viewer/data/", dataHandler) // ----------------------------- - // HTML view (/ts6viewer + /ts6viewer/) + // HTML view endpoint // ----------------------------- viewHandler := func(w http.ResponseWriter, r *http.Request) { ip := getIP(r) + log.Printf("[HTTP] /ts6viewer requested from IP: %s\n", ip) - var data view.ViewerData + var data view.VMTS6Viewer var err error - if allowRequest(ip) { - data, err = getViewerData(&cfg, baseURL, apiKey, serverID) + data, err = getViewerData(&cfg, true) } else { + log.Printf("[HTTP] Rate limit hit for IP: %s\n", ip) data = cacheData } if err != nil { + log.Printf("[HTTP] Error getting viewer data: %v\n", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { - log.Println("Template error:", err) + log.Printf("[HTTP] Template execution error: %v\n", err) } } @@ -111,9 +128,18 @@ func NewRouter(cfg config.Config) http.Handler { mux.HandleFunc("/ts6viewer/", viewHandler) // ----------------------------- - // Health endpoint + // Health check + // ----------------------------- + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + log.Printf("[HTTP] /health check from IP: %s\n", getIP(r)) + w.WriteHeader(http.StatusOK) + }) + + // ----------------------------- + // Root endpoint // ----------------------------- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.Printf("[HTTP] / root requested from IP: %s\n", getIP(r)) w.WriteHeader(http.StatusOK) w.Write([]byte("TS6Viewer is running!")) }) diff --git a/internal/config/config.go b/internal/config/config.go index 91d282d..df5f06c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,15 +6,19 @@ import ( ) type Config struct { - ServerPort string `json:"server_port"` - Theme string `json:"theme"` + ServerPort string `json:"server_port"` + HostConnectionLink string `json:"host_connection_link"` Teamspeak6 struct { - BaseURL string `json:"base_url"` - ApiKey string `json:"api_key"` - ServerID string `json:"server_id"` + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + EnableVoiceStatus string `json:"enable_voice_status"` + ServerID string `json:"server_id"` } `json:"teamspeak6"` + Theme string `json:"theme"` RefreshInterval string `json:"refresh_interval"` } diff --git a/internal/domain/models.go b/internal/domain/models.go deleted file mode 100644 index dda520a..0000000 --- a/internal/domain/models.go +++ /dev/null @@ -1,69 +0,0 @@ -package domain - -type ChannelType int - -const ( - NormalChannel ChannelType = iota // Regular TS6 channel - SolidSpacer // ___ - DashSpacer // --- - DotSpacer // ... - DashDotSpacer // -.- - DashDotDotSpacer // -.. - AlignedSpacer // [cSpacer#], [lSpacer#], [rSpacer#] - RepeatingSpacer // [*spacer#]X - BlankSpacer // Empty spacer like [cSpacer0] -) - -type Aligned int - -const ( - AlignLeft Aligned = iota - AlignCenter - AlignRight -) - -type Channel struct { - ID string - ParentID string - Name string - Topic string - - Type ChannelType - Align Aligned - Repeat bool - - Children []*Channel - Clients []*FullClient -} - -type Client struct { - ID string - Nickname string - ChannelID string -} - -type ClientInfo struct { - MicMuted bool - OutputMuted bool - IsTalking bool -} - -type FullClient struct { - Client - Info *ClientInfo -} - -type Server struct { - Name string - ClientsOnline string - MaxClients string - UptimePretty string - ChannelsOnline string - HostBannerURL string - ClientConnections string -} - -type ViewerData struct { - Server *Server - ChannelTree []*Channel -} diff --git a/internal/mapper/api_to_domain.go b/internal/mapper/api_to_domain.go deleted file mode 100644 index 8bcfcc6..0000000 --- a/internal/mapper/api_to_domain.go +++ /dev/null @@ -1,58 +0,0 @@ -package mapper - -import ( - "ts6-viewer/internal/domain" - "ts6-viewer/internal/ts6" -) - -func MapAPIServer(server *ts6.ServerInfo) *domain.Server { - uptimePretty := domain.MakeUptimePretty(server.Uptime) - - return &domain.Server{ - Name: server.Name, - ClientsOnline: server.ClientsOnline, - MaxClients: server.MaxClients, - UptimePretty: uptimePretty, - ChannelsOnline: server.ChannelsOnline, - HostBannerURL: server.HostBannerURL, - ClientConnections: server.ClientConnections, - } -} - -func MapAPIChannel(api ts6.Channel) *domain.Channel { - chType, align, repeat, cleanName := domain.ParseChannelName(api.Name) - - return &domain.Channel{ - ID: api.CID, - ParentID: api.PID, - Name: cleanName, - Topic: api.Topic, - Type: chType, - Align: align, - Repeat: repeat, - } -} - -func MapAPIClient(c ts6.Client) *domain.Client { - return &domain.Client{ - ID: c.CLID, - Nickname: c.Nickname, - ChannelID: c.CID, - } -} - -func MapAPIClientInfo(info *ts6.ClientInfo) *domain.ClientInfo { - if info == nil { - return &domain.ClientInfo{ - MicMuted: false, - OutputMuted: false, - IsTalking: false, - } - } - - return &domain.ClientInfo{ - MicMuted: info.InputMuted == "1" || info.InputHardware == "1", - OutputMuted: info.OutputMuted == "1", - IsTalking: info.IsTalker == "1", - } -} diff --git a/internal/mapper/domain_to_api.go b/internal/mapper/domain_to_api.go deleted file mode 100644 index ebb604d..0000000 --- a/internal/mapper/domain_to_api.go +++ /dev/null @@ -1,59 +0,0 @@ -package mapper - -import ( - "ts6-viewer/internal/domain" - "ts6-viewer/internal/view" -) - -func MapServerToView(s *domain.Server) *view.ServerView { - return &view.ServerView{ - Name: s.Name, - ClientsOnline: s.ClientsOnline, - MaxClients: s.MaxClients, - UptimePretty: s.UptimePretty, - ChannelsOnline: s.ChannelsOnline, - HostBannerURL: s.HostBannerURL, - ClientConnections: s.ClientConnections, - } -} - -func MapClientToView(c *domain.FullClient) *view.ClientView { - v := &view.ClientView{ - Nickname: c.Nickname, - } - - if c.Info != nil { - v.MicMuted = c.Info.MicMuted - v.OutputMuted = c.Info.OutputMuted - v.IsTalking = c.Info.IsTalking - } - - return v -} - -func MapChannelToView(ch *domain.Channel) *view.ChannelView { - out := &view.ChannelView{ - Name: ch.Name, - Type: ch.Type, - Align: ch.Align, - Repeat: ch.Repeat, - } - - for _, c := range ch.Clients { - out.Clients = append(out.Clients, MapClientToView(c)) - } - - for _, child := range ch.Children { - out.Children = append(out.Children, MapChannelToView(child)) - } - - return out -} - -func MapChannelTreeToView(tree []*domain.Channel) []*view.ChannelView { - out := make([]*view.ChannelView, 0, len(tree)) - for _, ch := range tree { - out = append(out, MapChannelToView(ch)) - } - return out -} diff --git a/internal/ts6/channel.go b/internal/ts6/channel.go index 769c919..03ba577 100644 --- a/internal/ts6/channel.go +++ b/internal/ts6/channel.go @@ -2,47 +2,108 @@ package ts6 import ( "fmt" + "strings" + "ts6-viewer/internal/config" ) -// Channel represents a TeamSpeak channel type Channel struct { - CID string `json:"cid"` - PID string `json:"pid"` - ChannelOrder string `json:"channel_order"` - Name string `json:"channel_name"` - Topic string `json:"channel_topic"` - FlagPermanent string `json:"channel_flag_permanent"` - FlagSemiPermanent string `json:"channel_flag_semi_permanent"` - FlagDefault string `json:"channel_flag_default"` - FlagPassword string `json:"channel_flag_password"` - MaxClients string `json:"channel_maxclients"` - MaxFamilyClients string `json:"channel_maxfamilyclients"` - NeededTalkPower string `json:"channel_needed_talk_power"` + CID string + PID string + ChannelOrder string + Name string + Topic string + FlagPermanent string + FlagSemiPermanent string + FlagDefault string + FlagPassword string + FlagMaxClientsUnlimited string + FlagMaxFamilyClientsUnlimited string + MaxClients string + MaxFamilyClients string + NeededTalkPower string + Codec string + CodecQuality string + TotalClients string + IconID string + SecondsEmpty string } -// ChannelListResponse represents the TS6 API response -type ChannelListResponse struct { - Body []Channel `json:"body"` - Status Status `json:"status"` -} +// GetChannelList retrieves all channels using ServerQuery (SSH) +func GetChannelList(cfg *config.Config, ssh *SSHClient) ([]Channel, error) { -// GetChannelList returns all channels for a virtual server -func GetChannelList(baseURL, apiKey string, serverID string) ([]Channel, error) { - var resp ChannelListResponse - - err := doGET( - baseURL, - apiKey, - fmt.Sprintf("/%s/channellist", serverID), - &resp, - ) + raw, err := ssh.exec("channellist -topic -flags -limits -voice -icon -secondsempty") if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute channellist: %w", err) } - if resp.Status.Code != 0 { - return nil, fmt.Errorf("ts6 error %d: %s", resp.Status.Code, resp.Status.Message) + parts := strings.Split(strings.TrimSpace(raw), "|") + channels := make([]Channel, 0, len(parts)) + + for _, p := range parts { + + fields := strings.Fields(p) + ch := Channel{} + + for _, f := range fields { + if !strings.Contains(f, "=") { + continue + } + + kv := strings.SplitN(f, "=", 2) + key := kv[0] + val := UnescapeTS6(kv[1]) + + switch key { + + case "cid": + ch.CID = val + case "pid": + ch.PID = val + case "channel_order": + ch.ChannelOrder = val + case "channel_name": + ch.Name = val + case "channel_topic": + ch.Topic = val + + case "channel_flag_permanent": + ch.FlagPermanent = val + case "channel_flag_semi_permanent": + ch.FlagSemiPermanent = val + case "channel_flag_default": + ch.FlagDefault = val + case "channel_flag_password": + ch.FlagPassword = val + case "channel_flag_maxclients_unlimited": + ch.FlagMaxClientsUnlimited = val + case "channel_flag_maxfamilyclients_unlimited": + ch.FlagMaxFamilyClientsUnlimited = val + + case "channel_maxclients": + ch.MaxClients = val + case "channel_maxfamilyclients": + ch.MaxFamilyClients = val + + case "channel_needed_talk_power": + ch.NeededTalkPower = val + + case "channel_codec": + ch.Codec = val + case "channel_codec_quality": + ch.CodecQuality = val + case "total_clients": + ch.TotalClients = val + + case "channel_icon_id": + ch.IconID = val + + case "seconds_empty": + ch.SecondsEmpty = val + } + } + + channels = append(channels, ch) } - return resp.Body, nil + return channels, nil } diff --git a/internal/ts6/client.go b/internal/ts6/client.go index 98edb82..d913343 100644 --- a/internal/ts6/client.go +++ b/internal/ts6/client.go @@ -1,57 +1,136 @@ package ts6 import ( - "encoding/json" "fmt" + "strings" + "ts6-viewer/internal/config" ) -// Client represents an online TeamSpeak client type Client struct { - CID string `json:"cid"` - CLID string `json:"clid"` - DatabaseID string `json:"client_database_id"` - Nickname string `json:"client_nickname"` - ClientType string `json:"client_type"` + CLID string + CID string + DatabaseID string + Nickname string + Type string + UniqueIdentifier string + + Away string + AwayMessage string + + InputMuted string + OutputMuted string + OutputOnlyMuted string + InputHardware string + OutputHardware string + TalkPower string + IsTalking string + + ServerGroups string + ChannelGroupID string + + IdleTime string + ConnectionTime string + + Country string + IconID string + + Version string + Platform string } -// ClientListResponse represents the TS6 API response -type ClientListResponse struct { - Body []Client `json:"body"` - Status Status `json:"status"` -} +func GetClientList(cfg *config.Config, ssh *SSHClient) ([]Client, error) { -// GetClientList returns all connected clients for a virtual server -func GetClientList(baseURL, apiKey string, serverID string) ([]Client, error) { - var raw map[string]any + voiceCmd := "" + if cfg.Teamspeak6.EnableVoiceStatus == "true" { + voiceCmd = "-voice" + } - err := doGET( - baseURL, - apiKey, - fmt.Sprintf("/%s/clientlist", serverID), - &raw, - ) + raw, err := ssh.exec("clientlist -uid -away -groups -times -info -country -icon " + voiceCmd) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute clientlist: %w", err) } - var resp ClientListResponse - b, _ := json.Marshal(raw) - json.Unmarshal(b, &resp) + parts := strings.Split(strings.TrimSpace(raw), "|") + clients := make([]Client, 0, len(parts)) - if resp.Status.Code != 0 { - return nil, fmt.Errorf( - "ts6 error %d: %s", - resp.Status.Code, - resp.Status.Message, - ) - } + for _, p := range parts { - filtered := make([]Client, 0, len(resp.Body)) - for _, c := range resp.Body { - if c.DatabaseID != "1" { - filtered = append(filtered, c) + fields := strings.Fields(p) + cl := Client{} + + for _, f := range fields { + if !strings.Contains(f, "=") { + continue + } + + kv := strings.SplitN(f, "=", 2) + key := kv[0] + val := UnescapeTS6(kv[1]) + + switch key { + + case "clid": + cl.CLID = val + case "cid": + cl.CID = val + case "client_database_id": + cl.DatabaseID = val + case "client_nickname": + cl.Nickname = val + case "client_type": + cl.Type = val + case "client_unique_identifier": + cl.UniqueIdentifier = val + + case "client_away": + cl.Away = val + case "client_away_message": + cl.AwayMessage = val + + case "client_input_muted": + cl.InputMuted = val + case "client_output_muted": + cl.OutputMuted = val + case "client_outputonly_muted": + cl.OutputOnlyMuted = val + case "client_input_hardware": + cl.InputHardware = val + case "client_output_hardware": + cl.OutputHardware = val + case "client_talk_power": + cl.TalkPower = val + case "client_is_talking": + cl.IsTalking = val + + case "client_servergroups": + cl.ServerGroups = val + case "client_channel_group_id": + cl.ChannelGroupID = val + + case "client_idle_time": + cl.IdleTime = val + case "client_connection_connected_time": + cl.ConnectionTime = val + + case "client_country": + cl.Country = val + case "client_icon_id": + cl.IconID = val + + case "client_version": + cl.Version = val + case "client_platform": + cl.Platform = val + } } + + // Query Clients rausfiltern + if cl.Type == "1" { + continue + } + + clients = append(clients, cl) } - return filtered, nil + return clients, nil } diff --git a/internal/ts6/clientInfo.go b/internal/ts6/clientInfo.go deleted file mode 100644 index cb33ded..0000000 --- a/internal/ts6/clientInfo.go +++ /dev/null @@ -1,53 +0,0 @@ -package ts6 - -import ( - "encoding/json" - "fmt" -) - -type ClientInfo struct { - CID string `json:"cid"` - CLID string `json:"clid"` - DatabaseID string `json:"client_database_id"` - Nickname string `json:"client_nickname"` - ClientType string `json:"client_type"` - - InputMuted string `json:"client_input_muted"` - InputHardware string `json:"client_input_hardware"` - OutputMuted string `json:"client_output_muted"` - OutputOnlyMuted string `json:"client_outputonly_muted"` - IsTalker string `json:"client_is_talker"` -} - -type ClientInfoResponse struct { - Body ClientInfo `json:"body"` - Status Status `json:"status"` -} - -func GetClientInfo(baseURL, apiKey, serverID, clid string) (*ClientInfo, error) { - var raw map[string]any - - err := doGET( - baseURL, - apiKey, - fmt.Sprintf("/%s/clientinfo?clid=%s", serverID, clid), - &raw, - ) - if err != nil { - return nil, err - } - - var resp ClientInfoResponse - b, _ := json.Marshal(raw) - json.Unmarshal(b, &resp) - - if resp.Status.Code != 0 { - return nil, fmt.Errorf( - "ts6 error %d: %s", - resp.Status.Code, - resp.Status.Message, - ) - } - - return &resp.Body, nil -} diff --git a/internal/ts6/http.go b/internal/ts6/http.go deleted file mode 100644 index 910e640..0000000 --- a/internal/ts6/http.go +++ /dev/null @@ -1,35 +0,0 @@ -package ts6 - -import ( - "encoding/json" - "fmt" - "net/http" - "time" -) - -// doGET performs an authenticated HTTP GET request against TS6 -func doGET(baseURL, apiKey, path string, out any) error { - req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) - if err != nil { - return err - } - - req.Header.Set("x-api-key", apiKey) - req.Header.Set("Accept", "application/json") - - client := &http.Client{ - Timeout: 10 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("http error: %s", resp.Status) - } - - return json.NewDecoder(resp.Body).Decode(out) -} diff --git a/internal/ts6/serverinfo.go b/internal/ts6/serverinfo.go index 362b4ac..109a2db 100644 --- a/internal/ts6/serverinfo.go +++ b/internal/ts6/serverinfo.go @@ -3,67 +3,77 @@ package ts6 import ( "fmt" "strconv" + "strings" + "ts6-viewer/internal/config" ) -// ServerInfo represents basic virtual server information type ServerInfo struct { - ServerID string `json:"virtualserver_id"` - Name string `json:"virtualserver_name"` - Uptime string `json:"virtualserver_uptime"` - ClientsOnline string `json:"virtualserver_clientsonline"` - MaxClients string `json:"virtualserver_maxclients"` - ChannelsOnline string `json:"virtualserver_channelsonline"` - HostBannerURL string `json:"virtualserver_hostbanner_url"` - HostBannerGfxURL string `json:"virtualserver_hostbanner_gfx_url"` - NeededIdentitySecurity string `json:"virtualserver_needed_identity_security_level"` - QueryClientConnections string `json:"virtualserver_query_client_connections"` - ClientConnections string `json:"virtualserver_client_connections"` + ServerID string + Name string + Uptime string + ClientsOnline string + MaxClients string + ChannelsOnline string + HostBannerURL string + HostBannerGfxURL string + NeededIdentitySecurity string + QueryClientConnections string + ClientConnections string } -// ServerInfoResponse represents the TS6 API response -type ServerInfoResponse struct { - Body []ServerInfo `json:"body"` - Status Status `json:"status"` -} - -// GetServerInfo retrieves information about a virtual server -func GetServerInfo(baseURL, apiKey string, serverID string) (*ServerInfo, error) { - var resp ServerInfoResponse - - err := doGET( - baseURL, - apiKey, - fmt.Sprintf("/%s/serverinfo", serverID), - &resp, - ) +func GetServerInfo(cfg *config.Config, c *SSHClient) (*ServerInfo, error) { + raw, err := c.exec("serverinfo") if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute serverinfo: %w", err) } - if resp.Status.Code != 0 { - return nil, fmt.Errorf( - "ts6 error %d: %s", - resp.Status.Code, - resp.Status.Message, - ) - } + blocks := strings.Split(raw, "|") + first := strings.TrimSpace(blocks[0]) + fields := strings.Fields(first) - if len(resp.Body) != 1 { - return nil, fmt.Errorf( - "unexpected serverinfo result count: %d", - len(resp.Body), - ) - } + info := &ServerInfo{} - info := resp.Body[0] + for _, f := range fields { + if !strings.Contains(f, "=") { + continue + } + + kv := strings.SplitN(f, "=", 2) + key := kv[0] + val := UnescapeTS6(kv[1]) + + switch key { + case "virtualserver_id": + info.ServerID = val + case "virtualserver_name": + info.Name = val + case "virtualserver_uptime": + info.Uptime = val + case "virtualserver_clientsonline": + info.ClientsOnline = val + case "virtualserver_maxclients": + info.MaxClients = val + case "virtualserver_channelsonline": + info.ChannelsOnline = val + case "virtualserver_hostbanner_url": + info.HostBannerURL = val + case "virtualserver_hostbanner_gfx_url": + info.HostBannerGfxURL = val + case "virtualserver_needed_identity_security_level": + info.NeededIdentitySecurity = val + case "virtualserver_query_client_connections": + info.QueryClientConnections = val + case "virtualserver_client_connections": + info.ClientConnections = val + } + } if n, err := strconv.Atoi(info.ClientsOnline); err == nil { - n-- - if n < 0 { - n = 0 + if n > 0 { + n-- } info.ClientsOnline = strconv.Itoa(n) } - return &info, nil + return info, nil } diff --git a/internal/ts6/ssh.go b/internal/ts6/ssh.go new file mode 100644 index 0000000..f9d4826 --- /dev/null +++ b/internal/ts6/ssh.go @@ -0,0 +1,421 @@ +package ts6 + +import ( + "bufio" + "fmt" + "io" + "log" + "math/rand" + "net" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "ts6-viewer/internal/config" + + "golang.org/x/crypto/ssh" +) + +// SSHClient represents a persistent SSH ServerQuery connection. +type SSHClient struct { + cfg *config.Config + serverID string + + ssh *ssh.Client + session *ssh.Session + stdin io.WriteCloser + reader *bufio.Reader + + mu sync.Mutex // protects command execution and reconnect +} + +var ( + globalClient *SSHClient + globalMu sync.Mutex + + floodWaitRegex = regexp.MustCompile(`wait (\d+)ms`) + errIDRegex = regexp.MustCompile(`^error id=(\d+)`) +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// GetPersistentClient returns a singleton SSH connection. +func GetPersistentClient(cfg *config.Config, serverID string) (*SSHClient, error) { + globalMu.Lock() + defer globalMu.Unlock() + + if globalClient != nil && !globalClient.IsClosed() { + log.Println("[SSH] Reusing existing persistent connection") + return globalClient, nil + } + + log.Println("[SSH] Creating new persistent SSH connection") + + client, err := newSSHClientWithUse(cfg, serverID) + if err != nil { + log.Printf("[SSH] Connection creation failed: %v\n", err) + return nil, err + } + + globalClient = client + log.Println("[SSH] Persistent SSH connection established") + + return globalClient, nil +} + +// newSSHClientWithUse establishes a new SSH connection and selects the server. +func newSSHClientWithUse(cfg *config.Config, serverID string) (*SSHClient, error) { + client, err := newSSHClientBase(cfg) + if err != nil { + return nil, err + } + + if err := client.Use(serverID); err != nil { + client.Close() + return nil, err + } + + client.cfg = cfg + client.serverID = serverID + + go client.keepAlive() + + return client, nil +} + +// Use selects the virtual server by ID. +func (c *SSHClient) Use(serverID string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.IsClosed() { + return fmt.Errorf("ssh connection is closed") + } + + log.Printf("[SSH] Selecting virtual server: %s\n", serverID) + + _, err := c.stdin.Write([]byte(fmt.Sprintf("use %s\n", serverID))) + if err != nil { + return fmt.Errorf("failed to send use command: %w", err) + } + + timeout := time.After(5 * time.Second) + + for { + select { + case <-timeout: + return fmt.Errorf("timeout while waiting for use response") + default: + line, err := c.reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read use response: %w", err) + } + + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "error id=") { + if line != "error id=0 msg=ok" { + return fmt.Errorf("use command failed: %s", line) + } + log.Println("[SSH] Virtual server selected successfully") + return nil + } + } + } +} + +// newSSHClientBase creates a raw SSH connection and performs login. +func newSSHClientBase(cfg *config.Config) (*SSHClient, error) { + host := cfg.Teamspeak6.Host + port := cfg.Teamspeak6.Port + user := cfg.Teamspeak6.User + password := cfg.Teamspeak6.Password + + addr := net.JoinHostPort(host, port) + + log.Printf("[SSH] Connecting to %s\n", addr) + + sshConfig := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.Password(password)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + + rawConn, err := dialer.Dial("tcp", addr) + if err != nil { + log.Printf("[SSH] TCP dial failed: %v\n", err) + return nil, err + } + + sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, addr, sshConfig) + if err != nil { + rawConn.Close() + log.Printf("[SSH] SSH handshake failed: %v\n", err) + return nil, err + } + + client := ssh.NewClient(sshConn, chans, reqs) + + session, err := client.NewSession() + if err != nil { + client.Close() + return nil, err + } + + stdin, err := session.StdinPipe() + if err != nil { + client.Close() + return nil, err + } + + stdout, err := session.StdoutPipe() + if err != nil { + client.Close() + return nil, err + } + + if err := session.Shell(); err != nil { + client.Close() + return nil, err + } + + c := &SSHClient{ + ssh: client, + session: session, + stdin: stdin, + reader: bufio.NewReader(stdout), + } + + log.Println("[SSH] Waiting for welcome message") + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + c.Close() + return nil, err + } + if strings.Contains(line, "Welcome") || strings.Contains(line, "TS3") { + break + } + } + + log.Println("[SSH] Sending login command") + + if _, err := c.stdin.Write([]byte(fmt.Sprintf("login %s %s\n", user, password))); err != nil { + c.Close() + return nil, err + } + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + c.Close() + return nil, err + } + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "error id=") { + if line != "error id=0 msg=ok" { + c.Close() + return nil, fmt.Errorf("login failed: %s", line) + } + break + } + } + + log.Printf("[SSH] Login successful to %s\n", addr) + + return c, nil +} + +// keepAlive sends periodic version commands to prevent idle timeout. +func (c *SSHClient) keepAlive() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if c.IsClosed() { + return + } + log.Println("[SSH] Sending keepalive ping") + _, err := c.Exec("version") + if err != nil { + log.Printf("[SSH] Keepalive failed: %v. Attempting reconnect\n", err) + _ = c.reconnect() + } + } +} + +// Exec executes a ServerQuery command safely. +func (c *SSHClient) Exec(cmd string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + log.Printf("[SSH] Executing command: %s\n", cmd) + return c.execSafe(cmd) +} + +// exec sends a raw command and reads the response. +func (c *SSHClient) exec(cmd string) (string, error) { + _, err := c.stdin.Write([]byte(cmd + "\n")) + if err != nil { + return "", err + } + + var lines []string + var last string + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + lines = append(lines, line) + last = line + if strings.HasPrefix(line, "error id=") { + break + } + } + + raw := strings.Join(lines, "\n") + + if strings.HasPrefix(last, "error id=") && last != "error id=0 msg=ok" { + return raw, fmt.Errorf("%s", last) + } + + return raw, nil +} + +// execSafe handles flood and reconnect logic. +func (c *SSHClient) execSafe(cmd string) (string, error) { + const ( + maxFloodRetries = 5 + maxReconnects = 2 + maxWaitMs = 10000 + jitterMs = 250 + ) + + floodRetries := 0 + reconnects := 0 + + for { + raw, err := c.exec(cmd) + if err == nil { + return raw, nil + } + + if m := errIDRegex.FindStringSubmatch(err.Error()); len(m) == 2 { + id, _ := strconv.Atoi(m[1]) + if id == 524 { + wait := 1000 + if match := floodWaitRegex.FindStringSubmatch(err.Error()); len(match) == 2 { + if ms, convErr := strconv.Atoi(match[1]); convErr == nil { + wait = ms + } + } + + backoff := wait * (1 << floodRetries) + if backoff > maxWaitMs { + backoff = maxWaitMs + } + + jitter := rand.Intn(jitterMs + 1) + sleepMs := backoff + jitter + + log.Printf("[SSH] Flood detected. Backing off %d ms\n", sleepMs) + + time.Sleep(time.Duration(sleepMs) * time.Millisecond) + + floodRetries++ + if floodRetries >= maxFloodRetries { + if reconnects >= maxReconnects { + return "", fmt.Errorf("max flood retries reached: %w", err) + } + log.Println("[SSH] Flood retry limit reached. Reconnecting") + c.Close() + time.Sleep(300 * time.Millisecond) + _ = c.reconnect() + floodRetries = 0 + reconnects++ + } + continue + } + return raw, err + } + + if isConnectionError(err) { + if reconnects >= maxReconnects { + return "", err + } + log.Printf("[SSH] Connection error detected: %v. Reconnecting\n", err) + c.Close() + time.Sleep(300 * time.Millisecond) + _ = c.reconnect() + reconnects++ + continue + } + + return "", err + } +} + +// reconnect recreates the SSH connection. +func (c *SSHClient) reconnect() error { + globalMu.Lock() + defer globalMu.Unlock() + + log.Println("[SSH] Attempting reconnect") + + if c.cfg == nil { + return fmt.Errorf("missing configuration for reconnect") + } + + newClient, err := newSSHClientWithUse(c.cfg, c.serverID) + if err != nil { + log.Printf("[SSH] Reconnect failed: %v\n", err) + return err + } + + globalClient = newClient + log.Println("[SSH] Reconnect successful") + + return nil +} + +// isConnectionError detects network-level failures. +func isConnectionError(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "eof") || + strings.Contains(msg, "broken pipe") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "use of closed network connection") +} + +// IsClosed checks whether the client is closed. +func (c *SSHClient) IsClosed() bool { + return c == nil || c.ssh == nil +} + +// Close terminates the SSH session. +func (c *SSHClient) Close() { + log.Println("[SSH] Closing SSH connection") + + if c.session != nil { + _ = c.session.Close() + c.session = nil + } + if c.ssh != nil { + _ = c.ssh.Close() + c.ssh = nil + } +} diff --git a/internal/ts6/status.go b/internal/ts6/status.go deleted file mode 100644 index 3f856d2..0000000 --- a/internal/ts6/status.go +++ /dev/null @@ -1,7 +0,0 @@ -package ts6 - -// Status represents the standard TS6 API status block -type Status struct { - Code int `json:"code"` - Message string `json:"message"` -} diff --git a/internal/ts6/utilities.go b/internal/ts6/utilities.go new file mode 100644 index 0000000..8f9a308 --- /dev/null +++ b/internal/ts6/utilities.go @@ -0,0 +1,22 @@ +package ts6 + +import ( + "strings" +) + +func UnescapeTS6(s string) string { + replacer := strings.NewReplacer( + `\s`, " ", + `\p`, "|", + `\/`, "/", + `\:`, ":", + `\.`, ".", + `\(`, "(", + `\)`, ")", + `\?`, "?", + `\!`, "!", + `\-`, "-", + `\_`, "_", + ) + return replacer.Replace(s) +} diff --git a/internal/view/models.go b/internal/view/models.go deleted file mode 100644 index 5962a44..0000000 --- a/internal/view/models.go +++ /dev/null @@ -1,40 +0,0 @@ -package view - -import ( - "ts6-viewer/internal/domain" -) - -type ViewerData struct { - Server *ServerView - ChannelTree []*ChannelView - Theme string - RefreshInterval string -} - -type ServerView struct { - Name string - ClientsOnline string - MaxClients string - UptimePretty string - ChannelsOnline string - HostBannerURL string - ClientConnections string -} - -type ClientView struct { - Nickname string - Platform string - Version string - MicMuted bool - OutputMuted bool - IsTalking bool -} - -type ChannelView struct { - Name string - Type domain.ChannelType - Align domain.Aligned - Repeat bool - Clients []*ClientView - Children []*ChannelView -} diff --git a/internal/domain/domain.go b/internal/view/utilities.go similarity index 71% rename from internal/domain/domain.go rename to internal/view/utilities.go index 0008f41..414230c 100644 --- a/internal/domain/domain.go +++ b/internal/view/utilities.go @@ -1,4 +1,4 @@ -package domain +package view import ( "fmt" @@ -9,7 +9,6 @@ import ( var reCmd = regexp.MustCompile(`(?i)\[([clr]|\*)?spacer([^\]]*?)\]`) -// ParseChannelName parses TeamSpeak spacer syntax. func ParseChannelName(name string) (ChannelType, Aligned, bool, string) { name = strings.TrimSpace(name) @@ -73,27 +72,3 @@ func MakeUptimePretty(secondsStr string) string { return fmt.Sprintf("%dD %02d:%02d:%02d", days, hours, minutes, seconds) } - -func BuildChannelTree(channels []*Channel, clients []*FullClient) []*Channel { - lookup := make(map[string]*Channel) - for _, ch := range channels { - lookup[ch.ID] = ch - } - - for _, cl := range clients { - if ch, ok := lookup[cl.ChannelID]; ok { - ch.Clients = append(ch.Clients, cl) - } - } - - var roots []*Channel - for _, ch := range channels { - if ch.ParentID == "0" { - roots = append(roots, ch) - } else if parent, ok := lookup[ch.ParentID]; ok { - parent.Children = append(parent.Children, ch) - } - } - - return roots -} diff --git a/internal/view/vm-builder.go b/internal/view/vm-builder.go new file mode 100644 index 0000000..35af5b5 --- /dev/null +++ b/internal/view/vm-builder.go @@ -0,0 +1,98 @@ +package view + +import ( + "sort" + "strconv" + "ts6-viewer/internal/config" + "ts6-viewer/internal/ts6" +) + +type ChannelType int + +const ( + NormalChannel ChannelType = iota // Regular TS6 channel + SolidSpacer // ___ + DashSpacer // --- + DotSpacer // ... + DashDotSpacer // -.- + DashDotDotSpacer // -.. + AlignedSpacer // [cSpacer#], [lSpacer#], [rSpacer#] + RepeatingSpacer // [*spacer#]X + BlankSpacer // Empty spacer like [cSpacer0] +) + +type Aligned int + +const ( + AlignLeft Aligned = iota + AlignCenter + AlignRight +) + +func BuildVMChannels(channels []ts6.Channel, clients []ts6.Client) []*VMChannel { + // Channels + viewMap := make(map[string]*VMChannel) + for _, ch := range channels { + viewMap[ch.CID] = BuildVMChannel(ch) + } + + // Clients + for _, c := range clients { + if vch, ok := viewMap[c.CID]; ok { + vch.Clients = append(vch.Clients, BuildVMClient(c)) + } + } + + // Sort clients in each channel alphabetically + for _, vch := range viewMap { + sort.Slice(vch.Clients, func(i, j int) bool { + return vch.Clients[i].Nickname < vch.Clients[j].Nickname + }) + } + + // Build tree + var roots []*VMChannel + for _, ch := range channels { + vch := viewMap[ch.CID] + if ch.PID == "0" { + roots = append(roots, vch) + } else if parent, ok := viewMap[ch.PID]; ok { + parent.Children = append(parent.Children, vch) + } + } + + return roots +} + +func BuildVMServer(cfg *config.Config, info *ts6.ServerInfo, clients []ts6.Client) *VMServer { + return &VMServer{ + Name: info.Name, + ClientsOnline: strconv.Itoa(len(clients)), + MaxClients: info.MaxClients, + UptimePretty: MakeUptimePretty(info.Uptime), + ChannelsOnline: info.ChannelsOnline, + HostBannerURL: info.HostBannerURL, + HostConnectionLink: cfg.HostConnectionLink, + ClientConnections: info.ClientConnections, + } +} + +func BuildVMChannel(ch ts6.Channel) *VMChannel { + chType, align, repeat, cleanName := ParseChannelName(ch.Name) + + return &VMChannel{ + Name: cleanName, + Type: chType, + Align: align, + Repeat: repeat, + } +} + +func BuildVMClient(c ts6.Client) *VMClient { + return &VMClient{ + Nickname: c.Nickname, + MicMuted: c.InputMuted == "1" || c.InputHardware == "0", + OutputMuted: c.OutputMuted == "1", + IsTalking: c.IsTalking == "1", + } +} diff --git a/internal/view/vm.go b/internal/view/vm.go new file mode 100644 index 0000000..e53229f --- /dev/null +++ b/internal/view/vm.go @@ -0,0 +1,37 @@ +package view + +type VMTS6Viewer struct { + VMServer *VMServer + VMChannels []*VMChannel + Theme string + RefreshInterval string +} + +type VMServer struct { + Name string + ClientsOnline string + MaxClients string + UptimePretty string + ChannelsOnline string + HostBannerURL string + HostConnectionLink string + ClientConnections string +} + +type VMClient struct { + Nickname string + Platform string + Version string + MicMuted bool + OutputMuted bool + IsTalking bool +} + +type VMChannel struct { + Name string + Type ChannelType + Align Aligned + Repeat bool + Clients []*VMClient + Children []*VMChannel +} diff --git a/internal/web/static/dark.css b/internal/web/static/dark.css index 68e6b22..163e732 100644 --- a/internal/web/static/dark.css +++ b/internal/web/static/dark.css @@ -58,14 +58,14 @@ h1, h2 { white-space: nowrap; } -.banner-url { +.banner-url, .host-connection-link { display: block; width: 100%; text-align: center; padding: 4px 0; } -.banner-url a { +.banner-url a, .host-connection-link a { display: inline-block; color: #6aa9ff; text-decoration: none; @@ -106,15 +106,36 @@ h1, h2 { padding-left: 26px; } -.status-dot { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - width: 12px; - height: 12px; - background-color: #4CAF50; - border-radius: 50%; +.status-online { + color: #4CAF50; + margin-right: 6px; + font-size: 12px; + padding-left: 1px; + padding-right: 0.5px; +} + +.status-mic { + color: #888; + margin-right: 6px; + font-size: 12px; +} + +.status-mic::before { + color: #d9534f; +} + +.status-audio { + color: #888; + margin-right: 6px; + font-size: 12px; +} + +.status-audio::before { + color: #d9534f; +} + +.client-name { + vertical-align: middle; } .spacer { @@ -132,9 +153,9 @@ h1, h2 { line-height: 16px; } -.spacer-left { text-align: left; } +.spacer-left { text-align: left; } .spacer-center { text-align: center; } -.spacer-right { text-align: right; } +.spacer-right { text-align: right; } .blank-spacer { height: 16px; @@ -166,11 +187,6 @@ h1, h2 { } @media (max-width: 600px) { - .status-dot { - width: 12px; - height: 12px; - } - .server-info { max-width: 90%; font-size: 16px; diff --git a/internal/web/static/light.css b/internal/web/static/light.css index f7fc85c..c22e518 100644 --- a/internal/web/static/light.css +++ b/internal/web/static/light.css @@ -63,14 +63,14 @@ h1, h2 { white-space: nowrap; } -.banner-url { +.banner-url, .host-connection-link { display: block; width: 100%; text-align: center; padding: 4px 0; } -.banner-url a { +.banner-url a, .host-connection-link a { display: inline-block; color: #6aa9ff; text-decoration: none; @@ -106,15 +106,36 @@ h1, h2 { padding-left: 26px; } -.status-dot { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - width: 12px; - height: 12px; - background-color: #4CAF50; - border-radius: 50%; +.status-online { + color: #4CAF50; + margin-right: 6px; + font-size: 12px; + padding-left: 1px; + padding-right: 0.5px; +} + +.status-mic { + color: #777; + margin-right: 6px; + font-size: 12px; +} + +.status-mic::before { + color: #d9534f; +} + +.status-audio { + color: #777; + margin-right: 6px; + font-size: 12px; +} + +.status-audio::before { + color: #d9534f; +} + +.client-name { + vertical-align: middle; } .spacer { @@ -132,9 +153,9 @@ h1, h2 { line-height: 16px; } -.spacer-left { text-align: left; } +.spacer-left { text-align: left; } .spacer-center { text-align: center; } -.spacer-right { text-align: right; } +.spacer-right { text-align: right; } .blank-spacer { height: 16px; @@ -166,11 +187,6 @@ h1, h2 { } @media (max-width: 600px) { - .status-dot { - width: 12px; - height: 12px; - } - .server-info { max-width: 90%; font-size: 16px; diff --git a/internal/web/static/ts6viewer.js b/internal/web/static/ts6viewer.js index deee978..83e2567 100644 --- a/internal/web/static/ts6viewer.js +++ b/internal/web/static/ts6viewer.js @@ -31,7 +31,7 @@ setInterval(() => { counter = refreshTime; refreshText.textContent = counter; sessionStorage.setItem("refreshCounter", counter); - fetchViewerData(); + fetchViewerData(true); } }, 1000); @@ -111,41 +111,64 @@ function debug(msg) { box.innerHTML += msg + "
"; } - // ========================================== // Fetch viewer data from backend // ========================================== -async function fetchViewerData() { +async function fetchViewerData(force = false) { + const url = force ? "/ts6viewer/data?force=1" : "/ts6viewer/data"; + try { - const response = await fetch("/ts6viewer/data"); + const response = await fetch(url); const data = await response.json(); - updateServerInfo(data.Server); - updateChannelTree(data.ChannelTree); - + updateServerInfo(data.VMServer); + updateChannelTree(data.VMChannels); updateAllSpacers(); } catch (err) { console.error("Polling error:", err); } } - // ========================================== // Update server info box // ========================================== -function updateServerInfo(server) { - document.querySelector(".server-info").innerHTML = ` -

${server.Name}

+function updateServerInfo(vmServer) { + let bannerHtml = ""; + if (vmServer.HostBannerURL && vmServer.HostBannerURL.trim() !== "") { + bannerHtml = ` + `; + } -
User: ${server.ClientsOnline} / ${server.MaxClients}
-
Client Connections: ${server.ClientConnections}
-
Uptime: ${server.UptimePretty}
-
ChannelsOnline: ${server.ChannelsOnline}
- + let serverNameHtml = ""; + if (vmServer.HostConnectionLink && vmServer.HostConnectionLink.trim() !== "") { + serverNameHtml = ` +

+ + ${vmServer.Name} + +

`; + } else { + serverNameHtml = `

${vmServer.Name}

`; + } + + document.querySelector(".server-info").innerHTML = ` + ${serverNameHtml} + +
User: ${vmServer.ClientsOnline} / ${vmServer.MaxClients}
+
Client Connections: ${vmServer.ClientConnections}
+
Uptime: ${vmServer.UptimePretty}
+
ChannelsOnline: ${vmServer.ChannelsOnline}
+ + ${bannerHtml} `; } - // ========================================== // Render channel tree // ========================================== @@ -188,13 +211,25 @@ function renderChannel(ch) { } html += '>' + ch.Name + ''; - + if (ch.Clients && ch.Clients.length > 0) { html += '
'; for (const c of ch.Clients) { - html += '
' + - c.Nickname + + let icon = ''; + + if (c.OutputMuted) { + icon = ''; + } else if (c.MicMuted) { + icon = ''; + } else { + icon = ''; + } + + html += '
' + + icon + + '' + c.Nickname + '' + '
'; + } html += '
'; } @@ -210,7 +245,6 @@ function renderChannel(ch) { return html; } - // ========================================== // Initial load // ========================================== diff --git a/internal/web/templates/ts6viewer.html b/internal/web/templates/ts6viewer.html index 88ccb17..301637d 100644 --- a/internal/web/templates/ts6viewer.html +++ b/internal/web/templates/ts6viewer.html @@ -36,7 +36,11 @@ - + @@ -46,17 +50,31 @@
-

{{.Server.Name}}

+ {{ if .VMServer.HostConnectionLink }} +

+ + {{ .VMServer.Name }} + +

+ {{ else }} +

{{ .VMServer.Name }}

+ {{ end }} -
User: {{.Server.ClientsOnline}} / {{.Server.MaxClients}}
-
Client Connections: {{.Server.ClientConnections}}
-
Uptime: {{.Server.UptimePretty}}
-
ChannelsOnline: {{.Server.ChannelsOnline}}
- +
User: {{.VMServer.ClientsOnline}} / {{.VMServer.MaxClients}}
+
Client Connections: {{.VMServer.ClientConnections}}
+
Uptime: {{.VMServer.UptimePretty}}
+
ChannelsOnline: {{.VMServer.ChannelsOnline}}
+ {{ if .VMServer.HostBannerURL }} + + {{ end }}
- {{range .ChannelTree}} + {{range .VMChannels}} {{template "channel" .}} {{end}}
diff --git a/light.png b/light.png index 8c3660d..b087eb5 100644 Binary files a/light.png and b/light.png differ