2026-01-01 21:05:54 +00:00

782 lines
19 KiB
Go

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/time/rate"
)
//go:embed assets
var assetsFS embed.FS
var metricsHost = os.Getenv("FLUXER_METRICS_HOST")
var metricsClient = &http.Client{
Timeout: 5 * time.Second,
}
var defaultSentryProxyPath = "/error-reporting-proxy"
var sentryProxyPath = normalizeProxyPath(os.Getenv("SENTRY_PROXY_PATH"))
var sentryReportHost = func() string {
if host := strings.TrimSpace(os.Getenv("SENTRY_REPORT_HOST")); host != "" {
return strings.TrimRight(host, "/")
}
return ""
}()
func normalizeProxyPath(value string) string {
clean := strings.TrimSpace(value)
if clean == "" {
return defaultSentryProxyPath
}
if !strings.HasPrefix(clean, "/") {
clean = "/" + clean
}
if clean != "/" {
clean = strings.TrimRight(clean, "/")
if clean == "" {
return "/"
}
}
return clean
}
var staticCDNEndpoint = func() string {
if v := strings.TrimSpace(os.Getenv("FLUXER_STATIC_CDN_ENDPOINT")); v != "" {
return v
}
return "https://fluxerstatic.com"
}()
type responseWriter struct {
http.ResponseWriter
status int
size int64
wroteHeader bool
}
func (rw *responseWriter) WriteHeader(status int) {
if !rw.wroteHeader {
rw.status = status
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(status)
}
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.WriteHeader(http.StatusOK)
}
size, err := rw.ResponseWriter.Write(b)
rw.size += int64(size)
return size, err
}
type ipLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
type ipRateLimiter struct {
mu sync.Mutex
limit rate.Limit
burst int
expiry time.Duration
limiters map[string]*ipLimiterEntry
}
const ipRateLimiterMaxEntries = 2048
func newIPRateLimiter(limit rate.Limit, burst int, expiry time.Duration) *ipRateLimiter {
return &ipRateLimiter{
limit: limit,
burst: burst,
expiry: expiry,
limiters: make(map[string]*ipLimiterEntry),
}
}
func (r *ipRateLimiter) Allow(key string) bool {
if key == "" {
key = "unknown"
}
r.mu.Lock()
defer r.mu.Unlock()
entry := r.limiters[key]
if entry == nil {
entry = &ipLimiterEntry{
limiter: rate.NewLimiter(r.limit, r.burst),
}
r.limiters[key] = entry
}
entry.lastSeen = time.Now()
allowed := entry.limiter.Allow()
if len(r.limiters) > ipRateLimiterMaxEntries {
r.cleanup()
}
return allowed
}
func (r *ipRateLimiter) cleanup() {
cutoff := time.Now().Add(-r.expiry)
for key, entry := range r.limiters {
if entry.lastSeen.Before(cutoff) {
delete(r.limiters, key)
}
}
}
type Server struct {
accessLog *log.Logger
errorLog *log.Logger
httpServer *http.Server
assetsProxy *httputil.ReverseProxy
sentryProxy *httputil.ReverseProxy
sentryLimiter *ipRateLimiter
sentryProjectID string
sentryPublicKey string
}
func NewServer() *Server {
s := &Server{
accessLog: log.New(os.Stdout, "[ACCESS] ", log.LstdFlags|log.LUTC),
errorLog: log.New(os.Stderr, "[ERROR] ", log.LstdFlags|log.LUTC|log.Lshortfile),
}
s.initAssetsProxy()
s.initSentryProxy()
return s
}
func (s *Server) initAssetsProxy() {
target, err := url.Parse(staticCDNEndpoint)
if err != nil {
s.errorLog.Printf("Invalid FLUXER_STATIC_CDN_ENDPOINT %q: %v", staticCDNEndpoint, err)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
origDirector := proxy.Director
proxy.Director = func(req *http.Request) {
origDirector(req)
req.Host = target.Host
req.Header.Del("Cookie")
req.Header.Del("Authorization")
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.errorLog.Printf("assets proxy error %s: %v", r.URL.Path, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
s.assetsProxy = proxy
}
func (s *Server) initSentryProxy() {
rawDSN := strings.TrimSpace(os.Getenv("SENTRY_DSN"))
if rawDSN == "" {
return
}
parsed, err := url.Parse(rawDSN)
if err != nil {
s.errorLog.Printf("Invalid SENTRY_DSN %q: %v", rawDSN, err)
return
}
if parsed.Scheme == "" || parsed.Host == "" {
s.errorLog.Printf("Invalid SENTRY_DSN %q: missing scheme or host", rawDSN)
return
}
pathPart := strings.Trim(parsed.Path, "/")
var segments []string
if pathPart != "" {
segments = strings.Split(pathPart, "/")
}
if len(segments) == 0 {
s.errorLog.Printf("Invalid SENTRY_DSN %q: missing project id", rawDSN)
return
}
projectID := segments[len(segments)-1]
prefixSegments := segments[:len(segments)-1]
targetPathPrefix := ""
if len(prefixSegments) > 0 {
targetPathPrefix = "/" + strings.Join(prefixSegments, "/")
}
user := parsed.User
if user == nil {
s.errorLog.Printf("Invalid SENTRY_DSN %q: missing public key", rawDSN)
return
}
publicKey := user.Username()
if publicKey == "" {
s.errorLog.Printf("Invalid SENTRY_DSN %q: missing public key", rawDSN)
return
}
s.sentryPublicKey = publicKey
s.sentryProjectID = projectID
targetURL := &url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
origDirector := proxy.Director
proxy.Director = func(req *http.Request) {
origDirector(req)
trimmedPath := strings.TrimPrefix(req.URL.Path, sentryProxyPath)
if trimmedPath == "" {
trimmedPath = "/"
} else if !strings.HasPrefix(trimmedPath, "/") {
trimmedPath = "/" + trimmedPath
}
req.URL.Path = targetPathPrefix + trimmedPath
req.URL.RawPath = req.URL.Path
req.Host = targetURL.Host
req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host
req.Header.Del("Cookie")
req.Header.Del("Authorization")
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.errorLog.Printf("sentry proxy error %s: %v", r.URL.Path, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
s.sentryProxy = proxy
s.sentryLimiter = newIPRateLimiter(rate.Every(200*time.Millisecond), 20, 5*time.Minute)
}
func (s *Server) buildSentryReportURI() string {
if s.sentryProjectID == "" {
return ""
}
pathPrefix := strings.TrimRight(sentryProxyPath, "/")
if pathPrefix == "" {
pathPrefix = ""
}
var b strings.Builder
b.WriteString(pathPrefix)
b.WriteString("/api/")
b.WriteString(s.sentryProjectID)
b.WriteString("/security/?sentry_version=7")
if s.sentryPublicKey != "" {
b.WriteString("&sentry_key=")
b.WriteString(s.sentryPublicKey)
}
uri := b.String()
if sentryReportHost != "" {
return sentryReportHost + uri
}
return uri
}
func (s *Server) matchesStrippedSentryPath(path string) bool {
if s.sentryProjectID == "" {
return false
}
apiPath := "/api/" + s.sentryProjectID
return path == apiPath || strings.HasPrefix(path, apiPath+"/")
}
func (s *Server) generateNonce() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
return hex.EncodeToString(b), nil
}
func (s *Server) buildCSP(nonce string) string {
cspHosts := map[string][]string{
"FRAME": {
"https://www.youtube.com/embed/",
"https://www.youtube.com/s/player/",
"https://hcaptcha.com",
"https://*.hcaptcha.com",
"https://challenges.cloudflare.com",
},
"IMAGE": {
"https://*.fluxer.app",
"https://i.ytimg.com",
"https://*.youtube.com",
"https://fluxerusercontent.com",
"https://fluxerstatic.com",
"https://*.fluxer.media",
"https://fluxer.media",
"http://127.0.0.1:21867",
"http://127.0.0.1:21868",
},
"MEDIA": {
"https://*.fluxer.app",
"https://*.youtube.com",
"https://fluxerusercontent.com",
"https://fluxerstatic.com",
"https://*.fluxer.media",
"https://fluxer.media",
"http://127.0.0.1:21867",
"http://127.0.0.1:21868",
},
"SCRIPT": {
"https://*.fluxer.app",
"https://hcaptcha.com",
"https://*.hcaptcha.com",
"https://challenges.cloudflare.com",
"https://fluxerstatic.com",
},
"STYLE": {
"https://*.fluxer.app",
"https://hcaptcha.com",
"https://*.hcaptcha.com",
"https://challenges.cloudflare.com",
"https://fluxerstatic.com",
},
"FONT": {
"https://*.fluxer.app",
"https://fluxerstatic.com",
},
"CONNECT": {
"https://*.fluxer.app",
"wss://*.fluxer.app",
"https://*.fluxer.media",
"wss://*.fluxer.media",
"https://hcaptcha.com",
"https://*.hcaptcha.com",
"https://challenges.cloudflare.com",
"https://*.fluxer.workers.dev",
"https://fluxerusercontent.com",
"https://fluxerstatic.com",
"https://sentry.web.fluxer.app",
"https://sentry.web.canary.fluxer.app",
"https://fluxer.media",
"ipc:",
"http://ipc.localhost",
"http://127.0.0.1:21865",
"ws://127.0.0.1:21865",
"http://127.0.0.1:21866",
"ws://127.0.0.1:21866",
"http://127.0.0.1:21867",
"http://127.0.0.1:21868",
"http://127.0.0.1:21861",
"http://127.0.0.1:21862",
"http://127.0.0.1:21863",
"http://127.0.0.1:21864",
},
"WORKER": {
"https://*.fluxer.app",
"https://fluxerstatic.com",
"blob:",
},
"MANIFEST": {
"https://*.fluxer.app",
},
}
directives := []string{
"default-src 'self'",
fmt.Sprintf(
"script-src 'self' 'nonce-%s' 'wasm-unsafe-eval' %s",
nonce,
strings.Join(cspHosts["SCRIPT"], " "),
),
"style-src 'self' 'unsafe-inline' " + strings.Join(cspHosts["STYLE"], " "),
"img-src 'self' blob: data: " + strings.Join(cspHosts["IMAGE"], " "),
"media-src 'self' blob: " + strings.Join(cspHosts["MEDIA"], " "),
"font-src 'self' data: " + strings.Join(cspHosts["FONT"], " "),
"connect-src 'self' data: " + strings.Join(cspHosts["CONNECT"], " "),
"frame-src 'self' " + strings.Join(cspHosts["FRAME"], " "),
"worker-src 'self' blob: " + strings.Join(cspHosts["WORKER"], " "),
"manifest-src 'self' " + strings.Join(cspHosts["MANIFEST"], " "),
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
}
if uri := s.buildSentryReportURI(); uri != "" {
directives = append(directives, fmt.Sprintf("report-uri %s", uri))
}
return strings.Join(directives, "; ")
}
func (s *Server) getRealIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return ""
}
ip := strings.TrimSpace(strings.Split(xff, ",")[0])
ip = strings.Trim(ip, "[]")
if net.ParseIP(ip) == nil {
return ""
}
return ip
}
func (s *Server) getRateLimitKey(r *http.Request) string {
if ip := s.getRealIP(r); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return host
}
return r.RemoteAddr
}
func (s *Server) handleIndex(w http.ResponseWriter) {
nonce, err := s.generateNonce()
if err != nil {
s.errorLog.Printf("Failed to generate nonce: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Security-Policy", s.buildCSP(nonce))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
indexBytes, err := assetsFS.ReadFile("assets/index.html")
if err != nil {
s.errorLog.Printf("Failed to read index.html: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
indexContent := strings.ReplaceAll(string(indexBytes), "{{CSP_NONCE_PLACEHOLDER}}", nonce)
if _, err := w.Write([]byte(indexContent)); err != nil {
s.errorLog.Printf("Failed to write response: %v", err)
}
}
func (s *Server) handleStaticAsset(w http.ResponseWriter, r *http.Request, filename, contentType string) {
data, err := assetsFS.ReadFile("assets/" + filename)
if err != nil {
s.errorLog.Printf("Failed to read %s: %v", filename, err)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
if _, err := w.Write(data); err != nil {
s.errorLog.Printf("Failed to write response for %s: %v", filename, err)
}
}
func (s *Server) handleAssetsProxy(w http.ResponseWriter, r *http.Request) {
if s.assetsProxy == nil {
http.Error(w, "Assets proxy not configured", http.StatusInternalServerError)
return
}
s.assetsProxy.ServeHTTP(w, r)
}
func (s *Server) handleSentryProxy(w http.ResponseWriter, r *http.Request) {
if s.sentryProxy == nil {
http.Error(w, "Sentry proxy not configured", http.StatusServiceUnavailable)
return
}
key := s.getRateLimitKey(r)
if s.sentryLimiter != nil && !s.sentryLimiter.Allow(key) {
s.errorLog.Printf("Sentry proxy rate limited request from %s", key)
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
s.sentryProxy.ServeHTTP(w, r)
}
func (s *Server) logRequest(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := fmt.Sprintf("%x", time.Now().UnixNano())
w.Header().Set("X-Request-ID", requestID)
wrapped := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
realIP := s.getRealIP(r)
s.accessLog.Printf("→ %s %s %s [%s] (IP: %s, UA: %s)",
r.Method,
r.URL.Path,
r.Proto,
requestID,
realIP,
r.UserAgent(),
)
handler(wrapped, r)
duration := time.Since(start)
recordProxyMetrics(r, wrapped, duration)
duration = duration.Round(time.Millisecond)
s.accessLog.Printf("← %s %d %s [%s] %dB %s",
r.Method,
wrapped.status,
http.StatusText(wrapped.status),
requestID,
wrapped.size,
duration,
)
}
}
func recordProxyMetrics(r *http.Request, rw *responseWriter, duration time.Duration) {
status := rw.status
if status == 0 {
status = http.StatusOK
}
dims := map[string]string{
"method": r.Method,
"path": r.URL.Path,
"status": strconv.Itoa(status),
}
recordHistogram("/metrics/histogram", "app.proxy.latency", dims, float64(duration.Milliseconds()))
recordCounter("/metrics/counter", "app.proxy.request", dims, 1)
metric := "app.proxy.success"
if status >= 400 {
metric = "app.proxy.failure"
}
recordCounter("/metrics/counter", metric, dims, 1)
}
func recordHistogram(endpointPath, metric string, dimensions map[string]string, valueMs float64) {
payload := map[string]any{
"name": metric,
"dimensions": dimensions,
"value_ms": valueMs,
}
sendMetric(endpointPath, payload)
}
func recordCounter(endpointPath, metric string, dimensions map[string]string, value float64) {
payload := map[string]any{
"name": metric,
"dimensions": dimensions,
"value": value,
}
sendMetric(endpointPath, payload)
}
func sendMetric(path string, body map[string]any) {
if metricsHost == "" {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
data, err := json.Marshal(body)
if err != nil {
return
}
url := fmt.Sprintf("http://%s%s", metricsHost, path)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "fluxer-proxy/metrics")
resp, err := metricsClient.Do(req)
if err != nil {
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
}
func (s *Server) recoveryHandler(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
s.errorLog.Printf("Panic recovered in %s %s: %v", r.Method, r.URL.Path, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next(w, r)
}
}
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/_health":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if _, err := w.Write([]byte("OK")); err != nil {
s.errorLog.Printf("Failed to write health response: %v", err)
}
return
case r.URL.Path == sentryProxyPath || strings.HasPrefix(r.URL.Path, sentryProxyPath+"/"):
s.handleSentryProxy(w, r)
return
case s.matchesStrippedSentryPath(r.URL.Path):
s.handleSentryProxy(w, r)
return
case r.URL.Path == "/assets" || strings.HasPrefix(r.URL.Path, "/assets/"):
s.handleAssetsProxy(w, r)
return
case r.URL.Path == "/sw.js":
s.handleStaticAsset(w, r, "sw.js", "application/javascript")
return
case r.URL.Path == "/sw.js.map":
s.handleStaticAsset(w, r, "sw.js.map", "application/json")
return
case r.URL.Path == "/manifest.json":
s.handleStaticAsset(w, r, "manifest.json", "application/manifest+json")
return
case r.URL.Path == "/version.json":
s.handleStaticAsset(w, r, "version.json", "application/json")
return
case r.URL.Path == "/.well-known/apple-app-site-association":
s.handleAppleAppSiteAssociation(w)
return
default:
s.handleIndex(w)
}
}
func (s *Server) handleAppleAppSiteAssociation(w http.ResponseWriter) {
aasa := `{
"webcredentials": {
"apps": [
"3G5837T29K.app.fluxer",
"3G5837T29K.app.fluxer.canary"
]
}
}`
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
if _, err := w.Write([]byte(aasa)); err != nil {
s.errorLog.Printf("Failed to write AASA response: %v", err)
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := s.recoveryHandler(s.logRequest(s.dispatch))
handler(w, r)
}
func (s *Server) Start(port string) error {
if port == "" {
port = "8080"
}
addr := ":" + port
s.httpServer = &http.Server{
Addr: addr,
Handler: s,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
errs := make(chan error, 1)
go func() {
s.accessLog.Printf("Starting server on %s", addr)
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
errs <- err
}
}()
select {
case err := <-errs:
return fmt.Errorf("server error: %w", err)
case <-stop:
s.accessLog.Printf("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown error: %w", err)
}
}
return nil
}
func main() {
server := NewServer()
if err := server.Start(os.Getenv("PORT")); err != nil {
server.errorLog.Fatal(err)
}
}