commit fccfe2a4c8802e83398dd951779279da60036429 Author: Mateusz Gruszczyński Date: Fri Oct 30 10:56:13 2026 +0100 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e250b04 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c5156b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..837e581 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e0ba60 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..96e5fcf --- /dev/null +++ b/README.md @@ -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 + +``` + +`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. diff --git a/build-release.sh b/build-release.sh new file mode 100644 index 0000000..18faebc --- /dev/null +++ b/build-release.sh @@ -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" diff --git a/cmd/ts3monolith/main.go b/cmd/ts3monolith/main.go new file mode 100644 index 0000000..bbd585c --- /dev/null +++ b/cmd/ts3monolith/main.go @@ -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() + } + } +} diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..b7a6f4c --- /dev/null +++ b/deploy/nginx.conf.example @@ -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; + } +} diff --git a/dist/ts3monolith-linux-amd64 b/dist/ts3monolith-linux-amd64 new file mode 100755 index 0000000..fd79837 Binary files /dev/null and b/dist/ts3monolith-linux-amd64 differ diff --git a/dist/ts3monolith-linux-amd64.sha256 b/dist/ts3monolith-linux-amd64.sha256 new file mode 100644 index 0000000..f0a4779 --- /dev/null +++ b/dist/ts3monolith-linux-amd64.sha256 @@ -0,0 +1 @@ +3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f89215d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..52dbdd7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ts3monolith + +go 1.23 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c50b1d0 --- /dev/null +++ b/install.sh @@ -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}" </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 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..11cb201 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/rrd/rrd.go b/internal/rrd/rrd.go new file mode 100644 index 0000000..ffd62e7 --- /dev/null +++ b/internal/rrd/rrd.go @@ -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 +} diff --git a/internal/ts3/query.go b/internal/ts3/query.go new file mode 100644 index 0000000..2d1fbc1 --- /dev/null +++ b/internal/ts3/query.go @@ -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) +} diff --git a/internal/ts3/query_test.go b/internal/ts3/query_test.go new file mode 100644 index 0000000..11c0a33 --- /dev/null +++ b/internal/ts3/query_test.go @@ -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) + } +} diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css new file mode 100644 index 0000000..0269217 --- /dev/null +++ b/internal/web/assets/app.css @@ -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:column;gap:2px}.alert-success{border-color:rgba(67,181,129,.45)}.alert-danger{border-color:rgba(230,106,112,.55);color:#ffd9db}.empty-state,.selection-box{margin:0 28px 28px;padding:22px;border:1px solid var(--border);background:var(--surface-2);border-radius:12px;text-align:center}.empty-state h2,.selection-box h2{font-size:17px;margin:0 0 5px}.empty-state p,.selection-box p{margin:0 0 18px;color:var(--muted);font-size:13px}.selection-box{text-align:left}.client-list{display:grid;gap:8px}.client-choice{width:100%;display:flex;align-items:center;gap:12px;text-align:left;border:1px solid var(--border);background:var(--surface-3);color:var(--text);padding:10px 12px;border-radius:9px;cursor:pointer}.client-choice:hover{border-color:#3b4a5d;background:#141d27}.client-choice .avatar{width:34px;height:34px;border-radius:8px;background:#253347;display:grid;place-items:center;font-weight:800}.client-choice span:nth-child(2){display:flex;flex-direction:column;flex:1}.client-choice small{color:var(--muted)}.client-badge{margin:0 28px 4px;padding:11px 13px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);display:flex;align-items:center;gap:9px;font-size:13px}.client-badge span:nth-child(2){color:var(--muted)}.channel-form{padding:18px 28px 28px}.field{margin-bottom:16px}.field label{display:block;font-size:13px;font-weight:650;margin-bottom:6px}.field input{width:100%;border:1px solid var(--border);background:var(--surface-3);color:var(--text);border-radius:9px;padding:11px 12px;font:inherit;outline:none}.field input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(50,116,217,.14)}.field input::placeholder{color:#647487}.field small{display:block;color:var(--muted);font-size:11px;margin-top:5px}@media(max-width:1060px){.graphs{grid-template-columns:1fr}.graph-image-button img{aspect-ratio:auto;min-height:260px}}@media(max-width:720px){.topbar{align-items:flex-start;padding:12px 16px;gap:12px}.brand small{display:none}.header-right{align-items:flex-end;flex-direction:column}.header-online{height:34px}.top-actions .btn{min-height:36px;padding:7px 10px;font-size:13px}.container{padding:18px 12px 34px}.page-intro{align-items:flex-start;flex-direction:column;gap:8px}.graph-head{padding:13px 14px}.graph-image-button{overflow-x:auto}.graph-image-button img{width:auto;min-width:900px;max-width:none;min-height:0;aspect-ratio:auto}.lightbox{padding:10px}.lightbox-dialog{width:100%;padding:46px 8px 8px}.channel-page{display:block;padding:0}.channel-shell{width:100%}.channel-card{min-height:100vh;border-radius:0;border-left:0;border-right:0}.channel-title{padding:23px 18px 17px}.channel-title h1{font-size:24px}.alert,.empty-state,.selection-box,.client-badge{margin-left:18px;margin-right:18px}.channel-form{padding:18px}.button-row{flex-direction:column}.button-row .btn{width:100%}} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js new file mode 100644 index 0000000..397f6fa --- /dev/null +++ b/internal/web/assets/app.js @@ -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); +})(); diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 0000000..affb650 --- /dev/null +++ b/internal/web/ratelimit.go @@ -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 +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..a2970f3 --- /dev/null +++ b/internal/web/server.go @@ -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) } diff --git a/internal/web/templates/channel.html b/internal/web/templates/channel.html new file mode 100644 index 0000000..3dbaadf --- /dev/null +++ b/internal/web/templates/channel.html @@ -0,0 +1,102 @@ +{{define "channel.html"}} + + + + +Utwórz kanał + + + +
+
+ + +
+
+

TeamSpeak 3

+

Utwórz swój kanał

+

Aplikacja wykryje Twojego klienta, utworzy kanał, nada prawa i przeniesie Cię automatycznie.

+
+
+ + {{if .Error}}
{{.Error}}
{{end}} + + {{if .Success}} +
+ Kanał został utworzony. + {{.Success}} ID kanału: {{.ChannelID}} +
+ + {{else}} + {{if eq (len .Clients) 0}}{{if not .Client}} +
+

Nie wykryto klienta TS3

+

Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.

+ +
+ {{end}}{{end}} + + {{if gt (len .Clients) 1}} +
+

Wybierz swojego klienta

+

Pod tym samym adresem IP jest kilka osób. Kod potwierdzający przyjdzie jako prywatna wiadomość na TS3.

+
+ {{range .Clients}} +
+ + + +
+ {{end}} +
+
+ {{end}} + + {{if .Client}} +
+ + Wykryty klient + {{.Client.Nick}} +
+ +
+ + + +
+ + + Pozostaw puste, aby wygenerować nazwę automatycznie. +
+ +
+ + +
+ +
+ + +
+ + +
+ {{end}} + {{end}} +
+
+ +{{end}} diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html new file mode 100644 index 0000000..46192fc --- /dev/null +++ b/internal/web/templates/stats.html @@ -0,0 +1,66 @@ +{{define "stats.html"}} + + + + +TeamSpeak — statystyki + + + + +
+
+ + TeamSpeakPanel serwera + + +
+
+ + Online + {{.Current}} +
+ +
+
+ +
+
+
+

Statystyki serwera

+

Aktywność TeamSpeak 3

+

Historia liczby użytkowników połączonych z serwerem.

+
+ Odświeżanie automatyczne +
+ +
+ {{range .Ranges}} +
+
+
+

{{.Title}}

+

Liczba użytkowników online

+
+
+ +
+ {{end}} +
+
+
+ + + +{{end}} diff --git a/internal/web/templates/verify.html b/internal/web/templates/verify.html new file mode 100644 index 0000000..455f202 --- /dev/null +++ b/internal/web/templates/verify.html @@ -0,0 +1,3 @@ +{{define "verify.html"}} +Weryfikacja TS3 +

Potwierdź klienta TS3

Kod został wysłany prywatnie do {{.Client.Nick}}.

{{end}} diff --git a/internal/web/tokens.go b/internal/web/tokens.go new file mode 100644 index 0000000..ba47576 --- /dev/null +++ b/internal/web/tokens.go @@ -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) +} diff --git a/internal/web/tokens_test.go b/internal/web/tokens_test.go new file mode 100644 index 0000000..9bd4492 --- /dev/null +++ b/internal/web/tokens_test.go @@ -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") + } +}