first commit
This commit is contained in:
commit
fccfe2a4c8
26 files changed
+1819
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
data
|
||||
.env
|
||||
+11
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64
|
||||
@@ -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
|
||||
+110
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user