Compare commits
3
Commits
4f3ded1663
...
6ab1be3511
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab1be3511 | ||
|
|
91856fa170 | ||
|
|
39ee6ca839 |
@@ -104,9 +104,12 @@ code lives in `src/main.js` with a class-based architecture:
|
||||
color-coded line segments, error regions, and DPR-aware scaling
|
||||
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
|
||||
`updateSummary()` / `updateHealthBox()` handle incremental updates
|
||||
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
|
||||
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
|
||||
false outage)
|
||||
- **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's
|
||||
sample and redrawing its row as soon as its check ends, then redraws every
|
||||
row, the summary and the health box once the last check ends. The rows are
|
||||
sorted then too, after the first round that is not discarded and every tenth
|
||||
round after that. When paused, pushes blank markers (no probes, no false
|
||||
outage)
|
||||
- **`Reporter`**: Posts collected samples to the backend
|
||||
|
||||
### Reporting
|
||||
@@ -144,8 +147,8 @@ 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.
|
||||
last round's checks if they are still waiting, and that round records nothing
|
||||
more, so rounds never overlap. IPv4 only.
|
||||
|
||||
### Color coding
|
||||
|
||||
@@ -224,8 +227,10 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
||||
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
|
||||
the API
|
||||
- `DEBUG`, default `false`: debug logging
|
||||
- `DATA_DIR`, default `/data/reports`: leave unset; reports kept outside
|
||||
`/data` do not survive a redeploy
|
||||
- `DATA_DIR`, default `/data/reports`: the directory the reports are kept
|
||||
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
|
||||
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
|
||||
in front of the container connects from, as an IP address or CIDR; several
|
||||
are separated by commas. nginx takes the client address from
|
||||
|
||||
@@ -23,6 +23,28 @@ latest run passes.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
|
||||
visible pin button per WAN host row (issue #46), where it expected at least 10
|
||||
of the 26, so pin buttons missing from only some rows now fail it. The host
|
||||
row count the harness gathers, which the `app-rendered` check also reads, now
|
||||
counts only the WAN host rows: the local host rows have no pin button
|
||||
- 2026-10-03: each target's row shows its result as soon as its check ends
|
||||
(issue #91), where every row waited for the round's slowest check, up to 24
|
||||
seconds at a 30-second interval. Every row is still redrawn, and sorting, the
|
||||
summary, the health box and offline detection still run, once, when the
|
||||
round's last check ends, so no row reads "paused" after a pause and resume
|
||||
during the round. A check that ends after the user pauses or after its round
|
||||
is given up shows nothing, and the first round is still discarded as a whole
|
||||
- 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: `DATA_DIR_MAX_BYTES` is now how much of the report files is kept
|
||||
(issue #54): when a report would take them past it, the oldest report files
|
||||
are deleted to make room, each deletion logged, and at start files already
|
||||
@@ -220,9 +242,11 @@ latest run passes.
|
||||
- 2026-08-09: automated responsive-layout harness
|
||||
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
||||
against the built `dist/`, viewport widths derived from the breakpoints in
|
||||
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). Every
|
||||
check carries a presence guard so none of them can pass against a page it is
|
||||
not actually measuring. Found two real layout defects, filed as
|
||||
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The
|
||||
tap-target and host-row checks each fail when they measured nothing; the
|
||||
overflow, viewport-edge and clipped-text checks have no such guard of their
|
||||
own and rely on the `app-rendered` check, which fails the run when the app did
|
||||
not render. Found two real layout defects, filed as
|
||||
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
|
||||
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||
|
||||
+3
-2
@@ -107,8 +107,9 @@ 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
|
||||
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
|
||||
the `/data` volume; the entrypoint creates it and gives it and `/data` to
|
||||
`netwatch` before starting the server. nginx replaces the security headers
|
||||
the `/data` volume; before starting the server, the entrypoint creates it and
|
||||
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
|
||||
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
|
||||
what clients of the image see.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
@@ -37,6 +38,26 @@ func main() {
|
||||
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.Version = Version
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package reportbuf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// 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. Even then, the os.Root checks each link as it reaches
|
||||
// it. MkdirAll creates each directory inside a parent it already has
|
||||
// open, never following a link at the name it creates, and follows a
|
||||
// link on the path only as the os.Root allows, so a relative link
|
||||
// inside volume can lead it to create directories elsewhere inside
|
||||
// volume. Lchown never changes what a link points to, and the modes
|
||||
// are set on directories already opened (see chmodDir), so the most
|
||||
// that process can do is make a step fail or wait, 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
|
||||
}
|
||||
|
||||
err = lchownAll(root, ".", uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = chmodDir(root, ".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return chmodDir(root, rel)
|
||||
}
|
||||
|
||||
// lchownAll gives name, a directory inside root, and everything in it
|
||||
// to uid and gid. It reads each directory opened through root, not
|
||||
// through root.FS(), which refuses a name that is not valid UTF-8, and
|
||||
// calls Lchown on every entry, which gives a symbolic link itself to
|
||||
// them, not what it points to. It goes into an entry only when the
|
||||
// read found a directory there, so it follows no link it finds; one
|
||||
// swapped in for that directory afterwards is followed only as the
|
||||
// os.Root allows.
|
||||
func lchownAll(root *os.Root, name string, uid, gid int) error {
|
||||
err := root.Lchown(name, uid, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := dir.ReadDir(-1)
|
||||
_ = dir.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
entryName := filepath.Join(name, entry.Name())
|
||||
if entry.IsDir() {
|
||||
err = lchownAll(root, entryName, uid, gid)
|
||||
} else {
|
||||
err = root.Lchown(entryName, uid, gid)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// chmodDir gives name, a directory inside root, the mode the server
|
||||
// gives a directory it creates. Root.Chmod would not hold: on Linux it
|
||||
// checks that name is not a symbolic link, then sets the mode by name,
|
||||
// following a link swapped in between. So chmodDir opens name through
|
||||
// root and sets the mode on the open directory. It refuses anything
|
||||
// but a directory: a directory has no second name (hard link), so the
|
||||
// one opened is inside root, where any other file could be a hard link
|
||||
// to one outside.
|
||||
func chmodDir(root *os.Root, name string) error {
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = dir.Close() }()
|
||||
|
||||
info, err := dir.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR}
|
||||
}
|
||||
|
||||
return dir.Chmod(dirPerms)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on
|
||||
// the host with another mode gets the mode too, not only a new one.
|
||||
func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dir := filepath.Join(volume, reports)
|
||||
|
||||
err := os.Mkdir(dir, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, dir, currentUser())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can
|
||||
// hold names that are not valid UTF-8, here "café" written in Latin-1.
|
||||
// A test not run as root can only check that PrepareDataDir goes into
|
||||
// such a directory and gives it, and what it holds, to the current
|
||||
// user.
|
||||
func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
latin1 := filepath.Join(volume, "caf\xe9")
|
||||
|
||||
err := os.Mkdir(latin1, 0o700)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner := currentUser()
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||
owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid, _ := strconv.ParseUint(owner.Uid, 10, 32)
|
||||
gid, _ := strconv.ParseUint(owner.Gid, 10, 32)
|
||||
|
||||
requireOwner(t, latin1, uint32(uid), uint32(gid))
|
||||
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
+6
-19
@@ -63,26 +63,13 @@ done > /etc/nginx/trusted-proxies.conf
|
||||
|
||||
# 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
|
||||
# uid. Both are given to the netwatch user here, with the mode the
|
||||
# server gives a directory it creates, so the host directory needs no
|
||||
# preparing.
|
||||
#
|
||||
# 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.
|
||||
# uid. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR
|
||||
# and gives /data and everything in it to the netwatch user, so the
|
||||
# host directory needs no preparing. It stops the start, naming
|
||||
# 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.
|
||||
export DATA_DIR="${DATA_DIR:-/data/reports}"
|
||||
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
|
||||
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
|
||||
|
||||
# A stop signal is only noted here; the loop below acts on it.
|
||||
stop_requested=""
|
||||
|
||||
+27
-16
@@ -263,7 +263,7 @@ class HostState {
|
||||
}
|
||||
}
|
||||
|
||||
class AppState {
|
||||
export class AppState {
|
||||
constructor(localHosts) {
|
||||
this.wan = WAN_HOSTS.map(
|
||||
(h) => new HostState(h, h.name === "datavi.be"),
|
||||
@@ -1105,7 +1105,7 @@ function sortAndRebuildWAN(state) {
|
||||
|
||||
// --- Main Loop ---------------------------------------------------------------
|
||||
|
||||
async function tick(state, signal, onOffline) {
|
||||
export async function tick(state, signal, onOffline) {
|
||||
const ts = Date.now();
|
||||
|
||||
if (state.paused) {
|
||||
@@ -1126,12 +1126,26 @@ async function tick(state, signal, onOffline) {
|
||||
|
||||
log.debug(`Tick #${state.tickCount + 1} started`);
|
||||
|
||||
const results = await Promise.all(
|
||||
state.allHosts.map((h) => measureLatency(h.url, signal)),
|
||||
// Each host's row shows its result as soon as its check ends. The
|
||||
// result is discarded if by then the user has paused or the next round
|
||||
// has given up this one's checks, and in the first tick (tickCount is
|
||||
// still 0), which is discarded as a whole below. The row is looked up
|
||||
// when the check ends, as a pin click may have re-sorted the rows since
|
||||
// the round started.
|
||||
await Promise.all(
|
||||
state.allHosts.map(async (host) => {
|
||||
const r = await measureLatency(host.url, signal);
|
||||
if (state.paused || signal.aborted || state.tickCount === 0) {
|
||||
return;
|
||||
}
|
||||
host.pushSample(ts, r);
|
||||
updateHostRow(host, state.allHosts.indexOf(host));
|
||||
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
||||
}),
|
||||
);
|
||||
|
||||
// User may have paused, or the next round may have given up this
|
||||
// one's checks, while awaiting results — discard them
|
||||
// one's checks, while awaiting results — skip the rest of the round
|
||||
if (state.paused || signal.aborted) return;
|
||||
|
||||
state.tickCount++;
|
||||
@@ -1142,12 +1156,9 @@ async function tick(state, signal, onOffline) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.allHosts.forEach((host, i) => {
|
||||
const r = results[i];
|
||||
host.pushSample(ts, r);
|
||||
updateHostRow(host, i);
|
||||
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
||||
});
|
||||
// Redraw every row: if the user paused and resumed during this round,
|
||||
// rows whose check ended before the resume still read "paused"
|
||||
state.allHosts.forEach((host, i) => updateHostRow(host, i));
|
||||
|
||||
// Sort after the first real check, then every 10 ticks thereafter
|
||||
if (state.tickCount === 2 || state.tickCount % 10 === 1) {
|
||||
@@ -1213,7 +1224,7 @@ function stopRecoveryProbe(state) {
|
||||
|
||||
// --- Pause / Resume ----------------------------------------------------------
|
||||
|
||||
function greyOutUI(state) {
|
||||
export function greyOutUI(state) {
|
||||
// Grey out all host rows
|
||||
state.allHosts.forEach((host, i) => {
|
||||
const latencyEl = document.querySelector(
|
||||
@@ -1385,10 +1396,10 @@ async function init() {
|
||||
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.
|
||||
// if they are still waiting, and the last round then records nothing
|
||||
// more. 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() {
|
||||
roundChecks.abort();
|
||||
|
||||
+172
-12
@@ -1,19 +1,70 @@
|
||||
// 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 { beforeEach, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { CONFIG, measureLatency } from "../../src/main.js";
|
||||
import {
|
||||
AppState,
|
||||
CONFIG,
|
||||
greyOutUI,
|
||||
measureLatency,
|
||||
tick,
|
||||
} 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 };
|
||||
// There is no page here, so the tests stand in for it. The debug log looks
|
||||
// for its panel by id and finds none. Each element of a host's row that
|
||||
// tick or greyOutUI draws into is a plain object, made the first time a
|
||||
// test looks it up and kept in elements under its selector until the next
|
||||
// test starts. As on a page, writing its text replaces its markup; the
|
||||
// status dot greyOutUI looks for in it is not there. Drawing a sparkline
|
||||
// does nothing; it looks for the pixel ratio on window and finds none.
|
||||
let elements;
|
||||
beforeEach(() => {
|
||||
elements = {};
|
||||
});
|
||||
const doNothing = () => {};
|
||||
const canvasContext = {
|
||||
clearRect: doNothing,
|
||||
beginPath: doNothing,
|
||||
moveTo: doNothing,
|
||||
lineTo: doNothing,
|
||||
stroke: doNothing,
|
||||
fill: doNothing,
|
||||
fillRect: doNothing,
|
||||
fillText: doNothing,
|
||||
arc: doNothing,
|
||||
};
|
||||
globalThis.window = {};
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
querySelector: (selector) =>
|
||||
(elements[selector] ??= {
|
||||
getContext: () => canvasContext,
|
||||
querySelector: () => null,
|
||||
set textContent(text) {
|
||||
this.innerHTML = text;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// What tick last wrote into the latency figure in host's row, or undefined
|
||||
// if it has written nothing there.
|
||||
function latencyFigure(state, host) {
|
||||
const index = state.allHosts.indexOf(host);
|
||||
return elements[`.latency-value[data-host="${index}"]`]?.innerHTML;
|
||||
}
|
||||
|
||||
// What was last written into the status text in host's row.
|
||||
function statusText(state, host) {
|
||||
const index = state.allHosts.indexOf(host);
|
||||
return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// time, and replaces fetch with targets that each answer after
|
||||
// answerAfter(url) milliseconds of that clock, or never when that is
|
||||
// Infinity. Both are restored when the test ends.
|
||||
function mockTargets(t, answerAfter) {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
||||
t.mock.method(performance, "now", () => Date.now());
|
||||
t.mock.method(
|
||||
@@ -21,7 +72,9 @@ function mockTarget(t, answerAfter) {
|
||||
"fetch",
|
||||
(url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
|
||||
if (answerAfter(url) !== Infinity) {
|
||||
setTimeout(resolve, answerAfter(url));
|
||||
}
|
||||
signal.addEventListener("abort", () => reject(signal.reason));
|
||||
}),
|
||||
);
|
||||
@@ -42,7 +95,7 @@ for (const interval of [10000, 30000]) {
|
||||
|
||||
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);
|
||||
mockTargets(t, () => slowAnswer);
|
||||
const check = measureLatency("https://target.test");
|
||||
t.mock.timers.tick(slowAnswer);
|
||||
assert.deepEqual(await settled(check), {
|
||||
@@ -53,7 +106,7 @@ for (const interval of [10000, 30000]) {
|
||||
|
||||
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);
|
||||
mockTargets(t, () => Infinity);
|
||||
const check = measureLatency("https://target.test");
|
||||
t.mock.timers.tick(timeout - 1);
|
||||
assert.equal(await settled(check), "still waiting");
|
||||
@@ -64,3 +117,110 @@ for (const interval of [10000, 30000]) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => {
|
||||
CONFIG.updateInterval = 30000;
|
||||
const state = new AppState([
|
||||
{ name: "Answering", url: "https://answering.test" },
|
||||
]);
|
||||
const answering = state.local[0];
|
||||
const waiting = state.wan[0];
|
||||
// No target but the answering one ever answers.
|
||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||
// The third tick: the first is discarded as a whole, and the second ends
|
||||
// by sorting the rows, which rebuilds a page that is not here.
|
||||
state.tickCount = 2;
|
||||
|
||||
const round = tick(state, new AbortController().signal);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(await settled(round), "still waiting");
|
||||
assert.match(latencyFigure(state, answering), />1000</);
|
||||
assert.equal(latencyFigure(state, waiting), undefined);
|
||||
assert.equal(state.tickCount, 2);
|
||||
|
||||
// The round ends, once, when the last check times out.
|
||||
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
||||
assert.notEqual(await settled(round), "still waiting");
|
||||
assert.equal(state.tickCount, 3);
|
||||
});
|
||||
|
||||
// In the next three tests, the answering target's check is still waiting
|
||||
// when something happens after which its result must not show.
|
||||
|
||||
test("at a 30000ms interval, a check still waiting when its round is given up does not show in its row", async (t) => {
|
||||
CONFIG.updateInterval = 30000;
|
||||
const state = new AppState([
|
||||
{ name: "Answering", url: "https://answering.test" },
|
||||
]);
|
||||
const answering = state.local[0];
|
||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||
state.tickCount = 2;
|
||||
|
||||
const roundChecks = new AbortController();
|
||||
const round = tick(state, roundChecks.signal);
|
||||
t.mock.timers.tick(500);
|
||||
// As a round started early does to the last round's checks.
|
||||
roundChecks.abort();
|
||||
assert.notEqual(await settled(round), "still waiting");
|
||||
assert.equal(latencyFigure(state, answering), undefined);
|
||||
});
|
||||
|
||||
test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => {
|
||||
CONFIG.updateInterval = 30000;
|
||||
const state = new AppState([
|
||||
{ name: "Answering", url: "https://answering.test" },
|
||||
]);
|
||||
const answering = state.local[0];
|
||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||
state.tickCount = 2;
|
||||
|
||||
const round = tick(state, new AbortController().signal);
|
||||
t.mock.timers.tick(500);
|
||||
state.paused = true;
|
||||
t.mock.timers.tick(500);
|
||||
assert.equal(await settled(round), "still waiting");
|
||||
assert.equal(latencyFigure(state, answering), undefined);
|
||||
});
|
||||
|
||||
test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => {
|
||||
CONFIG.updateInterval = 30000;
|
||||
const state = new AppState([
|
||||
{ name: "Answering", url: "https://answering.test" },
|
||||
]);
|
||||
const answering = state.local[0];
|
||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||
|
||||
const round = tick(state, new AbortController().signal);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(await settled(round), "still waiting");
|
||||
assert.equal(latencyFigure(state, answering), undefined);
|
||||
});
|
||||
|
||||
test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => {
|
||||
CONFIG.updateInterval = 30000;
|
||||
const state = new AppState([
|
||||
{ name: "Answering", url: "https://answering.test" },
|
||||
]);
|
||||
const answering = state.local[0];
|
||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||
state.tickCount = 2;
|
||||
|
||||
const round = tick(state, new AbortController().signal);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(await settled(round), "still waiting");
|
||||
// The user pauses, which greys out every row, and resumes, which leaves
|
||||
// the rows as they are, as togglePause does. The answering target's
|
||||
// check has already ended, so only the redraw of every row at the end
|
||||
// of the round can take "paused" out of its row.
|
||||
state.paused = true;
|
||||
greyOutUI(state);
|
||||
state.paused = false;
|
||||
assert.equal(statusText(state, answering), "paused");
|
||||
|
||||
// The round ends when the last check times out.
|
||||
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
||||
assert.notEqual(await settled(round), "still waiting");
|
||||
for (const host of state.allHosts) {
|
||||
assert.notEqual(statusText(state, host), "paused", host.name);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,10 +49,11 @@ sizes straddling the breakpoint for the rotation case.
|
||||
excluded: it is a design choice, not breakage.
|
||||
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
||||
touch viewports, _and_ each selector in the control list matched at least the
|
||||
number of visible elements it declares. The second half is what stops the
|
||||
check passing vacuously: with size alone, a renamed class would take its
|
||||
controls out of the measured set and the check would report "all 0 controls
|
||||
are at least 44x44" and pass. See below.
|
||||
number of visible elements it declares: one of each single control, and one
|
||||
pin button per WAN host row. The second half is what stops the check passing
|
||||
vacuously: with size alone, a renamed class would take its controls out of the
|
||||
measured set and the check would report "all 0 controls are at least 44x44"
|
||||
and pass. See below.
|
||||
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
||||
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
||||
narrow layout the info block and the sparkline must each occupy essentially
|
||||
|
||||
+14
-13
@@ -15,7 +15,8 @@ export const MIN_TAP_TARGET_PX = 44;
|
||||
|
||||
// The controls named in the definition of done, plus the pause button.
|
||||
// Each carries the smallest number of *visible* instances the page has to
|
||||
// contain for the tap-target oracle to be measuring anything at all.
|
||||
// contain, worked out from the facts gathered from that page, for the
|
||||
// tap-target oracle to be measuring every control it should.
|
||||
//
|
||||
// Without those floors the check is inert: `undersized` is empty both when
|
||||
// every control is large enough and when the selectors have gone stale and
|
||||
@@ -24,13 +25,12 @@ export const MIN_TAP_TARGET_PX = 44;
|
||||
// for all three singleton controls vanishing at once — so the floor is per
|
||||
// selector, and one stale selector out of four fails the check.
|
||||
export const INTERACTIVE_CONTROLS = [
|
||||
{ selector: "#pause-btn", minCount: 1 },
|
||||
{ selector: "#interval-select", minCount: 1 },
|
||||
// One per pinnable host row. `app-rendered` already requires at least
|
||||
// 10 host rows, so a count below that means the pin buttons stopped
|
||||
// being rendered per row rather than that there were fewer hosts.
|
||||
{ selector: ".pin-btn", minCount: 10 },
|
||||
{ selector: "#debug-toggle", minCount: 1 },
|
||||
{ selector: "#pause-btn", minCount: () => 1 },
|
||||
{ selector: "#interval-select", minCount: () => 1 },
|
||||
// One per WAN host row, so pin buttons missing from even one row fail
|
||||
// the check rather than only a drop below some fixed number.
|
||||
{ selector: ".pin-btn", minCount: (facts) => facts.wanRowCount },
|
||||
{ selector: "#debug-toggle", minCount: () => 1 },
|
||||
];
|
||||
|
||||
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
||||
@@ -105,8 +105,8 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
// never rendered. Everything below is only meaningful if this holds.
|
||||
check(
|
||||
"app-rendered",
|
||||
facts.rowCount >= 10 && facts.numericLatencies >= 5,
|
||||
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
|
||||
facts.wanRowCount >= 10 && facts.numericLatencies >= 5,
|
||||
`${facts.wanRowCount} WAN host rows, ${facts.numericLatencies} showing a numeric latency`,
|
||||
);
|
||||
|
||||
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
|
||||
@@ -162,7 +162,8 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
||||
}
|
||||
const missing = INTERACTIVE_CONTROLS.filter(
|
||||
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
|
||||
(control) =>
|
||||
(seen.get(control.selector) ?? 0) < control.minCount(facts),
|
||||
);
|
||||
|
||||
const undersized = facts.tapTargets.filter(
|
||||
@@ -184,11 +185,11 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
const detail = [];
|
||||
if (missing.length > 0) {
|
||||
detail.push(
|
||||
"oracle is not measuring the page: " +
|
||||
"oracle is not measuring every control: " +
|
||||
summarise(
|
||||
missing,
|
||||
(c) =>
|
||||
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
|
||||
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount(facts)}`,
|
||||
4,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -173,7 +173,9 @@ export function collectLayoutFacts(options) {
|
||||
clipped,
|
||||
tapTargets,
|
||||
rows,
|
||||
rowCount: document.querySelectorAll(".host-row").length,
|
||||
// WAN host rows only: each has a pin button, and the tap-target
|
||||
// check expects one per row. The local host rows have none.
|
||||
wanRowCount: document.querySelectorAll("#wan-hosts .host-row").length,
|
||||
numericLatencies: Array.from(
|
||||
document.querySelectorAll(".latency-value"),
|
||||
).filter((el) => /\d/.test(el.textContent)).length,
|
||||
|
||||
Reference in New Issue
Block a user