Author SHA1 Message Date
sneak 485aa4194a Merge branch 'main' into feature/mobile-detection-message
check / check (push) Successful in 24s
2026-07-25 17:49:06 +07:00
user a279cf8583 feat: add mobile viewport detection with friendly unavailable message
check / check (push) Failing after 3m26s
Detect mobile viewport (window.innerWidth < 768) at startup and show a
centered 'Not yet available on mobile' message instead of the full
monitoring UI. All polling, gateway detection, and network requests are
skipped entirely on mobile viewports.

Desktop behavior is completely unchanged — the mobile check is the very
first thing in init() and returns early before any other setup runs.
2026-03-16 21:18:55 -07:00
17 changed files with 104 additions and 700 deletions
+1 -24
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+5 -15
View File
@@ -23,9 +23,6 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
`netwatch-server` (see `backend/`) receives the reports the page posts.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -74,18 +71,6 @@ code lives in `src/main.js` with a class-based architecture:
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
false outage) false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
Every `reportInterval` (default 60s) the page POSTs a JSON report to the
same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in
`localStorage`, `geo` sent as null, and each host's unreported, non-paused
samples (timestamp, latency, error). A per-host high-water mark makes every
report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Delivery failure is quiet —
one debug-log line per outage, retried at the next interval, never blocking
probing. The report-building step is a pure function of host state.
### Monitoring targets ### Monitoring targets
@@ -140,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
@@ -163,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.
-27
View File
@@ -22,33 +22,6 @@ files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-09-21: frontend reporting client — a `Reporter` class posts collected
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
state; the per-browser client id works in insecure (plain-HTTP) contexts;
`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
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
View File
@@ -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 ./...
+1 -7
View File
@@ -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
-28
View File
@@ -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: &params, params: &params,
} }
@@ -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)
}
+1 -128
View File
@@ -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 = &params s.params = &params
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)
}
}
}
-4
View File
@@ -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()
-1
View File
@@ -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))
+55 -200
View File
@@ -7,11 +7,9 @@ import "./styles.css";
// graphMaxLatency — values above it pin to the top of the chart but still // graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds // display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval). // maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend.
const CONFIG = { const CONFIG = {
updateInterval: 3000, updateInterval: 3000,
maxHistoryPoints: 100, maxHistoryPoints: 100,
reportInterval: 60000,
get historyDuration() { get historyDuration() {
return (this.maxHistoryPoints * this.updateInterval) / 1000; return (this.maxHistoryPoints * this.updateInterval) / 1000;
}, },
@@ -348,147 +346,6 @@ class AppState {
} }
} }
// --- 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
// kept in localStorage; if storage is unavailable (e.g. private mode) a
// fresh id is used for this session only.
function getClientId() {
const key = "netwatch-client-id";
try {
let id = localStorage.getItem(key);
if (!id) {
id = randomId();
localStorage.setItem(key, id);
}
return id;
} catch {
return randomId();
}
}
// Build the delta report body the backend decodes, plus the new per-host
// high-water marks. Pure function of the passed state: `hosts` is an array
// of { name, url, status, history }, `since` maps a host url to the Unix-ms
// 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
// when no host has an unreported sample.
export function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
const marks = new Map();
for (const host of hosts) {
const mark = since.get(host.url) ?? 0;
const samples = [];
let high = mark;
for (const p of host.history) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
t: p.timestamp,
latency: p.latency,
error: p.error ?? null,
});
if (p.timestamp > high) high = p.timestamp;
}
if (samples.length === 0) continue;
reportHosts.push({
name: host.name,
url: host.url,
status: host.status,
history: samples,
});
marks.set(host.url, high);
}
if (reportHosts.length === 0) return null;
return {
body: {
clientId,
geo: null,
hosts: reportHosts,
timestamp: now.toISOString(),
},
marks,
};
}
// Periodically POSTs unreported samples to the same-origin backend. Holds
// the per-host high-water marks so each report is a delta; marks only
// advance on a delivered report, so a failed POST simply re-sends those
// samples next interval (bounded by the history window — whatever has since
// fallen out is dropped). Failure is quiet: one debug line per outage, one
// on recovery, never an alert, never a tight retry loop.
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
this.intervalMs = intervalMs;
this.marks = new Map();
this.failing = false;
this.timerId = null;
}
start() {
if (this.timerId) return;
this.timerId = setInterval(() => this.flush(), this.intervalMs);
}
async flush() {
if (this.state.paused) return;
const report = buildReport(
this.state.allHosts,
this.clientId,
new Date(),
this.marks,
);
if (!report) return;
try {
const resp = await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify(report.body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
for (const [url, t] of report.marks) this.marks.set(url, t);
if (this.failing) {
log.debug("Report delivery recovered");
this.failing = false;
}
} catch (err) {
if (!this.failing) {
log.debug(`Report delivery failed: ${err.message}`);
this.failing = true;
}
}
}
}
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { async function measureLatency(url) {
@@ -680,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"
@@ -711,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>
@@ -820,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>
@@ -837,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}"]`,
@@ -881,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);
@@ -1207,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}"]`);
@@ -1288,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];
@@ -1305,19 +1178,6 @@ 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
// crypto for the client id) must never stop the monitor from probing.
try {
const reporter = new Reporter(
state,
getClientId(),
CONFIG.reportInterval,
);
reporter.start();
} catch (err) {
log.error(`Reporting disabled: ${err.message}`);
}
document document
.getElementById("pause-btn") .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state)); .addEventListener("click", () => togglePause(state));
@@ -1430,13 +1290,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();
} }
}
+1 -34
View File
@@ -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 {
-7
View File
@@ -7,13 +7,6 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim();
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
server: {
// Proxy /api to a locally running netwatch-server so `yarn dev`
// exercises the real report-posting path.
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
define: { define: {
__COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_HASH__: JSON.stringify(commitHash),
__COMMIT_FULL__: JSON.stringify(commitFull), __COMMIT_FULL__: JSON.stringify(commitFull),