first commit
This commit is contained in:
commit
fccfe2a4c8
26 files changed
+1819
No files matched your search
@@ -0,0 +1,372 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"ts3monolith/internal/config"
|
||||
"ts3monolith/internal/rrd"
|
||||
"ts3monolith/internal/ts3"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html assets/*
|
||||
var embedded embed.FS
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
ts *ts3.Query
|
||||
rrd *rrd.Manager
|
||||
tpl *template.Template
|
||||
tokens *tokenManager
|
||||
limit *limiter
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
ClientIP string
|
||||
Clients []ts3.Client
|
||||
Client *ts3.Client
|
||||
ClientToken string
|
||||
FormToken string
|
||||
ChallengeToken string
|
||||
Error string
|
||||
Success string
|
||||
ChannelID int
|
||||
VoiceURL string
|
||||
}
|
||||
|
||||
type statsPage struct {
|
||||
Current int
|
||||
VoiceURL string
|
||||
Ranges []rrd.Range
|
||||
}
|
||||
|
||||
func New(cfg config.Config, tq *ts3.Query, rm *rrd.Manager) (*Server, error) {
|
||||
tpl, err := template.ParseFS(embedded, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{cfg: cfg, ts: tq, rrd: rm, tpl: tpl, tokens: newTokenManager(cfg.AppSecret), limit: newLimiter()}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
assets, _ := fs.Sub(embedded, "assets")
|
||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assets))))
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
|
||||
mux.HandleFunc("GET /stats", s.stats)
|
||||
mux.HandleFunc("GET /graph/{file}", s.graph)
|
||||
mux.HandleFunc("GET /channel", s.channel)
|
||||
mux.HandleFunc("GET /embed/channel", s.channel)
|
||||
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
||||
mux.HandleFunc("POST /channel/verify/complete", s.verifyComplete)
|
||||
mux.HandleFunc("POST /channel/create", s.createChannel)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
return s.security(mux)
|
||||
}
|
||||
|
||||
func (s *Server) security(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if s.cfg.HTTPSOnly {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
frame := "'none'"
|
||||
if strings.HasPrefix(r.URL.Path, "/embed/channel") || strings.HasPrefix(r.URL.Path, "/channel/") {
|
||||
frame = strings.Join(s.cfg.FrameAncestors, " ")
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors "+frame+"; form-action 'self'; img-src 'self'; style-src 'self'; script-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r).String()
|
||||
if !s.limit.allow("stats|"+ip, 60, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
|
||||
defer cancel()
|
||||
current, err := s.ts.CountOnline(ctx)
|
||||
if err != nil {
|
||||
ctx2, cancel2 := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel2()
|
||||
current, _ = s.rrd.Last(ctx2)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.render(w, "stats.html", statsPage{Current: current, VoiceURL: s.voiceURL(), Ranges: rrd.Ranges})
|
||||
}
|
||||
|
||||
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r).String()
|
||||
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
file := r.PathValue("file")
|
||||
if !strings.HasSuffix(file, ".png") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSuffix(file, ".png")
|
||||
if key == "" || strings.Contains(key, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p, err := s.rrd.Graph(ctx, key)
|
||||
if err != nil {
|
||||
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
http.ServeFile(w, r, p)
|
||||
}
|
||||
|
||||
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r)
|
||||
if ip == nil {
|
||||
http.Error(w, "invalid client address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
|
||||
defer cancel()
|
||||
clients, err := s.ts.ClientsByIP(ctx, ip)
|
||||
p := channelPage{ClientIP: ip.String(), Clients: clients, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
|
||||
if err != nil {
|
||||
p.Error = "Nie udało się sprawdzić połączenia z TeamSpeak."
|
||||
}
|
||||
if len(clients) == 1 {
|
||||
p.Client = &clients[0]
|
||||
p.ClientToken = s.tokens.clientToken(ip.String(), clients[0])
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
s.render(w, "channel.html", p)
|
||||
}
|
||||
|
||||
func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r)
|
||||
if ip == nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
if !s.requirePost(w, r, ip.String()) {
|
||||
return
|
||||
}
|
||||
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
clid, _ := strconv.Atoi(r.FormValue("clid"))
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.TSQueryTimeout)
|
||||
defer cancel()
|
||||
clients, err := s.ts.ClientsByIP(ctx, ip)
|
||||
if err != nil {
|
||||
http.Error(w, "verification unavailable", 503)
|
||||
return
|
||||
}
|
||||
var selected *ts3.Client
|
||||
for i := range clients {
|
||||
if clients[i].ID == clid {
|
||||
selected = &clients[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
http.Error(w, "client unavailable", 409)
|
||||
return
|
||||
}
|
||||
challenge, code, _ := s.tokens.challengeToken(ip.String(), *selected)
|
||||
if err := s.ts.SendPrivateMessage(ctx, selected.ID, "Kod weryfikacyjny strony tworzenia kanału: "+code+". Kod jest ważny 5 minut."); err != nil {
|
||||
http.Error(w, "could not send verification", 503)
|
||||
return
|
||||
}
|
||||
p := channelPage{ClientIP: ip.String(), Client: selected, ChallengeToken: challenge, FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
|
||||
s.render(w, "verify.html", p)
|
||||
}
|
||||
|
||||
func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r)
|
||||
if ip == nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
if !s.requirePost(w, r, ip.String()) {
|
||||
return
|
||||
}
|
||||
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||
http.Error(w, "invalid form", 403)
|
||||
return
|
||||
}
|
||||
pld, err := s.tokens.verify(r.FormValue("challenge_token"), "challenge", ip.String())
|
||||
if err != nil || r.FormValue("code") != s.tokens.challengeCode(pld) {
|
||||
http.Error(w, "invalid or expired code", 403)
|
||||
return
|
||||
}
|
||||
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
|
||||
p := channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), VoiceURL: s.voiceURL()}
|
||||
s.render(w, "channel.html", p)
|
||||
}
|
||||
|
||||
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
|
||||
ip := s.clientIP(r)
|
||||
if ip == nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
if !s.requirePost(w, r, ip.String()) {
|
||||
return
|
||||
}
|
||||
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
|
||||
s.tooMany(w)
|
||||
return
|
||||
}
|
||||
if _, err := s.tokens.verify(r.FormValue("form_token"), "form", ip.String()); err != nil {
|
||||
http.Error(w, "invalid form", 403)
|
||||
return
|
||||
}
|
||||
pld, err := s.tokens.verify(r.FormValue("client_token"), "client", ip.String())
|
||||
if err != nil {
|
||||
http.Error(w, "client verification expired", 403)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
topic := strings.TrimSpace(r.FormValue("topic"))
|
||||
password := r.FormValue("password")
|
||||
if name == "" {
|
||||
name = randomChannelName()
|
||||
}
|
||||
if err := validateInput(name, topic, password); err != nil {
|
||||
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
|
||||
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: r.FormValue("client_token"), FormToken: s.tokens.formToken(ip.String()), Error: err.Error(), VoiceURL: s.voiceURL()})
|
||||
return
|
||||
}
|
||||
c := ts3.Client{ID: pld.ClID, DatabaseID: pld.DBID, Nick: pld.Nick, UID: pld.UID, IP: ip.String()}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
cid, err := s.ts.CreateChannelForClient(ctx, c, s.cfg.TSChannelAdminGID, name, topic, password)
|
||||
if err != nil {
|
||||
log.Printf("channel create failed for client dbid=%d: %v", c.DatabaseID, err)
|
||||
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, ClientToken: s.tokens.clientToken(ip.String(), c), FormToken: s.tokens.formToken(ip.String()), Error: "Nie udało się utworzyć kanału. Sprawdź połączenie z TS3 i spróbuj ponownie.", VoiceURL: s.voiceURL()})
|
||||
return
|
||||
}
|
||||
s.render(w, "channel.html", channelPage{ClientIP: ip.String(), Client: &c, FormToken: s.tokens.formToken(ip.String()), Success: "Kanał został utworzony, uprawnienia nadane, a klient przeniesiony.", ChannelID: cid, VoiceURL: s.voiceURL()})
|
||||
}
|
||||
|
||||
func (s *Server) requirePost(w http.ResponseWriter, r *http.Request, ip string) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return false
|
||||
}
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
u, err := url.Parse(o)
|
||||
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
||||
http.Error(w, "forbidden", 403)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateInput(name, topic, password string) error {
|
||||
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > 40 {
|
||||
return fmt.Errorf("Nazwa kanału musi mieć 1–40 znaków.")
|
||||
}
|
||||
if utf8.RuneCountInString(topic) > 255 {
|
||||
return fmt.Errorf("Temat może mieć maksymalnie 255 znaków.")
|
||||
}
|
||||
if utf8.RuneCountInString(password) > 64 {
|
||||
return fmt.Errorf("Hasło może mieć maksymalnie 64 znaki.")
|
||||
}
|
||||
for _, v := range []string{name, topic, password} {
|
||||
if strings.ContainsRune(v, '\x00') {
|
||||
return fmt.Errorf("Nieprawidłowe dane wejściowe.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomChannelName() string { return "channel-" + randomNonce()[:10] }
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) net.IP {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
remote := net.ParseIP(strings.TrimSpace(host))
|
||||
if remote == nil {
|
||||
return nil
|
||||
}
|
||||
trusted := false
|
||||
for _, n := range s.cfg.TrustedProxies {
|
||||
if n.Contains(remote) {
|
||||
trusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !trusted {
|
||||
return remote
|
||||
}
|
||||
for _, h := range []string{"CF-Connecting-IP", "X-Real-IP"} {
|
||||
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
|
||||
if ip := net.ParseIP(v); ip != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := r.Header.Get("X-Forwarded-For"); v != "" {
|
||||
if ip := net.ParseIP(strings.TrimSpace(strings.Split(v, ",")[0])); ip != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
func (s *Server) voiceURL() string {
|
||||
return "ts3server://" + s.cfg.TSVoiceHost + "?port=" + strconv.Itoa(s.cfg.TSVoicePort)
|
||||
}
|
||||
func (s *Server) tooMany(w http.ResponseWriter) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
}
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }
|
||||
Reference in new issue
Block a user