Compare commits
1
Commits
7b34ce4668
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
871b986978 |
@@ -4,6 +4,3 @@ tmp
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
# .git is sent so the build can stamp the version, without its config.
|
|
||||||
.git/config
|
|
||||||
|
|||||||
+6
-20
@@ -20,7 +20,7 @@ RUN make lint
|
|||||||
# 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 git make
|
RUN apk add --no-cache make
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -37,24 +37,11 @@ RUN make test
|
|||||||
|
|
||||||
# make build is a shim around backend/script/build, the one definition
|
# make build is a shim around backend/script/build, the one definition
|
||||||
# of the build command:
|
# of the build command:
|
||||||
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=..."
|
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
|
||||||
# That script reads VERSION from the environment, so it is handed over
|
# That script reads VERSION from the environment, so it is handed over
|
||||||
# there rather than as a make variable.
|
# there rather than as a make variable.
|
||||||
#
|
ARG VERSION=dev
|
||||||
# The version is the VERSION build argument when one is given, otherwise
|
RUN VERSION="${VERSION}" make build
|
||||||
# `git describe --tags --always` of the repo's .git: the tag on a tagged
|
|
||||||
# commit, tag-N-gHASH on a commit after one, the short commit when no
|
|
||||||
# tag is reachable. A version that still comes out empty, dev or unknown
|
|
||||||
# fails the build. .git goes to /git, not /src/.git, where go build would
|
|
||||||
# find it and record VCS details of a work tree holding only backend/.
|
|
||||||
COPY .git /git
|
|
||||||
ARG VERSION
|
|
||||||
RUN version="${VERSION:-$(git --git-dir=/git describe --tags --always)}"; \
|
|
||||||
case "$version" in ""|dev|unknown) \
|
|
||||||
echo "version is '$version' although .git is present" >&2; \
|
|
||||||
exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
VERSION="$version" make build
|
|
||||||
|
|
||||||
# Frontend stage
|
# Frontend stage
|
||||||
# node:22-alpine as of 2026-02-22
|
# node:22-alpine as of 2026-02-22
|
||||||
@@ -65,9 +52,8 @@ RUN yarn install --frozen-lockfile
|
|||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
COPY . .
|
COPY . .
|
||||||
# make frontend-check is the frontend half of make check (test + lint +
|
# make frontend-check is the frontend half of make check (test + lint +
|
||||||
# fmt-check); its test step runs the unit tests, then the production
|
# fmt-check); its test step is the production yarn build, so this both
|
||||||
# yarn build, so this both produces dist/ and gates the image on
|
# produces dist/ and gates the image on lint/fmt-check/test regressions.
|
||||||
# lint/fmt-check/test regressions.
|
|
||||||
# This node stage has neither Go nor Docker; the lint and builder stages
|
# This node stage has neither Go nor Docker; the lint and builder stages
|
||||||
# above gate the backend half.
|
# above gate the backend half.
|
||||||
RUN make frontend-check
|
RUN make frontend-check
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
|||||||
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
||||||
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
|
- `script/frontend-test` — run the production build as the frontend's test (no
|
||||||
built-in test runner, then the production build
|
unit tests yet)
|
||||||
- `script/frontend-lint` — run prettier in check mode
|
- `script/frontend-lint` — run prettier in check mode
|
||||||
- `script/frontend-fmt` — format everything prettier understands (writes)
|
- `script/frontend-fmt` — format everything prettier understands (writes)
|
||||||
- `script/frontend-fmt-check` — check prettier formatting (read-only)
|
- `script/frontend-fmt-check` — check prettier formatting (read-only)
|
||||||
@@ -136,14 +136,8 @@ Local hosts are tracked separately from WAN stats.
|
|||||||
### Latency measurement
|
### Latency measurement
|
||||||
|
|
||||||
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
|
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
|
||||||
`performance.now()`. Each check times out after 80% of the refresh interval (24
|
`performance.now()`. 1-second timeout; anything over 1000ms is clamped to
|
||||||
seconds at 30 seconds) and is then recorded as a timeout, so a round's checks
|
unreachable. IPv4 only.
|
||||||
have all finished before the next round is due. When no WAN host answers, a
|
|
||||||
recovery probe checks 4 random WAN hosts every half second, giving up the checks
|
|
||||||
it started half a second before. As soon as one answers, a new round starts at
|
|
||||||
once, as it does after an interval change. A round started early gives up the
|
|
||||||
last round's checks if they are still waiting, and that round records nothing,
|
|
||||||
so rounds never overlap. IPv4 only.
|
|
||||||
|
|
||||||
### Color coding
|
### Color coding
|
||||||
|
|
||||||
@@ -222,10 +216,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
|||||||
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
|
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
|
||||||
the API
|
the API
|
||||||
- `DEBUG`, default `false`: debug logging
|
- `DEBUG`, default `false`: debug logging
|
||||||
- `DATA_DIR`, default `/data/reports`: the directory the reports are kept
|
- `DATA_DIR`, default `/data/reports`: leave unset; reports kept outside
|
||||||
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
|
`/data` do not survive a redeploy
|
||||||
The container also stops if the path goes through a symbolic link that
|
|
||||||
leads out of `/data` or is written as a full path
|
|
||||||
- `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
|
- `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
|
||||||
in front of the container connects from, as an IP address or CIDR; several
|
in front of the container connects from, as an IP address or CIDR; several
|
||||||
are separated by commas. nginx takes the client address from
|
are separated by commas. nginx takes the client address from
|
||||||
|
|||||||
@@ -23,24 +23,6 @@ latest run passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
|
|
||||||
(issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
|
|
||||||
which refuses a `DATA_DIR` that is not `/data` or a path below it written in
|
|
||||||
full, then creates `DATA_DIR`, gives `/data` and everything in it to
|
|
||||||
`netwatch` and sets the modes, all through a Go `os.Root` opened on `/data`.
|
|
||||||
That refuses any path leading out of `/data`, so neither a symbolic link
|
|
||||||
already there nor one a host process swaps in during the start can make root
|
|
||||||
create or change anything elsewhere, and `DATA_DIR=/etc` no longer gives
|
|
||||||
`/etc` to `netwatch`. The `README.md` section "Running under upaas" says which
|
|
||||||
values are accepted
|
|
||||||
- 2026-10-03: each target check times out after 80% of the refresh interval
|
|
||||||
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
|
|
||||||
round started early, after an interval change or when the recovery probe finds
|
|
||||||
a target answering, gives up the last round's checks if they are still
|
|
||||||
waiting, so rounds never overlap; the recovery probe gives up its own checks
|
|
||||||
after half a second. The frontend has its first unit tests, run by
|
|
||||||
`script/frontend-test` with Node's built-in test runner; for them,
|
|
||||||
`index.html` now links `src/styles.css`, which `src/main.js` used to import
|
|
||||||
- 2026-09-29: the container sets up its own data directory (issue #75):
|
- 2026-09-29: the container sets up its own data directory (issue #75):
|
||||||
`bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it
|
`bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it
|
||||||
and `/data` to the `netwatch` user with mode 750 before starting the backend
|
and `/data` to the `netwatch` user with mode 750 before starting the backend
|
||||||
|
|||||||
+3
-4
@@ -32,7 +32,7 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
|
|||||||
`test`, `fmt` and `fmt-check`:
|
`test`, `fmt` and `fmt-check`:
|
||||||
|
|
||||||
- `script/build` — compile the static `netwatch-server` binary with its version
|
- `script/build` — compile the static `netwatch-server` binary with its version
|
||||||
stamped in. The version is `VERSION` from the environment;
|
and architecture stamped in. The version is `VERSION` from the environment;
|
||||||
when that is unset or empty, it falls back to `git describe` inside a git
|
when that is unset or empty, it falls back to `git describe` inside a git
|
||||||
checkout, then to `dev`
|
checkout, then to `dev`
|
||||||
- `script/test` — run the Go tests under a 30-second timeout
|
- `script/test` — run the Go tests under a 30-second timeout
|
||||||
@@ -104,9 +104,8 @@ this server. The image's entrypoint, `bin/entrypoint.sh`, starts the server as
|
|||||||
user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so
|
user `netwatch` (uid 1000) with `BIND_ADDRESS=127.0.0.1` and `PORT=8081`, so
|
||||||
only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
|
only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
|
||||||
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
|
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
|
||||||
the `/data` volume; before starting the server, the entrypoint creates it and
|
the `/data` volume; the entrypoint creates it and gives it and `/data` to
|
||||||
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
|
`netwatch` before starting the server. nginx replaces the security headers
|
||||||
which acts on nothing outside `/data`. nginx replaces the security headers
|
|
||||||
this server sets with those in the root `security-headers.conf`, so those are
|
this server sets with those in the root `security-headers.conf`, so those are
|
||||||
what clients of the image see.
|
what clients of the image see.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/config"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
@@ -20,8 +19,9 @@ import (
|
|||||||
|
|
||||||
//nolint:gochecknoglobals // set via ldflags at build time
|
//nolint:gochecknoglobals // set via ldflags at build time
|
||||||
var (
|
var (
|
||||||
Appname = "netwatch-server"
|
Appname = "netwatch-server"
|
||||||
Version string
|
Version string
|
||||||
|
Buildarch string
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -38,28 +38,9 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
|
|
||||||
// for the netwatch user, or exits 1 with the error; see
|
|
||||||
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
|
|
||||||
// before it starts this server as that user.
|
|
||||||
if len(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
|
|
||||||
netwatch, err := user.Lookup("netwatch")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globals.Appname = Appname
|
globals.Appname = Appname
|
||||||
globals.Version = Version
|
globals.Version = Version
|
||||||
|
globals.Buildarch = Buildarch
|
||||||
|
|
||||||
fx.New(
|
fx.New(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ var (
|
|||||||
Appname string
|
Appname string
|
||||||
// Version is the git version tag.
|
// Version is the git version tag.
|
||||||
Version string
|
Version string
|
||||||
|
// Buildarch is the build architecture.
|
||||||
|
Buildarch string
|
||||||
)
|
)
|
||||||
|
|
||||||
// Globals holds build-time metadata for the application.
|
// Globals holds build-time metadata for the application.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
|
Buildarch string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Globals instance from package-level variables.
|
// New creates a Globals instance from package-level variables.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
return &Globals{
|
return &Globals{
|
||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Buildarch: Buildarch,
|
||||||
|
Version: Version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package logger
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
|
|
||||||
@@ -96,6 +95,6 @@ func (l *Logger) Identify() {
|
|||||||
l.log.Info("starting",
|
l.log.Info("starting",
|
||||||
"appname", l.params.Globals.Appname,
|
"appname", l.params.Globals.Appname,
|
||||||
"version", l.params.Globals.Version,
|
"version", l.params.Globals.Version,
|
||||||
"arch", runtime.GOARCH,
|
"buildarch", l.params.Globals.Buildarch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
package reportbuf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
|
|
||||||
// that is not the volume or a path below it, written in full.
|
|
||||||
var ErrDataDirOutsideVolume = errors.New(
|
|
||||||
"must be /data or a path below it, with no '.', '..' or extra '/'")
|
|
||||||
|
|
||||||
// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
|
|
||||||
// user the server runs as, so that a host directory mounted at volume,
|
|
||||||
// /data in the image, needs no preparing: it creates dir, gives volume
|
|
||||||
// and everything in it to owner, and gives volume and dir the mode the
|
|
||||||
// server gives a directory it creates. dir must be volume or a path
|
|
||||||
// below it, with no '.', '..', empty part or '/' at the end.
|
|
||||||
//
|
|
||||||
// bin/entrypoint.sh runs this as root, which would follow a symbolic
|
|
||||||
// link anywhere, so every step goes through an os.Root opened on
|
|
||||||
// volume: it follows a link only when it is written as a relative
|
|
||||||
// path that stays inside volume, and refuses any other. A process on
|
|
||||||
// the host can swap a link onto a path in volume at any moment while
|
|
||||||
// this runs, but the os.Root checks each link as it reaches it, so the
|
|
||||||
// most that can do is make a step fail or act on something else
|
|
||||||
// inside volume.
|
|
||||||
func PrepareDataDir(volume, dir string, owner *user.User) error {
|
|
||||||
// rel is dir as a path from volume; IsLocal is false for one that
|
|
||||||
// leads out of it.
|
|
||||||
rel, err := filepath.Rel(volume, dir)
|
|
||||||
if err != nil || dir != filepath.Clean(dir) || !filepath.IsLocal(rel) {
|
|
||||||
return ErrDataDirOutsideVolume
|
|
||||||
}
|
|
||||||
|
|
||||||
uid, err := strconv.Atoi(owner.Uid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
gid, err := strconv.Atoi(owner.Gid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
root, err := os.OpenRoot(volume)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = root.Close() }()
|
|
||||||
|
|
||||||
err = root.MkdirAll(rel, dirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// WalkDir does not follow a symbolic link, and Lchown gives the
|
|
||||||
// link itself to owner, not what it points to.
|
|
||||||
err = fs.WalkDir(root.FS(), ".",
|
|
||||||
func(name string, _ fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return root.Lchown(name, uid, gid)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = root.Chmod(".", dirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return root.Chmod(rel, dirPerms)
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
package reportbuf_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"os/user"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reports is the last part of DATA_DIR in these tests, as in the
|
|
||||||
// image's /data/reports.
|
|
||||||
const reports = "reports"
|
|
||||||
|
|
||||||
// currentUser is the user the test runs as, the only owner a test not
|
|
||||||
// run as root can give files to.
|
|
||||||
func currentUser() *user.User {
|
|
||||||
return &user.User{
|
|
||||||
Uid: strconv.Itoa(os.Getuid()),
|
|
||||||
Gid: strconv.Itoa(os.Getgid()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so
|
|
||||||
// a test can tell that PrepareDataDir left its mode alone.
|
|
||||||
func tempDirMode700(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "d")
|
|
||||||
|
|
||||||
err := os.Mkdir(dir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireMode(t *testing.T, path string, want fs.FileMode) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Mode() != want {
|
|
||||||
t.Errorf("%s: mode %v, want %v", path, info.Mode(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireMissing(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, err := os.Lstat(path)
|
|
||||||
if !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
t.Errorf("%s: created, or Lstat failed: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireOwner(t *testing.T, path string, uid, gid uint32) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, _ := info.Sys().(*syscall.Stat_t)
|
|
||||||
if stat.Uid != uid || stat.Gid != gid {
|
|
||||||
t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid,
|
|
||||||
uid, gid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrepareDataDirCreatesDataDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
volume := t.TempDir()
|
|
||||||
dir := filepath.Join(volume, "a", reports)
|
|
||||||
|
|
||||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
requireMode(t, volume, fs.ModeDir|0o750)
|
|
||||||
requireMode(t, dir, fs.ModeDir|0o750)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
volume := t.TempDir()
|
|
||||||
|
|
||||||
err := reportbuf.PrepareDataDir(volume, volume, currentUser())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
requireMode(t, volume, fs.ModeDir|0o750)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that
|
|
||||||
// is relative, outside the volume, or not written in full.
|
|
||||||
func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
volume := tempDirMode700(t)
|
|
||||||
for _, dir := range []string{
|
|
||||||
reports, volume + "/../new", volume + "/", volume + "//" + reports,
|
|
||||||
volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x",
|
|
||||||
"/etc",
|
|
||||||
} {
|
|
||||||
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
|
||||||
if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) {
|
|
||||||
t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requireMissing(t, filepath.Join(filepath.Dir(volume), "new"))
|
|
||||||
requireMode(t, volume, fs.ModeDir|0o700)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a
|
|
||||||
// directory outside the volume on the path to DATA_DIR, written as a
|
|
||||||
// full path and as one that climbs out with '..', and as DATA_DIR
|
|
||||||
// itself, where the mode would be set through it.
|
|
||||||
func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
climbsOut bool
|
|
||||||
link, dir string
|
|
||||||
}{
|
|
||||||
{"full path", false, "x", "x/reports"},
|
|
||||||
{"climbs out", true, "x", "x/reports"},
|
|
||||||
{"DATA_DIR itself", false, reports, reports},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
outside := tempDirMode700(t)
|
|
||||||
volume := t.TempDir()
|
|
||||||
|
|
||||||
climbOut, err := filepath.Rel(volume, outside)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := outside
|
|
||||||
if tc.climbsOut {
|
|
||||||
target = climbOut
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Symlink(target, filepath.Join(volume, tc.link))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = reportbuf.PrepareDataDir(volume,
|
|
||||||
filepath.Join(volume, tc.dir), currentUser())
|
|
||||||
if err == nil {
|
|
||||||
t.Error("no error")
|
|
||||||
}
|
|
||||||
|
|
||||||
requireMissing(t, filepath.Join(outside, reports))
|
|
||||||
requireMode(t, outside, fs.ModeDir|0o700)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link
|
|
||||||
// to a name in the volume that does not exist, which is not created.
|
|
||||||
func TestPrepareDataDirRefusesDanglingLink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
volume := t.TempDir()
|
|
||||||
|
|
||||||
err := os.Symlink("missing", filepath.Join(volume, reports))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
|
||||||
currentUser())
|
|
||||||
if err == nil {
|
|
||||||
t.Error("no error")
|
|
||||||
}
|
|
||||||
|
|
||||||
requireMissing(t, filepath.Join(volume, "missing"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrepareDataDirGivesVolumeToOwner gives everything in the volume
|
|
||||||
// to a uid and gid that own nothing, which only root can do. A
|
|
||||||
// symbolic link in the volume to a directory outside it is given to
|
|
||||||
// them itself; what it points to is left as it was.
|
|
||||||
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
|
||||||
t.Skip("only root can give files to another uid")
|
|
||||||
}
|
|
||||||
|
|
||||||
outside := t.TempDir()
|
|
||||||
volume := t.TempDir()
|
|
||||||
old := filepath.Join(volume, "old")
|
|
||||||
|
|
||||||
err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Mkdir(old, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Symlink(outside, filepath.Join(old, "link"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
|
||||||
&user.User{Uid: "4242", Gid: "4343"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
volume, filepath.Join(volume, reports), old,
|
|
||||||
filepath.Join(old, "f"), filepath.Join(old, "link"),
|
|
||||||
} {
|
|
||||||
requireOwner(t, path, 4242, 4343)
|
|
||||||
}
|
|
||||||
|
|
||||||
requireOwner(t, outside, 0, 0)
|
|
||||||
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ func (s *Server) listenAndServe() {
|
|||||||
s.log.Info("http begin listen",
|
s.log.Info("http begin listen",
|
||||||
"listenaddr", s.httpServer.Addr,
|
"listenaddr", s.httpServer.Addr,
|
||||||
"version", s.params.Globals.Version,
|
"version", s.params.Globals.Version,
|
||||||
"arch", runtime.GOARCH,
|
"buildarch", s.params.Globals.Buildarch,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := s.httpServer.ListenAndServe()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/build: compile the static netwatch-server binary into the
|
# script/build: compile the static netwatch-server binary into the
|
||||||
# backend project root, with its version stamped in.
|
# backend project root, with its version and architecture stamped in.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -14,7 +14,7 @@ main() {
|
|||||||
version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
|
version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
|
||||||
|
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
CGO_ENABLED=0 go build -trimpath \
|
||||||
-ldflags "-s -w -X main.Version=$version" \
|
-ldflags "-s -w -X main.Version=$version -X main.Buildarch=$(uname -m)" \
|
||||||
-o netwatch-server ./cmd/netwatch-server/
|
-o netwatch-server ./cmd/netwatch-server/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -63,13 +63,26 @@ done > /etc/nginx/trusted-proxies.conf
|
|||||||
|
|
||||||
# netwatch-server keeps its report files in DATA_DIR, on the /data
|
# netwatch-server keeps its report files in DATA_DIR, on the /data
|
||||||
# volume, which may be a host directory owned by root or by another
|
# volume, which may be a host directory owned by root or by another
|
||||||
# uid. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR
|
# uid. Both are given to the netwatch user here, with the mode the
|
||||||
# and gives /data and everything in it to the netwatch user, so the
|
# server gives a directory it creates, so the host directory needs no
|
||||||
# host directory needs no preparing. It stops the start, naming
|
# preparing.
|
||||||
# DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
|
#
|
||||||
# nothing outside /data, whatever symbolic links it meets there.
|
# chown and chmod, run as root, change whatever a symbolic link on the
|
||||||
|
# path points to, anywhere in the container, and the netwatch user can
|
||||||
|
# put one in /data. So the start stops unless readlink -f, which
|
||||||
|
# follows every link on a path, gives /data and DATA_DIR back as they
|
||||||
|
# are. It also writes a path in full, so a DATA_DIR with '.', '..' or
|
||||||
|
# an extra '/' in it is refused too.
|
||||||
export DATA_DIR="${DATA_DIR:-/data/reports}"
|
export DATA_DIR="${DATA_DIR:-/data/reports}"
|
||||||
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
|
mkdir -p "$DATA_DIR" || exit 1
|
||||||
|
if [ "$(readlink -f /data)" != /data ] ||
|
||||||
|
[ "$(readlink -f "$DATA_DIR")" != "$DATA_DIR" ]; then
|
||||||
|
echo "entrypoint: DATA_DIR must be a full path with no '.', '..'," \
|
||||||
|
"extra '/' or symbolic link on it or on /data, not '$DATA_DIR'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown -R netwatch:netwatch /data "$DATA_DIR" || exit 1
|
||||||
|
chmod 750 /data "$DATA_DIR" || exit 1
|
||||||
|
|
||||||
# A stop signal is only noted here; the loop below acts on it.
|
# A stop signal is only noted here; the loop below acts on it.
|
||||||
stop_requested=""
|
stop_requested=""
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
type="image/svg+xml"
|
type="image/svg+xml"
|
||||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>"
|
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>"
|
||||||
/>
|
/>
|
||||||
<!-- Linked here, not imported by src/main.js, so the unit tests can
|
|
||||||
import that module in Node, which cannot import CSS. -->
|
|
||||||
<link rel="stylesheet" href="/src/styles.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-white min-h-screen">
|
<body class="bg-gray-900 text-white min-h-screen">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+3
-2
@@ -16,8 +16,9 @@ main() {
|
|||||||
"$SCRIPT_DIR/check"
|
"$SCRIPT_DIR/check"
|
||||||
# Own line: a failing command substitution inside an argument does
|
# Own line: a failing command substitution inside an argument does
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
# empty constant. VERSION is computed here because .dockerignore
|
||||||
# the version a build stage derives from the .git in the context.
|
# excludes .git, so `git describe` in a build stage yields an empty
|
||||||
|
# version without failing.
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
|
|||||||
+3
-2
@@ -12,8 +12,9 @@ main() {
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Own line: a failing command substitution inside an argument does
|
# Own line: a failing command substitution inside an argument does
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
# not trip `set -e`, so the inline form degrades silently to an
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
# empty constant. VERSION is computed here because .dockerignore
|
||||||
# the version a build stage derives from the .git in the context.
|
# excludes .git, so `git describe` in a build stage yields an empty
|
||||||
|
# version without failing.
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||||
[ -n "$version" ] || version="unknown"
|
[ -n "$version" ] || version="unknown"
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/frontend-test: run the frontend test suite: the unit tests in
|
# script/frontend-test: run the frontend test suite. The frontend has no
|
||||||
# test/unit/ with Node's built-in test runner, then the production
|
# unit tests; the production build serves as the test (fails on broken
|
||||||
# build, which fails on broken code.
|
# code).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
timeout 30 node --test test/unit/*.test.js
|
|
||||||
timeout 30 yarn build
|
timeout 30 yarn build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-54
@@ -1,14 +1,14 @@
|
|||||||
|
import "./styles.css";
|
||||||
|
|
||||||
// --- Configuration -----------------------------------------------------------
|
// --- Configuration -----------------------------------------------------------
|
||||||
|
|
||||||
// Timing, axis labels, and display constants. A target check times out
|
// Timing, axis labels, and display constants. Latency above maxLatency is
|
||||||
// after requestTimeout, 80% of updateInterval, so a round's checks have
|
// clamped to "unreachable". The sparkline Y-axis is capped at
|
||||||
// all finished before the next round is due; latency above maxLatency is
|
|
||||||
// recorded as a timeout. The sparkline Y-axis is capped at
|
|
||||||
// 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.
|
// reportInterval is how often collected samples are POSTed to the backend.
|
||||||
export const CONFIG = {
|
const CONFIG = {
|
||||||
updateInterval: 3000,
|
updateInterval: 3000,
|
||||||
maxHistoryPoints: 100,
|
maxHistoryPoints: 100,
|
||||||
reportInterval: 60000,
|
reportInterval: 60000,
|
||||||
@@ -16,7 +16,7 @@ export const CONFIG = {
|
|||||||
return (this.maxHistoryPoints * this.updateInterval) / 1000;
|
return (this.maxHistoryPoints * this.updateInterval) / 1000;
|
||||||
},
|
},
|
||||||
get requestTimeout() {
|
get requestTimeout() {
|
||||||
return this.updateInterval * 0.8;
|
return Math.min(this.updateInterval - 100, 3000);
|
||||||
},
|
},
|
||||||
get maxLatency() {
|
get maxLatency() {
|
||||||
return this.requestTimeout;
|
return this.requestTimeout;
|
||||||
@@ -503,16 +503,12 @@ class Reporter {
|
|||||||
|
|
||||||
// --- Latency Measurement -----------------------------------------------------
|
// --- Latency Measurement -----------------------------------------------------
|
||||||
|
|
||||||
// Checks one target. The check times out after CONFIG.requestTimeout; the
|
async function measureLatency(url) {
|
||||||
// caller can give it up sooner through the optional signal, which also ends
|
|
||||||
// it as a timeout.
|
|
||||||
export async function measureLatency(url, signal) {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(
|
const timeoutId = setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
CONFIG.requestTimeout,
|
CONFIG.requestTimeout,
|
||||||
);
|
);
|
||||||
signal?.addEventListener("abort", () => controller.abort());
|
|
||||||
|
|
||||||
const targetUrl = new URL(url);
|
const targetUrl = new URL(url);
|
||||||
targetUrl.searchParams.set("_cb", Date.now().toString());
|
targetUrl.searchParams.set("_cb", Date.now().toString());
|
||||||
@@ -1105,7 +1101,7 @@ function sortAndRebuildWAN(state) {
|
|||||||
|
|
||||||
// --- Main Loop ---------------------------------------------------------------
|
// --- Main Loop ---------------------------------------------------------------
|
||||||
|
|
||||||
async function tick(state, signal, onOffline) {
|
async function tick(state, onOffline) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
if (state.paused) {
|
if (state.paused) {
|
||||||
@@ -1127,12 +1123,11 @@ async function tick(state, signal, onOffline) {
|
|||||||
log.debug(`Tick #${state.tickCount + 1} started`);
|
log.debug(`Tick #${state.tickCount + 1} started`);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
state.allHosts.map((h) => measureLatency(h.url, signal)),
|
state.allHosts.map((h) => measureLatency(h.url)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// User may have paused, or the next round may have given up this
|
// User may have paused while awaiting results — discard them
|
||||||
// one's checks, while awaiting results — discard them
|
if (state.paused) return;
|
||||||
if (state.paused || signal.aborted) return;
|
|
||||||
|
|
||||||
state.tickCount++;
|
state.tickCount++;
|
||||||
|
|
||||||
@@ -1173,10 +1168,9 @@ async function tick(state, signal, onOffline) {
|
|||||||
|
|
||||||
// --- Recovery Probe ----------------------------------------------------------
|
// --- Recovery Probe ----------------------------------------------------------
|
||||||
|
|
||||||
// When offline, check 4 random WAN hosts every 500ms, giving up the checks
|
// When offline, rapidly poll 4 random WAN hosts every 500ms. As soon as any
|
||||||
// started 500ms before, so at most 4 are ever waiting. As soon as one
|
// responds, stop probing and fire a normal tick to refresh all hosts.
|
||||||
// answers, stop probing and start a new round at once.
|
function startRecoveryProbe(state, triggerTick) {
|
||||||
function startRecoveryProbe(state, startRounds) {
|
|
||||||
if (state._recoveryProbeId) return; // already running
|
if (state._recoveryProbeId) return; // already running
|
||||||
const candidates = [...state.wan];
|
const candidates = [...state.wan];
|
||||||
for (let i = candidates.length - 1; i > 0; i--) {
|
for (let i = candidates.length - 1; i > 0; i--) {
|
||||||
@@ -1187,18 +1181,15 @@ function startRecoveryProbe(state, startRounds) {
|
|||||||
log.notice(
|
log.notice(
|
||||||
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
|
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
|
||||||
);
|
);
|
||||||
state._recoveryProbeId = setInterval(() => {
|
state._recoveryProbeId = setInterval(async () => {
|
||||||
if (state.paused) return;
|
if (state.paused) return;
|
||||||
state._recoveryProbeChecks?.abort();
|
const results = await Promise.all(
|
||||||
const checks = new AbortController();
|
canaries.map((h) => measureLatency(h.url)),
|
||||||
state._recoveryProbeChecks = checks;
|
);
|
||||||
for (const host of canaries) {
|
if (results.some((r) => r.error === null)) {
|
||||||
measureLatency(host.url, checks.signal).then((r) => {
|
log.notice("Recovery probe: connectivity detected");
|
||||||
if (r.error !== null || checks.signal.aborted) return;
|
stopRecoveryProbe(state);
|
||||||
log.notice("Recovery probe: connectivity detected");
|
triggerTick();
|
||||||
stopRecoveryProbe(state);
|
|
||||||
startRounds();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1198,6 @@ function stopRecoveryProbe(state) {
|
|||||||
if (state._recoveryProbeId) {
|
if (state._recoveryProbeId) {
|
||||||
clearInterval(state._recoveryProbeId);
|
clearInterval(state._recoveryProbeId);
|
||||||
state._recoveryProbeId = null;
|
state._recoveryProbeId = null;
|
||||||
state._recoveryProbeChecks?.abort();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1384,34 +1374,18 @@ async function init() {
|
|||||||
updateClocks();
|
updateClocks();
|
||||||
setInterval(updateClocks, 1000);
|
setInterval(updateClocks, 1000);
|
||||||
|
|
||||||
// Rounds never overlap: a round first gives up the last round's checks
|
|
||||||
// if they are still waiting, and the last round then records nothing.
|
|
||||||
// At a steady interval they never are, as they time out at 80% of it;
|
|
||||||
// they can be when a round starts early, after an interval change or
|
|
||||||
// when the recovery probe finds a target answering.
|
|
||||||
let roundChecks = new AbortController();
|
|
||||||
function doTick() {
|
function doTick() {
|
||||||
roundChecks.abort();
|
tick(state, () => startRecoveryProbe(state, doTick));
|
||||||
roundChecks = new AbortController();
|
|
||||||
tick(state, roundChecks.signal, () =>
|
|
||||||
startRecoveryProbe(state, startRounds),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts a round now and then one every CONFIG.updateInterval.
|
doTick();
|
||||||
let tickIntervalId;
|
let tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
|
||||||
function startRounds() {
|
|
||||||
clearInterval(tickIntervalId);
|
|
||||||
doTick();
|
|
||||||
tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
startRounds();
|
|
||||||
|
|
||||||
document
|
document
|
||||||
.getElementById("interval-select")
|
.getElementById("interval-select")
|
||||||
.addEventListener("change", (e) => {
|
.addEventListener("change", (e) => {
|
||||||
const newInterval = parseInt(e.target.value, 10);
|
const newInterval = parseInt(e.target.value, 10);
|
||||||
|
clearInterval(tickIntervalId);
|
||||||
CONFIG.updateInterval = newInterval;
|
CONFIG.updateInterval = newInterval;
|
||||||
log.notice(
|
log.notice(
|
||||||
`Interval changed to ${humanDuration(newInterval / 1000)}, history reset`,
|
`Interval changed to ${humanDuration(newInterval / 1000)}, history reset`,
|
||||||
@@ -1460,7 +1434,8 @@ async function init() {
|
|||||||
|
|
||||||
// Start immediately with new interval
|
// Start immediately with new interval
|
||||||
stopRecoveryProbe(state);
|
stopRecoveryProbe(state);
|
||||||
startRounds();
|
doTick();
|
||||||
|
tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => handleResize(state));
|
window.addEventListener("resize", () => handleResize(state));
|
||||||
@@ -1469,7 +1444,7 @@ async function init() {
|
|||||||
|
|
||||||
// Bootstrap only when loaded as the page: a real DOM containing the #app
|
// 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
|
// mount point this module renders into. Importing the module in a unit test
|
||||||
// (which has no #app) runs nothing, so its exports can be tested in isolation.
|
// (which has no #app) runs nothing, so buildReport can be tested in isolation.
|
||||||
if (typeof document !== "undefined" && document.getElementById("app")) {
|
if (typeof document !== "undefined" && document.getElementById("app")) {
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
// Unit tests for src/main.js, run by script/frontend-test with Node's
|
|
||||||
// built-in test runner. Importing the module does not start the page.
|
|
||||||
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { CONFIG, measureLatency } from "../../src/main.js";
|
|
||||||
|
|
||||||
// measureLatency writes timeouts to the debug log, which looks for its
|
|
||||||
// panel in the page. There is no page here.
|
|
||||||
globalThis.document = { getElementById: () => null };
|
|
||||||
|
|
||||||
// Mocks the clock for test t, so that a check lasting seconds takes no real
|
|
||||||
// time, and replaces fetch with a target that answers after answerAfter
|
|
||||||
// milliseconds of that clock, or never when answerAfter is Infinity. Both
|
|
||||||
// are restored when the test ends.
|
|
||||||
function mockTarget(t, answerAfter) {
|
|
||||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
|
||||||
t.mock.method(performance, "now", () => Date.now());
|
|
||||||
t.mock.method(
|
|
||||||
globalThis,
|
|
||||||
"fetch",
|
|
||||||
(url, { signal }) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
|
|
||||||
signal.addEventListener("abort", () => reject(signal.reason));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The result of check if it has ended, otherwise "still waiting".
|
|
||||||
function settled(check) {
|
|
||||||
return Promise.race([
|
|
||||||
check,
|
|
||||||
new Promise((resolve) => setImmediate(resolve, "still waiting")),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const interval of [10000, 30000]) {
|
|
||||||
const timeout = interval * 0.8;
|
|
||||||
// Over 3 seconds, which the timeout used to be capped at.
|
|
||||||
const slowAnswer = timeout - 1000;
|
|
||||||
|
|
||||||
test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
|
|
||||||
CONFIG.updateInterval = interval;
|
|
||||||
mockTarget(t, slowAnswer);
|
|
||||||
const check = measureLatency("https://target.test");
|
|
||||||
t.mock.timers.tick(slowAnswer);
|
|
||||||
assert.deepEqual(await settled(check), {
|
|
||||||
latency: slowAnswer,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
|
|
||||||
CONFIG.updateInterval = interval;
|
|
||||||
mockTarget(t, Infinity);
|
|
||||||
const check = measureLatency("https://target.test");
|
|
||||||
t.mock.timers.tick(timeout - 1);
|
|
||||||
assert.equal(await settled(check), "still waiting");
|
|
||||||
t.mock.timers.tick(1);
|
|
||||||
assert.deepEqual(await settled(check), {
|
|
||||||
latency: null,
|
|
||||||
error: "timeout",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user