package main import ( "encoding/json" "errors" "net" "net/http" "net/http/httputil" "net/url" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" ) const cctvTTL = 12 * time.Hour // Only an explicitly configured local tunnel may become the public camera backend. func cctvTarget(value string) (*url.URL, error) { if value == "" { return nil, nil } u, err := url.Parse(value) if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") { return nil, errors.New("cctv_upstream must be an HTTP loopback origin without credentials, path or query") } ip := net.ParseIP(u.Hostname()) port, err := strconv.Atoi(u.Port()) if ip == nil || !ip.IsLoopback() || err != nil || port < 1 || port > 65535 { return nil, errors.New("cctv_upstream requires a numeric loopback address and explicit port") } u.Path = "" return u, nil } func cctvSameOrigin(r *http.Request) bool { return r.Header.Get("Origin") == "https://"+r.Host && len(r.Header.Values("Origin")) == 1 && r.Header.Get("Sec-Fetch-Site") != "cross-site" } func (s *Server) cctvMux(upstream string) (http.Handler, error) { target, err := cctvTarget(upstream) if err != nil { return nil, err } if target != nil { listenHost, listenPort, _ := net.SplitHostPort(s.cfg.Listen) if target.Host == s.cfg.Listen || (listenPort == target.Port() && (net.ParseIP(listenHost).IsLoopback() || net.ParseIP(listenHost).IsUnspecified() || listenHost == "")) { return nil, errors.New("cctv_upstream cannot point to the wxch listener") } } var proxy *httputil.ReverseProxy if target != nil { proxy = &httputil.ReverseProxy{ FlushInterval: -1, Rewrite: func(p *httputil.ProxyRequest) { p.SetURL(target) p.Out.Host = p.In.Host p.Out.Header.Del("Cookie") for _, cookie := range p.In.Cookies() { if !strings.HasPrefix(cookie.Name, "wxch_") { p.Out.AddCookie(cookie) } } p.Out.Header.Del("Authorization") p.Out.Header.Del("X-Real-IP") for name := range p.Out.Header { if strings.HasPrefix(strings.ToLower(name), "x-forwarded-") { p.Out.Header.Del(name) } } }, ModifyResponse: func(r *http.Response) error { cookies := r.Cookies() r.Header.Del("Set-Cookie") for _, cookie := range cookies { if !strings.HasPrefix(cookie.Name, "wxch_") { r.Header.Add("Set-Cookie", cookie.String()) } } r.Header.Set("Cache-Control", "no-store") return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, _ error) { cctvUnavailable(w) }, } } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Robots-Tag", "noindex") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Frame-Options", "DENY") if r.URL.Path == "/_wxch/login" { if r.Method != http.MethodPost { http.Error(w, "POST required", 405) return } if !cctvSameOrigin(r) || !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { http.Error(w, "same-origin JSON required", 403) return } s.cctvLogin(w, r) return } cookie, _ := r.Cookie("wxch_cctv") if cookie == nil || !s.cctvSess.valid(cookie.Value) { if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/_wxch/") && strings.Contains(r.Header.Get("Accept"), "text/html") { nonce := randHex(16) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-"+nonce+"'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") page := strings.ReplaceAll(cctvLoginPage, "@@NONCE@@", nonce) showCode := "hidden" if s.cfg.TOTPSecret != "" { showCode = "" } w.Write([]byte(strings.ReplaceAll(page, "@@CODE@@", showCode))) } else { http.Error(w, "CCTV login required", 401) } return } if r.Method != http.MethodGet && r.Method != http.MethodHead && !cctvSameOrigin(r) { http.Error(w, "same-origin request required", 403) return } if origin := r.Header.Get("Origin"); origin != "" && !cctvSameOrigin(r) { http.Error(w, "origin rejected", 403) return } if r.Header.Get("Sec-Fetch-Site") == "cross-site" { http.Error(w, "cross-site request rejected", 403) return } if r.URL.Path == "/_wxch/logout" && r.Method == http.MethodPost { s.cctvSess.remove(cookie.Value) http.SetCookie(w, &http.Cookie{Name: "wxch_cctv", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1}) w.Write([]byte("ok")) return } if r.URL.Path == "/_wxch/status" && r.Method == http.MethodGet { writeJSON(w, map[string]bool{"upstream_configured": target != nil}) return } if strings.HasPrefix(r.URL.Path, "/_wxch/") { http.NotFound(w, r) return } if proxy == nil { cctvUnavailable(w) return } proxy.ServeHTTP(w, r) }), nil } func (s *Server) cctvLogin(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if s.locked(ip) { http.Error(w, "too many attempts", 429) return } var req struct{ Password, Code string } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req) != nil { http.Error(w, "bad request", 400) return } if bcrypt.CompareHashAndPassword([]byte(s.pwHash()), []byte(req.Password)) != nil || (s.cfg.TOTPSecret != "" && !totpOK(s.cfg.TOTPSecret, req.Code)) { s.fail(ip) time.Sleep(time.Second) http.Error(w, "invalid credentials", 401) return } token := randHex(32) s.cctvSess.add(token, cctvTTL) http.SetCookie(w, &http.Cookie{Name: "wxch_cctv", Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(cctvTTL.Seconds())}) w.Write([]byte("ok")) } func cctvUnavailable(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte(`
เข้าสู่ระบบแล้ว กำลังรอเชื่อมเครื่อง CCTV
ยังไม่สามารถดูภาพกล้องผ่านโดเมนได้
`)) } const cctvLoginPage = `