Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f279dda27a |
@@ -24,14 +24,8 @@ files, so merging it also closes most compliance gaps.
|
|||||||
|
|
||||||
- 2026-09-21: frontend reporting client — a `Reporter` class posts collected
|
- 2026-09-21: frontend reporting client — a `Reporter` class posts collected
|
||||||
samples to `/api/v1/reports` every `reportInterval` (default 60s) as a
|
samples to `/api/v1/reports` every `reportInterval` (default 60s) as a
|
||||||
per-host delta, with the report-building step a pure exported function of host
|
per-host delta, with the report-building step a pure function of host state;
|
||||||
state; the per-browser client id works in insecure (plain-HTTP) contexts;
|
|
||||||
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
|
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
|
||||||
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
|
|
||||||
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
|
|
||||||
frame/sniff/referrer/permissions headers) registered before CORS, and
|
|
||||||
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
|
|
||||||
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
|
|
||||||
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
|
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
|
||||||
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
||||||
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
||||||
|
|||||||
+1
-7
@@ -43,16 +43,10 @@ Internal packages in `internal/` follow standard Go project layout:
|
|||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
|
| ---------- | ------------------ | --------------------------------- |
|
||||||
| `PORT` | `8080` | HTTP listen port |
|
| `PORT` | `8080` | HTTP listen port |
|
||||||
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
|
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
|
||||||
| `DEBUG` | `false` | Enable debug logging |
|
| `DEBUG` | `false` | Enable debug logging |
|
||||||
| `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
|
|
||||||
|
|
||||||
`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
|
|
||||||
The loopback entries cover the reverse proxy that shares the container; the
|
|
||||||
RFC1918 ranges match `nginx.conf`. A request whose direct peer is outside this
|
|
||||||
set has its forwarded headers ignored, and the direct peer is logged instead.
|
|
||||||
|
|
||||||
### Report storage
|
### Report storage
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
"sneak.berlin/go/netwatch/internal/logger"
|
"sneak.berlin/go/netwatch/internal/logger"
|
||||||
@@ -15,14 +14,6 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultTrustedProxies lists the networks whose forwarded
|
|
||||||
// headers are honoured by default. It covers the RFC1918
|
|
||||||
// ranges (to match nginx.conf) plus IPv4 and IPv6 loopback,
|
|
||||||
// because the reverse proxy shares the container and reaches
|
|
||||||
// the backend over loopback.
|
|
||||||
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
|
|
||||||
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
|
||||||
|
|
||||||
// Params defines the dependencies for Config.
|
// Params defines the dependencies for Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -39,7 +30,6 @@ type Config struct {
|
|||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
Port int
|
Port int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
TrustedProxies []string
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
}
|
}
|
||||||
@@ -66,7 +56,6 @@ func New(
|
|||||||
viper.SetDefault("SENTRY_DSN", "")
|
viper.SetDefault("SENTRY_DSN", "")
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
|
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,7 +73,6 @@ func New(
|
|||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
|
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
@@ -96,19 +84,3 @@ func New(
|
|||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitList turns a comma-separated setting into a trimmed
|
|
||||||
// slice, dropping empty entries.
|
|
||||||
func splitList(raw string) []string {
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test-only wrappers exposing unexported helpers to the
|
|
||||||
// external middleware_test package.
|
|
||||||
|
|
||||||
func ClientIP(
|
|
||||||
remoteAddr string,
|
|
||||||
header http.Header,
|
|
||||||
trusted []netip.Prefix,
|
|
||||||
) string {
|
|
||||||
return clientIP(remoteAddr, header, trusted)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
|
|
||||||
return parseTrustedProxies(cidrs)
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/config"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
@@ -22,15 +19,6 @@ import (
|
|||||||
|
|
||||||
const corsMaxAgeSec = 300
|
const corsMaxAgeSec = 300
|
||||||
|
|
||||||
// Security header values. The backend is a JSON API with no
|
|
||||||
// HTML surface, so the CSP forbids every resource type and
|
|
||||||
// framing outright.
|
|
||||||
const (
|
|
||||||
hstsValue = "max-age=31536000; includeSubDomains"
|
|
||||||
cspValue = "default-src 'none'; frame-ancestors 'none'"
|
|
||||||
permissionsPolicyValue = "camera=(), microphone=(), geolocation=()"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Params defines the dependencies for Middleware.
|
// Params defines the dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -44,7 +32,6 @@ type Params struct {
|
|||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
trustedProxies []netip.Prefix
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Middleware instance.
|
// New creates a Middleware instance.
|
||||||
@@ -52,38 +39,13 @@ func New(
|
|||||||
_ fx.Lifecycle,
|
_ fx.Lifecycle,
|
||||||
params Params,
|
params Params,
|
||||||
) (*Middleware, error) {
|
) (*Middleware, error) {
|
||||||
trusted, err := parseTrustedProxies(params.Config.TrustedProxies)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := new(Middleware)
|
s := new(Middleware)
|
||||||
s.params = ¶ms
|
s.params = ¶ms
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
s.trustedProxies = trusted
|
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTrustedProxies converts CIDR strings into prefixes,
|
|
||||||
// failing fast on any malformed entry.
|
|
||||||
func parseTrustedProxies(cidrs []string) ([]netip.Prefix, error) {
|
|
||||||
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
|
||||||
|
|
||||||
for _, cidr := range cidrs {
|
|
||||||
prefix, err := netip.ParsePrefix(cidr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"trusted proxy %q: %w", cidr, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefixes = append(prefixes, prefix.Masked())
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefixes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type loggingResponseWriter struct {
|
type loggingResponseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
|
|
||||||
@@ -110,70 +72,6 @@ func ipFromHostPort(hostPort string) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP resolves the caller's address. X-Forwarded-For and
|
|
||||||
// X-Real-IP are honoured only when the direct peer is a
|
|
||||||
// trusted proxy; otherwise the direct peer is returned so a
|
|
||||||
// spoofed header cannot forge the logged address.
|
|
||||||
func clientIP(
|
|
||||||
remoteAddr string,
|
|
||||||
header http.Header,
|
|
||||||
trusted []netip.Prefix,
|
|
||||||
) string {
|
|
||||||
peer := ipFromHostPort(remoteAddr)
|
|
||||||
|
|
||||||
if !addrInAny(peer, trusted) {
|
|
||||||
return peer
|
|
||||||
}
|
|
||||||
|
|
||||||
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
|
|
||||||
return xff
|
|
||||||
}
|
|
||||||
|
|
||||||
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
|
|
||||||
return xr
|
|
||||||
}
|
|
||||||
|
|
||||||
return peer
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstForwardedFor returns the left-most valid address in an
|
|
||||||
// X-Forwarded-For list (the original client), or "" if none.
|
|
||||||
func firstForwardedFor(value string) string {
|
|
||||||
for part := range strings.SplitSeq(value, ",") {
|
|
||||||
candidate := strings.TrimSpace(part)
|
|
||||||
if validIP(candidate) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func validIP(s string) bool {
|
|
||||||
_, err := netip.ParseAddr(s)
|
|
||||||
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addrInAny reports whether s parses as an address contained
|
|
||||||
// in any of the trusted prefixes.
|
|
||||||
func addrInAny(s string, trusted []netip.Prefix) bool {
|
|
||||||
addr, err := netip.ParseAddr(s)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
addr = addr.Unmap()
|
|
||||||
|
|
||||||
for _, prefix := range trusted {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logging returns middleware that logs each request with
|
// Logging returns middleware that logs each request with
|
||||||
// timing, status code, and client information.
|
// timing, status code, and client information.
|
||||||
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||||
@@ -198,11 +96,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
"referer", r.Referer(),
|
"referer", r.Referer(),
|
||||||
"proto", r.Proto,
|
"proto", r.Proto,
|
||||||
"remote_ip",
|
"remote_ip",
|
||||||
clientIP(
|
ipFromHostPort(r.RemoteAddr),
|
||||||
r.RemoteAddr,
|
|
||||||
r.Header,
|
|
||||||
s.trustedProxies,
|
|
||||||
),
|
|
||||||
"status", lrw.statusCode,
|
"status", lrw.statusCode,
|
||||||
"latency_ms",
|
"latency_ms",
|
||||||
latency.Milliseconds(),
|
latency.Milliseconds(),
|
||||||
@@ -215,27 +109,6 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaders returns middleware that sets response
|
|
||||||
// security headers. It runs before CORS so the headers are
|
|
||||||
// present on preflight responses the CORS handler writes.
|
|
||||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h := w.Header()
|
|
||||||
h.Set("Strict-Transport-Security", hstsValue)
|
|
||||||
h.Set("Content-Security-Policy", cspValue)
|
|
||||||
h.Set("X-Frame-Options", "DENY")
|
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
|
||||||
h.Set("Referrer-Policy", "no-referrer")
|
|
||||||
h.Set("Permissions-Policy", permissionsPolicyValue)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORS returns middleware that adds permissive CORS headers.
|
// CORS returns middleware that adds permissive CORS headers.
|
||||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
prefixes, err := middleware.ParseTrustedProxies(cidrs)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseTrustedProxies(%v): %v", cidrs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefixes
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseTrustedProxiesRejectsMalformed(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := middleware.ParseTrustedProxies([]string{"not-a-cidr"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for malformed CIDR, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type clientIPCase struct {
|
|
||||||
name string
|
|
||||||
remoteAddr string
|
|
||||||
xff string
|
|
||||||
xRealIP string
|
|
||||||
want string
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientIPCases() []clientIPCase {
|
|
||||||
return []clientIPCase{
|
|
||||||
{
|
|
||||||
name: "trusted proxy uses forwarded-for",
|
|
||||||
remoteAddr: "127.0.0.1:5000",
|
|
||||||
xff: "203.0.113.7",
|
|
||||||
want: "203.0.113.7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted proxy uses left-most of chain",
|
|
||||||
remoteAddr: "10.1.2.3:5000",
|
|
||||||
xff: "203.0.113.7, 10.1.2.3",
|
|
||||||
want: "203.0.113.7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted proxy falls back to x-real-ip",
|
|
||||||
remoteAddr: "127.0.0.1:5000",
|
|
||||||
xRealIP: "203.0.113.9",
|
|
||||||
want: "203.0.113.9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "untrusted peer ignores forwarded-for",
|
|
||||||
remoteAddr: "198.51.100.4:5000",
|
|
||||||
xff: "203.0.113.7",
|
|
||||||
want: "198.51.100.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "untrusted peer ignores x-real-ip",
|
|
||||||
remoteAddr: "198.51.100.4:5000",
|
|
||||||
xRealIP: "203.0.113.9",
|
|
||||||
want: "198.51.100.4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted proxy with no headers uses peer",
|
|
||||||
remoteAddr: "10.1.2.3:5000",
|
|
||||||
want: "10.1.2.3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trusted proxy with garbage header uses peer",
|
|
||||||
remoteAddr: "127.0.0.1:5000",
|
|
||||||
xff: "not-an-ip",
|
|
||||||
want: "127.0.0.1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIP(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
trusted := mustPrefixes(t, "127.0.0.1/32", "::1/128", "10.0.0.0/8")
|
|
||||||
|
|
||||||
for _, tc := range clientIPCases() {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
header := http.Header{}
|
|
||||||
if tc.xff != "" {
|
|
||||||
header.Set("X-Forwarded-For", tc.xff)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.xRealIP != "" {
|
|
||||||
header.Set("X-Real-IP", tc.xRealIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := middleware.ClientIP(tc.remoteAddr, header, trusted)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("ClientIP() = %q, want %q", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
handler := (&middleware.Middleware{}).SecurityHeaders()(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
want := map[string]string{
|
|
||||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
|
||||||
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
||||||
"X-Frame-Options": "DENY",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"Referrer-Policy": "no-referrer",
|
|
||||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, value := range want {
|
|
||||||
if got := rec.Header().Get(name); got != value {
|
|
||||||
t.Errorf("header %s = %q, want %q", name, got, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
readTimeout = 10 * time.Second
|
readTimeout = 10 * time.Second
|
||||||
readHeaderTimeout = 5 * time.Second
|
|
||||||
writeTimeout = 10 * time.Second
|
writeTimeout = 10 * time.Second
|
||||||
idleTimeout = 60 * time.Second
|
|
||||||
maxHeaderBytes = 1 << 20 // 1 MiB
|
maxHeaderBytes = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,9 +21,7 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
Handler: s,
|
Handler: s,
|
||||||
MaxHeaderBytes: maxHeaderBytes,
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
ReadTimeout: readTimeout,
|
ReadTimeout: readTimeout,
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
|
||||||
WriteTimeout: writeTimeout,
|
WriteTimeout: writeTimeout,
|
||||||
IdleTimeout: idleTimeout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Use(middleware.Recoverer)
|
s.router.Use(middleware.Recoverer)
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(requestTimeout))
|
s.router.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
|
|||||||
+4
-45
@@ -350,32 +350,6 @@ class AppState {
|
|||||||
|
|
||||||
// --- Reporting ---------------------------------------------------------------
|
// --- Reporting ---------------------------------------------------------------
|
||||||
|
|
||||||
// A random UUIDv4. `crypto.randomUUID` exists only in secure contexts
|
|
||||||
// (HTTPS or localhost); over plain HTTP to any other host — the normal LAN
|
|
||||||
// deployment — it is undefined, so feature-detect it and otherwise build the
|
|
||||||
// id from `crypto.getRandomValues`, which is available in insecure contexts.
|
|
||||||
function randomId() {
|
|
||||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
|
||||||
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0"));
|
|
||||||
return (
|
|
||||||
hex.slice(0, 4).join("") +
|
|
||||||
"-" +
|
|
||||||
hex.slice(4, 6).join("") +
|
|
||||||
"-" +
|
|
||||||
hex.slice(6, 8).join("") +
|
|
||||||
"-" +
|
|
||||||
hex.slice(8, 10).join("") +
|
|
||||||
"-" +
|
|
||||||
hex.slice(10, 16).join("")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A random id identifying this browser across reports. Generated once and
|
// A random id identifying this browser across reports. Generated once and
|
||||||
// kept in localStorage; if storage is unavailable (e.g. private mode) a
|
// kept in localStorage; if storage is unavailable (e.g. private mode) a
|
||||||
// fresh id is used for this session only.
|
// fresh id is used for this session only.
|
||||||
@@ -384,12 +358,12 @@ function getClientId() {
|
|||||||
try {
|
try {
|
||||||
let id = localStorage.getItem(key);
|
let id = localStorage.getItem(key);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = randomId();
|
id = crypto.randomUUID();
|
||||||
localStorage.setItem(key, id);
|
localStorage.setItem(key, id);
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
} catch {
|
} catch {
|
||||||
return randomId();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +373,7 @@ function getClientId() {
|
|||||||
// timestamp of the last sample already reported for it, and `now` is a Date.
|
// timestamp of the last sample already reported for it, and `now` is a Date.
|
||||||
// Only non-paused samples newer than the mark are included. Returns null
|
// Only non-paused samples newer than the mark are included. Returns null
|
||||||
// when no host has an unreported sample.
|
// when no host has an unreported sample.
|
||||||
export function buildReport(hosts, clientId, now, since) {
|
function buildReport(hosts, clientId, now, since) {
|
||||||
const reportHosts = [];
|
const reportHosts = [];
|
||||||
const marks = new Map();
|
const marks = new Map();
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
@@ -1305,18 +1279,8 @@ async function init() {
|
|||||||
buildUI(state);
|
buildUI(state);
|
||||||
log.info("UI built, starting tick loop");
|
log.info("UI built, starting tick loop");
|
||||||
|
|
||||||
// Reporting is best-effort: any failure setting it up (e.g. no usable
|
const reporter = new Reporter(state, getClientId(), CONFIG.reportInterval);
|
||||||
// crypto for the client id) must never stop the monitor from probing.
|
|
||||||
try {
|
|
||||||
const reporter = new Reporter(
|
|
||||||
state,
|
|
||||||
getClientId(),
|
|
||||||
CONFIG.reportInterval,
|
|
||||||
);
|
|
||||||
reporter.start();
|
reporter.start();
|
||||||
} catch (err) {
|
|
||||||
log.error(`Reporting disabled: ${err.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("pause-btn")
|
.getElementById("pause-btn")
|
||||||
@@ -1430,13 +1394,8 @@ async function init() {
|
|||||||
setTimeout(() => handleResize(state), 100);
|
setTimeout(() => handleResize(state), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap only when loaded as the page: a real DOM containing the #app
|
|
||||||
// mount point this module renders into. Importing the module in a unit test
|
|
||||||
// (which has no #app) runs nothing, so buildReport can be tested in isolation.
|
|
||||||
if (typeof document !== "undefined" && document.getElementById("app")) {
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
} else {
|
} else {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user