Files
ts3-monolith-go/internal/web/server.go
T
2026-11-04 14:57:48 +01:00

577 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"ts3monolith/internal/config"
"ts3monolith/internal/rrd"
"ts3monolith/internal/ts3"
)
//go:embed templates/*.html assets/*
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 {
cfg config.Config
ts *ts3.Query
rrd *rrd.Manager
tpl *template.Template
tokens *tokenManager
limit *limiter
}
type channelPage struct {
ClientIP string
Clients []ts3.Client
Client *ts3.Client
ClientToken string
FormToken string
ChallengeToken string
Error string
Success string
ChannelID int
VoiceURL template.URL
Name string
Topic string
MaxClients string
CodecQuality string
}
type statsPage struct {
Current int
VoiceURL template.URL
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) {
tpl, err := template.New("").Funcs(template.FuncMap{"asset": assetURL}).ParseFS(embedded, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{cfg: cfg, ts: tq, rrd: rm, tpl: tpl, tokens: newTokenManager(cfg.AppSecret), limit: newLimiter()}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /assets/{file}", s.asset)
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/live", s.statsLive)
mux.HandleFunc("GET /graph/{file}", s.graph)
mux.HandleFunc("GET /channel", s.channel)
mux.HandleFunc("GET /embed/channel", s.channel)
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
mux.HandleFunc("POST /channel/verify/complete", s.verifyComplete)
mux.HandleFunc("POST /channel/create", s.createChannel)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
return s.security(mux)
}
func (s *Server) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
if s.cfg.HTTPSOnly {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
frame := "'none'"
if strings.HasPrefix(r.URL.Path, "/embed/channel") || strings.HasPrefix(r.URL.Path, "/channel/") {
frame = strings.Join(s.cfg.FrameAncestors, " ")
}
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors "+frame+"; form-action 'self'; img-src 'self'; style-src 'self'; script-src 'self'")
next.ServeHTTP(w, r)
})
}
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("stats|"+ip, 60, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
current, err := s.ts.CountOnline(ctx)
if err != nil {
ctx2, cancel2 := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel2()
current, _ = s.rrd.Last(ctx2)
}
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.limit.allow("statslive|"+ip, 60, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
current, err := s.ts.CountOnline(ctx)
if err != nil {
ctx2, cancel2 := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel2()
current, _ = s.rrd.Last(ctx2)
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = fmt.Fprintf(w, `{"current":%d}`, current)
}
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) graph(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r).String()
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
file := r.PathValue("file")
if !strings.HasSuffix(file, ".png") {
http.NotFound(w, r)
return
}
key := strings.TrimSuffix(file, ".png")
theme := "dark"
if strings.HasSuffix(key, "-light") {
theme = "light"
key = strings.TrimSuffix(key, "-light")
}
if key == "" || strings.Contains(key, "/") {
http.NotFound(w, r)
return
}
p, err := s.rrd.Graph(ctx, key, theme)
if err != nil {
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Cache-Control", "public, max-age=60")
http.ServeFile(w, r, p)
}
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "invalid client address", http.StatusBadRequest)
return
}
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
s.tooMany(w)
return
}
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
p := channelPage{ClientIP: ip.String(), Clients: clients, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
if err != nil {
p.Error = "Nie udało się sprawdzić połączenia z TeamSpeak."
}
if len(clients) == 1 {
p.Client = &clients[0]
p.ClientToken = s.tokens.clientToken(ip.String(), clients[0])
}
w.Header().Set("Cache-Control", "no-store")
s.render(w, "channel.html", p)
}
func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", http.StatusForbidden)
return
}
clid, _ := strconv.Atoi(r.FormValue("clid"))
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
defer cancel()
clients, err := s.ts.ClientsByIP(ctx, ip)
if err != nil {
http.Error(w, "verification unavailable", 503)
return
}
var selected *ts3.Client
for i := range clients {
if clients[i].ID == clid {
selected = &clients[i]
break
}
}
if selected == nil {
http.Error(w, "client unavailable", 409)
return
}
challenge, code, _ := s.tokens.challengeToken(ip.String(), *selected)
if err := s.ts.SendPrivateMessage(ctx, selected.ID, "Kod weryfikacyjny strony tworzenia kanału: "+code+". Kod jest ważny 5 minut."); err != nil {
http.Error(w, "could not send verification", 503)
return
}
p := channelPage{ClientIP: ip.String(), Client: selected, ChallengeToken: challenge, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "verify.html", p)
}
func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
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)
return
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
p := channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
s.render(w, "channel.html", p)
}
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if ip == nil {
http.Error(w, "bad request", 400)
return
}
if !s.requirePost(w, r, ip.String()) {
return
}
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
s.tooMany(w)
return
}
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
log.Printf("403 invalid form token: ip=%s path=%s err=%v", ip, r.URL.Path, err)
http.Error(w, "invalid form", 403)
return
}
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
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)
return
}
name := strings.TrimSpace(r.FormValue("name"))
topic := strings.TrimSpace(r.FormValue("topic"))
password := r.FormValue("password")
maxClientsRaw := strings.TrimSpace(r.FormValue("maxclients"))
codecQualityRaw := strings.TrimSpace(r.FormValue("codec_quality"))
if name == "" {
name = randomChannelName()
}
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()}
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
}
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
defer cancel()
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password, maxClients, codecQuality)
if err != nil {
log.Printf("channel create failed for client dbid=%d: %v", c.DatabaseID, err)
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), Error: "Nie udało się utworzyć kanału. Sprawdź połączenie z TS3 i spróbuj ponownie.", VoiceURL: s.voiceURL(), Name: name, Topic: topic, MaxClients: maxClientsRaw, CodecQuality: codecQualityRaw})
return
}
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, FormToken: s.tokens.formToken(ip.String()), Success: "Kanał został utworzony, uprawnienia nadane, a klient przeniesiony.", ChannelID: cid, VoiceURL: s.voiceURL()})
}
func 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 {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", 400)
return false
}
// 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)
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)
return false
}
}
return true
}
func validateInput(name, topic, password string) error {
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 40 {
return fmt.Errorf("Nazwa kanału musi mieć 1–40 znaków.")
}
if utf8.RuneCountInString(topic) > 255 {
return fmt.Errorf("Temat może mieć maksymalnie 255 znaków.")
}
if utf8.RuneCountInString(password) > 64 {
return fmt.Errorf("Hasło może mieć maksymalnie 64 znaki.")
}
for _, v := range []string{name, topic, password} {
if strings.ContainsRune(v, '\x00') {
return fmt.Errorf("Nieprawidłowe dane wejściowe.")
}
}
return nil
}
func randomChannelName() string { return "channel-" + randomNonce()[:10] }
func (s *Server) clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
remote := net.ParseIP(strings.TrimSpace(host))
if remote == nil {
return nil
}
trusted := false
for _, n := range s.cfg.TrustedProxies {
if n.Contains(remote) {
trusted = true
break
}
}
if !trusted {
return remote
}
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
if ip := net.ParseIP(v); ip != nil {
return ip
}
}
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
if ip := net.ParseIP(strings.TrimSpace(strings.Split(v, ",")[0])); ip != nil {
return ip
}
}
return remote
}
// voiceURL builds the ts3:// deep link used to launch the TeamSpeak client.
// 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) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many requests", http.StatusTooManyRequests)
}
func (s *Server) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template error: %v", err)
}
}
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }