Added fontawesome

Added detail client info (voice)
Added host connection link
Added compose.yml
Adjusted Dockerfile
Adjusted entrypoint.sh
Adjusted README.md
This commit is contained in:
Maxallica 2026-02-12 18:16:00 +01:00
parent b678f8c4bf
commit cdbfe86d32
31 changed files with 1281 additions and 729 deletions

View file

@ -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"]

161
README.md
View file

@ -1,22 +1,23 @@
# TS6 Viewer
TS6 Viewer
<p align="center">
<img src="dark.png" alt="Dark Theme" width="45%">
<img src="light.png" alt="Light Theme" width="45%">
</p>
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 clientside autorefreshing
- 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 fullwidth channel support
- Caching + ratelimit protection
- Works on Windows, Linux, and Dockerfriendly 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://<ip>: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 — multistage 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 readytouse 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)\://\<ip\>:\<port\>/ts6viewer
## Navigate to the TS6 Viewer page
http(s)\/\/\<ip\>:\<port\>/ts6viewer
---
## ❤️ Made with love in Germany
## ❤️ Made with love in Germany

View file

@ -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'."
}
}

23
compose.yml Normal file
View file

@ -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

BIN
dark.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before After
Before After

View file

@ -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"

4
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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!"))
})

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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",
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

421
internal/ts6/ssh.go Normal file
View file

@ -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
}
}

View file

@ -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"`
}

22
internal/ts6/utilities.go Normal file
View file

@ -0,0 +1,22 @@
package ts6
import (
"strings"
)
func UnescapeTS6(s string) string {
replacer := strings.NewReplacer(
`\s`, " ",
`\p`, "|",
`\/`, "/",
`\:`, ":",
`\.`, ".",
`\(`, "(",
`\)`, ")",
`\?`, "?",
`\!`, "!",
`\-`, "-",
`\_`, "_",
)
return replacer.Replace(s)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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",
}
}

37
internal/view/vm.go Normal file
View file

@ -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
}

View file

@ -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;

View file

@ -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;

View file

@ -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 + "<br>";
}
// ==========================================
// 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 = `
<h1 id='server-name'>${server.Name}</h1>
function updateServerInfo(vmServer) {
let bannerHtml = "";
if (vmServer.HostBannerURL && vmServer.HostBannerURL.trim() !== "") {
bannerHtml = `
<div>
<div class="banner-url">
<a href="${vmServer.HostBannerURL}">
${vmServer.HostBannerURL}
</a>
</div>
</div>`;
}
<div><span>User: </span> ${server.ClientsOnline} / ${server.MaxClients}</div>
<div><span>Client Connections:</span> ${server.ClientConnections}</div>
<div><span>Uptime:</span> ${server.UptimePretty}</div>
<div><span>ChannelsOnline:</span> ${server.ChannelsOnline}</div>
<div><div class='.banner-url'><a href="${server.HostBannerURL}">${server.HostBannerURL}</a></div></div>
let serverNameHtml = "";
if (vmServer.HostConnectionLink && vmServer.HostConnectionLink.trim() !== "") {
serverNameHtml = `
<h1 id="server-name">
<a href="ts3server://${vmServer.HostConnectionLink}">
${vmServer.Name}
</a>
</h1>`;
} else {
serverNameHtml = `<h1 id="server-name">${vmServer.Name}</h1>`;
}
document.querySelector(".server-info").innerHTML = `
${serverNameHtml}
<div><span>User: </span> ${vmServer.ClientsOnline} / ${vmServer.MaxClients}</div>
<div><span>Client Connections:</span> ${vmServer.ClientConnections}</div>
<div><span>Uptime:</span> ${vmServer.UptimePretty}</div>
<div><span>ChannelsOnline:</span> ${vmServer.ChannelsOnline}</div>
${bannerHtml}
`;
}
// ==========================================
// Render channel tree
// ==========================================
@ -188,13 +211,25 @@ function renderChannel(ch) {
}
html += '>' + ch.Name + '</div>';
if (ch.Clients && ch.Clients.length > 0) {
html += '<div class="children">';
for (const c of ch.Clients) {
html += '<div class="row client"><span class="status-dot"></span>' +
c.Nickname +
let icon = '<i class="fa-solid fa-circle status-online"></i>';
if (c.OutputMuted) {
icon = '<i class="fa-solid fa-volume-xmark status-audio"></i>';
} else if (c.MicMuted) {
icon = '<i class="fa-solid fa-microphone-slash status-mic"></i>';
} else {
icon = '<i class="fa-solid fa-circle status-online"></i>';
}
html += '<div class="row client">' +
icon +
'<span class="client-name">' + c.Nickname + '</span>' +
'</div>';
}
html += '</div>';
}
@ -210,7 +245,6 @@ function renderChannel(ch) {
return html;
}
// ==========================================
// Initial load
// ==========================================

View file

@ -36,7 +36,11 @@
<link rel="stylesheet" href="/static/{{.Theme}}.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
</head>
<body>
@ -46,17 +50,31 @@
</button>
<div class="server-info">
<h1 id="server-name">{{.Server.Name}}</h1>
{{ if .VMServer.HostConnectionLink }}
<h1 id="server-name">
<a href="ts3server://{{ .VMServer.HostConnectionLink }}">
{{ .VMServer.Name }}
</a>
</h1>
{{ else }}
<h1 id="server-name">{{ .VMServer.Name }}</h1>
{{ end }}
<div><span>User: </span> {{.Server.ClientsOnline}} / {{.Server.MaxClients}}</div>
<div><span>Client Connections:</span> {{.Server.ClientConnections}}</div>
<div><span>Uptime:</span> {{.Server.UptimePretty}}</div>
<div><span>ChannelsOnline:</span> {{.Server.ChannelsOnline}}</div>
<div><div class=".banner-url"><a href="{{.Server.HostBannerURL}}">{{.Server.HostBannerURL}}</a></div></div>
<div><span>User: </span> {{.VMServer.ClientsOnline}} / {{.VMServer.MaxClients}}</div>
<div><span>Client Connections:</span> {{.VMServer.ClientConnections}}</div>
<div><span>Uptime:</span> {{.VMServer.UptimePretty}}</div>
<div><span>ChannelsOnline:</span> {{.VMServer.ChannelsOnline}}</div>
{{ if .VMServer.HostBannerURL }}
<div>
<div class="banner-url">
<a href="{{ .VMServer.HostBannerURL }}">{{ .VMServer.HostBannerURL }}</a>
</div>
</div>
{{ end }}
</div>
<div id="channels">
{{range .ChannelTree}}
{{range .VMChannels}}
{{template "channel" .}}
{{end}}
</div>

BIN
light.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before After
Before After