initial commit
This commit is contained in:
33
fluxer_app/proxy/Dockerfile
Normal file
33
fluxer_app/proxy/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM golang:1.25.5-alpine AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY fluxer_app/proxy/go.mod ./
|
||||
COPY fluxer_app/proxy/go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY fluxer_app/proxy/ .
|
||||
|
||||
COPY fluxer_app/dist/index.html assets/index.html
|
||||
COPY fluxer_app/dist/manifest.json assets/manifest.json
|
||||
COPY fluxer_app/dist/sw.js* assets/
|
||||
COPY fluxer_app/dist/version.json assets/version.json
|
||||
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o fluxer-app-proxy
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /build/fluxer-app-proxy /app/fluxer-app-proxy
|
||||
|
||||
USER nobody
|
||||
|
||||
EXPOSE 8080
|
||||
ENV PORT=8080
|
||||
|
||||
ENTRYPOINT ["/app/fluxer-app-proxy"]
|
||||
2
fluxer_app/proxy/assets/.gitignore
vendored
Normal file
2
fluxer_app/proxy/assets/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
fluxer_app/proxy/go.mod
Normal file
5
fluxer_app/proxy/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/fluxerapp/fluxer/proxy
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require golang.org/x/time v0.14.0
|
||||
2
fluxer_app/proxy/go.sum
Normal file
2
fluxer_app/proxy/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
781
fluxer_app/proxy/main.go
Normal file
781
fluxer_app/proxy/main.go
Normal file
@@ -0,0 +1,781 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user