first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 10:56:13 +01:00
commit fccfe2a4c8
26 files changed
+1819

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Minimum 32 random characters, e.g. openssl rand -base64 48
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
LISTEN_ADDR=0.0.0.0:8080
HTTPS_ONLY=true
# Origins allowed to embed /embed/channel in an iframe. Space-separated, origins only.
FRAME_ANCESTORS=https://www.example.org
# Set only reverse-proxy networks you operate. Leave empty if app is directly exposed.
TRUSTED_PROXIES=172.16.0.0/12
TS_QUERY_HOST=127.0.0.1
TS_QUERY_PORT=10011
TS_QUERY_USER=CHANGE_ME
TS_QUERY_PASSWORD=CHANGE_ME
TS_VOICE_HOST=linuxiarz.pl
TS_VOICE_PORT=9987
TS_CHANNEL_ADMIN_GID=5
TS_QUERY_TIMEOUT=3s
RRDTOOL=rrdtool
RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd
GRAPH_DIR=/var/lib/ts3monolith/graphs
STATS_INTERVAL=60s
+2
View File
@@ -0,0 +1,2 @@
data
.env
+11
View File
@@ -0,0 +1,11 @@
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go test ./... && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" -o /out/ts3monolith ./cmd/ts3monolith
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata rrdtool && addgroup -g 10001 app && adduser -D -H -u 10001 -G app app
COPY --from=build /out/ts3monolith /usr/local/bin/ts3monolith
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/ts3monolith"]
+7
View File
@@ -0,0 +1,7 @@
.PHONY: test build
test:
go test ./...
build:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" -o bin/ts3monolith ./cmd/ts3monolith
+64
View File
@@ -0,0 +1,64 @@
# TS3 monolith (Go)
Jedna aplikacja zastępująca stare `sk.linuxiarz.pl` i `ts3stats`.
## Zachowana funkcjonalność
- wykrycie klienta TeamSpeak po IP widzianym przez HTTP i ServerQuery;
- utworzenie permanentnego kanału z nazwą, tematem i opcjonalnym hasłem;
- `setclientchannelgroup` dla wskazanego `client_database_id`;
- `clientmove` wskazanego `clid` do nowego kanału;
- obecna baza RRD `online` i wykresy: 12h, 24h, 72h, tydzień, miesiąc, rok;
- formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie.
Przy jednym kliencie pod danym IP wszystko działa automatycznie. Jeżeli kilka klientów TS3 ma ten sam publiczny IP (NAT), aplikacja prosi o wybór nicku i wysyła 6-cyfrowy kod prywatną wiadomością TS3. Dzięki temu nie przenosi przypadkowej osoby i nie nadaje jej Channel Admina.
## Uruchomienie
1. Skopiuj `.env.example` do `.env` i ustaw sekrety oraz dozwoloną domenę iframe.
2. Zachowaj `data/linuxiarz.rrd`. To kopia dotychczasowej bazy z archiwum.
3. Zapewnij zapis do katalogu `data` dla UID 10001 przy Dockerze: `chown -R 10001:10001 data`.
4. Uruchom: `docker compose up -d --build`.
5. Reverse proxy kieruje HTTPS do `127.0.0.1:8080`.
Przykład iframe:
```html
<iframe
src="https://app.example.org/embed/channel"
title="Utwórz kanał TeamSpeak"
loading="lazy"
referrerpolicy="no-referrer"
style="width:100%;min-height:620px;border:0">
</iframe>
```
`FRAME_ANCESTORS` musi zawierać origin strony nadrzędnej, np. `https://www.example.org`.
## TeamSpeak ServerQuery
Najbezpieczniej uruchamiać aplikację na tym samym hoście lub w prywatnej sieci z TS3 i ustawić `TS_QUERY_HOST=127.0.0.1`/adres prywatny. Nie wystawiaj portu 10011 publicznie. Query powinien mieć tylko prawa potrzebne do: `clientlist -ip -uid`, `channelcreate`, `setclientchannelgroup`, `clientmove`, `sendtextmessage`, `channeldelete` (rollback).
## RRD
Aplikacja używa systemowego `rrdtool`, a nie biblioteki Go, więc istniejący plik RRD pozostaje bez migracji. Co 60 s liczona jest liczba klientów `client_type=0` na wybranym virtual serverze i zapisywana do DS `online`. Błąd ServerQuery nie zapisuje sztucznego zera.
Jeżeli docelowy serwer ma inną architekturę/endianness niż źródłowy x86_64 little-endian, przenieś RRD przez `rrdtool dump`/`rrdtool restore` zamiast kopiowania binarnego pliku.
## Bezpieczeństwo / fingerprinting
- brak PHP, frameworków frontendowych, CDN i nagłówka `X-Powered-By`;
- standardowy `net/http` nie dodaje nagłówka `Server`;
- CSP z kontrolowanym `frame-ancestors`, HSTS, `nosniff`, restrykcyjne Permissions-Policy;
- nagłówki proxy są ufane wyłącznie z `TRUSTED_PROXIES`;
- stateless HMAC tokeny formularzy i klientów, sprawdzanie `Origin`, limity request body i rate limiting;
- sekrety tylko przez środowisko, brak danych dostępowych w repo;
- rollback kanału, jeśli nadanie grupy lub przeniesienie klienta się nie powiedzie;
- kontener non-root, `cap_drop: ALL`, `no-new-privileges`, read-only root filesystem;
- build z `-trimpath` i usuniętymi symbolami/debug info.
Nie da się zagwarantować, że stos technologiczny będzie całkowicie nierozpoznawalny na podstawie zachowania sieci/TLS. Celem jest brak prostych bannerów, wersji i sygnatur frameworka.
## Ważne po migracji
W dostarczonym archiwum stare aplikacje mają dane logowania ServerQuery wpisane bezpośrednio w PHP. Zmień hasło konta Query po wdrożeniu i nie kopiuj starego sekretu do repozytorium.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
set -eu
TARGET_OS="${TARGET_OS:-linux}"
TARGET_ARCH="${TARGET_ARCH:-amd64}"
OUT_DIR="${OUT_DIR:-dist}"
NAME="ts3monolith-${TARGET_OS}-${TARGET_ARCH}"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
go test ./...
CGO_ENABLED=0 GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -ldflags="-s -w -buildid=" -o "$OUT_DIR/$NAME" ./cmd/ts3monolith
chmod 0755 "$OUT_DIR/$NAME"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$OUT_DIR" && sha256sum "$NAME" > "$NAME.sha256")
elif command -v shasum >/dev/null 2>&1; then
(cd "$OUT_DIR" && shasum -a 256 "$NAME" > "$NAME.sha256")
fi
printf '%s\n' "$OUT_DIR/$NAME"
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"ts3monolith/internal/config"
"ts3monolith/internal/rrd"
"ts3monolith/internal/ts3"
webapp "ts3monolith/internal/web"
)
func main() {
log.SetFlags(log.LstdFlags | log.LUTC)
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
tq := &ts3.Query{Addr: cfg.TSQueryHost + ":" + strconv.Itoa(cfg.TSQueryPort), User: cfg.TSQueryUser, Password: cfg.TSQueryPassword, VoicePort: cfg.TSVoicePort, Timeout: cfg.TSQueryTimeout}
rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath, GraphDir: cfg.GraphDir}
if err := rm.Validate(); err != nil {
log.Fatal(err)
}
app, err := webapp.New(cfg, tq, rm)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Addr: cfg.ListenAddr, Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go collect(ctx, cfg, tq, rm)
go func() {
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = srv.Shutdown(c)
}()
log.Printf("listening on %s", cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) {
run := func() {
c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout)
defer cancel()
n, err := tq.CountOnline(c)
if err != nil {
log.Printf("stats query failed: %v", err)
return
}
c2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
defer cancel2()
if err := rm.Update(c2, n); err != nil {
log.Printf("rrd update failed: %v", err)
}
}
run()
t := time.NewTicker(cfg.StatsInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
+17
View File
@@ -0,0 +1,17 @@
server {
listen 443 ssl http2;
server_name app.example.org;
server_tokens off;
# ssl_certificate /...;
# ssl_certificate_key /...;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
Vendored Executable
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64
+16
View File
@@ -0,0 +1,16 @@
services:
ts3monolith:
build: .
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./data:/var/lib/ts3monolith
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
+3
View File
@@ -0,0 +1,3 @@
module ts3monolith
go 1.23
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="ts3monolith"
APP_USER="${APP_USER:-ts3app}"
APP_GROUP="${APP_GROUP:-ts3app}"
APP_DIR="${APP_DIR:-/opt/ts3monolith}"
BIN_SOURCE="${BIN_SOURCE:-./dist/ts3monolith-linux-amd64}"
ENV_SOURCE="${ENV_SOURCE:-./.env}"
DATA_SOURCE="${DATA_SOURCE:-./data}"
SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service"
if [ "${EUID}" -ne 0 ]; then
echo "Uruchom jako root: sudo ./install.sh"
exit 1
fi
if [ ! -f "${BIN_SOURCE}" ]; then
echo "Brak binarki: ${BIN_SOURCE}"
echo "Najpierw uruchom ./build-release.sh albo ustaw BIN_SOURCE=/sciezka/do/binarki"
exit 1
fi
if [ ! -f "${ENV_SOURCE}" ]; then
echo "Brak pliku .env: ${ENV_SOURCE}"
echo "Utworz .env albo ustaw ENV_SOURCE=/sciezka/do/.env"
exit 1
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "Brak systemd/systemctl. Ten instalator wymaga systemd."
exit 1
fi
if ! command -v rrdtool >/dev/null 2>&1; then
echo "UWAGA: rrdtool nie jest zainstalowany."
echo "Aplikacja wymaga go do aktualizacji i renderowania RRD."
fi
if ! getent group "${APP_GROUP}" >/dev/null 2>&1; then
groupadd --system "${APP_GROUP}"
fi
if ! id "${APP_USER}" >/dev/null 2>&1; then
useradd \
--system \
--gid "${APP_GROUP}" \
--home-dir "${APP_DIR}" \
--shell /usr/sbin/nologin \
"${APP_USER}"
fi
install -d -m 0750 -o "${APP_USER}" -g "${APP_GROUP}" "${APP_DIR}"
install -d -m 0750 -o "${APP_USER}" -g "${APP_GROUP}" "${APP_DIR}/data"
install -m 0755 -o root -g root "${BIN_SOURCE}" "${APP_DIR}/${APP_NAME}"
install -m 0640 -o root -g "${APP_GROUP}" "${ENV_SOURCE}" "${APP_DIR}/.env"
if [ -d "${DATA_SOURCE}" ]; then
cp -a "${DATA_SOURCE}/." "${APP_DIR}/data/"
chown -R "${APP_USER}:${APP_GROUP}" "${APP_DIR}/data"
fi
cat > "${SERVICE_FILE}" <<SERVICE
[Unit]
Description=TS3 Monolith
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${APP_DIR}/.env
ExecStart=${APP_DIR}/${APP_NAME}
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${APP_DIR}/data
LockPersonality=true
RestrictSUIDSGID=true
RestrictNamespaces=true
[Install]
WantedBy=multi-user.target
SERVICE
chmod 0644 "${SERVICE_FILE}"
systemctl daemon-reload
systemctl enable "${APP_NAME}.service" >/dev/null
systemctl restart "${APP_NAME}.service"
sleep 1
if systemctl is-active --quiet "${APP_NAME}.service"; then
echo
echo "OK: ${APP_NAME} dziala."
echo "Katalog: ${APP_DIR}"
echo "Status: systemctl status ${APP_NAME}"
echo "Logi: journalctl -u ${APP_NAME} -f"
else
echo
echo "Usluga nie wystartowala. Ostatnie logi:"
journalctl -u "${APP_NAME}" -n 50 --no-pager || true
exit 1
fi
+161
View File
@@ -0,0 +1,161 @@
package config
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddr string
AppSecret string
HTTPSOnly bool
FrameAncestors []string
TrustedProxies []*net.IPNet
TSQueryHost string
TSQueryPort int
TSVoiceHost string
TSVoicePort int
TSQueryUser string
TSQueryPassword string
TSChannelAdminGID int
TSQueryTimeout time.Duration
RRDTool string
RRDPath string
GraphDir string
StatsInterval time.Duration
}
func Load() (Config, error) {
var c Config
c.ListenAddr = env("LISTEN_ADDR", "127.0.0.1:8080")
c.AppSecret = os.Getenv("APP_SECRET")
if len(c.AppSecret) < 32 {
return c, errors.New("APP_SECRET must contain at least 32 characters")
}
c.HTTPSOnly = envBool("HTTPS_ONLY", true)
var err error
c.FrameAncestors, err = frameAncestors(os.Getenv("FRAME_ANCESTORS"))
if err != nil {
return c, err
}
c.TrustedProxies, err = cidrs(os.Getenv("TRUSTED_PROXIES"))
if err != nil {
return c, err
}
c.TSQueryHost = env("TS_QUERY_HOST", "127.0.0.1")
c.TSQueryPort, err = envInt("TS_QUERY_PORT", 10011)
if err != nil {
return c, err
}
c.TSVoiceHost = env("TS_VOICE_HOST", "linuxiarz.pl")
c.TSVoicePort, err = envInt("TS_VOICE_PORT", 9987)
if err != nil {
return c, err
}
c.TSQueryUser = os.Getenv("TS_QUERY_USER")
c.TSQueryPassword = os.Getenv("TS_QUERY_PASSWORD")
if c.TSQueryUser == "" || c.TSQueryPassword == "" {
return c, errors.New("TS_QUERY_USER and TS_QUERY_PASSWORD are required")
}
c.TSChannelAdminGID, err = envInt("TS_CHANNEL_ADMIN_GID", 5)
if err != nil {
return c, err
}
c.TSQueryTimeout, err = envDuration("TS_QUERY_TIMEOUT", 3*time.Second)
if err != nil {
return c, err
}
c.RRDTool = env("RRDTOOL", "rrdtool")
c.RRDPath = env("RRD_PATH", "/var/lib/ts3monolith/linuxiarz.rrd")
c.GraphDir = env("GRAPH_DIR", "/var/lib/ts3monolith/graphs")
c.StatsInterval, err = envDuration("STATS_INTERVAL", time.Minute)
if err != nil {
return c, err
}
if c.StatsInterval < 10*time.Second {
return c, errors.New("STATS_INTERVAL must be at least 10s")
}
return c, nil
}
func env(k, d string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return d
}
func envBool(k string, d bool) bool {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return d
}
b, err := strconv.ParseBool(v)
if err != nil {
return d
}
return b
}
func envInt(k string, d int) (int, error) {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return d, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("invalid %s", k)
}
return n, nil
}
func envDuration(k string, d time.Duration) (time.Duration, error) {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
return d, nil
}
x, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", k, err)
}
return x, nil
}
func frameAncestors(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return []string{"'self'"}, nil
}
out := []string{"'self'"}
for _, part := range strings.Fields(raw) {
u, err := url.Parse(part)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("invalid FRAME_ANCESTORS origin: %q", part)
}
out = append(out, u.Scheme+"://"+u.Host)
}
return out, nil
}
func cidrs(raw string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
if strings.TrimSpace(part) == "" {
continue
}
_, n, err := net.ParseCIDR(strings.TrimSpace(part))
if err != nil {
return nil, fmt.Errorf("invalid TRUSTED_PROXIES entry %q", part)
}
out = append(out, n)
}
return out, nil
}
+111
View File
@@ -0,0 +1,111 @@
package rrd
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type Manager struct {
Tool string
Path string
GraphDir string
mu sync.Mutex
}
type Range struct{ Key, Start, Title string }
var Ranges = []Range{
{"hour", "-12h", "Ostatnie 12 godzin"},
{"day", "-24h", "Ostatnie 24 godziny"},
{"72h", "-72h", "Ostatnie 72 godziny"},
{"week", "-1w", "Ostatni tydzień"},
{"month", "-1m", "Ostatni miesiąc"},
{"year", "-1y", "Ostatni rok"},
}
func (m *Manager) Validate() error {
if _, err := exec.LookPath(m.Tool); err != nil {
return fmt.Errorf("rrdtool not found: %w", err)
}
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
return fmt.Errorf("RRD file unavailable: %s", m.Path)
}
return os.MkdirAll(m.GraphDir, 0750)
}
func (m *Manager) Update(ctx context.Context, online int) error {
if online < 0 {
return errors.New("online count cannot be negative")
}
cmd := exec.CommandContext(ctx, m.Tool, "update", m.Path, "N:"+strconv.Itoa(online))
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("rrd update: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func (m *Manager) Last(ctx context.Context) (int, error) {
cmd := exec.CommandContext(ctx, m.Tool, "lastupdate", m.Path)
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0, errors.New("unexpected rrd lastupdate output")
}
fields := strings.Fields(lines[len(lines)-1])
if len(fields) < 2 {
return 0, errors.New("unexpected rrd lastupdate data")
}
v := fields[len(fields)-1]
if v == "U" {
return 0, nil
}
f, err := strconv.ParseFloat(v, 64)
return int(f + 0.5), err
}
func (m *Manager) Graph(ctx context.Context, key string) (string, error) {
var rg *Range
for i := range Ranges {
if Ranges[i].Key == key {
rg = &Ranges[i]
break
}
}
if rg == nil {
return "", errors.New("unknown graph range")
}
if err := os.MkdirAll(m.GraphDir, 0750); err != nil {
return "", err
}
out := filepath.Join(m.GraphDir, key+".png")
m.mu.Lock()
defer m.mu.Unlock()
if st, err := os.Stat(out); err == nil && time.Since(st.ModTime()) < 55*time.Second {
return out, nil
}
def := "DEF:online=" + m.Path + ":online:LAST"
args := []string{"graph", out, "--imgformat", "PNG", "--start", rg.Start, "--end", "now", "--width", "1200", "--height", "320", "--lower-limit", "0", "--alt-autoscale-max", "--title", rg.Title, "--vertical-label", "użytkownicy", "--border", "0", def, "LINE2:online#2F6FED:Online", "GPRINT:online:LAST:Aktualnie\\: %4.0lf", "GPRINT:online:AVERAGE:Średnio\\: %4.0lf", "GPRINT:online:MAX:Maks.\\: %4.0lf\\n"}
cmd := exec.CommandContext(ctx, m.Tool, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("rrd graph: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return out, nil
}
+319
View File
@@ -0,0 +1,319 @@
package ts3
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
)
type Query struct {
Addr string
User string
Password string
VoicePort int
Timeout time.Duration
}
type Client struct {
ID int
DatabaseID int
Nick string
UID string
IP string
}
type queryConn struct {
c net.Conn
r *bufio.Reader
w *bufio.Writer
}
type QueryError struct {
ID int
Msg string
}
func (e *QueryError) Error() string { return fmt.Sprintf("TeamSpeak query error %d: %s", e.ID, e.Msg) }
func (q *Query) connect(ctx context.Context) (*queryConn, error) {
d := net.Dialer{Timeout: q.Timeout}
c, err := d.DialContext(ctx, "tcp", q.Addr)
if err != nil {
return nil, err
}
qc := &queryConn{c: c, r: bufio.NewReaderSize(c, 64*1024), w: bufio.NewWriterSize(c, 16*1024)}
if _, err = qc.run(ctx, "login client_login_name="+escape(q.User)+" client_login_password="+escape(q.Password)); err != nil {
c.Close()
return nil, err
}
if _, err = qc.run(ctx, "use port="+strconv.Itoa(q.VoicePort)); err != nil {
c.Close()
return nil, err
}
return qc, nil
}
func (q *Query) withConn(ctx context.Context, fn func(*queryConn) error) error {
qc, err := q.connect(ctx)
if err != nil {
return err
}
defer qc.close()
return fn(qc)
}
func (qc *queryConn) close() {
_ = qc.c.SetWriteDeadline(time.Now().Add(time.Second))
_, _ = io.WriteString(qc.c, "quit\n")
_ = qc.c.Close()
}
func (qc *queryConn) run(ctx context.Context, command string) ([]string, error) {
if deadline, ok := ctx.Deadline(); ok {
_ = qc.c.SetDeadline(deadline)
}
if _, err := qc.w.WriteString(command + "\n"); err != nil {
return nil, err
}
if err := qc.w.Flush(); err != nil {
return nil, err
}
var lines []string
for {
line, err := qc.r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "error ") {
fields := parseFields(strings.TrimPrefix(line, "error "))
id, _ := strconv.Atoi(fields["id"])
if id != 0 {
return lines, &QueryError{ID: id, Msg: fields["msg"]}
}
return lines, nil
}
// Initial TS3 greeting arrives before the first command reply. It is not data.
if line == "TS3" || strings.HasPrefix(line, "Welcome to the TeamSpeak 3 ServerQuery interface") {
continue
}
lines = append(lines, line)
}
}
func (q *Query) ListClients(ctx context.Context) ([]Client, error) {
var out []Client
err := q.withConn(ctx, func(qc *queryConn) error {
var err error
out, err = listClients(ctx, qc)
return err
})
return out, err
}
func listClients(ctx context.Context, qc *queryConn) ([]Client, error) {
lines, err := qc.run(ctx, "clientlist -ip -uid")
if err != nil {
return nil, err
}
var out []Client
for _, line := range lines {
for _, row := range strings.Split(line, "|") {
f := parseFields(row)
if f["client_type"] != "0" {
continue
}
id, err1 := strconv.Atoi(f["clid"])
dbid, err2 := strconv.Atoi(f["client_database_id"])
if err1 != nil || err2 != nil {
continue
}
out = append(out, Client{ID: id, DatabaseID: dbid, Nick: f["client_nickname"], UID: f["client_unique_identifier"], IP: f["connection_client_ip"]})
}
}
return out, nil
}
func (q *Query) ClientsByIP(ctx context.Context, ip net.IP) ([]Client, error) {
clients, err := q.ListClients(ctx)
if err != nil {
return nil, err
}
var out []Client
for _, c := range clients {
if sameIP(ip, net.ParseIP(c.IP)) {
out = append(out, c)
}
}
return out, nil
}
func (q *Query) CountOnline(ctx context.Context) (int, error) {
clients, err := q.ListClients(ctx)
if err != nil {
return 0, err
}
return len(clients), nil
}
func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string) error {
return q.withConn(ctx, func(qc *queryConn) error {
_, err := qc.run(ctx, "sendtextmessage targetmode=1 target="+strconv.Itoa(clid)+" msg="+escape(message))
return err
})
}
func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string) (int, error) {
var cid int
err := q.withConn(ctx, func(qc *queryConn) error {
clients, err := listClients(ctx, qc)
if err != nil {
return err
}
found := false
for _, c := range clients {
sameIdentity := c.DatabaseID == expected.DatabaseID
if expected.UID != "" {
sameIdentity = sameIdentity && c.UID == expected.UID
}
if sameIdentity && sameIP(net.ParseIP(c.IP), net.ParseIP(expected.IP)) {
expected = c // refresh clid in case the client reconnected
found = true
break
}
}
if !found {
return errors.New("client disconnected or changed")
}
cmd := "channelcreate channel_name=" + escape(name) + " channel_topic=" + escape(topic) + " channel_codec=4 channel_flag_permanent=1"
if password != "" {
cmd += " channel_password=" + escape(password)
}
lines, err := qc.run(ctx, cmd)
if err != nil {
return err
}
for _, line := range lines {
f := parseFields(line)
if v := f["cid"]; v != "" {
cid, _ = strconv.Atoi(v)
}
}
if cid == 0 {
return errors.New("TeamSpeak did not return a channel id")
}
rollback := func() { _, _ = qc.run(ctx, "channeldelete cid="+strconv.Itoa(cid)+" force=1") }
if _, err = qc.run(ctx, "setclientchannelgroup cgid="+strconv.Itoa(adminGroupID)+" cid="+strconv.Itoa(cid)+" cldbid="+strconv.Itoa(expected.DatabaseID)); err != nil {
rollback()
return err
}
if _, err = qc.run(ctx, "clientmove clid="+strconv.Itoa(expected.ID)+" cid="+strconv.Itoa(cid)); err != nil {
rollback()
return err
}
return nil
})
return cid, err
}
func parseFields(s string) map[string]string {
out := make(map[string]string)
for _, p := range strings.Fields(s) {
k, v, ok := strings.Cut(p, "=")
if !ok {
continue
}
out[k] = unescape(v)
}
return out
}
func escape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '\\':
b.WriteString(`\\`)
case '/':
b.WriteString(`\/`)
case ' ':
b.WriteString(`\s`)
case '|':
b.WriteString(`\p`)
case '\a':
b.WriteString(`\a`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\v':
b.WriteString(`\v`)
default:
b.WriteRune(r)
}
}
return b.String()
}
func unescape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 >= len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case '\\':
b.WriteByte('\\')
case '/':
b.WriteByte('/')
case 's':
b.WriteByte(' ')
case 'p':
b.WriteByte('|')
case 'a':
b.WriteByte('\a')
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'v':
b.WriteByte('\v')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
func sameIP(a, b net.IP) bool {
if a == nil || b == nil {
return false
}
return a.Equal(b)
}
+95
View File
@@ -0,0 +1,95 @@
package ts3
import (
"bufio"
"context"
"io"
"net"
"strings"
"testing"
"time"
)
func TestEscapeRoundTrip(t *testing.T) {
values := []string{"abc", "a b|c", "slash/and\\backslash", "ąęść", "x\ny\tz"}
for _, v := range values {
if got := unescape(escape(v)); got != v {
t.Fatalf("roundtrip %q -> %q", v, got)
}
}
}
func TestParseFields(t *testing.T) {
f := parseFields(`clid=12 client_nickname=Jan\sKowalski connection_client_ip=1.2.3.4`)
if f["client_nickname"] != "Jan Kowalski" || f["clid"] != "12" {
t.Fatalf("bad fields: %#v", f)
}
}
func TestCreateChannelUsesDatabaseIDAndRefreshesClientID(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
commands := make(chan []string, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
_, _ = io.WriteString(c, "TS3\nWelcome to the TeamSpeak 3 ServerQuery interface\n")
r := bufio.NewReader(c)
var got []string
for {
line, err := r.ReadString('\n')
if err != nil {
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
got = append(got, line)
switch {
case strings.HasPrefix(line, "login "):
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
case strings.HasPrefix(line, "use "):
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
case line == "clientlist -ip -uid":
_, _ = io.WriteString(c, "clid=8 cid=1 client_database_id=42 client_nickname=Jan\\sKowalski client_type=0 client_unique_identifier=abc connection_client_ip=1.2.3.4\nerror id=0 msg=ok\n")
case strings.HasPrefix(line, "channelcreate "):
_, _ = io.WriteString(c, "cid=321\nerror id=0 msg=ok\n")
case strings.HasPrefix(line, "setclientchannelgroup "):
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
case strings.HasPrefix(line, "clientmove "):
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
case line == "quit":
commands <- got
return
}
}
commands <- got
}()
q := &Query{Addr: ln.Addr().String(), User: "u", Password: "p", VoicePort: 9987, Timeout: 2 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cid, err := q.CreateChannelForClient(ctx, Client{ID: 7, DatabaseID: 42, Nick: "old", UID: "abc", IP: "1.2.3.4"}, 5, "Moj kanal", "temat", "haslo")
if err != nil {
t.Fatal(err)
}
if cid != 321 {
t.Fatalf("cid=%d", cid)
}
got := <-commands
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "setclientchannelgroup cgid=5 cid=321 cldbid=42") {
t.Fatalf("missing channel group command:\n%s", joined)
}
if !strings.Contains(joined, "clientmove clid=8 cid=321") {
t.Fatalf("expected refreshed clid:\n%s", joined)
}
}
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#0b0f14;--surface:#121821;--surface-2:#171f2a;--surface-3:#0f151d;--border:#263140;--border-soft:#1c2531;--text:#f5f7fa;--muted:#91a0b2;--primary:#3274d9;--primary-hover:#3d82eb;--success:#43b581;--danger:#e66a70;--shadow:0 14px 35px rgba(0,0,0,.25);--radius:14px}*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}a{color:inherit}.app-shell{min-height:100vh}.topbar{min-height:70px;border-bottom:1px solid var(--border-soft);background:rgba(18,24,33,.96);display:flex;align-items:center;justify-content:space-between;gap:24px;padding:10px max(24px,calc((100vw - 1480px)/2));position:sticky;top:0;z-index:20;backdrop-filter:blur(10px)}.brand{text-decoration:none}.brand span{display:flex;flex-direction:column;line-height:1.2}.brand strong{font-size:15px}.brand small{margin-top:3px;color:var(--muted);font-size:12px}.header-right,.top-actions,.button-row{display:flex;align-items:center;gap:10px}.header-online{height:40px;padding:0 13px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}.header-online strong{color:var(--text);font-size:17px}.online-dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 0 3px rgba(67,181,129,.12);flex:none}.btn{appearance:none;border:1px solid transparent;border-radius:9px;min-height:40px;padding:9px 14px;display:inline-flex;align-items:center;justify-content:center;font:inherit;font-weight:650;text-decoration:none;cursor:pointer;transition:background .16s ease,border-color .16s ease,transform .16s ease}.btn:hover{transform:translateY(-1px)}.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}.btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}.btn-ghost{background:var(--surface-2);border-color:var(--border);color:var(--text)}.btn-ghost:hover{background:#1c2633}.btn-large{width:100%;min-height:48px;font-size:15px;margin-top:4px}.grow{flex:1}.container{max-width:1480px;margin:0 auto;padding:30px 24px 48px}.page-intro{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:18px}.eyebrow{margin:0 0 5px;text-transform:uppercase;letter-spacing:.13em;font-size:11px;font-weight:800;color:#7890aa}.page-intro h1,.channel-title h1{margin:0;font-size:clamp(26px,3vw,36px);line-height:1.15;letter-spacing:-.035em}.page-intro p:not(.eyebrow),.channel-title p{margin:8px 0 0;color:var(--muted)}.refresh-status{font-size:12px;color:var(--muted)}.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.graph-card{min-width:0;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:0 8px 26px rgba(0,0,0,.16)}.graph-head{padding:15px 17px 12px;border-bottom:1px solid var(--border-soft)}.graph-head h2{margin:0;font-size:15px;letter-spacing:-.01em}.graph-head p{margin:3px 0 0;font-size:12px;color:var(--muted)}.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--surface-3);cursor:zoom-in;overflow:hidden}.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:3.56/1;object-fit:contain;transition:transform .18s ease,opacity .18s ease}.graph-image-button:hover img{transform:scale(1.015);opacity:.96}.lightbox[hidden]{display:none}.lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px}.lightbox-backdrop{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.82);cursor:zoom-out}.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1500px);max-height:92vh;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:48px 16px 16px;box-shadow:0 24px 80px rgba(0,0,0,.55);overflow:auto}.lightbox-dialog img{display:block;width:100%;height:auto}.lightbox-close{position:absolute;top:10px;right:12px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer}.lightbox-open{overflow:hidden}.channel-page{min-height:100vh;display:grid;place-items:center;padding:28px;background:var(--bg)}.channel-shell{width:min(100%,700px)}.channel-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}.channel-topbar{min-height:56px;padding:0 22px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-soft)}.back-link{color:var(--muted);font-size:13px;text-decoration:none}.back-link:hover{color:var(--text)}.channel-title{padding:28px 28px 20px}.channel-title h1{font-size:28px}.alert{margin:0 28px 20px;padding:13px 15px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);display:flex;flex-direction:Line truncated
+67
View File
@@ -0,0 +1,67 @@
(function () {
var count = document.getElementById('online-count');
var status = document.getElementById('refresh-status');
var graphs = document.querySelectorAll('.js-live-graph');
var lightbox = document.getElementById('graph-lightbox');
var lightboxImage = document.getElementById('lightbox-image');
var openers = document.querySelectorAll('.js-graph-open');
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
function refresh() {
if (status) status.textContent = 'Odświeżanie…';
if (count) {
fetch('/stats/live', {cache: 'no-store'})
.then(function (response) {
if (!response.ok) throw new Error('stats');
return response.json();
})
.then(function (data) {
if (typeof data.current === 'number') count.textContent = String(data.current);
if (status) status.textContent = 'Dane aktualne';
})
.catch(function () {
if (status) status.textContent = 'Nie udało się odświeżyć';
});
}
var stamp = Date.now();
graphs.forEach(function (img) {
var src = img.dataset.src;
if (src) img.src = src + '?v=' + stamp;
});
}
function openLightbox(src) {
if (!lightbox || !lightboxImage) return;
lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now();
lightbox.hidden = false;
lightbox.setAttribute('aria-hidden', 'false');
document.body.classList.add('lightbox-open');
}
function closeLightbox() {
if (!lightbox || !lightboxImage) return;
lightbox.hidden = true;
lightbox.setAttribute('aria-hidden', 'true');
lightboxImage.src = '';
document.body.classList.remove('lightbox-open');
}
openers.forEach(function (button) {
button.addEventListener('click', function () {
var src = button.getAttribute('data-full');
if (src) openLightbox(src);
});
});
closeButtons.forEach(function (button) {
button.addEventListener('click', closeLightbox);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox();
});
window.setInterval(refresh, 60000);
})();
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"sync"
"time"
)
type rateEntry struct {
since time.Time
n int
}
type limiter struct {
mu sync.Mutex
entries map[string]rateEntry
}
func newLimiter() *limiter { return &limiter{entries: make(map[string]rateEntry)} }
func (l *limiter) allow(key string, max int, window time.Duration) bool {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e.since.IsZero() || now.Sub(e.since) >= window {
l.entries[key] = rateEntry{since: now, n: 1}
return true
}
if e.n >= max {
return false
}
e.n++
l.entries[key] = e
if len(l.entries) > 10000 {
for k, v := range l.entries {
if now.Sub(v.since) > 2*window {
delete(l.entries, k)
}
}
}
return true
}
+372
View File
@@ -0,0 +1,372 @@
package web
import (
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"ts3monolith/internal/config"
"ts3monolith/internal/rrd"
"ts3monolith/internal/ts3"
)
//go:embed templates/*.html assets/*
var embedded embed.FS
type Server struct {
cfg config.Config
ts *ts3.Query
rrd *rrd.Manager
tpl *template.Template
tokens *tokenManager
limit *limiter
}
type channelPage struct {
ClientIP string
Clients []ts3.Client
Client *ts3.Client
ClientToken string
FormToken string
ChallengeToken string
Error string
Success string
ChannelID int
VoiceURL string
}
type statsPage struct {
Current int
VoiceURL string
Ranges []rrd.Range
}
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
tpl, err := template.ParseFS(embedded, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{cfg: cfg, ts: tq, rrd: rm, tpl: tpl, tokens: newTokenManager(cfg.AppSecret), limit: newLimiter()}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
assets, _ := fs.Sub(embedded, "assets")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assets))))
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
mux.HandleFunc("GET /stats", s.stats)
mux.HandleFunc("GET /graph/{file}", s.graph)
mux.HandleFunc("GET /channel", s.channel)
mux.HandleFunc("GET /embed/channel", s.channel)
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
mux.HandleFunc("POST /channel/verify/complete", s.verifyComplete)
mux.HandleFunc("POST /channel/create", s.createChannel)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
return s.security(mux)
}
func (s *Server) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
if s.cfg.HTTPSOnly {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
frame := "'none'"
if strings.HasPrefix(r.URL.Path, "/embed/channel") || strings.HasPrefix(r.URL.Path, "/channel/") {
frame = strings.Join(s.cfg.FrameAncestors, " ")
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors "+frame+"; form-action 'self'; img-src 'self'; style-src 'self'; script-src 'self'")
next.ServeHTTP(w, r)
})
}
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("stats|"+ip, 60, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
current, err := s.ts.CountOnline(ctx)
if err != nil {
ctx2, cancel2 := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel2()
current, _ = s.rrd.Last(ctx2)
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "stats.html", statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges})
}
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
file := r.PathValue("file")
if !strings.HasSuffix(file, ".png") {
http.NotFound(w, r)
return
}
key := strings.TrimSuffix(file, ".png")
if key == "" || strings.Contains(key, "/") {
http.NotFound(w, r)
return
}
p, err := s.rrd.Graph(ctx, key)
if err != nil {
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Cache-Control", "public, max-age=60")
http.ServeFile(w, r, p)
}
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "invalid client address", http.StatusBadRequest)
return
}
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
p := channelPage{ClientIP: ip.String(), Clients: clients, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
if err != nil {
p.Error = "Nie udało się sprawdzić połączenia z TeamSpeak."
}
if len(clients) == 1 {
p.Client = &clients[0]
p.ClientToken = s.tokens.clientToken(ip.String(), clients[0])
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "channel.html", p)
}
func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
http.Error(w, "invalid form", http.StatusForbidden)
return
}
clid, _ := strconv.Atoi(r.FormValue("clid"))
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
if err != nil {
http.Error(w, "verification unavailable", 503)
return
}
var selected *ts3.Client
for i := range clients {
if clients[i].ID == clid {
selected = &clients[i]
break
}
}
if selected == nil {
http.Error(w, "client unavailable", 409)
return
}
challenge, code, _ := s.tokens.challengeToken(ip.String(), *selected)
if err := s.ts.SendPrivateMessage(ctx, selected.ID, "Kod weryfikacyjny strony tworzenia kanału: "+code+". Kod jest ważny 5 minut."); err != nil {
http.Error(w, "could not send verification", 503)
return
}
p := channelPage{ClientIP: ip.String(), Client: selected, ChallengeToken: challenge, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "verify.html", p)
}
func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
http.Error(w, "invalid or expired code", 403)
return
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
p := channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "channel.html", p)
}
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
if err != nil {
http.Error(w, "client verification expired", 403)
return
}
name := strings.TrimSpace(r.FormValue("name"))
topic := strings.TrimSpace(r.FormValue("topic"))
password := r.FormValue("password")
if name == "" {
name = randomChannelName()
}
if err := validateInput(name, topic, password); err != nil {
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: r.FormValue("client_token"), FormToken: s.tokens.formToken(ip.String()), Error: err.Error(), VoiceURL: s.voiceURL()})
return
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
defer cancel()
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password)
if err != nil {
log.Printf("channel create failed for client dbid=%d: %v", c.DatabaseID, err)
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), Error: "Nie udało się utworzyć kanału. Sprawdź połączenie z TS3 i spróbuj ponownie.", VoiceURL: s.voiceURL()})
return
}
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, FormToken: s.tokens.formToken(ip.String()), Success: "Kanał został utworzony, uprawnienia nadane, a klient przeniesiony.", ChannelID: cid, VoiceURL: s.voiceURL()})
}
func (s *Server) requirePost(w http.ResponseWriter, r *http.Request, ip string) bool {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", 400)
return false
}
if o := r.Header.Get("Origin"); o != "" {
u, err := url.Parse(o)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
http.Error(w, "forbidden", 403)
return false
}
}
return true
}
func validateInput(name, topic, password string) error {
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 40 {
return fmt.Errorf("Nazwa kanału musi mieć 1–40 znaków.")
}
if utf8.RuneCountInString(topic) > 255 {
return fmt.Errorf("Temat może mieć maksymalnie 255 znaków.")
}
if utf8.RuneCountInString(password) > 64 {
return fmt.Errorf("Hasło może mieć maksymalnie 64 znaki.")
}
for _, v := range []string{name, topic, password} {
if strings.ContainsRune(v, '\x00') {
return fmt.Errorf("Nieprawidłowe dane wejściowe.")
}
}
return nil
}
func randomChannelName() string { return "channel-" + randomNonce()[:10] }
func (s *Server) clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
remote := net.ParseIP(strings.TrimSpace(host))
if remote == nil {
return nil
}
trusted := false
for _, n := range s.cfg.TrustedProxies {
if n.Contains(remote) {
trusted = true
break
}
}
if !trusted {
return remote
}
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
if ip := net.ParseIP(v); ip != nil {
return ip
}
}
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
if ip := net.ParseIP(strings.TrimSpace(strings.Split(v, ",")[0])); ip != nil {
return ip
}
}
return remote
}
func (s *Server) voiceURL() string {
return "ts3server://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort)
}
func (s *Server) tooMany(w http.ResponseWriter) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many requests", http.StatusTooManyRequests)
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template error: %v", err)
}
}
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }
+102
View File
@@ -0,0 +1,102 @@
{{define "channel.html"}}<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Utwórz kanał</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body class="channel-page">
<main class="channel-shell">
<section class="channel-card">
<div class="channel-topbar">
<a class="back-link" href="/stats">Statystyki
</a>
<a class="back-link" href="{{.VoiceURL}}" target="_top">Połącz z TS3</a>
</div>
<div class="channel-title">
<div>
<p class="eyebrow">TeamSpeak 3</p>
<h1>Utwórz swój kanał</h1>
<p>Aplikacja wykryje Twojego klienta, utworzy kanał, nada prawa i przeniesie Cię automatycznie.</p>
</div>
</div>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Success}}
<div class="alert alert-success">
<strong>Kanał został utworzony.</strong>
<span>{{.Success}} ID kanału: {{.ChannelID}}</span>
</div>
<div class="button-row">
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Otwórz TeamSpeak</a>
<a class="btn btn-ghost" href="/stats">Statystyki</a>
</div>
{{else}}
{{if eq (len .Clients) 0}}{{if not .Client}}
<div class="empty-state">
<h2>Nie wykryto klienta TS3</h2>
<p>Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.</p>
<div class="button-row">
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Połącz z TeamSpeak</a>
<a class="btn btn-ghost" href="">Odśwież</a>
</div>
</div>
{{end}}{{end}}
{{if gt (len .Clients) 1}}
<div class="selection-box">
<h2>Wybierz swojego klienta</h2>
<p>Pod tym samym adresem IP jest kilka osób. Kod potwierdzający przyjdzie jako prywatna wiadomość na TS3.</p>
<div class="client-list">
{{range .Clients}}
<form method="post" action="/channel/verify/start">
<input type="hidden" name="form_token" value="{{$.FormToken}}">
<input type="hidden" name="clid" value="{{.ID}}">
<button type="submit" class="client-choice">
<span class="avatar" aria-hidden="true">{{printf "%.1s" .Nick}}</span>
<span><strong>{{.Nick}}</strong><small>Wyślij kod weryfikacyjny</small></span></button>
</form>
{{end}}
</div>
</div>
{{end}}
{{if .Client}}
<div class="client-badge">
<span class="online-dot" aria-hidden="true"></span>
<span>Wykryty klient</span>
<strong>{{.Client.Nick}}</strong>
</div>
<form method="post" action="/channel/create" class="channel-form">
<input type="hidden" name="form_token" value="{{.FormToken}}">
<input type="hidden" name="client_token" value="{{.ClientToken}}">
<div class="field">
<label for="channel-name">Nazwa kanału</label>
<input id="channel-name" name="name" maxlength="40" autocomplete="off" placeholder="Np. Pokój Mateusza">
<small>Pozostaw puste, aby wygenerować nazwę automatycznie.</small>
</div>
<div class="field">
<label for="channel-password">Hasło</label>
<input id="channel-password" name="password" maxlength="64" type="password" autocomplete="new-password" placeholder="Opcjonalne">
</div>
<div class="field">
<label for="channel-topic">Temat / opis</label>
<input id="channel-topic" name="topic" maxlength="255" autocomplete="off" placeholder="Opcjonalne">
</div>
<button class="btn btn-primary btn-large" type="submit">
Utwórz kanał i przenieś mnie</button>
</form>
{{end}}
{{end}}
</section>
</main>
</body>
</html>{{end}}
+66
View File
@@ -0,0 +1,66 @@
{{define "stats.html"}}<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TeamSpeak — statystyki</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<a class="brand" href="/stats" aria-label="TeamSpeak — statystyki">
<span><strong>TeamSpeak</strong><small>Panel serwera</small></span>
</a>
<div class="header-right">
<div class="header-online" aria-live="polite">
<span class="online-dot" aria-hidden="true"></span>
<span>Online</span>
<strong id="online-count">{{.Current}}</strong>
</div>
<nav class="top-actions" aria-label="Nawigacja">
<a class="btn btn-ghost" href="{{.VoiceURL}}">Połącz</a>
<a class="btn btn-primary" href="/channel">Utwórz kanał</a>
</nav>
</div>
</header>
<main class="container">
<section class="page-intro">
<div>
<p class="eyebrow">Statystyki serwera</p>
<h1>Aktywność TeamSpeak 3</h1>
<p>Historia liczby użytkowników połączonych z serwerem.</p>
</div>
<span class="refresh-status" id="refresh-status">Odświeżanie automatyczne</span>
</section>
<section class="graphs" aria-label="Wykresy statystyk">
{{range .Ranges}}
<article class="graph-card">
<div class="graph-head">
<div>
<h2>{{.Title}}</h2>
<p>Liczba użytkowników online</p>
</div>
</div>
<button class="graph-image-button js-graph-open" type="button" data-full="/graph/{{.Key}}.png" aria-label="Powiększ wykres {{.Title}}">
<img class="js-live-graph" loading="lazy" src="/graph/{{.Key}}.png" data-src="/graph/{{.Key}}.png" alt="{{.Title}}">
</button>
</article>
{{end}}
</section>
</main>
</div>
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
<button class="lightbox-backdrop" type="button" data-lightbox-close aria-label="Zamknij podgląd"></button>
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="Powiększony wykres">
<button class="lightbox-close" type="button" data-lightbox-close>Zamknij</button>
<img id="lightbox-image" src="" alt="Powiększony wykres">
</div>
</div>
</body>
</html>{{end}}
+3
View File
@@ -0,0 +1,3 @@
{{define "verify.html"}}<!doctype html>
<html lang="pl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Weryfikacja TS3</title><link rel="stylesheet" href="/assets/app.css"></head>
<body class="embed"><main class="panel"><nav class="panel-nav"><a href="/stats">← Statystyki</a><a href="{{.VoiceURL}}" target="_top">Połącz z TS3</a></nav><h1>Potwierdź klienta TS3</h1><p>Kod został wysłany prywatnie do <strong>{{.Client.Nick}}</strong>.</p><form method="post" action="/channel/verify/complete"><input type="hidden" name="form_token" value="{{.FormToken}}"><input type="hidden" name="challenge_token" value="{{.ChallengeToken}}"><label>Kod<input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus></label><button class="button full" type="submit">Potwierdź</button></form></main></body></html>{{end}}
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"ts3monolith/internal/ts3"
)
type tokenManager struct{ secret []byte }
type tokenPayload struct {
Kind string `json:"k"`
IP string `json:"ip"`
Exp int64 `json:"exp"`
Nonce string `json:"n"`
ClID int `json:"clid,omitempty"`
DBID int `json:"dbid,omitempty"`
Nick string `json:"nick,omitempty"`
UID string `json:"uid,omitempty"`
}
func newTokenManager(secret string) *tokenManager { return &tokenManager{secret: []byte(secret)} }
func (m *tokenManager) sign(p tokenPayload) (string, error) {
raw, err := json.Marshal(p)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(raw)
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte(body))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return body + "." + sig, nil
}
func (m *tokenManager) verify(tok, kind, ip string) (tokenPayload, error) {
var p tokenPayload
body, sig, ok := splitToken(tok)
if !ok {
return p, errors.New("invalid token")
}
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte(body))
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
return p, errors.New("invalid token signature")
}
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return p, err
}
if err = json.Unmarshal(raw, &p); err != nil {
return p, err
}
if p.Kind != kind || p.IP != ip || time.Now().Unix() > p.Exp {
return p, errors.New("expired or mismatched token")
}
return p, nil
}
func splitToken(s string) (string, string, bool) {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '.' {
return s[:i], s[i+1:], true
}
}
return "", "", false
}
func randomNonce() string {
b := make([]byte, 18)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (m *tokenManager) formToken(ip string) string {
t, _ := m.sign(tokenPayload{Kind: "form", IP: ip, Exp: time.Now().Add(10 * time.Minute).Unix(), Nonce: randomNonce()})
return t
}
func (m *tokenManager) clientToken(ip string, c ts3.Client) string {
t, _ := m.sign(tokenPayload{Kind: "client", IP: ip, Exp: time.Now().Add(10 * time.Minute).Unix(), Nonce: randomNonce(), ClID: c.ID, DBID: c.DatabaseID, Nick: c.Nick, UID: c.UID})
return t
}
func (m *tokenManager) challengeToken(ip string, c ts3.Client) (token string, code string, err error) {
p := tokenPayload{Kind: "challenge", IP: ip, Exp: time.Now().Add(5 * time.Minute).Unix(), Nonce: randomNonce(), ClID: c.ID, DBID: c.DatabaseID, Nick: c.Nick, UID: c.UID}
token, err = m.sign(p)
if err != nil {
return "", "", err
}
return token, m.challengeCode(p), nil
}
func (m *tokenManager) challengeCode(p tokenPayload) string {
mac := hmac.New(sha256.New, m.secret)
_, _ = mac.Write([]byte("verify|" + p.Nonce + "|" + strconv.Itoa(p.ClID) + "|" + p.IP))
sum := mac.Sum(nil)
n := (uint32(sum[0])<<24 | uint32(sum[1])<<16 | uint32(sum[2])<<8 | uint32(sum[3])) % 1000000
return fmt.Sprintf("%06d", n)
}
+18
View File
@@ -0,0 +1,18 @@
package web
import (
"testing"
"ts3monolith/internal/ts3"
)
func TestTokens(t *testing.T) {
m := newTokenManager("0123456789012345678901234567890123456789")
tok := m.clientToken("1.2.3.4", ts3.Client{ID: 7, DatabaseID: 9, Nick: "Jan", UID: "u"})
p, err := m.verify(tok, "client", "1.2.3.4")
if err != nil || p.ClID != 7 || p.DBID != 9 {
t.Fatalf("verify failed: %#v %v", p, err)
}
if _, err := m.verify(tok, "client", "5.6.7.8"); err == nil {
t.Fatal("expected IP mismatch")
}
}