package web import ( "context" "crypto/sha256" "embed" "encoding/hex" "encoding/json" "fmt" "html/template" "log" "net" "net/http" "net/url" "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/ 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 /stats/graph/{key}", s.graphData) 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.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) 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) graphData(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r).String() // 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) return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() data, err := s.rrd.Fetch(ctx, r.PathValue("key")) if err != nil { http.Error(w, "graph data unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Cache-Control", "no-store") 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) { ip := s.clientIP(r) if ip == nil { http.Error(w, "invalid client address", http.StatusBadRequest) return } if s.cfg.RateLimitEnabled && !s.limit.allow("channel|"+ip.String(), s.cfg.RateLimitChannel, 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.cfg.RateLimitEnabled && !s.limit.allow("verify|"+ip.String(), s.cfg.RateLimitVerify, 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.cfg.RateLimitEnabled && !s.limit.allow("verifydone|"+ip.String(), s.cfg.RateLimitVerifyDone, 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.cfg.RateLimitEnabled && !s.limit.allow("create|"+ip.String(), s.cfg.RateLimitCreate, 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) } }