Compare commits
7
Commits
387933957e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa646c904 | ||
|
|
e0b80d8304 | ||
|
|
722a49f6bc | ||
|
|
439cc20dcf | ||
|
|
50b53e72f0 | ||
|
|
c85c9fc144 | ||
|
|
677a0e4d2c |
No files matched your search
+14
-1
@@ -2,6 +2,9 @@
|
|||||||
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||||
LISTEN_ADDR=0.0.0.0:8080
|
LISTEN_ADDR=0.0.0.0:8080
|
||||||
HTTPS_ONLY=true
|
HTTPS_ONLY=true
|
||||||
|
# Verbose per-cycle stats-collector logging (raw TS3 reading vs what got
|
||||||
|
# written to the RRD). Noisy, meant for troubleshooting only.
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
# Origins allowed to embed /embed/channel in an iframe. Space-separated, origins only.
|
# Origins allowed to embed /embed/channel in an iframe. Space-separated, origins only.
|
||||||
FRAME_ANCESTORS=https://www.example.org
|
FRAME_ANCESTORS=https://www.example.org
|
||||||
@@ -20,5 +23,15 @@ TS_QUERY_TIMEOUT=3s
|
|||||||
|
|
||||||
RRDTOOL=rrdtool
|
RRDTOOL=rrdtool
|
||||||
RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd
|
RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd
|
||||||
GRAPH_DIR=/var/lib/ts3monolith/graphs
|
|
||||||
STATS_INTERVAL=60s
|
STATS_INTERVAL=60s
|
||||||
|
|
||||||
|
# Per-IP rate limiting, off by default (see README "Rate limiting"). When
|
||||||
|
# RATE_LIMIT_ENABLED=false, none of the RATE_LIMIT_* values below do anything.
|
||||||
|
RATE_LIMIT_ENABLED=false
|
||||||
|
RATE_LIMIT_STATS=60
|
||||||
|
RATE_LIMIT_STATS_LIVE=60
|
||||||
|
RATE_LIMIT_GRAPH=600
|
||||||
|
RATE_LIMIT_CHANNEL=40
|
||||||
|
RATE_LIMIT_VERIFY=10
|
||||||
|
RATE_LIMIT_VERIFY_DONE=10
|
||||||
|
RATE_LIMIT_CREATE=5
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
data
|
data
|
||||||
.env
|
.env
|
||||||
|
/bin
|
||||||
|
/dist
|
||||||
|
/packages
|
||||||
@@ -1,7 +1,26 @@
|
|||||||
.PHONY: test build
|
.PHONY: test build quick dev deb clean
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
# Pelny build produkcyjny: testy + statyczna binarka bez symboli/debug info.
|
||||||
build:
|
build:
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" -o bin/ts3monolith ./cmd/ts3monolith
|
CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o bin/ts3monolith ./cmd/ts3monolith
|
||||||
|
|
||||||
|
# Szybka iteracja lokalna: bez testow, bez trimpath/ldflags, z cache'em go
|
||||||
|
# builda -> najszybsza mozliwa petla edit/build podczas developmentu.
|
||||||
|
quick:
|
||||||
|
go build -buildvcs=false -o bin/ts3monolith ./cmd/ts3monolith
|
||||||
|
|
||||||
|
# quick build + natychmiastowe uruchomienie z lokalnym .env (musi istniec,
|
||||||
|
# patrz .env.example).
|
||||||
|
dev: quick
|
||||||
|
@test -f .env || { echo "Brak .env - skopiuj .env.example do .env i uzupelnij sekrety."; exit 1; }
|
||||||
|
set -a; . ./.env; set +a; ./bin/ts3monolith
|
||||||
|
|
||||||
|
# Pakiet .deb + jednostka systemd (scripts/build-deb.sh), wynik w packages/.
|
||||||
|
deb:
|
||||||
|
./scripts/build-deb.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin dist packages
|
||||||
@@ -1,26 +1,82 @@
|
|||||||
# TS3 monolith (Go)
|
# TS3 monolith (Go)
|
||||||
|
|
||||||
Jedna aplikacja zastępująca stare `sk.linuxiarz.pl` i `ts3stats.linuxiarz.pl`.
|
Jedna aplikacja zastępująca stare `sk.linuxiarz.pl` i `ts3stats.linuxiarz.pl`.
|
||||||
|
Wygląd dashboardu (topbar + sidebar, karty, panele, jasny/ciemny motyw)
|
||||||
|
jest celowo spójny ze stylem [PostfixAdminRS](https://github.com/postfixadmin/postfixadmin) —
|
||||||
|
ten sam układ tokenów kolorów, te same komponenty UI.
|
||||||
|
|
||||||
## Zachowana funkcjonalność
|
## Funkcjonalność
|
||||||
|
|
||||||
- wykrycie klienta TeamSpeak po IP widzianym przez HTTP i ServerQuery;
|
- wykrycie klienta TeamSpeak po IP widzianym przez HTTP i ServerQuery;
|
||||||
- utworzenie permanentnego kanału z nazwą, tematem i opcjonalnym hasłem;
|
- utworzenie permanentnego kanału z nazwą, tematem, opcjonalnym hasłem,
|
||||||
|
limitem użytkowników (`channel_maxclients`) i jakością kodeka
|
||||||
|
(`channel_codec_quality`, 1–10);
|
||||||
- `setclientchannelgroup` dla wskazanego `client_database_id`;
|
- `setclientchannelgroup` dla wskazanego `client_database_id`;
|
||||||
- `clientmove` wskazanego `clid` do nowego kanału;
|
- `clientmove` wskazanego `clid` do nowego kanału;
|
||||||
- obecna baza RRD `online` i wykresy: 12h, 24h, 72h, tydzień, miesiąc, rok;
|
- dashboard statystyk: liczba online na żywo (auto-odświeżanie co 60 s przez
|
||||||
|
`GET /stats/live`), uptime/wersja/platforma serwera (`serverinfo`),
|
||||||
|
wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — dane pobierane jako
|
||||||
|
JSON i rysowane responsywnie w `<canvas>` po stronie przeglądarki (patrz
|
||||||
|
sekcja [RRD](#rrd)), z podglądem na powiększenie;
|
||||||
|
- przełącznik jasny/ciemny motyw (zapamiętywany w `localStorage`);
|
||||||
- formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie.
|
- 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.
|
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
|
## Szybki development
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.example .env # uzupełnij sekrety
|
||||||
|
make quick # najszybszy build (bez testów/trimpath) -> bin/ts3monolith
|
||||||
|
make dev # quick build + start z lokalnym .env
|
||||||
|
make test # go test ./...
|
||||||
|
make build # pełny build produkcyjny (testy + trimpath + bez symboli)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uruchomienie — Docker
|
||||||
|
|
||||||
1. Skopiuj `.env.example` do `.env` i ustaw sekrety oraz dozwoloną domenę iframe.
|
1. Skopiuj `.env.example` do `.env` i ustaw sekrety oraz dozwoloną domenę iframe.
|
||||||
2. Zachowaj `data/linuxiarz.rrd`. To kopia dotychczasowej bazy z archiwum.
|
2. Zachowaj `data/linuxiarz.rrd` (kopia dotychczasowej bazy z archiwum) albo
|
||||||
3. Zapewnij zapis do katalogu `data` dla UID 10001 przy Dockerze: `chown -R 10001:10001 data`.
|
zainicjuj nową bazę — patrz sekcja [RRD](#rrd).
|
||||||
|
3. Zapewnij zapis do katalogu `data` dla UID 10001: `chown -R 10001:10001 data`.
|
||||||
4. Uruchom: `docker compose up -d --build`.
|
4. Uruchom: `docker compose up -d --build`.
|
||||||
5. Reverse proxy kieruje HTTPS do `127.0.0.1:8080`.
|
5. Reverse proxy kieruje HTTPS do `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
## Uruchomienie — pakiet .deb + systemd
|
||||||
|
|
||||||
|
Alternatywa dla Dockera, zalecana na dedykowanym hoście Debian/Ubuntu:
|
||||||
|
|
||||||
|
```
|
||||||
|
make deb # scripts/build-deb.sh -> packages/ts3monolith_<wersja>_amd64.deb
|
||||||
|
sudo dpkg -i packages/ts3monolith_*.deb
|
||||||
|
sudo nano /etc/default/ts3monolith # APP_SECRET, TS_QUERY_USER, TS_QUERY_PASSWORD, ...
|
||||||
|
sudo -u ts3monolith /usr/lib/ts3monolith/init-rrd # tylko przy świeżej instalacji, bez starej bazy
|
||||||
|
sudo systemctl enable --now ts3monolith
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/build-deb.sh` buduje wszystko w kontenerze Docker — dokładnie tak
|
||||||
|
samo jak odpowiadający skrypt w PostfixAdminRS — więc host nie potrzebuje
|
||||||
|
lokalnie zainstalowanego Go ani żadnych zależności poza samym Dockerem.
|
||||||
|
Wersja obrazu (`golang:<wersja z go.mod>-bookworm`) jest odczytywana z
|
||||||
|
`go.mod`, a sam moduł nie ma żadnych zewnętrznych zależności, więc build jest
|
||||||
|
w pełni offline po ściągnięciu obrazu. Wynikowy pakiet trafia do `packages/`.
|
||||||
|
Pakiet instaluje:
|
||||||
|
|
||||||
|
- `/usr/bin/ts3monolith` — binarka;
|
||||||
|
- `/etc/default/ts3monolith` — zmienne środowiskowe (conffile);
|
||||||
|
- `/lib/systemd/system/ts3monolith.service` — jednostka z pełnym hardeningiem
|
||||||
|
(`ProtectSystem=strict`, `NoNewPrivileges`, `RestrictAddressFamilies`,
|
||||||
|
`MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, ...), takim
|
||||||
|
samym duchem jak w PostfixAdminRS;
|
||||||
|
- `/usr/lib/ts3monolith/init-rrd` — jednorazowy skrypt tworzący świeżą bazę
|
||||||
|
RRD z RRA pokrywającymi wszystkie zakresy wykresów (12h…rok), gdy nie ma
|
||||||
|
archiwalnego pliku do skopiowania;
|
||||||
|
- dedykowanego użytkownika systemowego `ts3monolith` (bez loginu, bez home).
|
||||||
|
|
||||||
|
`install.sh` z korzenia repo zostaje jako prostszy, ręczny installer bez
|
||||||
|
budowania pakietu `.deb` (np. do szybkiego postawienia na hoście bez
|
||||||
|
uprawnień do `dpkg`).
|
||||||
|
|
||||||
Przykład iframe:
|
Przykład iframe:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -37,24 +93,75 @@ Przykład iframe:
|
|||||||
|
|
||||||
## TeamSpeak ServerQuery
|
## 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).
|
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), `serverinfo` (karty uptime/wersji na dashboardzie).
|
||||||
|
|
||||||
## RRD
|
## 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.
|
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.
|
||||||
|
|
||||||
|
Świeża instalacja bez archiwalnej bazy: `packaging/ts3monolith-init-rrd`
|
||||||
|
(instalowany jako `/usr/lib/ts3monolith/init-rrd` w pakiecie `.deb`) tworzy
|
||||||
|
nową bazę `rrdtool create` z RRA pokrywającymi 12h/24h/72h/tydzień/miesiąc/rok
|
||||||
|
przy kroku 60 s — ten sam layout logiczny co istniejące wykresy aplikacji.
|
||||||
|
|
||||||
|
Wykresy na dashboardzie **nie są** PNG renderowanymi przez `rrdtool graph`.
|
||||||
|
`GET /stats/graph/{zakres}` (`internal/rrd/rrd.go`, `Manager.Fetch`) woła
|
||||||
|
`rrdtool fetch` i zwraca surowe próbki jako mały JSON (`{"points":[{"t":...,
|
||||||
|
"v":...},...],"current":...,"average":...,"max":...}`); front-end
|
||||||
|
(`internal/web/assets/app.js`) rysuje z tego wykres samodzielnie w
|
||||||
|
`<canvas>`, kolorami wziętymi na żywo z CSS (`--line`/--muted`/`--accent`),
|
||||||
|
więc przy przełączeniu jasny/ciemny motyw wykres się po prostu przerysowuje
|
||||||
|
z już pobranych danych — bez kolejnego zapytania do serwera i bez osobnych
|
||||||
|
plików PNG per motyw. Dzięki temu wykres jest też w pełni responsywny
|
||||||
|
(łącznie z telefonem) i powiększenie w lightboksie to kolejne przerysowanie
|
||||||
|
tych samych danych w większym `<canvas>`, a nie osobny plik.
|
||||||
|
|
||||||
|
`Fetch` dobiera funkcję konsolidacji RRD do zakresu: `LAST` dla 12h/24h/72h
|
||||||
|
(pełna rozdzielczość, RRA `RRA:LAST:0.5:1:4500`), `AVERAGE` dla
|
||||||
|
tydzień/miesiąc/rok (te okna istnieją tylko w gęściej uśrednionych RRA —
|
||||||
|
pytanie ich o `LAST` nie zwróciłoby nic starszego niż ok. 75h).
|
||||||
|
|
||||||
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.
|
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
|
## Bezpieczeństwo / fingerprinting
|
||||||
|
|
||||||
- brak PHP, frameworków frontendowych, CDN i nagłówka `X-Powered-By`;
|
- brak PHP, frameworków frontendowych, CDN i nagłówka `X-Powered-By`;
|
||||||
- standardowy `net/http` nie dodaje nagłówka `Server`;
|
- standardowy `net/http` nie dodaje nagłówka `Server`;
|
||||||
- CSP z kontrolowanym `frame-ancestors`, HSTS, `nosniff`, restrykcyjne Permissions-Policy;
|
- CSP z kontrolowanym `frame-ancestors`, HSTS, `nosniff`, restrykcyjne Permissions-Policy, `style-src`/`script-src 'self'` (bez inline — cały CSS/JS w plikach statycznych);
|
||||||
- nagłówki proxy są ufane wyłącznie z `TRUSTED_PROXIES`;
|
- 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;
|
- 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;
|
- sekrety tylko przez środowisko, brak danych dostępowych w repo;
|
||||||
- rollback kanału, jeśli nadanie grupy lub przeniesienie klienta się nie powiedzie;
|
- 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;
|
- kontener non-root, `cap_drop: ALL`, `no-new-privileges`, read-only root filesystem (Docker) albo analogiczny hardening jednostki systemd (pakiet `.deb`);
|
||||||
- build z `-trimpath` i usuniętymi symbolami/debug info.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
|
||||||
|
Domyślnie **wyłączony** (`RATE_LIMIT_ENABLED=false`) — endpointy mają limity
|
||||||
|
zahardkodowane w kodzie od początku, ale same w sobie nie chronią niczego
|
||||||
|
krytycznego (ServerQuery i tak ma własny timeout, `channelcreate` i tak
|
||||||
|
wymaga poprawnego tokenu CSRF), a przy nietypowym ruchu (kilku odwiedzających
|
||||||
|
za tym samym IP/NAT, kilka kart, lightbox z wykresami) dawały false-positive
|
||||||
|
`429` bez realnej korzyści. Włącz (`RATE_LIMIT_ENABLED=true`), jeśli chcesz
|
||||||
|
dodatkową warstwę ochrony przed nadużyciami na konkretnym endpoincie.
|
||||||
|
|
||||||
|
Każdy limit jest per-IP (po rozwiązaniu `TRUSTED_PROXIES`/`X-Forwarded-For`,
|
||||||
|
patrz `clientIP()` w `internal/web/server.go`) i liczony w oknie czasowym
|
||||||
|
podanym w nawiasie:
|
||||||
|
|
||||||
|
| Zmienna | Endpoint | Domyślnie | Co chroni |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RATE_LIMIT_STATS` | `GET /stats` (na minutę) | 60 | render strony statystyk |
|
||||||
|
| `RATE_LIMIT_STATS_LIVE` | `GET /stats/live` (na minutę) | 60 | auto-odświeżanie licznika online co 60 s |
|
||||||
|
| `RATE_LIMIT_GRAPH` | `GET /stats/graph/{zakres}` (na minutę) | 600 | dane wykresów — każde trafienie to jedno tanie `rrdtool fetch`, limit jest wysoki, bo jeden widok strony sam zużywa 6 zakresów, a NAT/kilka kart to łatwo pomnoży |
|
||||||
|
| `RATE_LIMIT_CHANNEL` | `POST /channel/create` (na minutę) | 40 | tworzenie kanału |
|
||||||
|
| `RATE_LIMIT_VERIFY` | `POST /verify/start` (na minutę) | 10 | start weryfikacji nicku (NAT) |
|
||||||
|
| `RATE_LIMIT_VERIFY_DONE` | `POST /verify/complete` (na minutę) | 10 | wpisanie 6-cyfrowego kodu |
|
||||||
|
| `RATE_LIMIT_CREATE` | `POST /channel/create` po weryfikacji (na godzinę) | 5 | utworzenie kanału po weryfikacji nicku przez NAT |
|
||||||
|
|
||||||
|
Przy przekroczeniu limitu endpoint odpowiada `429 Too Many Requests` z
|
||||||
|
nagłówkiem `Retry-After: 60`. Wartości domyślne są w `.env.example` i
|
||||||
|
`packaging/ts3monolith.default` — dostosuj je do realnego ruchu na danej
|
||||||
|
instalacji zanim włączysz `RATE_LIMIT_ENABLED` na produkcji.
|
||||||
+1
-1
@@ -10,7 +10,7 @@ rm -rf "$OUT_DIR"
|
|||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
go test ./...
|
go test ./...
|
||||||
CGO_ENABLED=0 GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -ldflags="-s -w -buildid=" -o "$OUT_DIR/$NAME" ./cmd/ts3monolith
|
CGO_ENABLED=0 GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o "$OUT_DIR/$NAME" ./cmd/ts3monolith
|
||||||
chmod 0755 "$OUT_DIR/$NAME"
|
chmod 0755 "$OUT_DIR/$NAME"
|
||||||
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
|||||||
+32
-6
@@ -24,7 +24,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
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}
|
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}
|
rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath}
|
||||||
if err := rm.Validate(); err != nil {
|
if err := rm.Validate(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -52,15 +52,41 @@ func main() {
|
|||||||
func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) {
|
func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) {
|
||||||
run := func() {
|
run := func() {
|
||||||
c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout)
|
c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout)
|
||||||
defer cancel()
|
|
||||||
n, err := tq.CountOnline(c)
|
n, err := tq.CountOnline(c)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stats query failed: %v", err)
|
log.Printf("stats query failed: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
c2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
|
// Treat "query failed" and "query succeeded but nobody is online"
|
||||||
defer cancel2()
|
// the same way: repeat the last known sample instead of writing 0.
|
||||||
if err := rm.Update(c2, n); err != nil {
|
// This matches the original PHP app's collector exactly (it never
|
||||||
|
// distinguished the two cases either). At a 1-minute sampling
|
||||||
|
// interval a real but brief empty-server moment plots as a sharp
|
||||||
|
// spike down to 0 and back up, which reads as broken/jagged far
|
||||||
|
// more than it reads as useful information -- so, deliberately,
|
||||||
|
// a true "server went empty for a while" no longer shows as 0 in
|
||||||
|
// the graph, same trade-off the original app made.
|
||||||
|
raw := n
|
||||||
|
if n == 0 {
|
||||||
|
c2, cancel2 := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
last, lastErr := rm.Last(c2)
|
||||||
|
cancel2()
|
||||||
|
if lastErr != nil {
|
||||||
|
log.Printf("collect: raw=0, rrd lastupdate failed (writing raw 0): %v", lastErr)
|
||||||
|
} else {
|
||||||
|
n = last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DEBUG=1 turns on a log line for every single collection cycle
|
||||||
|
// (raw TS3 reading vs. what actually got written to the RRD) --
|
||||||
|
// useful while diagnosing the stats collector, far too noisy to
|
||||||
|
// leave on permanently (one line per STATS_INTERVAL, forever).
|
||||||
|
if cfg.Debug {
|
||||||
|
log.Printf("collect: raw=%d written=%d", raw, n)
|
||||||
|
}
|
||||||
|
c3, cancel3 := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel3()
|
||||||
|
if err := rm.Update(c3, n); err != nil {
|
||||||
log.Printf("rrd update failed: %v", err)
|
log.Printf("rrd update failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64
|
84c245bc8b2534b0f3b822637cc7c7d51fa7c6918e60909025a326578d284e2a ts3monolith-linux-amd64
|
||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
ListenAddr string
|
ListenAddr string
|
||||||
AppSecret string
|
AppSecret string
|
||||||
HTTPSOnly bool
|
HTTPSOnly bool
|
||||||
|
Debug bool
|
||||||
FrameAncestors []string
|
FrameAncestors []string
|
||||||
TrustedProxies []*net.IPNet
|
TrustedProxies []*net.IPNet
|
||||||
TSQueryHost string
|
TSQueryHost string
|
||||||
@@ -27,8 +28,19 @@ type Config struct {
|
|||||||
TSQueryTimeout time.Duration
|
TSQueryTimeout time.Duration
|
||||||
RRDTool string
|
RRDTool string
|
||||||
RRDPath string
|
RRDPath string
|
||||||
GraphDir string
|
|
||||||
StatsInterval time.Duration
|
StatsInterval time.Duration
|
||||||
|
|
||||||
|
// Per-IP rate limiting. Off by default -- see README "Rate limiting"
|
||||||
|
// for what each endpoint's limit protects against and reasonable
|
||||||
|
// values if you turn it on.
|
||||||
|
RateLimitEnabled bool
|
||||||
|
RateLimitStats int
|
||||||
|
RateLimitStatsLive int
|
||||||
|
RateLimitGraph int
|
||||||
|
RateLimitChannel int
|
||||||
|
RateLimitVerify int
|
||||||
|
RateLimitVerifyDone int
|
||||||
|
RateLimitCreate int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -39,6 +51,7 @@ func Load() (Config, error) {
|
|||||||
return c, errors.New("APP_SECRET must contain at least 32 characters")
|
return c, errors.New("APP_SECRET must contain at least 32 characters")
|
||||||
}
|
}
|
||||||
c.HTTPSOnly = envBool("HTTPS_ONLY", true)
|
c.HTTPSOnly = envBool("HTTPS_ONLY", true)
|
||||||
|
c.Debug = envBool("DEBUG", false)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
c.FrameAncestors, err = frameAncestors(os.Getenv("FRAME_ANCESTORS"))
|
c.FrameAncestors, err = frameAncestors(os.Getenv("FRAME_ANCESTORS"))
|
||||||
@@ -76,7 +89,6 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
c.RRDTool = env("RRDTOOL", "rrdtool")
|
c.RRDTool = env("RRDTOOL", "rrdtool")
|
||||||
c.RRDPath = env("RRD_PATH", "/var/lib/ts3monolith/linuxiarz.rrd")
|
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)
|
c.StatsInterval, err = envDuration("STATS_INTERVAL", time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
@@ -84,6 +96,36 @@ func Load() (Config, error) {
|
|||||||
if c.StatsInterval < 10*time.Second {
|
if c.StatsInterval < 10*time.Second {
|
||||||
return c, errors.New("STATS_INTERVAL must be at least 10s")
|
return c, errors.New("STATS_INTERVAL must be at least 10s")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.RateLimitEnabled = envBool("RATE_LIMIT_ENABLED", false)
|
||||||
|
c.RateLimitStats, err = envInt("RATE_LIMIT_STATS", 60)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitStatsLive, err = envInt("RATE_LIMIT_STATS_LIVE", 60)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitGraph, err = envInt("RATE_LIMIT_GRAPH", 600)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitChannel, err = envInt("RATE_LIMIT_CHANNEL", 40)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitVerify, err = envInt("RATE_LIMIT_VERIFY", 10)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitVerifyDone, err = envInt("RATE_LIMIT_VERIFY_DONE", 10)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.RateLimitCreate, err = envInt("RATE_LIMIT_CREATE", 5)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+243
-32
@@ -5,31 +5,44 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Tool string
|
Tool string
|
||||||
Path string
|
Path string
|
||||||
GraphDir string
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type Range struct{ Key, Start, Title string }
|
// Range describes one of the dashboard's graph time windows. CF is the RRD
|
||||||
|
// consolidation function to fetch with: "LAST" for the windows covered by
|
||||||
|
// the full-resolution 1x60s RRA (see packaging/ts3monolith-init-rrd), and
|
||||||
|
// "AVERAGE" for the longer windows that only exist in the coarser,
|
||||||
|
// pre-averaged RRAs -- asking those for LAST would silently return nothing
|
||||||
|
// older than the full-resolution RRA's ~75h span.
|
||||||
|
type Range struct{ Key, Start, Title, CF string }
|
||||||
|
|
||||||
var Ranges = []Range{
|
var Ranges = []Range{
|
||||||
{"hour", "-12h", "Ostatnie 12 godzin"},
|
{"hour", "-12h", "Ostatnie 12 godzin", "LAST"},
|
||||||
{"day", "-24h", "Ostatnie 24 godziny"},
|
{"day", "-24h", "Ostatnie 24 godziny", "LAST"},
|
||||||
{"72h", "-72h", "Ostatnie 72 godziny"},
|
{"72h", "-72h", "Ostatnie 72 godziny", "LAST"},
|
||||||
{"week", "-1w", "Ostatni tydzień"},
|
{"week", "-1w", "Ostatni tydzień", "AVERAGE"},
|
||||||
{"month", "-1m", "Ostatni miesiąc"},
|
{"month", "-1m", "Ostatni miesiąc", "AVERAGE"},
|
||||||
{"year", "-1y", "Ostatni rok"},
|
{"year", "-1y", "Ostatni rok", "AVERAGE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func rangeByKey(key string) *Range {
|
||||||
|
for i := range Ranges {
|
||||||
|
if Ranges[i].Key == key {
|
||||||
|
return &Ranges[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Validate() error {
|
func (m *Manager) Validate() error {
|
||||||
@@ -39,7 +52,7 @@ func (m *Manager) Validate() error {
|
|||||||
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
|
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
|
||||||
return fmt.Errorf("RRD file unavailable: %s", m.Path)
|
return fmt.Errorf("RRD file unavailable: %s", m.Path)
|
||||||
}
|
}
|
||||||
return os.MkdirAll(m.GraphDir, 0750)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Update(ctx context.Context, online int) error {
|
func (m *Manager) Update(ctx context.Context, online int) error {
|
||||||
@@ -77,35 +90,233 @@ func (m *Manager) Last(ctx context.Context) (int, error) {
|
|||||||
return int(f + 0.5), err
|
return int(f + 0.5), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Graph(ctx context.Context, key string) (string, error) {
|
// Point is one JSON-serializable RRD sample. V is nil when the RRA has no
|
||||||
var rg *Range
|
// data for that timestamp (rrdtool reports it as "nan"), which the
|
||||||
for i := range Ranges {
|
// front-end renders as zero while preserving NULL as "missing" in the API.
|
||||||
if Ranges[i].Key == key {
|
type Point struct {
|
||||||
rg = &Ranges[i]
|
T int64 `json:"t"`
|
||||||
break
|
V *float64 `json:"v"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraphData is the JSON payload served at GET /stats/graph/{key}. Aktualnie
|
||||||
|
// / Średnio / Maks. are computed here (over the fetched window) so the
|
||||||
|
// front-end doesn't need to re-derive them, mirroring what the old
|
||||||
|
// rrdtool-rendered PNGs printed under the graph via GPRINT.
|
||||||
|
type GraphData struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Step int64 `json:"step"`
|
||||||
|
Points []Point `json:"points"`
|
||||||
|
PeakPoints []Point `json:"peak_points,omitempty"`
|
||||||
|
Current *float64 `json:"current"`
|
||||||
|
Average *float64 `json:"average"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxChartPoints caps how many samples Fetch returns per range. The raw
|
||||||
|
// RRA rows can be far denser than any chart will ever display pixel-for-
|
||||||
|
// pixel (72h at the full-resolution 60s RRA is 4320 rows), which both
|
||||||
|
// bloats the JSON response for no visual benefit and, rendered 1:1,
|
||||||
|
// produces a dense, spiky look where many adjacent ups/downs alias into
|
||||||
|
// what looks like noise. downsample buckets the raw series down to this
|
||||||
|
// many points (each bucket averaged, like an RRD AVERAGE RRA does) before
|
||||||
|
// it's returned, independent of any particular browser's viewport.
|
||||||
|
const maxChartPoints = 450
|
||||||
|
|
||||||
|
// chartPointTarget keeps roughly comparable visual density across ranges.
|
||||||
|
// Long ranges get wider buckets, which suppresses one-sample needles while
|
||||||
|
// preserving the overall trend. True peaks are returned separately from the
|
||||||
|
// RRD MAX archive, so smoothing the average line does not hide maxima.
|
||||||
|
func chartPointTarget(key string) int {
|
||||||
|
switch key {
|
||||||
|
case "week":
|
||||||
|
return 336 // about 30 minutes per point
|
||||||
|
case "month":
|
||||||
|
return 372 // about 2 hours per point
|
||||||
|
case "year":
|
||||||
|
return 365 // about 1 day per point
|
||||||
|
default:
|
||||||
|
return maxChartPoints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch reads the samples for one graph range directly from the RRD file
|
||||||
|
// via `rrdtool fetch`, for the front-end to render client-side. This
|
||||||
|
// replaces the previous approach of shelling out to `rrdtool graph` to
|
||||||
|
// render a PNG per theme: fetch returns a small JSON array instead of an
|
||||||
|
// image, so the browser can draw it (and redraw it on theme change or
|
||||||
|
// resize) without a round-trip, and it never produces the server-side-
|
||||||
|
// rendering quirks (fixed margins, fixed colors) that made the PNGs
|
||||||
|
// awkward to fit responsively.
|
||||||
|
func (m *Manager) Fetch(ctx context.Context, key string) (GraphData, error) {
|
||||||
|
rg := rangeByKey(key)
|
||||||
if rg == nil {
|
if rg == nil {
|
||||||
return "", errors.New("unknown graph range")
|
return GraphData{}, errors.New("unknown graph range")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(m.GraphDir, 0750); err != nil {
|
data, err := m.fetchCF(ctx, rg, rg.CF)
|
||||||
return "", err
|
if err != nil && rg.CF != "LAST" {
|
||||||
|
// Older imported RRDs may not contain AVERAGE/MAX archives. Fall
|
||||||
|
// back to LAST so the dashboard still works with such databases.
|
||||||
|
data, err = m.fetchCF(ctx, rg, "LAST")
|
||||||
}
|
}
|
||||||
out := filepath.Join(m.GraphDir, key+".png")
|
if err != nil {
|
||||||
|
return data, err
|
||||||
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"
|
// For consolidated long ranges, fetch the matching MAX archive too.
|
||||||
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"}
|
// The main line stays a smoothed average, while PeakPoints and Max keep
|
||||||
cmd := exec.CommandContext(ctx, m.Tool, args...)
|
// real peaks visible instead of deriving a fake maximum from averages.
|
||||||
|
if rg.CF == "AVERAGE" {
|
||||||
|
if peaks, peakErr := m.fetchCF(ctx, rg, "MAX"); peakErr == nil {
|
||||||
|
data.PeakPoints = peaks.Points
|
||||||
|
if peaks.Max != nil {
|
||||||
|
data.Max = peaks.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, m.Tool, "fetch", m.Path, cf, "--start", rg.Start, "--end", "now")
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
out, err := cmd.Output()
|
||||||
return "", fmt.Errorf("rrd graph: %w: %s", err, strings.TrimSpace(stderr.String()))
|
if err != nil {
|
||||||
|
return GraphData{}, fmt.Errorf("rrd fetch: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
}
|
}
|
||||||
return out, nil
|
|
||||||
|
data := GraphData{Key: rg.Key, Title: rg.Title}
|
||||||
|
var raw []Point
|
||||||
|
var sum float64
|
||||||
|
var n int
|
||||||
|
var prevT int64 = -1
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.IndexByte(line, ':')
|
||||||
|
if idx < 0 {
|
||||||
|
continue // header line, e.g. " online"
|
||||||
|
}
|
||||||
|
ts, err := strconv.ParseInt(strings.TrimSpace(line[:idx]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue // not a data row
|
||||||
|
}
|
||||||
|
pt := Point{T: ts}
|
||||||
|
if f, err := strconv.ParseFloat(strings.TrimSpace(line[idx+1:]), 64); err == nil && !math.IsNaN(f) {
|
||||||
|
v := f
|
||||||
|
pt.V = &v
|
||||||
|
sum += f
|
||||||
|
n++
|
||||||
|
if data.Max == nil || f > *data.Max {
|
||||||
|
mx := f
|
||||||
|
data.Max = &mx
|
||||||
|
}
|
||||||
|
cur := f
|
||||||
|
data.Current = &cur // last valid sample wins
|
||||||
|
}
|
||||||
|
if prevT >= 0 {
|
||||||
|
data.Step = ts - prevT
|
||||||
|
}
|
||||||
|
prevT = ts
|
||||||
|
raw = append(raw, pt)
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
avg := sum / float64(n)
|
||||||
|
data.Average = &avg
|
||||||
|
}
|
||||||
|
// Current/Average/Max above are computed from the full raw series, not
|
||||||
|
// diluted by downsampling; only the plotted points themselves shrink.
|
||||||
|
target := chartPointTarget(rg.Key)
|
||||||
|
if cf == "MAX" {
|
||||||
|
data.Points = downsampleMax(raw, target)
|
||||||
|
} else {
|
||||||
|
data.Points = downsample(raw, target)
|
||||||
|
}
|
||||||
|
if len(data.Points) > 1 {
|
||||||
|
data.Step = data.Points[1].T - data.Points[0].T
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downsample buckets points down to at most target entries. Missing samples
|
||||||
|
// inside a bucket count as zero for the plotted average, matching the UI's
|
||||||
|
// NULL -> 0 semantics and preventing one isolated non-zero sample among many
|
||||||
|
// NULLs from becoming a full-height needle. An all-NULL bucket remains NULL
|
||||||
|
// so a completely empty RRD is still detected as "Brak danych".
|
||||||
|
func downsample(points []Point, target int) []Point {
|
||||||
|
if target <= 0 || len(points) <= target {
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
bucket := float64(len(points)) / float64(target)
|
||||||
|
out := make([]Point, 0, target)
|
||||||
|
for i := 0; i < target; i++ {
|
||||||
|
start := int(float64(i) * bucket)
|
||||||
|
end := int(float64(i+1) * bucket)
|
||||||
|
if end <= start {
|
||||||
|
end = start + 1
|
||||||
|
}
|
||||||
|
if end > len(points) {
|
||||||
|
end = len(points)
|
||||||
|
}
|
||||||
|
if start >= end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
var n int
|
||||||
|
t := points[end-1].T
|
||||||
|
for _, p := range points[start:end] {
|
||||||
|
if p.V != nil {
|
||||||
|
sum += *p.V
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pt := Point{T: t}
|
||||||
|
if n > 0 {
|
||||||
|
avg := sum / float64(end-start)
|
||||||
|
pt.V = &avg
|
||||||
|
}
|
||||||
|
out = append(out, pt)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// downsampleMax keeps the highest valid value from each bucket. It is used
|
||||||
|
// only for the secondary peak line fetched from an RRD MAX archive.
|
||||||
|
func downsampleMax(points []Point, target int) []Point {
|
||||||
|
if target <= 0 || len(points) <= target {
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
bucket := float64(len(points)) / float64(target)
|
||||||
|
out := make([]Point, 0, target)
|
||||||
|
for i := 0; i < target; i++ {
|
||||||
|
start := int(float64(i) * bucket)
|
||||||
|
end := int(float64(i+1) * bucket)
|
||||||
|
if end <= start {
|
||||||
|
end = start + 1
|
||||||
|
}
|
||||||
|
if end > len(points) {
|
||||||
|
end = len(points)
|
||||||
|
}
|
||||||
|
if start >= end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pt := Point{T: points[end-1].T}
|
||||||
|
var max *float64
|
||||||
|
for _, p := range points[start:end] {
|
||||||
|
if p.V == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if max == nil || *p.V > *max {
|
||||||
|
v := *p.V
|
||||||
|
max = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pt.V = max
|
||||||
|
out = append(out, pt)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package rrd
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func f(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
func TestDownsampleNoop(t *testing.T) {
|
||||||
|
in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(2)}, {T: 3, V: f(3)}}
|
||||||
|
out := downsample(in, 10)
|
||||||
|
if len(out) != len(in) {
|
||||||
|
t.Fatalf("expected no-op for points <= target, got %d points", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownsampleAveragesBuckets(t *testing.T) {
|
||||||
|
// 10 points, 0..9, bucketed down to 5: expect roughly paired averages.
|
||||||
|
in := make([]Point, 10)
|
||||||
|
for i := range in {
|
||||||
|
in[i] = Point{T: int64(i), V: f(float64(i))}
|
||||||
|
}
|
||||||
|
out := downsample(in, 5)
|
||||||
|
if len(out) != 5 {
|
||||||
|
t.Fatalf("expected 5 points, got %d", len(out))
|
||||||
|
}
|
||||||
|
want := []float64{0.5, 2.5, 4.5, 6.5, 8.5}
|
||||||
|
for i, pt := range out {
|
||||||
|
if pt.V == nil {
|
||||||
|
t.Fatalf("bucket %d: expected a value, got nil", i)
|
||||||
|
}
|
||||||
|
if *pt.V != want[i] {
|
||||||
|
t.Errorf("bucket %d: got %v, want %v", i, *pt.V, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownsampleTreatsNullsAsZeroInsideNonEmptyBucket(t *testing.T) {
|
||||||
|
in := []Point{{T: 1, V: f(2)}, {T: 2, V: nil}, {T: 3, V: f(4)}, {T: 4, V: nil}}
|
||||||
|
out := downsample(in, 1)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].V == nil || *out[0].V != 1.5 {
|
||||||
|
t.Fatalf("expected NULL-as-zero bucket average (1.5), got %v", out[0].V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownsampleAllNullBucketStaysNull(t *testing.T) {
|
||||||
|
in := []Point{{T: 1, V: nil}, {T: 2, V: nil}}
|
||||||
|
out := downsample(in, 1)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].V != nil {
|
||||||
|
t.Fatalf("expected nil for an all-null bucket, got %v", *out[0].V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownsampleMaxKeepsPeak(t *testing.T) {
|
||||||
|
in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(7)}, {T: 3, V: nil}, {T: 4, V: f(3)}}
|
||||||
|
out := downsampleMax(in, 1)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 bucket, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].V == nil || *out[0].V != 7 {
|
||||||
|
t.Fatalf("expected bucket peak 7, got %v", out[0].V)
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-1
@@ -166,6 +166,55 @@ func (q *Query) CountOnline(ctx context.Context) (int, error) {
|
|||||||
return len(clients), nil
|
return len(clients), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerInfo describes the virtual server the query connection selects via
|
||||||
|
// "use port=...". Only the fields the UI needs are parsed; everything else
|
||||||
|
// returned by the TS3 server is ignored.
|
||||||
|
type ServerInfo struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Platform string
|
||||||
|
Uptime time.Duration
|
||||||
|
ClientsOnline int
|
||||||
|
MaxClients int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Query) ServerInfo(ctx context.Context) (ServerInfo, error) {
|
||||||
|
var info ServerInfo
|
||||||
|
err := q.withConn(ctx, func(qc *queryConn) error {
|
||||||
|
lines, err := qc.run(ctx, "serverinfo")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
for _, row := range strings.Split(line, "|") {
|
||||||
|
f := parseFields(row)
|
||||||
|
if v, ok := f["virtualserver_name"]; ok {
|
||||||
|
info.Name = v
|
||||||
|
}
|
||||||
|
if v, ok := f["virtualserver_version"]; ok {
|
||||||
|
info.Version = strings.Fields(v)[0]
|
||||||
|
}
|
||||||
|
if v, ok := f["virtualserver_platform"]; ok {
|
||||||
|
info.Platform = v
|
||||||
|
}
|
||||||
|
if v, ok := f["virtualserver_uptime"]; ok {
|
||||||
|
if secs, err := strconv.Atoi(v); err == nil {
|
||||||
|
info.Uptime = time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := f["virtualserver_clientsonline"]; ok {
|
||||||
|
info.ClientsOnline, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if v, ok := f["virtualserver_maxclients"]; ok {
|
||||||
|
info.MaxClients, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string) error {
|
func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string) error {
|
||||||
return q.withConn(ctx, func(qc *queryConn) error {
|
return q.withConn(ctx, func(qc *queryConn) error {
|
||||||
_, err := qc.run(ctx, "sendtextmessage targetmode=1 target="+strconv.Itoa(clid)+" msg="+escape(message))
|
_, err := qc.run(ctx, "sendtextmessage targetmode=1 target="+strconv.Itoa(clid)+" msg="+escape(message))
|
||||||
@@ -173,7 +222,7 @@ func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string) (int, error) {
|
func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string, maxClients, codecQuality int) (int, error) {
|
||||||
var cid int
|
var cid int
|
||||||
err := q.withConn(ctx, func(qc *queryConn) error {
|
err := q.withConn(ctx, func(qc *queryConn) error {
|
||||||
clients, err := listClients(ctx, qc)
|
clients, err := listClients(ctx, qc)
|
||||||
@@ -200,6 +249,14 @@ func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adm
|
|||||||
if password != "" {
|
if password != "" {
|
||||||
cmd += " channel_password=" + escape(password)
|
cmd += " channel_password=" + escape(password)
|
||||||
}
|
}
|
||||||
|
if maxClients > 0 {
|
||||||
|
cmd += " channel_maxclients=" + strconv.Itoa(maxClients) + " channel_flag_maxclients_unlimited=0"
|
||||||
|
} else {
|
||||||
|
cmd += " channel_flag_maxclients_unlimited=1"
|
||||||
|
}
|
||||||
|
if codecQuality > 0 {
|
||||||
|
cmd += " channel_codec_quality=" + strconv.Itoa(codecQuality)
|
||||||
|
}
|
||||||
lines, err := qc.run(ctx, cmd)
|
lines, err := qc.run(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestCreateChannelUsesDatabaseIDAndRefreshesClientID(t *testing.T) {
|
|||||||
q := &Query{Addr: ln.Addr().String(), User: "u", Password: "p", VoicePort: 9987, Timeout: 2 * time.Second}
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
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")
|
cid, err := q.CreateChannelForClient(ctx, Client{ID: 7, DatabaseID: 42, Nick: "old", UID: "abc", IP: "1.2.3.4"}, 5, "Moj kanal", "temat", "haslo", 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-1
@@ -1 +1,174 @@
|
|||||||
: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
|
/* === tokens ============================================================ */
|
||||||
|
:root{
|
||||||
|
color-scheme:dark;
|
||||||
|
--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;
|
||||||
|
--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;
|
||||||
|
--accent:#8fb9a4;--accent2:#a4c9b6;--accentink:#102018;--danger:#e18f8a;--warn:#d0ad70;--success:#a7c892;
|
||||||
|
--field:#121713;--selected:#28352e;--hover:#202621;--shadow:rgba(0,0,0,.32);
|
||||||
|
--font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||||
|
--mono:ui-monospace,SFMono-Regular,Consolas,monospace;--r:4px;--control:38px
|
||||||
|
}
|
||||||
|
html[data-theme=light]{
|
||||||
|
color-scheme:light;
|
||||||
|
--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;
|
||||||
|
--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;
|
||||||
|
--accent:#507b67;--accent2:#416b58;--accentink:#fff;--danger:#a94c48;--warn:#8a6930;--success:#587a49;
|
||||||
|
--field:#fff;--selected:#dce8e0;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === base =============================================================== */
|
||||||
|
*{box-sizing:border-box;scrollbar-color:var(--line2) var(--panel);scrollbar-width:thin}
|
||||||
|
html,body{min-height:100%}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--font);-webkit-font-smoothing:antialiased}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
button,input,select,textarea{font:inherit;color:inherit}
|
||||||
|
input,select,textarea{width:100%;min-height:var(--control);border:1px solid var(--line2);border-radius:var(--r);background:var(--field);padding:8px 11px;outline:0}
|
||||||
|
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
||||||
|
input::placeholder{color:var(--subtle)}
|
||||||
|
button{cursor:pointer}
|
||||||
|
.icon{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
|
||||||
|
.muted{color:var(--muted)}
|
||||||
|
.mono{font-family:var(--mono)}
|
||||||
|
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:var(--control);padding:7px 14px;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel2);color:var(--text);font-weight:650;white-space:nowrap}
|
||||||
|
.btn:hover{border-color:var(--accent)}
|
||||||
|
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accentink)}
|
||||||
|
.btn.primary:hover{background:var(--accent2);border-color:var(--accent2)}
|
||||||
|
.btn.ghost{background:transparent;border-color:transparent}
|
||||||
|
.btn.danger{color:var(--danger)}
|
||||||
|
.btn.full{width:100%}
|
||||||
|
.btn.lg{min-height:46px;font-size:15px}
|
||||||
|
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:11px;color:var(--muted)}
|
||||||
|
.badge.ok{color:var(--success);border-color:color-mix(in srgb,var(--success) 40%,var(--line))}
|
||||||
|
.badge .dot{width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,currentColor 18%,transparent)}
|
||||||
|
|
||||||
|
.alert{margin:0 0 16px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);display:flex;align-items:flex-start;gap:10px;font-size:13px;line-height:1.5}
|
||||||
|
.alert .icon{margin-top:1px;color:var(--muted)}
|
||||||
|
.alert.success{border-color:color-mix(in srgb,var(--success) 45%,var(--line));color:var(--success)}
|
||||||
|
.alert.success .icon{color:var(--success)}
|
||||||
|
.alert.danger{border-color:color-mix(in srgb,var(--danger) 45%,var(--line));color:var(--danger)}
|
||||||
|
.alert.danger .icon{color:var(--danger)}
|
||||||
|
.alert strong{color:var(--text)}
|
||||||
|
|
||||||
|
/* theme toggle, shared by every page */
|
||||||
|
.theme-toggle{width:36px;height:36px;min-height:36px;padding:0;border:1px solid var(--line2);border-radius:var(--r);background:var(--panel2);color:var(--accent2);outline:1px solid color-mix(in srgb,var(--text) 18%,transparent);flex:0 0 auto}
|
||||||
|
.theme-toggle:hover{background:var(--hover);border-color:var(--accent)}
|
||||||
|
.theme-toggle .icon{width:18px;height:18px;display:block;margin:auto}
|
||||||
|
.theme-toggle .ic-moon{display:none}
|
||||||
|
html[data-theme=light] .theme-toggle .ic-sun{display:none}
|
||||||
|
html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
||||||
|
|
||||||
|
/* === dashboard shell (stats page) ======================================= */
|
||||||
|
.shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)}
|
||||||
|
.topbar{position:relative;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px;min-width:0}
|
||||||
|
.brand svg{color:var(--accent);width:24px;height:24px;flex:0 0 auto}
|
||||||
|
.brand span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;justify-content:center}
|
||||||
|
.header-online strong{color:var(--text);font-size:16px}
|
||||||
|
.top-actions{display:flex;align-items:center;gap:8px;justify-content:flex-end}
|
||||||
|
.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;background:transparent}
|
||||||
|
.route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1.1s ease-in-out infinite}
|
||||||
|
.route-progress[hidden]{display:none}
|
||||||
|
@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
|
||||||
|
|
||||||
|
.icon-sprite{display:none}
|
||||||
|
|
||||||
|
.content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px}
|
||||||
|
.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap}
|
||||||
|
.page-head h1{margin:0;font-size:24px;letter-spacing:-.3px}
|
||||||
|
.page-head p{margin:4px 0 0;color:var(--muted)}
|
||||||
|
.eyebrow{margin:0 0 6px;text-transform:uppercase;letter-spacing:.12em;font-size:11px;font-weight:750;color:var(--subtle)}
|
||||||
|
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;margin-bottom:22px}
|
||||||
|
.card{padding:16px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px;align-items:start}
|
||||||
|
.card>.icon{width:22px;height:22px;color:var(--accent);margin-top:2px}
|
||||||
|
.card .k{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.07em}
|
||||||
|
.card .v{margin-top:5px;font-size:24px;font-weight:720;overflow-wrap:anywhere}
|
||||||
|
.card .vs{margin-top:5px;font-size:14px;font-weight:650;overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.section-title{margin:0 2px 10px;color:var(--muted);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}
|
||||||
|
.panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden}
|
||||||
|
.panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;min-height:44px;padding:0 13px;border-bottom:1px solid var(--line)}
|
||||||
|
.panel-head h2{margin:0;font-size:14px;font-weight:650}
|
||||||
|
|
||||||
|
.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||||
|
.graph-chart{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in}
|
||||||
|
.graph-chart canvas{display:block;width:100%;height:auto;aspect-ratio:3/1}
|
||||||
|
.graph-empty{margin:0;padding:58px 10px;text-align:center;font-size:12px}
|
||||||
|
.graph-stats{margin:0;padding:9px 13px 12px;font-size:11px;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
|
||||||
|
.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,.78);cursor:zoom-out}
|
||||||
|
.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1200px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 18px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto}
|
||||||
|
.lightbox-title{margin:0 0 10px;padding-right:90px;font-size:15px;font-weight:650}
|
||||||
|
.lightbox-canvas{display:block;width:100%;height:min(62vh,560px)}
|
||||||
|
.lightbox-dialog .graph-stats{padding:10px 2px 0}
|
||||||
|
.lightbox-close{position:absolute;top:10px;right:12px;display:inline-flex;gap:6px;align-items:center}
|
||||||
|
.lightbox-open{overflow:hidden}
|
||||||
|
|
||||||
|
/* === channel / verify (standalone card pages) ========================== */
|
||||||
|
.channel-view{min-height:100vh;display:grid;place-items:center;padding:28px}
|
||||||
|
.channel-shell{width:min(100%,640px)}
|
||||||
|
.channel-card{border:1px solid var(--line);background:var(--panel);border-radius:var(--r);box-shadow:0 18px 46px var(--shadow);overflow:hidden}
|
||||||
|
.channel-topbar{min-height:50px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:var(--panel2)}
|
||||||
|
.channel-topbar-right{display:flex;align-items:center;gap:10px}
|
||||||
|
.channel-topbar a{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;font-weight:650}
|
||||||
|
.channel-topbar a:hover{color:var(--text)}
|
||||||
|
.channel-topbar a svg{width:14px;height:14px}
|
||||||
|
.channel-title{padding:22px 24px 6px;display:flex;gap:12px;align-items:flex-start}
|
||||||
|
.channel-title svg{width:30px;height:30px;color:var(--accent);margin-top:2px}
|
||||||
|
.channel-title h1{margin:0;font-size:21px;letter-spacing:-.2px}
|
||||||
|
.channel-title p{margin:6px 0 0;color:var(--muted);font-size:13px}
|
||||||
|
.channel-body{padding:18px 24px 24px}
|
||||||
|
.empty-state,.selection-box{margin:0 0 18px;padding:20px;border:1px solid var(--line);background:var(--panel2);border-radius:var(--r);text-align:center}
|
||||||
|
.empty-state h2,.selection-box h2{font-size:15px;margin:0 0 6px}
|
||||||
|
.empty-state p,.selection-box p{margin:0 0 16px;color:var(--muted);font-size:13px}
|
||||||
|
.selection-box{text-align:left}
|
||||||
|
.client-list{display:grid;gap:7px}
|
||||||
|
.client-choice{width:100%;display:flex;align-items:center;gap:11px;text-align:left;border:1px solid var(--line);background:var(--field);color:var(--text);padding:10px 12px;border-radius:var(--r)}
|
||||||
|
.client-choice:hover{border-color:var(--accent)}
|
||||||
|
.client-choice .avatar{width:32px;height:32px;border-radius:var(--r);background:var(--selected);color:var(--accent2);display:grid;place-items:center;font-weight:800;flex:0 0 auto}
|
||||||
|
.client-choice .meta{display:flex;flex-direction:column;flex:1;min-width:0}
|
||||||
|
.client-choice small{color:var(--muted)}
|
||||||
|
.client-badge{margin:0 0 16px;padding:11px 13px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel2);display:flex;align-items:center;gap:9px;font-size:13px}
|
||||||
|
.client-badge .dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 18%,transparent);flex:0 0 auto}
|
||||||
|
.client-badge span{color:var(--muted)}
|
||||||
|
|
||||||
|
.button-row{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.button-row .btn{flex:1}
|
||||||
|
.field{margin-bottom:15px}
|
||||||
|
.field label{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:650;margin-bottom:6px;color:var(--text)}
|
||||||
|
.field label svg{width:14px;height:14px;color:var(--muted)}
|
||||||
|
.field small{display:block;color:var(--subtle);font-size:11px;margin-top:5px}
|
||||||
|
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}
|
||||||
|
.form-grid .wide{grid-column:1/-1}
|
||||||
|
|
||||||
|
/* === responsive ========================================================= */
|
||||||
|
@media(max-width:1060px){
|
||||||
|
.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}
|
||||||
|
.graphs{grid-template-columns:1fr}
|
||||||
|
.graph-chart canvas{aspect-ratio:auto;height:200px}
|
||||||
|
}
|
||||||
|
@media(max-width:860px){
|
||||||
|
.topbar{grid-template-columns:minmax(64px,auto) 1fr auto}
|
||||||
|
#refresh-status{display:none}
|
||||||
|
}
|
||||||
|
@media(max-width:520px){
|
||||||
|
.brand span{max-width:40vw}
|
||||||
|
.header-online .badge{display:none}
|
||||||
|
.btn-label{display:none}
|
||||||
|
.top-actions .btn{padding:7px 10px}
|
||||||
|
}
|
||||||
|
@media(max-width:620px){
|
||||||
|
.cards{grid-template-columns:1fr}
|
||||||
|
.form-grid{grid-template-columns:1fr}
|
||||||
|
.content{padding:18px 14px 36px}
|
||||||
|
.lightbox{padding:10px}
|
||||||
|
.lightbox-dialog{width:100%;padding:46px 8px 8px}
|
||||||
|
.channel-view{padding:0}
|
||||||
|
.channel-shell{width:100%}
|
||||||
|
.channel-card{min-height:100vh;border-radius:0;border-left:0;border-right:0}
|
||||||
|
}
|
||||||
+308
-25
@@ -1,57 +1,342 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- theme (dark/light), persisted per browser -------------------------
|
||||||
|
var THEME_KEY = 'ts3monolith-theme';
|
||||||
|
var root = document.documentElement;
|
||||||
|
var toggle = document.querySelector('.theme-toggle');
|
||||||
|
|
||||||
|
function currentTheme() {
|
||||||
|
return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(mode) {
|
||||||
|
if (mode === 'light') root.setAttribute('data-theme', 'light');
|
||||||
|
else root.removeAttribute('data-theme');
|
||||||
|
// Charts read their colors straight from the CSS custom properties at
|
||||||
|
// draw time, so a theme switch just needs a redraw of the data already
|
||||||
|
// in hand -- no re-fetch, unlike the old per-theme PNG swap.
|
||||||
|
renderAllCharts();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var saved = window.localStorage.getItem(THEME_KEY);
|
||||||
|
if (saved) applyTheme(saved);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var next = currentTheme() === 'light' ? 'dark' : 'light';
|
||||||
|
applyTheme(next);
|
||||||
|
try { window.localStorage.setItem(THEME_KEY, next); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RRD charts -----------------------------------------------------------
|
||||||
|
// Each panel's time series comes from GET /stats/graph/{range} as plain
|
||||||
|
// JSON (see rrd.Manager.Fetch / GraphData in internal/rrd/rrd.go) and is
|
||||||
|
// drawn here in a <canvas>, instead of asking the server to render a PNG
|
||||||
|
// with rrdtool. That makes the chart a normal responsive element (it
|
||||||
|
// resizes with its card, including on mobile, instead of being locked to
|
||||||
|
// whatever pixel size rrdtool was told to render) and lets a theme toggle
|
||||||
|
// or window resize just redraw the cached points rather than re-fetching
|
||||||
|
// an image.
|
||||||
|
var chartPanels = document.querySelectorAll('.js-chart');
|
||||||
|
var chartData = {};
|
||||||
|
|
||||||
|
function paletteColor(name, fallback) {
|
||||||
|
var v = getComputedStyle(root).getPropertyValue(name);
|
||||||
|
return v ? v.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeFormatter(key) {
|
||||||
|
if (key === 'week' || key === 'month') {
|
||||||
|
return function (t) {
|
||||||
|
return new Date(t * 1000).toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (key === 'year') {
|
||||||
|
return function (t) {
|
||||||
|
return new Date(t * 1000).toLocaleDateString('pl-PL', { month: 'short', year: 'numeric' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return function (t) {
|
||||||
|
return new Date(t * 1000).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(v) {
|
||||||
|
return v === null || v === undefined ? '—' : String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsText(data) {
|
||||||
|
return 'Aktualnie: ' + fmtNum(data.current) + ' Średnio: ' + fmtNum(data.average) + ' Maks.: ' + fmtNum(data.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws one series into canvas, sized to the canvas's current CSS box
|
||||||
|
// (so the caller controls size purely through layout/CSS). Returns false
|
||||||
|
// when there is nothing to draw (e.g. a fresh RRD with no samples yet),
|
||||||
|
// so the caller can show a text fallback instead of a blank box.
|
||||||
|
function drawChart(canvas, data) {
|
||||||
|
var points = (data && data.points) || [];
|
||||||
|
var hasData = points.some(function (p) { return p.v !== null && p.v !== undefined; });
|
||||||
|
if (!hasData) return false;
|
||||||
|
|
||||||
|
var cssW = canvas.clientWidth;
|
||||||
|
var cssH = canvas.clientHeight;
|
||||||
|
if (!cssW || !cssH) return false;
|
||||||
|
var dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.round(cssW * dpr);
|
||||||
|
canvas.height = Math.round(cssH * dpr);
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
|
var lineColor = paletteColor('--line', '#313832');
|
||||||
|
var textColor = paletteColor('--muted', '#9da79f');
|
||||||
|
var accent = paletteColor('--accent', '#8fb9a4');
|
||||||
|
|
||||||
|
var padL = 28, padR = 6, padT = 8, padB = 16;
|
||||||
|
var plotW = Math.max(1, cssW - padL - padR);
|
||||||
|
var plotH = Math.max(1, cssH - padT - padB);
|
||||||
|
|
||||||
|
var peakPoints = (data && data.peak_points) || [];
|
||||||
|
var values = [];
|
||||||
|
points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
|
||||||
|
peakPoints.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
|
||||||
|
var maxV = values.length ? Math.max.apply(null, values) : 1;
|
||||||
|
var top = Math.max(1, Math.ceil(maxV * 1.15));
|
||||||
|
|
||||||
|
function xAtFor(series, i) {
|
||||||
|
return padL + (series.length > 1 ? (i / (series.length - 1)) * plotW : plotW / 2);
|
||||||
|
}
|
||||||
|
function xAt(i) {
|
||||||
|
return xAtFor(points, i);
|
||||||
|
}
|
||||||
|
function yAt(v) {
|
||||||
|
return padT + plotH - (v / top) * plotH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// horizontal grid + y labels (0 / mid / top)
|
||||||
|
ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
[0, top / 2, top].forEach(function (t) {
|
||||||
|
var y = Math.round(yAt(t)) + 0.5;
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.globalAlpha = t === 0 ? 0.7 : 0.35;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padL, y);
|
||||||
|
ctx.lineTo(padL + plotW, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(String(Math.round(t)), padL - 6, yAt(t));
|
||||||
|
});
|
||||||
|
|
||||||
|
// x labels: first / middle / last sample
|
||||||
|
if (points.length > 1) {
|
||||||
|
var fmt = timeFormatter(data.key);
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
[0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx) {
|
||||||
|
ctx.textAlign = idx === 0 ? 'left' : (idx === 2 ? 'right' : 'center');
|
||||||
|
ctx.fillText(fmt(points[i].t), xAt(i), cssH - 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For week/month/year the API also returns a MAX series. Draw it first
|
||||||
|
// as a subtle dashed line: the solid/filled line remains the smoothed
|
||||||
|
// average trend, while real historical peaks are still visible.
|
||||||
|
if (peakPoints.length > 1) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
peakPoints.forEach(function (p, i) {
|
||||||
|
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
||||||
|
var x = xAtFor(peakPoints, i);
|
||||||
|
var y = yAt(v);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = accent;
|
||||||
|
ctx.globalAlpha = 0.42;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main average/trend line; missing samples are rendered as zero so the
|
||||||
|
// graph falls cleanly to the baseline instead of breaking into segments.
|
||||||
|
// Short windows represent a discrete user count, so draw them as steps:
|
||||||
|
// the value stays constant until the next sample instead of producing
|
||||||
|
// narrow triangular needles between 0 and 1/2 users.
|
||||||
|
var useSteps = data.key === 'hour' || data.key === 'day';
|
||||||
|
var seg = [];
|
||||||
|
|
||||||
|
function traceSeries(segment) {
|
||||||
|
ctx.moveTo(segment[0][0], segment[0][1]);
|
||||||
|
for (var i = 1; i < segment.length; i++) {
|
||||||
|
if (useSteps) {
|
||||||
|
ctx.lineTo(segment[i][0], segment[i - 1][1]);
|
||||||
|
}
|
||||||
|
ctx.lineTo(segment[i][0], segment[i][1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSegment() {
|
||||||
|
if (seg.length < 2) {
|
||||||
|
seg = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(seg[0][0], padT + plotH);
|
||||||
|
ctx.lineTo(seg[0][0], seg[0][1]);
|
||||||
|
for (var i = 1; i < seg.length; i++) {
|
||||||
|
if (useSteps) {
|
||||||
|
ctx.lineTo(seg[i][0], seg[i - 1][1]);
|
||||||
|
}
|
||||||
|
ctx.lineTo(seg[i][0], seg[i][1]);
|
||||||
|
}
|
||||||
|
ctx.lineTo(seg[seg.length - 1][0], padT + plotH);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = accent + '38';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
traceSeries(seg);
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineWidth = 1.6;
|
||||||
|
ctx.strokeStyle = accent;
|
||||||
|
ctx.stroke();
|
||||||
|
seg = [];
|
||||||
|
}
|
||||||
|
points.forEach(function (p, i) {
|
||||||
|
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
||||||
|
seg.push([xAt(i), yAt(v)]);
|
||||||
|
});
|
||||||
|
flushSegment();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(panel) {
|
||||||
|
var canvas = panel.querySelector('.js-chart-canvas');
|
||||||
|
var empty = panel.querySelector('.graph-empty');
|
||||||
|
var statsEl = panel.querySelector('.js-graph-stats');
|
||||||
|
if (!canvas) return;
|
||||||
|
var data = chartData[panel.dataset.range];
|
||||||
|
var ok = !!data && drawChart(canvas, data);
|
||||||
|
canvas.hidden = !ok;
|
||||||
|
if (empty) {
|
||||||
|
empty.hidden = ok;
|
||||||
|
empty.textContent = data ? 'Brak danych' : 'Wykres niedostępny';
|
||||||
|
}
|
||||||
|
if (statsEl) statsEl.textContent = ok ? statsText(data) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllCharts() {
|
||||||
|
chartPanels.forEach(renderPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCharts() {
|
||||||
|
return Promise.all(Array.prototype.map.call(chartPanels, function (panel) {
|
||||||
|
var range = panel.dataset.range;
|
||||||
|
return fetch('/stats/graph/' + range, { cache: 'no-store' })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('graph');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) { chartData[range] = data; })
|
||||||
|
.catch(function () { chartData[range] = null; });
|
||||||
|
})).then(renderAllCharts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce(fn, wait) {
|
||||||
|
var t;
|
||||||
|
return function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(fn, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartPanels.length) {
|
||||||
|
loadCharts();
|
||||||
|
window.addEventListener('resize', debounce(renderAllCharts, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- live stats + chart refresh --------------------------------------------
|
||||||
var count = document.getElementById('online-count');
|
var count = document.getElementById('online-count');
|
||||||
var status = document.getElementById('refresh-status');
|
var status = document.getElementById('refresh-status');
|
||||||
var graphs = document.querySelectorAll('.js-live-graph');
|
var progress = document.getElementById('route-progress');
|
||||||
var lightbox = document.getElementById('graph-lightbox');
|
|
||||||
var lightboxImage = document.getElementById('lightbox-image');
|
function setBusy(busy) {
|
||||||
var openers = document.querySelectorAll('.js-graph-open');
|
if (progress) progress.hidden = !busy;
|
||||||
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
|
setBusy(true);
|
||||||
if (status) status.textContent = 'Odświeżanie…';
|
if (status) status.textContent = 'Odświeżanie…';
|
||||||
|
|
||||||
|
var tasks = [];
|
||||||
if (count) {
|
if (count) {
|
||||||
fetch('/stats/live', {cache: 'no-store'})
|
tasks.push(fetch('/stats/live', { cache: 'no-store' })
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
if (!response.ok) throw new Error('stats');
|
if (!response.ok) throw new Error('stats');
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (typeof data.current === 'number') count.textContent = String(data.current);
|
if (typeof data.current === 'number') count.textContent = String(data.current);
|
||||||
if (status) status.textContent = 'Dane aktualne';
|
}));
|
||||||
})
|
}
|
||||||
.catch(function () {
|
if (chartPanels.length) tasks.push(loadCharts());
|
||||||
if (status) status.textContent = 'Nie udało się odświeżyć';
|
|
||||||
});
|
Promise.all(tasks)
|
||||||
|
.then(function () { if (status) status.textContent = 'Dane aktualne'; })
|
||||||
|
.catch(function () { if (status) status.textContent = 'Nie udało się odświeżyć'; })
|
||||||
|
.then(function () { setBusy(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
var stamp = Date.now();
|
if (count || chartPanels.length) {
|
||||||
graphs.forEach(function (img) {
|
window.setInterval(refresh, 60000);
|
||||||
var src = img.dataset.src;
|
|
||||||
if (src) img.src = src + '?v=' + stamp;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLightbox(src) {
|
// --- graph lightbox -------------------------------------------------------
|
||||||
if (!lightbox || !lightboxImage) return;
|
var lightbox = document.getElementById('graph-lightbox');
|
||||||
lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now();
|
var lightboxCanvas = document.getElementById('lightbox-canvas');
|
||||||
|
var lightboxTitle = document.getElementById('lightbox-title');
|
||||||
|
var lightboxStats = document.getElementById('lightbox-stats');
|
||||||
|
var openers = document.querySelectorAll('.js-graph-open');
|
||||||
|
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
|
||||||
|
|
||||||
|
function openLightbox(range) {
|
||||||
|
if (!lightbox || !lightboxCanvas) return;
|
||||||
|
var data = chartData[range];
|
||||||
lightbox.hidden = false;
|
lightbox.hidden = false;
|
||||||
lightbox.setAttribute('aria-hidden', 'false');
|
lightbox.setAttribute('aria-hidden', 'false');
|
||||||
document.body.classList.add('lightbox-open');
|
document.body.classList.add('lightbox-open');
|
||||||
|
if (lightboxTitle) lightboxTitle.textContent = data ? data.title : '';
|
||||||
|
if (lightboxStats) lightboxStats.textContent = data ? statsText(data) : '';
|
||||||
|
// Wait a frame so the now-visible dialog has its real layout size
|
||||||
|
// before measuring clientWidth/clientHeight for the bigger redraw.
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
if (data) drawChart(lightboxCanvas, data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLightbox() {
|
function closeLightbox() {
|
||||||
if (!lightbox || !lightboxImage) return;
|
if (!lightbox) return;
|
||||||
lightbox.hidden = true;
|
lightbox.hidden = true;
|
||||||
lightbox.setAttribute('aria-hidden', 'true');
|
lightbox.setAttribute('aria-hidden', 'true');
|
||||||
lightboxImage.src = '';
|
|
||||||
document.body.classList.remove('lightbox-open');
|
document.body.classList.remove('lightbox-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
openers.forEach(function (button) {
|
openers.forEach(function (button) {
|
||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
var src = button.getAttribute('data-full');
|
var panel = button.closest('.js-chart');
|
||||||
if (src) openLightbox(src);
|
if (panel) openLightbox(panel.dataset.range);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +347,4 @@
|
|||||||
document.addEventListener('keydown', function (event) {
|
document.addEventListener('keydown', function (event) {
|
||||||
if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox();
|
if (event.key === 'Escape' && lightbox && !lightbox.hidden) closeLightbox();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.setInterval(refresh, 60000);
|
|
||||||
})();
|
})();
|
||||||
+232
-40
@@ -2,16 +2,18 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,6 +27,38 @@ import (
|
|||||||
//go:embed templates/*.html assets/*
|
//go:embed templates/*.html assets/*
|
||||||
var embedded embed.FS
|
var embedded embed.FS
|
||||||
|
|
||||||
|
// assetHashes maps a plain asset filename ("app.css") to a short content
|
||||||
|
// hash, computed once at startup from the embedded file bytes. Templates
|
||||||
|
// reference assets through the "asset" func (assetURL below), which builds
|
||||||
|
// a hashed URL such as /assets/app.a1b2c3d4e5.css; the asset handler then
|
||||||
|
// serves that URL with a one-year immutable Cache-Control, since a content
|
||||||
|
// change always produces a new hash (and therefore a new URL) rather than
|
||||||
|
// invalidating a cached one.
|
||||||
|
var assetHashes = map[string]string{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, name := range []string{"app.css", "app.js"} {
|
||||||
|
data, err := embedded.ReadFile("assets/" + name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
assetHashes[name] = hex.EncodeToString(sum[:])[:10]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetURL returns the cache-busted URL for a static asset, falling back to
|
||||||
|
// the plain /assets/<name> path if no hash was computed for it.
|
||||||
|
func assetURL(name string) string {
|
||||||
|
hash, ok := assetHashes[name]
|
||||||
|
if !ok {
|
||||||
|
return "/assets/" + name
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
base := strings.TrimSuffix(name, ext)
|
||||||
|
return "/assets/" + base + "." + hash + ext
|
||||||
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
ts *ts3.Query
|
ts *ts3.Query
|
||||||
@@ -44,17 +78,27 @@ type channelPage struct {
|
|||||||
Error string
|
Error string
|
||||||
Success string
|
Success string
|
||||||
ChannelID int
|
ChannelID int
|
||||||
VoiceURL string
|
VoiceURL template.URL
|
||||||
|
Name string
|
||||||
|
Topic string
|
||||||
|
MaxClients string
|
||||||
|
CodecQuality string
|
||||||
}
|
}
|
||||||
|
|
||||||
type statsPage struct {
|
type statsPage struct {
|
||||||
Current int
|
Current int
|
||||||
VoiceURL string
|
VoiceURL template.URL
|
||||||
Ranges []rrd.Range
|
Ranges []rrd.Range
|
||||||
|
ServerName string
|
||||||
|
ServerVersion string
|
||||||
|
ServerPlatform string
|
||||||
|
Uptime string
|
||||||
|
MaxClients int
|
||||||
|
InfoAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
|
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
|
||||||
tpl, err := template.ParseFS(embedded, "templates/*.html")
|
tpl, err := template.New("").Funcs(template.FuncMap{"asset": assetURL}).ParseFS(embedded, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -63,11 +107,11 @@ func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
|
|||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
assets, _ := fs.Sub(embedded, "assets")
|
mux.HandleFunc("GET /assets/{file}", s.asset)
|
||||||
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 /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
|
||||||
mux.HandleFunc("GET /stats", s.stats)
|
mux.HandleFunc("GET /stats", s.stats)
|
||||||
mux.HandleFunc("GET /graph/{file}", s.graph)
|
mux.HandleFunc("GET /stats/live", s.statsLive)
|
||||||
|
mux.HandleFunc("GET /stats/graph/{key}", s.graphData)
|
||||||
mux.HandleFunc("GET /channel", s.channel)
|
mux.HandleFunc("GET /channel", s.channel)
|
||||||
mux.HandleFunc("GET /embed/channel", s.channel)
|
mux.HandleFunc("GET /embed/channel", s.channel)
|
||||||
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
||||||
@@ -100,7 +144,36 @@ func (s *Server) security(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := s.clientIP(r).String()
|
ip := s.clientIP(r).String()
|
||||||
if !s.limit.allow("stats|"+ip, 60, time.Minute) {
|
if s.cfg.RateLimitEnabled && !s.limit.allow("stats|"+ip, s.cfg.RateLimitStats, 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)
|
||||||
|
}
|
||||||
|
p := statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges}
|
||||||
|
ctx3, cancel3 := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
|
||||||
|
defer cancel3()
|
||||||
|
if info, err := s.ts.ServerInfo(ctx3); err == nil {
|
||||||
|
p.InfoAvailable = true
|
||||||
|
p.ServerName = info.Name
|
||||||
|
p.ServerVersion = info.Version
|
||||||
|
p.ServerPlatform = info.Platform
|
||||||
|
p.Uptime = formatUptime(info.Uptime)
|
||||||
|
p.MaxClients = info.MaxClients
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
s.render(w, "stats.html", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsLive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := s.clientIP(r).String()
|
||||||
|
if s.cfg.RateLimitEnabled && !s.limit.allow("statslive|"+ip, s.cfg.RateLimitStatsLive, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,34 +186,90 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
|||||||
current, _ = s.rrd.Last(ctx2)
|
current, _ = s.rrd.Last(ctx2)
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
s.render(w, "stats.html", statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges})
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_, _ = fmt.Fprintf(w, `{"current":%d}`, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
|
func formatUptime(d time.Duration) string {
|
||||||
|
if d <= 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
switch {
|
||||||
|
case days > 0:
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
case hours > 0:
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetHashPattern matches a cache-busted asset request such as
|
||||||
|
// "app.a1b2c3d4e5.css" and captures the base name, hash and extension.
|
||||||
|
var assetHashPattern = regexp.MustCompile(`^([a-zA-Z0-9_-]+)\.([0-9a-f]{10})\.(css|js)$`)
|
||||||
|
|
||||||
|
// asset serves /assets/{file}. A request matching the hashed pattern is
|
||||||
|
// served with a one-year immutable cache, but only if the hash still
|
||||||
|
// matches the currently embedded file (so a stale/forged hash 404s instead
|
||||||
|
// of being cached). Any other request (e.g. a bare "app.css" during local
|
||||||
|
// debugging) falls back to the embedded file with no special caching.
|
||||||
|
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file := r.PathValue("file")
|
||||||
|
name := file
|
||||||
|
cacheable := false
|
||||||
|
if m := assetHashPattern.FindStringSubmatch(file); m != nil {
|
||||||
|
base, hash, ext := m[1], m[2], m[3]
|
||||||
|
candidate := base + "." + ext
|
||||||
|
if assetHashes[candidate] == hash {
|
||||||
|
name = candidate
|
||||||
|
cacheable = true
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := embedded.ReadFile("assets/" + name)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch filepath.Ext(name) {
|
||||||
|
case ".css":
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
case ".js":
|
||||||
|
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||||
|
}
|
||||||
|
if cacheable {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) graphData(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := s.clientIP(r).String()
|
ip := s.clientIP(r).String()
|
||||||
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
|
// Each hit here is one rrdtool fetch returning a few hundred small
|
||||||
|
// JSON numbers read straight from the local RRD file -- cheap, so
|
||||||
|
// the limit mainly exists to stop outright abuse. See README "Rate
|
||||||
|
// limiting" for tuning RATE_LIMIT_GRAPH.
|
||||||
|
if s.cfg.RateLimitEnabled && !s.limit.allow("graph|"+ip, s.cfg.RateLimitGraph, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
file := r.PathValue("file")
|
data, err := s.rrd.Fetch(ctx, r.PathValue("key"))
|
||||||
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 {
|
if err != nil {
|
||||||
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
|
http.Error(w, "graph data unavailable", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
http.ServeFile(w, r, p)
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -149,7 +278,7 @@ func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid client address", http.StatusBadRequest)
|
http.Error(w, "invalid client address", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
|
if s.cfg.RateLimitEnabled && !s.limit.allow("channel|"+ip.String(), s.cfg.RateLimitChannel, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -177,11 +306,12 @@ func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
|
if s.cfg.RateLimitEnabled && !s.limit.allow("verify|"+ip.String(), s.cfg.RateLimitVerify, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||||
|
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
|
||||||
http.Error(w, "invalid form", http.StatusForbidden)
|
http.Error(w, "invalid form", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,16 +352,18 @@ func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
|
if s.cfg.RateLimitEnabled && !s.limit.allow("verifydone|"+ip.String(), s.cfg.RateLimitVerifyDone, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||||
|
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
|
||||||
http.Error(w, "invalid form", 403)
|
http.Error(w, "invalid form", 403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
|
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
|
||||||
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
|
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
|
||||||
|
log.Printf("403 invalid or expired code: ip=%s path=%s token_err=%v", ip, r.URL.Path, err)
|
||||||
http.Error(w, "invalid or expired code", 403)
|
http.Error(w, "invalid or expired code", 403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,51 +381,108 @@ func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
|
if s.cfg.RateLimitEnabled && !s.limit.allow("create|"+ip.String(), s.cfg.RateLimitCreate, time.Hour) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||||
|
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
|
||||||
http.Error(w, "invalid form", 403)
|
http.Error(w, "invalid form", 403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
|
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("403 client verification expired: ip=%s path=%s err=%v", ip, r.URL.Path, err)
|
||||||
http.Error(w, "client verification expired", 403)
|
http.Error(w, "client verification expired", 403)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
topic := strings.TrimSpace(r.FormValue("topic"))
|
topic := strings.TrimSpace(r.FormValue("topic"))
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
maxClientsRaw := strings.TrimSpace(r.FormValue("maxclients"))
|
||||||
|
codecQualityRaw := strings.TrimSpace(r.FormValue("codec_quality"))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = randomChannelName()
|
name = randomChannelName()
|
||||||
}
|
}
|
||||||
if err := validateInput(name, topic, password); err != nil {
|
maxClients, codecQuality, err := parseChannelOptions(maxClientsRaw, codecQualityRaw)
|
||||||
|
if err == nil {
|
||||||
|
err = validateInput(name, topic, password)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
|
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()})
|
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(), Name: name, Topic: topic, MaxClients: maxClientsRaw, CodecQuality: codecQualityRaw})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password)
|
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password, maxClients, codecQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("channel create failed for client dbid=%d: %v", c.DatabaseID, err)
|
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()})
|
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(), Name: name, Topic: topic, MaxClients: maxClientsRaw, CodecQuality: codecQualityRaw})
|
||||||
return
|
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()})
|
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 parseChannelOptions(maxClientsRaw, codecQualityRaw string) (maxClients, codecQuality int, err error) {
|
||||||
|
if maxClientsRaw != "" {
|
||||||
|
maxClients, err = strconv.Atoi(maxClientsRaw)
|
||||||
|
if err != nil || maxClients < 0 || maxClients > 512 {
|
||||||
|
return 0, 0, fmt.Errorf("Limit użytkowników musi być liczbą 0–512 (0 = bez limitu).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if codecQualityRaw != "" {
|
||||||
|
codecQuality, err = strconv.Atoi(codecQualityRaw)
|
||||||
|
if err != nil || codecQuality < 1 || codecQuality > 10 {
|
||||||
|
return 0, 0, fmt.Errorf("Jakość kodeka musi być liczbą 1–10.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxClients, codecQuality, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestHost returns the hostname the client actually used to reach us.
|
||||||
|
// Behind a reverse proxy, r.Host is the proxy's own upstream address (e.g.
|
||||||
|
// 127.0.0.1:8080) unless the proxy explicitly forwards the original Host --
|
||||||
|
// so when the request comes from a configured TRUSTED_PROXIES peer, prefer
|
||||||
|
// X-Forwarded-Host if the proxy sent one, falling back to r.Host otherwise.
|
||||||
|
func (s *Server) requestHost(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
if remote := net.ParseIP(strings.TrimSpace(host)); remote != nil {
|
||||||
|
for _, n := range s.cfg.TrustedProxies {
|
||||||
|
if n.Contains(remote) {
|
||||||
|
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.Host
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) requirePost(w http.ResponseWriter, r *http.Request, ip string) bool {
|
func (s *Server) requirePost(w http.ResponseWriter, r *http.Request, ip string) bool {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", 400)
|
http.Error(w, "bad request", 400)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if o := r.Header.Get("Origin"); o != "" {
|
// Browsers legitimately send "Origin: null" for requests originating
|
||||||
|
// from a sandboxed iframe without allow-same-origin (our documented
|
||||||
|
// embed widget, /embed/channel) and in a few redirect/privacy-mode
|
||||||
|
// edge cases -- it is not attacker-controlled, so treat it the same as
|
||||||
|
// no Origin header at all. The actual CSRF defense here is the signed,
|
||||||
|
// time-limited, IP-bound form/client/challenge token, not this header;
|
||||||
|
// this check is only a defense-in-depth cross-check for the common
|
||||||
|
// case where a real Origin is present.
|
||||||
|
if o := r.Header.Get("Origin"); o != "" && o != "null" {
|
||||||
u, err := url.Parse(o)
|
u, err := url.Parse(o)
|
||||||
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
host := s.requestHost(r)
|
||||||
|
if err != nil || !strings.EqualFold(u.Host, host) {
|
||||||
|
log.Printf("403 origin mismatch: ip=%s path=%s origin=%q expected_host=%q r.Host=%q x-forwarded-host=%q", ip, r.URL.Path, o, host, r.Host, r.Header.Get("X-Forwarded-Host"))
|
||||||
http.Error(w, "forbidden", 403)
|
http.Error(w, "forbidden", 403)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -355,8 +544,13 @@ func (s *Server) clientIP(r *http.Request) net.IP {
|
|||||||
return remote
|
return remote
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) voiceURL() string {
|
// voiceURL builds the ts3:// deep link used to launch the TeamSpeak client.
|
||||||
return "ts3server://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort)
|
// Returned as template.URL (not a plain string) because html/template's
|
||||||
|
// default URL sanitizer only allow-lists http/https/mailto and otherwise
|
||||||
|
// silently rewrites the href to "#ZgotmplZ" -- template.URL marks this
|
||||||
|
// server-built value as safe to emit verbatim.
|
||||||
|
func (s *Server) voiceURL() template.URL {
|
||||||
|
return template.URL("ts3://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort))
|
||||||
}
|
}
|
||||||
func (s *Server) tooMany(w http.ResponseWriter) {
|
func (s *Server) tooMany(w http.ResponseWriter) {
|
||||||
w.Header().Set("Retry-After", "60")
|
w.Header().Set("Retry-After", "60")
|
||||||
@@ -368,5 +562,3 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
|||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }
|
|
||||||
@@ -4,18 +4,27 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Utwórz kanał</title>
|
<title>Utwórz kanał</title>
|
||||||
<link rel="stylesheet" href="/assets/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "app.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="channel-page">
|
<body>
|
||||||
<main class="channel-shell">
|
{{template "icons" .}}
|
||||||
|
<main class="channel-view">
|
||||||
|
<div class="channel-shell">
|
||||||
<section class="channel-card">
|
<section class="channel-card">
|
||||||
<div class="channel-topbar">
|
<div class="channel-topbar">
|
||||||
<a class="back-link" href="/stats">Statystyki
|
<a href="/stats"><svg class="icon"><use href="#icon-arrow-left"/></svg>Statystyki</a>
|
||||||
</a>
|
<div class="channel-topbar-right">
|
||||||
<a class="back-link" href="{{.VoiceURL}}" target="_top">Połącz z TS3</a>
|
<a href="{{.VoiceURL}}" target="_top"><svg class="icon"><use href="#icon-zap"/></svg>Połącz z TS3</a>
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
||||||
|
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
||||||
|
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="channel-title">
|
<div class="channel-title">
|
||||||
|
<svg class="icon"><use href="#icon-headset"/></svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">TeamSpeak 3</p>
|
<p class="eyebrow">TeamSpeak 3</p>
|
||||||
<h1>Utwórz swój kanał</h1>
|
<h1>Utwórz swój kanał</h1>
|
||||||
@@ -23,16 +32,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
|
<div class="channel-body">
|
||||||
|
{{if .Error}}<div class="alert danger"><svg class="icon"><use href="#icon-circle-alert"/></svg><span>{{.Error}}</span></div>{{end}}
|
||||||
|
|
||||||
{{if .Success}}
|
{{if .Success}}
|
||||||
<div class="alert alert-success">
|
<div class="alert success">
|
||||||
<strong>Kanał został utworzony.</strong>
|
<svg class="icon"><use href="#icon-check-circle"/></svg>
|
||||||
<span>{{.Success}} ID kanału: {{.ChannelID}}</span>
|
<span><strong>Kanał został utworzony.</strong><br>{{.Success}} ID kanału: {{.ChannelID}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Otwórz TeamSpeak</a>
|
<a class="btn primary lg" href="{{.VoiceURL}}" target="_top"><svg class="icon"><use href="#icon-zap"/></svg>Otwórz TeamSpeak</a>
|
||||||
<a class="btn btn-ghost" href="/stats">Statystyki</a>
|
<a class="btn ghost" href="/stats">Statystyki</a>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if eq (len .Clients) 0}}{{if not .Client}}
|
{{if eq (len .Clients) 0}}{{if not .Client}}
|
||||||
@@ -40,8 +50,8 @@
|
|||||||
<h2>Nie wykryto klienta TS3</h2>
|
<h2>Nie wykryto klienta TS3</h2>
|
||||||
<p>Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.</p>
|
<p>Połącz się z serwerem TeamSpeak, a następnie odśwież stronę.</p>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<a class="btn btn-primary grow" href="{{.VoiceURL}}" target="_top">Połącz z TeamSpeak</a>
|
<a class="btn primary lg" href="{{.VoiceURL}}" target="_top"><svg class="icon"><use href="#icon-zap"/></svg>Połącz z TeamSpeak</a>
|
||||||
<a class="btn btn-ghost" href="">Odśwież</a>
|
<a class="btn ghost" href=""><svg class="icon"><use href="#icon-refresh-cw"/></svg>Odśwież</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
@@ -57,7 +67,8 @@
|
|||||||
<input type="hidden" name="clid" value="{{.ID}}">
|
<input type="hidden" name="clid" value="{{.ID}}">
|
||||||
<button type="submit" class="client-choice">
|
<button type="submit" class="client-choice">
|
||||||
<span class="avatar" aria-hidden="true">{{printf "%.1s" .Nick}}</span>
|
<span class="avatar" aria-hidden="true">{{printf "%.1s" .Nick}}</span>
|
||||||
<span><strong>{{.Nick}}</strong><small>Wyślij kod weryfikacyjny</small></span></button>
|
<span class="meta"><strong>{{.Nick}}</strong><small>Wyślij kod weryfikacyjny</small></span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -66,37 +77,53 @@
|
|||||||
|
|
||||||
{{if .Client}}
|
{{if .Client}}
|
||||||
<div class="client-badge">
|
<div class="client-badge">
|
||||||
<span class="online-dot" aria-hidden="true"></span>
|
<span class="dot" aria-hidden="true"></span>
|
||||||
<span>Wykryty klient</span>
|
<span>Wykryty klient</span>
|
||||||
<strong>{{.Client.Nick}}</strong>
|
<strong>{{.Client.Nick}}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/channel/create" class="channel-form">
|
<form method="post" action="/channel/create">
|
||||||
<input type="hidden" name="form_token" value="{{.FormToken}}">
|
<input type="hidden" name="form_token" value="{{.FormToken}}">
|
||||||
<input type="hidden" name="client_token" value="{{.ClientToken}}">
|
<input type="hidden" name="client_token" value="{{.ClientToken}}">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="channel-name">Nazwa kanału</label>
|
<label for="channel-name"><svg class="icon"><use href="#icon-hash"/></svg>Nazwa kanału</label>
|
||||||
<input id="channel-name" name="name" maxlength="40" autocomplete="off" placeholder="Np. Pokój Mateusza">
|
<input id="channel-name" name="name" maxlength="40" autocomplete="off" placeholder="Np. Pokój Mateusza" value="{{.Name}}">
|
||||||
<small>Pozostaw puste, aby wygenerować nazwę automatycznie.</small>
|
<small>Pozostaw puste, aby wygenerować nazwę automatycznie.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="channel-password">Hasło</label>
|
<label for="channel-password"><svg class="icon"><use href="#icon-lock"/></svg>Hasło</label>
|
||||||
<input id="channel-password" name="password" maxlength="64" type="password" autocomplete="new-password" placeholder="Opcjonalne">
|
<input id="channel-password" name="password" maxlength="64" type="password" autocomplete="new-password" placeholder="Opcjonalne">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="channel-topic">Temat / opis</label>
|
<label for="channel-maxclients"><svg class="icon"><use href="#icon-users"/></svg>Limit użytkowników</label>
|
||||||
<input id="channel-topic" name="topic" maxlength="255" autocomplete="off" placeholder="Opcjonalne">
|
<input id="channel-maxclients" name="maxclients" type="number" min="0" max="512" placeholder="0 = bez limitu" value="{{.MaxClients}}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary btn-large" type="submit">
|
<div class="field wide">
|
||||||
Utwórz kanał i przenieś mnie</button>
|
<label for="channel-topic"><svg class="icon"><use href="#icon-sliders"/></svg>Temat / opis</label>
|
||||||
|
<input id="channel-topic" name="topic" maxlength="255" autocomplete="off" placeholder="Opcjonalne" value="{{.Topic}}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field wide">
|
||||||
|
<label for="channel-codec-quality"><svg class="icon"><use href="#icon-activity"/></svg>Jakość kodeka (1–10)</label>
|
||||||
|
<input id="channel-codec-quality" name="codec_quality" type="number" min="1" max="10" placeholder="Domyślna serwera" value="{{.CodecQuality}}">
|
||||||
|
<small>Wyższa wartość oznacza lepszą jakość głosu, ale większe zużycie pasma.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn primary lg full" type="submit">
|
||||||
|
<svg class="icon"><use href="#icon-plus-circle"/></svg>Utwórz kanał i przenieś mnie
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{define "icons"}}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon-sprite">
|
||||||
|
<symbol id="icon-headset" viewBox="0 0 24 24"><path d="M4 13a8 8 0 0 1 16 0"/><path d="M4 13v3a2 2 0 0 0 2 2h1v-6H6a2 2 0 0 0-2 2Z"/><path d="M20 13v3a2 2 0 0 1-2 2h-1v-6h1a2 2 0 0 1 2 2Z"/><path d="M15 18v1a2 2 0 0 1-2 2h-1"/></symbol>
|
||||||
|
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M3 12h4l3 8 4-16 3 8h4"/></symbol>
|
||||||
|
<symbol id="icon-users" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2"/><path d="M3.2 20a6 6 0 0 1 11.6 0"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 13.2A5 5 0 0 1 20.8 18"/></symbol>
|
||||||
|
<symbol id="icon-zap" viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></symbol>
|
||||||
|
<symbol id="icon-lock" viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></symbol>
|
||||||
|
<symbol id="icon-server" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
|
||||||
|
<symbol id="icon-plus-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></symbol>
|
||||||
|
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></symbol>
|
||||||
|
<symbol id="icon-link-2" viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></symbol>
|
||||||
|
<symbol id="icon-arrow-left" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="m11 18-6-6 6-6"/></symbol>
|
||||||
|
<symbol id="icon-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></symbol>
|
||||||
|
<symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="1.5"/><path d="m21 15-5-5-4 4-2-2-5 5"/></symbol>
|
||||||
|
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 6h10M4 12h16M4 18h8"/><circle cx="16" cy="6" r="2"/><circle cx="8" cy="18" r="2"/></symbol>
|
||||||
|
<symbol id="icon-hash" viewBox="0 0 24 24"><path d="M5 9h14M5 15h14M9 4 7 20M17 4l-2 16"/></symbol>
|
||||||
|
<symbol id="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></symbol>
|
||||||
|
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5 8.5 8.5 0 1 0 20.5 14.2Z"/></symbol>
|
||||||
|
<symbol id="icon-check-circle" viewBox="0 0 24 24"><path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||||
|
<symbol id="icon-circle-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></symbol>
|
||||||
|
<symbol id="icon-refresh-cw" viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M4 17v-5h5"/><path d="M6.1 8a7 7 0 0 1 11.2-1L20 12M4 12l2.7 5a7 7 0 0 0 11.2-1"/></symbol>
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
@@ -4,51 +4,73 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>TeamSpeak — statystyki</title>
|
<title>TeamSpeak — statystyki</title>
|
||||||
<link rel="stylesheet" href="/assets/app.css">
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
<script src="/assets/app.js" defer></script>
|
<script src="{{asset "app.js"}}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
{{template "icons" .}}
|
||||||
|
<div class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/stats" aria-label="TeamSpeak — statystyki">
|
<a class="brand" href="/stats" title="{{if .InfoAvailable}}{{.ServerName}}{{end}}">
|
||||||
<span><strong>TeamSpeak</strong><small>Panel serwera</small></span>
|
<svg class="icon" width="24" height="24"><use href="#icon-headset"/></svg>
|
||||||
|
<span>{{if .InfoAvailable}}{{.ServerName}}{{else}}Serwer TS3{{end}}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="header-online" aria-live="polite">
|
<div class="header-online" aria-live="polite">
|
||||||
<span class="online-dot" aria-hidden="true"></span>
|
<span class="badge ok"><span class="dot"></span>Online</span>
|
||||||
<span>Online</span>
|
|
||||||
<strong id="online-count">{{.Current}}</strong>
|
<strong id="online-count">{{.Current}}</strong>
|
||||||
|
<span class="muted" id="refresh-status">Dane aktualne</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="top-actions" aria-label="Nawigacja">
|
<nav class="top-actions" aria-label="Nawigacja">
|
||||||
<a class="btn btn-ghost" href="{{.VoiceURL}}">Połącz</a>
|
<a class="btn ghost" href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg><span class="btn-label">Połącz</span></a>
|
||||||
<a class="btn btn-primary" href="/channel">Utwórz kanał</a>
|
<a class="btn primary" href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg><span class="btn-label">Utwórz kanał</span></a>
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
||||||
|
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
||||||
|
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="route-progress" id="route-progress" hidden><span></span></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container">
|
<main class="content">
|
||||||
<section class="page-intro">
|
<section class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Statystyki serwera</p>
|
<p class="eyebrow">Statystyki serwera</p>
|
||||||
<h1>Aktywność TeamSpeak 3</h1>
|
<h1>Aktywność TeamSpeak 3</h1>
|
||||||
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="refresh-status" id="refresh-status">Odświeżanie automatyczne</span>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="cards" aria-label="Podsumowanie">
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-users"/></svg>
|
||||||
|
<div><span class="k">Online teraz</span><div class="v">{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}}<span class="vs muted"> / {{.MaxClients}}</span>{{end}}{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-clock"/></svg>
|
||||||
|
<div><span class="k">Uptime</span><div class="vs">{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-server"/></svg>
|
||||||
|
<div><span class="k">Wersja serwera</span><div class="vs">{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-sliders"/></svg>
|
||||||
|
<div><span class="k">Platforma</span><div class="vs">{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="section-title">Użytkownicy online</p>
|
||||||
<section class="graphs" aria-label="Wykresy statystyk">
|
<section class="graphs" aria-label="Wykresy statystyk">
|
||||||
{{range .Ranges}}
|
{{range .Ranges}}
|
||||||
<article class="graph-card">
|
<article class="panel js-chart" data-range="{{.Key}}">
|
||||||
<div class="graph-head">
|
<div class="panel-head">
|
||||||
<div>
|
|
||||||
<h2>{{.Title}}</h2>
|
<h2>{{.Title}}</h2>
|
||||||
<p>Liczba użytkowników online</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="graph-chart js-graph-open" type="button" aria-label="Powiększ wykres {{.Title}}">
|
||||||
<button class="graph-image-button js-graph-open" type="button" data-full="/graph/{{.Key}}.png" aria-label="Powiększ wykres {{.Title}}">
|
<canvas class="js-chart-canvas" aria-hidden="true"></canvas>
|
||||||
<img class="js-live-graph" loading="lazy" src="/graph/{{.Key}}.png" data-src="/graph/{{.Key}}.png" alt="{{.Title}}">
|
<p class="graph-empty muted" hidden>Wczytywanie…</p>
|
||||||
</button>
|
</button>
|
||||||
|
<p class="graph-stats muted js-graph-stats"></p>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
@@ -58,8 +80,10 @@
|
|||||||
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
|
<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>
|
<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">
|
<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>
|
<button class="btn ghost lightbox-close" type="button" data-lightbox-close><svg class="icon"><use href="#icon-x"/></svg>Zamknij</button>
|
||||||
<img id="lightbox-image" src="" alt="Powiększony wykres">
|
<h2 class="lightbox-title" id="lightbox-title"></h2>
|
||||||
|
<canvas id="lightbox-canvas" class="lightbox-canvas"></canvas>
|
||||||
|
<p class="graph-stats muted" id="lightbox-stats"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
{{define "verify.html"}}<!doctype html>
|
{{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>
|
<html lang="pl">
|
||||||
<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}}
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Weryfikacja TS3</title>
|
||||||
|
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||||
|
<script src="{{asset "app.js"}}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "icons" .}}
|
||||||
|
<main class="channel-view">
|
||||||
|
<div class="channel-shell">
|
||||||
|
<section class="channel-card">
|
||||||
|
<div class="channel-topbar">
|
||||||
|
<a href="/stats"><svg class="icon"><use href="#icon-arrow-left"/></svg>Statystyki</a>
|
||||||
|
<div class="channel-topbar-right">
|
||||||
|
<a href="{{.VoiceURL}}" target="_top"><svg class="icon"><use href="#icon-zap"/></svg>Połącz z TS3</a>
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
||||||
|
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
||||||
|
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-title">
|
||||||
|
<svg class="icon"><use href="#icon-lock"/></svg>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Weryfikacja</p>
|
||||||
|
<h1>Potwierdź klienta TS3</h1>
|
||||||
|
<p>Kod został wysłany prywatnie do <strong>{{.Client.Nick}}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-body">
|
||||||
|
<form method="post" action="/channel/verify/complete">
|
||||||
|
<input type="hidden" name="form_token" value="{{.FormToken}}">
|
||||||
|
<input type="hidden" name="challenge_token" value="{{.ChallengeToken}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="verify-code"><svg class="icon"><use href="#icon-hash"/></svg>Kod weryfikacyjny</label>
|
||||||
|
<input id="verify-code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required autofocus placeholder="000000">
|
||||||
|
<small>Kod jest ważny 5 minut.</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn primary lg full" type="submit"><svg class="icon"><use href="#icon-check-circle"/></svg>Potwierdź</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
@@ -60,8 +60,14 @@ func (m *tokenManager) verify(tok, kind, ip string) (tokenPayload, error) {
|
|||||||
if err = json.Unmarshal(raw, &p); err != nil {
|
if err = json.Unmarshal(raw, &p); err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
}
|
}
|
||||||
if p.Kind != kind || p.IP != ip || time.Now().Unix() > p.Exp {
|
if p.Kind != kind {
|
||||||
return p, errors.New("expired or mismatched token")
|
return p, fmt.Errorf("token kind mismatch: got %q want %q", p.Kind, kind)
|
||||||
|
}
|
||||||
|
if p.IP != ip {
|
||||||
|
return p, fmt.Errorf("token ip mismatch: token_ip=%q request_ip=%q", p.IP, ip)
|
||||||
|
}
|
||||||
|
if now := time.Now().Unix(); now > p.Exp {
|
||||||
|
return p, fmt.Errorf("token expired %ds ago", now-p.Exp)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Tworzy nowa baze RRD dla ts3monolith, jesli jeszcze nie istnieje. Uzyj tego
|
||||||
|
# przy swiezej instalacji (brak starego pliku z archiwum). Uruchom jako
|
||||||
|
# uzytkownik ts3monolith, np.:
|
||||||
|
# sudo -u ts3monolith /usr/lib/ts3monolith/init-rrd
|
||||||
|
#
|
||||||
|
# Zmienne RRD_PATH / RRDTOOL mozna nadpisac w srodowisku, domyslnie czyta
|
||||||
|
# te same wartosci co /etc/default/ts3monolith.
|
||||||
|
|
||||||
|
RRD_PATH="${RRD_PATH:-/var/lib/ts3monolith/online.rrd}"
|
||||||
|
RRDTOOL="${RRDTOOL:-rrdtool}"
|
||||||
|
|
||||||
|
if [ -e "$RRD_PATH" ]; then
|
||||||
|
echo "RRD juz istnieje: $RRD_PATH (pomijam)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v "$RRDTOOL" >/dev/null 2>&1 || { echo "error: $RRDTOOL nie znaleziony w PATH" >&2; exit 1; }
|
||||||
|
|
||||||
|
install -d -m 0750 "$(dirname "$RRD_PATH")"
|
||||||
|
|
||||||
|
# step=60s. RRA pokrywaja wszystkie zakresy wykresow aplikacji:
|
||||||
|
# LAST 1x60s x4500 ~ 75h -> 12h / 24h / 72h
|
||||||
|
# AVG 5x60s x2016 ~ 7d -> tydzien
|
||||||
|
# AVG 30x60s x1488 ~ 31d -> miesiac
|
||||||
|
# AVG 360x60s x1460 ~ 365d -> rok
|
||||||
|
"$RRDTOOL" create "$RRD_PATH" \
|
||||||
|
--start now-1h --step 60 \
|
||||||
|
DS:online:GAUGE:120:0:U \
|
||||||
|
RRA:LAST:0.5:1:4500 \
|
||||||
|
RRA:AVERAGE:0.5:5:2016 \
|
||||||
|
RRA:MAX:0.5:5:2016 \
|
||||||
|
RRA:AVERAGE:0.5:30:1488 \
|
||||||
|
RRA:MAX:0.5:30:1488 \
|
||||||
|
RRA:AVERAGE:0.5:360:1460 \
|
||||||
|
RRA:MAX:0.5:360:1460
|
||||||
|
|
||||||
|
echo "Utworzono nowa baze RRD: $RRD_PATH"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Zmienne srodowiskowe ts3monolith (EnvironmentFile uslugi systemd).
|
||||||
|
# Po edycji: systemctl restart ts3monolith
|
||||||
|
#
|
||||||
|
# === WYMAGANE przed pierwszym uruchomieniem ================================
|
||||||
|
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||||
|
TS_QUERY_USER=CHANGE_ME
|
||||||
|
TS_QUERY_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
# === Siec / bezpieczenstwo ===================================================
|
||||||
|
LISTEN_ADDR=127.0.0.1:8080
|
||||||
|
HTTPS_ONLY=true
|
||||||
|
# Originy (tylko schemat+host), ktore moga osadzic /embed/channel w <iframe>.
|
||||||
|
FRAME_ANCESTORS=https://www.example.org
|
||||||
|
# Sieci reverse proxy, ktorym ufamy co do naglowkow X-Forwarded-For/X-Real-IP.
|
||||||
|
TRUSTED_PROXIES=127.0.0.1/32
|
||||||
|
|
||||||
|
# === TeamSpeak 3 ServerQuery ==================================================
|
||||||
|
TS_QUERY_HOST=127.0.0.1
|
||||||
|
TS_QUERY_PORT=10011
|
||||||
|
TS_VOICE_HOST=example.org
|
||||||
|
TS_VOICE_PORT=9987
|
||||||
|
TS_CHANNEL_ADMIN_GID=5
|
||||||
|
TS_QUERY_TIMEOUT=3s
|
||||||
|
|
||||||
|
# === RRD / statystyki =========================================================
|
||||||
|
RRDTOOL=rrdtool
|
||||||
|
RRD_PATH=/var/lib/ts3monolith/online.rrd
|
||||||
|
STATS_INTERVAL=60s
|
||||||
|
# Szczegolowy log kazdego cyklu zbierania statystyk (surowy odczyt z TS3 vs
|
||||||
|
# co faktycznie trafilo do RRD). Przydatne tylko przy diagnozowaniu -- zalewa
|
||||||
|
# journald jednym wpisem na STATS_INTERVAL, zostaw wylaczone na co dzien.
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# === Rate limiting ============================================================
|
||||||
|
# Domyslnie wylaczone. Przy wlaczeniu limity sa per-IP (patrz README,
|
||||||
|
# sekcja "Rate limiting") -- wartosci nizej to sensowne domyslne, dostosuj
|
||||||
|
# do realnego ruchu zanim wlaczysz na produkcji.
|
||||||
|
RATE_LIMIT_ENABLED=false
|
||||||
|
RATE_LIMIT_STATS=60
|
||||||
|
RATE_LIMIT_STATS_LIVE=60
|
||||||
|
RATE_LIMIT_GRAPH=600
|
||||||
|
RATE_LIMIT_CHANNEL=40
|
||||||
|
RATE_LIMIT_VERIFY=10
|
||||||
|
RATE_LIMIT_VERIFY_DONE=10
|
||||||
|
RATE_LIMIT_CREATE=5
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TS3 Monolith - panel TeamSpeak 3 (tworzenie kanalow + statystyki RRD)
|
||||||
|
Documentation=https://github.com/linuxiarz/ts3-monolith-go
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ts3monolith
|
||||||
|
Group=ts3monolith
|
||||||
|
WorkingDirectory=/var/lib/ts3monolith
|
||||||
|
EnvironmentFile=/etc/default/ts3monolith
|
||||||
|
ExecStart=/usr/bin/ts3monolith
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
UMask=0027
|
||||||
|
StateDirectory=ts3monolith
|
||||||
|
StateDirectoryMode=0750
|
||||||
|
|
||||||
|
# Hardening. Usluga nie wymaga zadnych uprawnien poza siecia (TS3 ServerQuery,
|
||||||
|
# HTTP) i zapisem do /var/lib/ts3monolith (RRD + wygenerowane wykresy PNG).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/ts3monolith
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
LockPersonality=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
|
||||||
|
# MemoryDenyWriteExecute=true i SystemCallFilter=~@privileged @resources sa
|
||||||
|
# NIE wlaczone celowo: sa znanym zrodlem "code=dumped, signal=SYS" (seccomp
|
||||||
|
# zabija proces) dla binarek Go, w przeciwienstwie do np. Rusta/LLVM. Jesli
|
||||||
|
# chcesz dokrecic bardziej, wlaczaj je pojedynczo i patrz na
|
||||||
|
# `journalctl -u ts3monolith` / `dmesg | grep -i seccomp` po kazdej zmianie.
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Buduje pakiet .deb dla ts3monolith w kontenerze Docker (tak samo jak
|
||||||
|
# scripts/build-deb.sh w PostfixAdminRS): zero zaleznosci na hoscie poza
|
||||||
|
# Dockerem, wersja Go dokladnie taka jak w go.mod, build w pelni offline
|
||||||
|
# (modul nie ma zadnych zewnetrznych zaleznosci).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT="$ROOT/packages"
|
||||||
|
IMAGE_BASE="${TS3MONOLITH_GO_IMAGE:-golang}"
|
||||||
|
ARCH="${DEB_ARCH:-amd64}"
|
||||||
|
TOOLCHAIN="$(sed -n 's/^go[[:space:]]*\([0-9.]*\)[[:space:]]*$/\1/p' "$ROOT/go.mod" | head -n1)"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo "error: docker is required" >&2; exit 1; }
|
||||||
|
[ -n "$TOOLCHAIN" ] || { echo "error: cannot read Go toolchain version from go.mod" >&2; exit 1; }
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
IMAGE="${IMAGE_BASE}:${TOOLCHAIN}-bookworm"
|
||||||
|
|
||||||
|
printf 'Building Debian package with %s (GOARCH=%s)\n' "$IMAGE" "$ARCH"
|
||||||
|
printf 'Output: %s\n' "$OUT"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-e CGO_ENABLED=0 \
|
||||||
|
-e GOOS=linux \
|
||||||
|
-e GOARCH="$ARCH" \
|
||||||
|
-e GOFLAGS=-mod=mod \
|
||||||
|
-v "$ROOT:/src:ro" \
|
||||||
|
-v "$OUT:/packages" \
|
||||||
|
-v ts3monolith-gocache:/root/.cache/go-build \
|
||||||
|
-v ts3monolith-gomodcache:/go/pkg/mod \
|
||||||
|
"$IMAGE" bash -euxo pipefail -c '
|
||||||
|
rm -rf /work/ts3monolith /work/pkg
|
||||||
|
mkdir -p /work/ts3monolith
|
||||||
|
cp -a /src/. /work/ts3monolith/
|
||||||
|
cd /work/ts3monolith
|
||||||
|
|
||||||
|
gofmt -l . > /tmp/fmt.out
|
||||||
|
if [ -s /tmp/fmt.out ]; then
|
||||||
|
echo "gofmt: nieotypowane pliki:"; cat /tmp/fmt.out
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
go build -trimpath -buildvcs=false -ldflags="-s -w -buildid=" -o target/ts3monolith ./cmd/ts3monolith
|
||||||
|
|
||||||
|
VERSION="$(tr -d " \t\n" < VERSION)"
|
||||||
|
DEB_VERSION="${VERSION}"
|
||||||
|
ARCH="'"$ARCH"'"
|
||||||
|
PKG=/work/pkg
|
||||||
|
|
||||||
|
install -d "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/usr/lib/ts3monolith" "$PKG/etc/default" "$PKG/lib/systemd/system" "$PKG/usr/share/doc/ts3monolith"
|
||||||
|
install -m 0755 target/ts3monolith "$PKG/usr/bin/ts3monolith"
|
||||||
|
install -m 0640 packaging/ts3monolith.default "$PKG/etc/default/ts3monolith"
|
||||||
|
install -m 0644 packaging/ts3monolith.service "$PKG/lib/systemd/system/ts3monolith.service"
|
||||||
|
install -m 0755 packaging/ts3monolith-init-rrd "$PKG/usr/lib/ts3monolith/init-rrd"
|
||||||
|
install -m 0644 README.md "$PKG/usr/share/doc/ts3monolith/"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/control" <<CONTROL
|
||||||
|
Package: ts3monolith
|
||||||
|
Version: $DEB_VERSION
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: $ARCH
|
||||||
|
Maintainer: local build <root@localhost>
|
||||||
|
Depends: adduser, passwd, rrdtool, ca-certificates
|
||||||
|
Description: Panel TeamSpeak 3 - tworzenie kanalow i statystyki RRD
|
||||||
|
TS3 Monolith to pojedyncza aplikacja Go laczaca tworzenie kanalow
|
||||||
|
TeamSpeak 3 na zadanie uzytkownika z historycznymi statystykami
|
||||||
|
obecnosci (RRD). Zastepuje stary zestaw aplikacji PHP.
|
||||||
|
CONTROL
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/conffiles" <<CONFFILES
|
||||||
|
/etc/default/ts3monolith
|
||||||
|
CONFFILES
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/postinst" <<"POSTINST"
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if ! getent group ts3monolith >/dev/null 2>&1; then
|
||||||
|
addgroup --system ts3monolith >/dev/null
|
||||||
|
fi
|
||||||
|
if ! getent passwd ts3monolith >/dev/null 2>&1; then
|
||||||
|
adduser --system \
|
||||||
|
--ingroup ts3monolith \
|
||||||
|
--home /var/lib/ts3monolith \
|
||||||
|
--no-create-home \
|
||||||
|
--disabled-login \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
ts3monolith >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o ts3monolith -g ts3monolith -m 0750 /var/lib/ts3monolith
|
||||||
|
|
||||||
|
if [ -f /etc/default/ts3monolith ]; then
|
||||||
|
chown root:ts3monolith /etc/default/ts3monolith
|
||||||
|
chmod 0640 /etc/default/ts3monolith
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v rrdtool >/dev/null 2>&1; then
|
||||||
|
echo "UWAGA: rrdtool nie jest zainstalowany - usluga sie nie uruchomi." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<MSG
|
||||||
|
|
||||||
|
ts3monolith zainstalowany.
|
||||||
|
1. Uzupelnij sekrety: nano /etc/default/ts3monolith
|
||||||
|
2. Jesli nie masz archiwalnej bazy RRD, zainicjuj nowa:
|
||||||
|
sudo -u ts3monolith /usr/lib/ts3monolith/init-rrd
|
||||||
|
3. Uruchom usluge: systemctl enable --now ts3monolith
|
||||||
|
4. Status / logi: systemctl status ts3monolith / journalctl -u ts3monolith -f
|
||||||
|
MSG
|
||||||
|
exit 0
|
||||||
|
POSTINST
|
||||||
|
chmod 0755 "$PKG/DEBIAN/postinst"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/prerm" <<"PRERM"
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "${1:-}" = remove ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop ts3monolith.service >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
PRERM
|
||||||
|
chmod 0755 "$PKG/DEBIAN/prerm"
|
||||||
|
|
||||||
|
cat > "$PKG/DEBIAN/postrm" <<"POSTRM"
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
POSTRM
|
||||||
|
chmod 0755 "$PKG/DEBIAN/postrm"
|
||||||
|
|
||||||
|
grep -qx "User=ts3monolith" "$PKG/lib/systemd/system/ts3monolith.service"
|
||||||
|
grep -qx "Group=ts3monolith" "$PKG/lib/systemd/system/ts3monolith.service"
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$PKG" "/packages/ts3monolith_${DEB_VERSION}_${ARCH}.deb"
|
||||||
|
sha256sum "/packages/ts3monolith_${DEB_VERSION}_${ARCH}.deb" > "/packages/ts3monolith_${DEB_VERSION}_${ARCH}.deb.sha256"
|
||||||
|
'
|
||||||
|
|
||||||
|
printf '\nPackages created:\n'
|
||||||
|
find "$OUT" -maxdepth 1 -type f -name 'ts3monolith_*.deb*' -printf ' %f\n' | sort
|
||||||
Reference in new issue
Block a user