Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485aa4194a | ||
|
|
a279cf8583 |
+1
-24
@@ -1,27 +1,4 @@
|
|||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Editors
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
*.bak
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.sublime-*
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Environment / secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
dist/
|
||||||
|
.DS_Store
|
||||||
# Logs
|
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+2
-5
@@ -3,12 +3,9 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git
|
||||||
COPY . .
|
COPY . .
|
||||||
# make check runs script/check (test + lint + fmt-check); its test step
|
RUN yarn build
|
||||||
# is the production yarn build, so this both produces dist/ and gates the
|
|
||||||
# image on lint/fmt-check/test regressions, not merely a broken build.
|
|
||||||
RUN make check
|
|
||||||
|
|
||||||
# nginx:stable-alpine as of 2026-02-22
|
# nginx:stable-alpine as of 2026-02-22
|
||||||
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
|
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
|
||||||
|
|||||||
+8
-33
@@ -1,50 +1,25 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues. The
|
|
||||||
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
|
|
||||||
# nothing is installed here.
|
|
||||||
# golangci/golangci-lint:v2.7.2 (2026-08-09)
|
|
||||||
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
COPY backend/go.mod backend/go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY backend/ .
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
# golang:1.25-alpine (2026-02-27)
|
# golang:1.25-alpine (2026-02-27)
|
||||||
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache make
|
RUN apk add --no-cache git make gcc musl-dev
|
||||||
|
|
||||||
WORKDIR /src
|
# golangci-lint v2.7.2 (2026-02-27)
|
||||||
|
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b
|
||||||
# Force BuildKit to run the lint stage before proceeding. BuildKit runs
|
|
||||||
# stages in parallel by default; without this no-op copy a lint failure
|
|
||||||
# would not gate compilation.
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
|
WORKDIR /repo/backend
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
COPY .git /repo/.git
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
RUN make test
|
RUN make check
|
||||||
|
RUN make build
|
||||||
|
|
||||||
# The build is driven through the Makefile so there is exactly one
|
|
||||||
# definition of the build command; it expands to
|
|
||||||
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
|
|
||||||
# VERSION is handed over in the environment rather than as a make
|
|
||||||
# variable so it still reaches the build if the target is ever turned
|
|
||||||
# into a shim around a script.
|
|
||||||
ARG VERSION=dev
|
|
||||||
RUN VERSION="${VERSION}" make build
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# alpine:3.23 (2026-02-27)
|
# alpine:3.23 (2026-02-27)
|
||||||
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates
|
||||||
COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server
|
COPY --from=builder /repo/backend/netwatch-server /usr/local/bin/netwatch-server
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["netwatch-server"]
|
ENTRYPOINT ["netwatch-server"]
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ dist/
|
|||||||
false outage)
|
false outage)
|
||||||
- Clickable service URLs
|
- Clickable service URLs
|
||||||
- Canvas-based sparkline rendering with devicePixelRatio scaling
|
- Canvas-based sparkline rendering with devicePixelRatio scaling
|
||||||
|
- Mobile detection: viewports narrower than 768px show a friendly "not yet
|
||||||
|
available on mobile" message instead of the monitoring UI (no polling or
|
||||||
|
network requests on mobile)
|
||||||
- Zero runtime dependencies: all resources bundled into build artifacts
|
- Zero runtime dependencies: all resources bundled into build artifacts
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -148,6 +151,8 @@ properties.
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
|
- **Mobile**: Viewports below 768px wide show a static "not yet available"
|
||||||
|
message. The full monitoring UI requires a desktop-width browser.
|
||||||
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
|
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
|
||||||
`no-cors` mode which allows the request but provides opaque responses. Latency
|
`no-cors` mode which allows the request but provides opaque responses. Latency
|
||||||
is still measurable based on request timing.
|
is still measurable based on request timing.
|
||||||
|
|||||||
@@ -22,35 +22,6 @@ files, so merging it also closes most compliance gaps.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-21: report ingest correctness (issue #23): a storage failure now
|
|
||||||
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
|
|
||||||
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
|
|
||||||
route, not just the report route; the raw attacker-controlled `geo` blob is no
|
|
||||||
longer logged (only its length) and `client_id`/`timestamp` are length-bounded
|
|
||||||
before logging; a `decodeJSON` handler helper was added; and panic recovery
|
|
||||||
now routes the stack through slog instead of chi's plain-text stderr
|
|
||||||
- 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
|
|
||||||
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
|
||||||
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
|
||||||
negative margins, so its layout footprint and row density are unchanged
|
|
||||||
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
|
|
||||||
forcing horizontal page scroll at 320px
|
|
||||||
- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage
|
|
||||||
lint-stage pattern: separate `lint` stage on the hash-pinned
|
|
||||||
`golangci/golangci-lint` image, `COPY --from=lint` stage dependency,
|
|
||||||
`CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git`
|
|
||||||
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
|
|
||||||
root so `root = true` covers the frontend too, and replaced `.gitignore` with
|
|
||||||
the org model (OS, editor, node, and environment/secrets sections) plus this
|
|
||||||
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
|
|
||||||
ignored repo-wide, not just under `backend/`. Excluding `.git` from
|
|
||||||
`.dockerignore` stays deferred: both images read git metadata at build time
|
|
||||||
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
|
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||||
|
|||||||
+12
-9
@@ -1,22 +1,25 @@
|
|||||||
# VERSION is overridable (the Dockerfile passes its ARG VERSION in) and
|
UNAME_S := $(shell uname -s)
|
||||||
# degrades to "dev" when git is unavailable or there is no .git — the
|
VERSION := $(shell git describe --always --dirty)
|
||||||
# build must not depend on the repository history being in the build
|
|
||||||
# context.
|
|
||||||
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
|
|
||||||
BUILDARCH := $(shell uname -m)
|
BUILDARCH := $(shell uname -m)
|
||||||
BINARY := netwatch-server
|
BINARY := netwatch-server
|
||||||
|
|
||||||
GOLDFLAGS += -s -w
|
|
||||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
||||||
|
|
||||||
|
ifeq ($(UNAME_S),Darwin)
|
||||||
|
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||||
|
else
|
||||||
|
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
|
||||||
|
endif
|
||||||
|
|
||||||
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
|
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build: ./$(BINARY)
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS)" \
|
|
||||||
-o ./$(BINARY) ./cmd/netwatch-server/
|
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum
|
||||||
|
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
|
||||||
|
|
||||||
test:
|
test:
|
||||||
timeout 30 go test ./...
|
timeout 30 go test ./...
|
||||||
|
|||||||
+5
-11
@@ -42,17 +42,11 @@ 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,10 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "log/slog"
|
|
||||||
|
|
||||||
// NewForTest builds a Handlers around a report sink and logger,
|
|
||||||
// bypassing the fx graph so handler behaviour (including the
|
|
||||||
// storage failure path) is exercisable in unit tests.
|
|
||||||
func NewForTest(buf reportAppender, log *slog.Logger) *Handlers {
|
|
||||||
return &Handlers{buf: buf, log: log}
|
|
||||||
}
|
|
||||||
@@ -18,13 +18,6 @@ import (
|
|||||||
|
|
||||||
const jsonContentType = "application/json; charset=utf-8"
|
const jsonContentType = "application/json; charset=utf-8"
|
||||||
|
|
||||||
// reportAppender is the subset of the report buffer the handlers
|
|
||||||
// depend on. Defining it here keeps the storage failure path
|
|
||||||
// exercisable with a stub in tests.
|
|
||||||
type reportAppender interface {
|
|
||||||
Append(v any) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params defines the dependencies for Handlers.
|
// Params defines the dependencies for Handlers.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -37,7 +30,7 @@ type Params struct {
|
|||||||
|
|
||||||
// Handlers provides HTTP handler factories for all endpoints.
|
// Handlers provides HTTP handler factories for all endpoints.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
buf reportAppender
|
buf *reportbuf.Buffer
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
@@ -79,15 +72,3 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeJSON decodes the request body into v. The body is
|
|
||||||
// expected to already be bounded by the body-size middleware, so
|
|
||||||
// a caller can distinguish an over-limit body from malformed
|
|
||||||
// JSON by testing the returned error for *http.MaxBytesError.
|
|
||||||
func (s *Handlers) decodeJSON(
|
|
||||||
_ http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
v any,
|
|
||||||
) error {
|
|
||||||
return json.NewDecoder(r.Body).Decode(v)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxLoggedFieldBytes bounds untrusted string fields before they
|
const maxReportBodyBytes = 1 << 20 // 1 MiB
|
||||||
// are logged, so a caller cannot inflate log volume with an
|
|
||||||
// oversized value.
|
|
||||||
const maxLoggedFieldBytes = 128
|
|
||||||
|
|
||||||
type reportSample struct {
|
type reportSample struct {
|
||||||
T int64 `json:"t"`
|
T int64 `json:"t"`
|
||||||
@@ -39,76 +35,48 @@ func (s *Handlers) HandleReport() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(
|
||||||
|
w, r.Body, maxReportBodyBytes,
|
||||||
|
)
|
||||||
|
|
||||||
var rpt report
|
var rpt report
|
||||||
|
|
||||||
err := s.decodeJSON(w, r, &rpt)
|
err := json.NewDecoder(r.Body).Decode(&rpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.log.Error("failed to decode report",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
s.respondJSON(w, r,
|
s.respondJSON(w, r,
|
||||||
&response{Status: "error"},
|
&response{Status: "error"},
|
||||||
s.decodeErrorStatus(err),
|
http.StatusBadRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logReportReceived(rpt)
|
totalSamples := 0
|
||||||
|
for _, h := range rpt.Hosts {
|
||||||
err = s.buf.Append(rpt)
|
totalSamples += len(h.History)
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to buffer report", "error", err)
|
|
||||||
s.respondJSON(w, r,
|
|
||||||
&response{Status: "error"},
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
|
s.log.Info("report received",
|
||||||
|
"client_id", rpt.ClientID,
|
||||||
|
"timestamp", rpt.Timestamp,
|
||||||
|
"host_count", len(rpt.Hosts),
|
||||||
|
"total_samples", totalSamples,
|
||||||
|
"geo", string(rpt.Geo),
|
||||||
|
)
|
||||||
|
|
||||||
|
bufErr := s.buf.Append(rpt)
|
||||||
|
if bufErr != nil {
|
||||||
|
s.log.Error("failed to buffer report",
|
||||||
|
"error", bufErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.respondJSON(w, r,
|
||||||
|
&response{Status: "ok"},
|
||||||
|
http.StatusOK,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeErrorStatus logs a report decode failure and returns the
|
|
||||||
// status to send: 413 when the body exceeded the size limit,
|
|
||||||
// otherwise 400 for malformed JSON.
|
|
||||||
func (s *Handlers) decodeErrorStatus(err error) int {
|
|
||||||
var tooLarge *http.MaxBytesError
|
|
||||||
if errors.As(err, &tooLarge) {
|
|
||||||
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
|
|
||||||
|
|
||||||
return http.StatusRequestEntityTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Error("failed to decode report", "error", err)
|
|
||||||
|
|
||||||
return http.StatusBadRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
// logReportReceived logs an accepted report. Untrusted fields are
|
|
||||||
// bounded (client_id, timestamp) or reduced to a length
|
|
||||||
// (geo_bytes) so the raw attacker-controlled body never reaches
|
|
||||||
// the log.
|
|
||||||
func (s *Handlers) logReportReceived(rpt report) {
|
|
||||||
totalSamples := 0
|
|
||||||
for _, h := range rpt.Hosts {
|
|
||||||
totalSamples += len(h.History)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("report received",
|
|
||||||
"client_id", boundedForLog(rpt.ClientID),
|
|
||||||
"timestamp", boundedForLog(rpt.Timestamp),
|
|
||||||
"host_count", len(rpt.Hosts),
|
|
||||||
"total_samples", totalSamples,
|
|
||||||
"geo_bytes", len(rpt.Geo),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundedForLog truncates an untrusted string to a fixed byte
|
|
||||||
// bound so an attacker-controlled field cannot dominate the log.
|
|
||||||
func boundedForLog(s string) string {
|
|
||||||
if len(s) > maxLoggedFieldBytes {
|
|
||||||
return s[:maxLoggedFieldBytes]
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errStorageFailed = errors.New("storage failed")
|
|
||||||
|
|
||||||
// stubAppender drives the storage success/failure path without a
|
|
||||||
// real buffer or disk.
|
|
||||||
type stubAppender struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s stubAppender) Append(any) error { return s.err }
|
|
||||||
|
|
||||||
func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers {
|
|
||||||
return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeStatus(t *testing.T, body []byte) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.Unmarshal(body, &resp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("response body not JSON: %v (%q)", err, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
h.HandleReport().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code < 500 {
|
|
||||||
t.Fatalf("storage failure status = %d, want a 5xx", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := decodeStatus(t, rec.Body.Bytes()); got != "error" {
|
|
||||||
t.Fatalf("status field = %q, want %q", got, "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReportMalformedJSONIs400(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(`{not json`),
|
|
||||||
)
|
|
||||||
|
|
||||||
h.HandleReport().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("malformed status = %d, want %d",
|
|
||||||
rec.Code, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReportOversizeIs413(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 32
|
|
||||||
|
|
||||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
|
|
||||||
)
|
|
||||||
// Emulate the body-size middleware capping the body so the
|
|
||||||
// handler observes a *http.MaxBytesError while decoding.
|
|
||||||
req.Body = http.MaxBytesReader(rec, req.Body, limit)
|
|
||||||
|
|
||||||
h.HandleReport().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
||||||
t.Fatalf("oversize status = %d, want %d",
|
|
||||||
rec.Code, http.StatusRequestEntityTooLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const sentinel = "SENSITIVE-GEO-BLOB"
|
|
||||||
|
|
||||||
var logbuf bytes.Buffer
|
|
||||||
|
|
||||||
h := newTestHandlers(stubAppender{}, &logbuf)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(
|
|
||||||
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
h.HandleReport().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(logbuf.String(), sentinel) {
|
|
||||||
t.Fatal("raw geo bytes were written to the log")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(logbuf.String(), "geo_bytes") {
|
|
||||||
t.Fatal("expected a bounded geo_bytes field in the log")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test-only wrappers exposing unexported helpers to the
|
|
||||||
// external middleware_test package.
|
|
||||||
|
|
||||||
// NewWithLogger builds a Middleware around a logger for tests
|
|
||||||
// that exercise the logging paths without the fx graph.
|
|
||||||
func NewWithLogger(log *slog.Logger) *Middleware {
|
|
||||||
return &Middleware{log: log}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,15 +3,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/config"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
@@ -25,23 +19,6 @@ import (
|
|||||||
|
|
||||||
const corsMaxAgeSec = 300
|
const corsMaxAgeSec = 300
|
||||||
|
|
||||||
// jsonErrorBody is the body written for errors raised inside
|
|
||||||
// middleware, matching the {"status":"error"} shape the handlers
|
|
||||||
// return so clients see one error contract across the API.
|
|
||||||
const (
|
|
||||||
jsonContentType = "application/json; charset=utf-8"
|
|
||||||
jsonErrorBody = "{\"status\":\"error\"}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -53,9 +30,8 @@ type Params struct {
|
|||||||
|
|
||||||
// Middleware holds shared state for middleware factories.
|
// Middleware holds shared state for middleware factories.
|
||||||
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.
|
||||||
@@ -63,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
|
||||||
|
|
||||||
@@ -121,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 {
|
||||||
@@ -209,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(),
|
||||||
@@ -226,100 +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)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSONError writes the shared JSON error body with the
|
|
||||||
// given status. Used where middleware must reject a request
|
|
||||||
// before it reaches a handler.
|
|
||||||
func writeJSONError(w http.ResponseWriter, status int) {
|
|
||||||
w.Header().Set("Content-Type", jsonContentType)
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_, _ = io.WriteString(w, jsonErrorBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxBodyBytes returns middleware that caps the request body at
|
|
||||||
// limit bytes. A declared Content-Length over the limit is
|
|
||||||
// rejected immediately with 413. Bodies without a declared
|
|
||||||
// length (or that understate it) are capped as they are read, so
|
|
||||||
// a handler that reads the body sees a *http.MaxBytesError it can
|
|
||||||
// map to 413. Mount it with a different limit on a route group
|
|
||||||
// that needs a different bound.
|
|
||||||
func (s *Middleware) MaxBodyBytes(
|
|
||||||
limit int64,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.ContentLength > limit {
|
|
||||||
writeJSONError(
|
|
||||||
w,
|
|
||||||
http.StatusRequestEntityTooLarge,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recoverer returns middleware that recovers from a panic in a
|
|
||||||
// downstream handler, logs the panic and stack trace through
|
|
||||||
// slog, and responds 500 with no body. http.ErrAbortHandler is
|
|
||||||
// re-panicked so the server can abort the response as intended.
|
|
||||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
defer func() {
|
|
||||||
rec := recover()
|
|
||||||
if rec == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err, ok := rec.(error)
|
|
||||||
if ok && errors.Is(err, http.ErrAbortHandler) {
|
|
||||||
panic(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.ErrorContext(r.Context(),
|
|
||||||
"panic recovered",
|
|
||||||
"panic", fmt.Sprintf("%v", rec),
|
|
||||||
"stack", string(debug.Stack()),
|
|
||||||
)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}()
|
|
||||||
|
|
||||||
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,231 +0,0 @@
|
|||||||
package middleware_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxBodyBytesRejectsOversizeOnNonReadingRoute confirms the
|
|
||||||
// limit is enforced even for a handler that never reads the body
|
|
||||||
// (for example the health check), via the Content-Length check.
|
|
||||||
func TestMaxBodyBytesRejectsOversizeOnNonReadingRoute(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 16
|
|
||||||
|
|
||||||
called := false
|
|
||||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
|
||||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
||||||
called = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/.well-known/healthcheck",
|
|
||||||
strings.NewReader(strings.Repeat("x", limit+1)),
|
|
||||||
)
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
||||||
t.Fatalf("status = %d, want %d",
|
|
||||||
rec.Code, http.StatusRequestEntityTooLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
if called {
|
|
||||||
t.Fatal("handler ran despite oversize body")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxBodyBytesAllowsWithinLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limit = 64
|
|
||||||
|
|
||||||
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
|
|
||||||
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(`{"clientId":"c1"}`),
|
|
||||||
)
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecovererReturns500AndLogsThroughSlog(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var logbuf bytes.Buffer
|
|
||||||
|
|
||||||
mw := middleware.NewWithLogger(
|
|
||||||
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
|
||||||
)
|
|
||||||
|
|
||||||
handler := mw.Recoverer()(
|
|
||||||
http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
||||||
panic("boom")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusInternalServerError {
|
|
||||||
t.Fatalf("status = %d, want %d",
|
|
||||||
rec.Code, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := logbuf.String()
|
|
||||||
if !strings.Contains(out, "panic recovered") {
|
|
||||||
t.Fatalf("panic was not logged through slog: %q", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(out, `"level":"ERROR"`) {
|
|
||||||
t.Fatalf("panic log was not structured JSON at error level: %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,24 +8,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
readTimeout = 10 * time.Second
|
readTimeout = 10 * time.Second
|
||||||
readHeaderTimeout = 5 * time.Second
|
writeTimeout = 10 * time.Second
|
||||||
writeTimeout = 10 * time.Second
|
maxHeaderBytes = 1 << 20 // 1 MiB
|
||||||
idleTimeout = 60 * time.Second
|
|
||||||
maxHeaderBytes = 1 << 20 // 1 MiB
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) serveUntilShutdown() {
|
func (s *Server) serveUntilShutdown() {
|
||||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||||
|
|
||||||
s.httpServer = &http.Server{
|
s.httpServer = &http.Server{
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
MaxHeaderBytes: maxHeaderBytes,
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
ReadTimeout: readTimeout,
|
ReadTimeout: readTimeout,
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
WriteTimeout: writeTimeout,
|
||||||
WriteTimeout: writeTimeout,
|
|
||||||
IdleTimeout: idleTimeout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|||||||
@@ -7,26 +7,17 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const requestTimeout = 60 * time.Second
|
||||||
requestTimeout = 60 * time.Second
|
|
||||||
|
|
||||||
// maxRequestBodyBytes caps every request body. A route that
|
|
||||||
// needs a different bound mounts s.mw.MaxBodyBytes with its
|
|
||||||
// own value on its group.
|
|
||||||
maxRequestBodyBytes int64 = 1 << 20 // 1 MiB
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetupRoutes configures the chi router with middleware and
|
// SetupRoutes configures the chi router with middleware and
|
||||||
// all application routes.
|
// all application routes.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
s.router.Use(s.mw.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(s.mw.MaxBodyBytes(maxRequestBodyBytes))
|
|
||||||
s.router.Use(middleware.Timeout(requestTimeout))
|
s.router.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
s.router.Get(
|
s.router.Get(
|
||||||
|
|||||||
+55
-39
@@ -537,12 +537,6 @@ class SparklineRenderer {
|
|||||||
|
|
||||||
// --- UI Renderer -------------------------------------------------------------
|
// --- UI Renderer -------------------------------------------------------------
|
||||||
|
|
||||||
// The per-host status line must stay wrappable: its populated content is
|
|
||||||
// wider than the host column at a 320px viewport, and `whitespace-nowrap`
|
|
||||||
// here overflows the element and forces the whole document to scroll
|
|
||||||
// horizontally.
|
|
||||||
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
|
|
||||||
|
|
||||||
function hostRowHTML(host, index, showPin = true) {
|
function hostRowHTML(host, index, showPin = true) {
|
||||||
const pinColor = host.pinned
|
const pinColor = host.pinned
|
||||||
? "text-blue-500"
|
? "text-blue-500"
|
||||||
@@ -568,7 +562,7 @@ function hostRowHTML(host, index, showPin = true) {
|
|||||||
<span class="text-gray-500">---</span>
|
<span class="text-gray-500">---</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
|
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
|
||||||
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
|
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" data-host="${index}">waiting...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
||||||
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
|
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
|
||||||
@@ -677,7 +671,7 @@ function buildUI(state) {
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
|
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
<label class="debug-toggle-label cursor-pointer">
|
<label class="cursor-pointer">
|
||||||
<input type="checkbox" id="debug-toggle" class="mr-1">
|
<input type="checkbox" id="debug-toggle" class="mr-1">
|
||||||
<span>Debug log</span>
|
<span>Debug log</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -694,26 +688,6 @@ function buildUI(state) {
|
|||||||
|
|
||||||
// --- UI Updaters -------------------------------------------------------------
|
// --- UI Updaters -------------------------------------------------------------
|
||||||
|
|
||||||
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
|
|
||||||
// trailing separator is one unbreakable unit, so wrapping only ever happens
|
|
||||||
// between stats and a wrapped line never starts with a separator.
|
|
||||||
function statusStatsHTML(stats) {
|
|
||||||
return stats
|
|
||||||
.map(([label, value], i) => {
|
|
||||||
const sep =
|
|
||||||
i < stats.length - 1
|
|
||||||
? ` <span class="text-gray-500">/</span>`
|
|
||||||
: "";
|
|
||||||
return (
|
|
||||||
`<span class="whitespace-nowrap">` +
|
|
||||||
`<span class="text-gray-400">${label} </span>` +
|
|
||||||
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
|
|
||||||
`${sep}</span>`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHostRow(host, index) {
|
function updateHostRow(host, index) {
|
||||||
const latencyEl = document.querySelector(
|
const latencyEl = document.querySelector(
|
||||||
`.latency-value[data-host="${index}"]`,
|
`.latency-value[data-host="${index}"]`,
|
||||||
@@ -738,22 +712,28 @@ function updateHostRow(host, index) {
|
|||||||
const min = host.minLatency();
|
const min = host.minLatency();
|
||||||
const max = host.maxLatency();
|
const max = host.maxLatency();
|
||||||
if (host.status === "online" && avg !== null) {
|
if (host.status === "online" && avg !== null) {
|
||||||
statusEl.innerHTML = statusStatsHTML([
|
statusEl.innerHTML =
|
||||||
["min", min],
|
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` +
|
||||||
["med", med],
|
` <span class="text-gray-500">/</span> ` +
|
||||||
["avg", avg],
|
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` +
|
||||||
["max", max],
|
` <span class="text-gray-500">/</span> ` +
|
||||||
]);
|
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` +
|
||||||
statusEl.className = STATUS_TEXT_CLASS;
|
` <span class="text-gray-500">/</span> ` +
|
||||||
|
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
|
||||||
|
statusEl.className =
|
||||||
|
"status-text text-xs whitespace-nowrap text-right col-span-2 mt-5";
|
||||||
} else if (host.status === "offline") {
|
} else if (host.status === "offline") {
|
||||||
statusEl.textContent = "unreachable";
|
statusEl.textContent = "unreachable";
|
||||||
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
|
statusEl.className =
|
||||||
|
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
|
||||||
} else if (host.status === "error") {
|
} else if (host.status === "error") {
|
||||||
statusEl.textContent = "timeout";
|
statusEl.textContent = "timeout";
|
||||||
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
|
statusEl.className =
|
||||||
|
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
|
||||||
} else {
|
} else {
|
||||||
statusEl.textContent = "connecting...";
|
statusEl.textContent = "connecting...";
|
||||||
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
|
statusEl.className =
|
||||||
|
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
|
||||||
}
|
}
|
||||||
|
|
||||||
SparklineRenderer.draw(canvas, host.history);
|
SparklineRenderer.draw(canvas, host.history);
|
||||||
@@ -1064,7 +1044,8 @@ function greyOutUI(state) {
|
|||||||
}
|
}
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = "paused";
|
statusEl.textContent = "paused";
|
||||||
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
|
statusEl.className =
|
||||||
|
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
|
||||||
}
|
}
|
||||||
// Grey out the status dot
|
// Grey out the status dot
|
||||||
const row = document.querySelector(`.host-row[data-index="${i}"]`);
|
const row = document.querySelector(`.host-row[data-index="${i}"]`);
|
||||||
@@ -1145,11 +1126,46 @@ function handleResize(state) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mobile Detection --------------------------------------------------------
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
function isMobileViewport() {
|
||||||
|
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMobileUI() {
|
||||||
|
const app = document.getElementById("app");
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="mx-auto px-[5%] py-8">
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-2">Real-time network latency monitor</p>
|
||||||
|
</header>
|
||||||
|
<div class="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div class="bg-gray-800/70 border border-gray-700/50 rounded-lg p-8 max-w-md text-center">
|
||||||
|
<p class="text-4xl mb-4">📡</p>
|
||||||
|
<p class="text-xl font-semibold text-white mb-2">Not yet available on mobile</p>
|
||||||
|
<p class="text-gray-400 text-sm">NetWatch requires a wider viewport to display latency charts and monitoring data. Please visit on a desktop browser.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="mt-8 text-center text-gray-600 text-xs">
|
||||||
|
<p><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
|
||||||
|
</footer>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bootstrap ---------------------------------------------------------------
|
// --- Bootstrap ---------------------------------------------------------------
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
log.info("NetWatch starting");
|
log.info("NetWatch starting");
|
||||||
|
|
||||||
|
if (isMobileViewport()) {
|
||||||
|
log.info("Mobile viewport detected — skipping monitoring");
|
||||||
|
buildMobileUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Probe common gateway IPs to find the local router
|
// Probe common gateway IPs to find the local router
|
||||||
const gateway = await detectGateway();
|
const gateway = await detectGateway();
|
||||||
const localHosts = [LOCAL_CPE];
|
const localHosts = [LOCAL_CPE];
|
||||||
|
|||||||
+1
-34
@@ -14,38 +14,6 @@ body {
|
|||||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Minimum tap targets ----------------------------------------------
|
|
||||||
Every interactive control is at least 44x44 CSS px (Apple HIG, WCAG 2.2
|
|
||||||
SC 2.5.5). Not scoped to a breakpoint or to `pointer: coarse`: a large
|
|
||||||
phone in landscape is above the 768px breakpoint and still a touch
|
|
||||||
device. */
|
|
||||||
|
|
||||||
/* The button grows to 44x44 while the negative margins keep its layout
|
|
||||||
footprint at the 16x16 of the icon inside it, so row height and the
|
|
||||||
icon's position are unchanged. */
|
|
||||||
.pin-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 2.75rem;
|
|
||||||
margin: -0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A select paints its own background and border, so it has to actually be
|
|
||||||
44 tall rather than borrow the trick above. */
|
|
||||||
#interval-select {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The tappable target for #debug-toggle is the label wrapping it. */
|
|
||||||
.debug-toggle-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sparkline-container {
|
.sparkline-container {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
@@ -70,10 +38,9 @@ body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pause button: smaller on mobile, but not below the tap-target floor */
|
/* Pause button: smaller on mobile */
|
||||||
#pause-btn {
|
#pause-btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pause-btn svg {
|
#pause-btn svg {
|
||||||
|
|||||||
Reference in New Issue
Block a user