Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e4ff908d |
No files matched your search
@@ -23,23 +23,14 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
|||||||
|
|
||||||
- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of
|
- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of
|
||||||
a file outside `/data` through a hard link in the volume
|
a file outside `/data` through a hard link in the volume
|
||||||
([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A regular file or
|
([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A regular file with a
|
||||||
symbolic link with a second name (hard link) keeps its owner, and the start
|
second name (hard link) keeps its owner, and the start logs its path. Owners
|
||||||
logs its path. Owners are now set on the opened directory, file or link, never
|
are now set on the opened directory or file, never by name, so a hard link
|
||||||
by name, so a hard link swapped in after the check is caught too; a symbolic
|
swapped in after the check is caught too; symbolic links and other files that
|
||||||
link is opened without following it and given to the netwatch user itself, so
|
are neither directories nor regular files keep their owner, as setting it by
|
||||||
the server can still follow one in a sticky directory that anyone can write
|
name could reach a hard link swapped in at that name. The test that runs as
|
||||||
to. Other files that are neither directories, regular files nor symbolic links
|
root hard-links a file from outside the volume into it and finds its owner
|
||||||
keep their owner. The test that runs as root hard-links a file from outside
|
unchanged
|
||||||
the volume into it and finds its owner unchanged
|
|
||||||
- 2026-10-18: after an interval change, the summary's Reachable slot reads
|
|
||||||
`--/--` in white, as at page load, until the second round since the change
|
|
||||||
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
|
|
||||||
0/26 in red from the change on, as no WAN host had a check recorded, for up to
|
|
||||||
two intervals with the network fine. The summary update now writes `--/--`
|
|
||||||
while every WAN host still waits for its first check. A unit test resets the
|
|
||||||
hosts as the interval menu does and checks the slot; another checks that it
|
|
||||||
reads 0/26 in red once every WAN host's check has failed
|
|
||||||
- 2026-10-14: stopping the server waits for a report file write already under
|
- 2026-10-14: stopping the server waits for a report file write already under
|
||||||
way, the periodic flush's or the one `Append` starts when 10 MiB are waiting,
|
way, the periodic flush's or the one `Append` starts when 10 MiB are waiting,
|
||||||
before the final flush
|
before the final flush
|
||||||
|
|||||||
+5
-6
@@ -66,12 +66,11 @@ 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. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR
|
||||||
# and gives /data and the directories, regular files and symbolic links
|
# and gives /data and the directories and regular files in it to the
|
||||||
# in it to the netwatch user, so the host directory needs no preparing,
|
# netwatch user, so the host directory needs no preparing. It stops the
|
||||||
# except a file with a second name (hard link), which keeps its owner,
|
# start, naming DATA_DIR, unless DATA_DIR is /data or a path below it,
|
||||||
# as that name could be outside /data. It stops the start, naming
|
# and it acts on nothing outside /data, whatever symbolic or hard links
|
||||||
# DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
|
# it meets there.
|
||||||
# nothing outside /data, whatever symbolic or hard links it meets there.
|
|
||||||
export DATA_DIR="${DATA_DIR:-/data/reports}"
|
export DATA_DIR="${DATA_DIR:-/data/reports}"
|
||||||
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
|
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ require (
|
|||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/sys v0.47.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -42,6 +41,7 @@ require (
|
|||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
|
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
|
||||||
@@ -21,13 +19,13 @@ var ErrDataDirOutsideVolume = errors.New(
|
|||||||
// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
|
// 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,
|
// 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
|
// /data in the image, needs no preparing: it creates dir, gives volume
|
||||||
// and the directories, regular files and symbolic links in it to
|
// and the directories and regular files in it to owner, and gives
|
||||||
// owner, and gives volume and dir the mode the server gives a directory
|
// volume and dir the mode the server gives a directory it creates. It
|
||||||
// it creates. It leaves as they are a regular file or symbolic link
|
// leaves as they are a regular file with a second name (hard link), as
|
||||||
// with a second name (hard link), as that name could be outside volume,
|
// that name could be outside volume, and any file that is neither a
|
||||||
// and any other file, such as a device (see chownAll). dir must be
|
// directory nor a regular file, such as a symbolic link (see
|
||||||
// volume or a path below it, with no '.', '..', empty part or '/' at
|
// chownAll). dir must be volume or a path below it, with no '.', '..',
|
||||||
// the end.
|
// empty part or '/' at the end.
|
||||||
//
|
//
|
||||||
// bin/entrypoint.sh runs this as root, which would follow a symbolic
|
// bin/entrypoint.sh runs this as root, which would follow a symbolic
|
||||||
// link anywhere, so every step goes through an os.Root opened on
|
// link anywhere, so every step goes through an os.Root opened on
|
||||||
@@ -85,22 +83,18 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
|
|||||||
return chmodDir(root, rel)
|
return chmodDir(root, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chownAll gives name, a directory inside root, and the directories,
|
// chownAll gives name, a directory inside root, and the directories
|
||||||
// regular files and symbolic links in it to uid and gid. It opens each
|
// and regular files in it to uid and gid. It opens each through root
|
||||||
// through root and gives the open file to them, as chmodDir sets a
|
// and gives the open file to them, as chmodDir sets a mode on the open
|
||||||
// mode on the open directory. Root.Lchown would not hold: it acts on
|
// directory. Root.Lchown would not hold: it acts on whatever is at the
|
||||||
// whatever is at the name by then, and a hard link swapped in there
|
// name by then, and a hard link swapped in there shares its owner with
|
||||||
// shares its owner with a file that could be outside root. A symbolic
|
// a file that could be outside root. chownAll goes into an entry only
|
||||||
// link is given to them itself, never what it points to, because in a
|
// when the read found a directory there, so it follows no link it
|
||||||
// directory that anyone can write to and that has the sticky bit set,
|
// finds; one swapped in afterwards for a directory or a regular file
|
||||||
// as /tmp has, the kernel follows a link only for the link's owner or
|
// is followed only as the os.Root allows. It leaves any file that is
|
||||||
// when the link and the directory have the same owner. chownAll goes
|
// neither a directory nor a regular file, such as a symbolic link:
|
||||||
// into an entry only when the read found a directory there, so it
|
// opening one would follow the link or open the device, Root.Lchown
|
||||||
// follows no link it finds; one swapped in afterwards for a directory
|
// would not hold, and the server needs to own none of them.
|
||||||
// is followed only as the os.Root allows, and one swapped in for a
|
|
||||||
// regular file or symbolic link is not followed at all (see
|
|
||||||
// chownFile). It leaves any other file, such as a device: the server
|
|
||||||
// needs to own none of them.
|
|
||||||
func chownAll(root *os.Root, name string, uid, gid int) error {
|
func chownAll(root *os.Root, name string, uid, gid int) error {
|
||||||
entries, err := chownDir(root, name, uid, gid)
|
entries, err := chownDir(root, name, uid, gid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,7 +107,7 @@ func chownAll(root *os.Root, name string, uid, gid int) error {
|
|||||||
switch {
|
switch {
|
||||||
case entry.IsDir():
|
case entry.IsDir():
|
||||||
err = chownAll(root, entryName, uid, gid)
|
err = chownAll(root, entryName, uid, gid)
|
||||||
case entry.Type().IsRegular(), entry.Type() == fs.ModeSymlink:
|
case entry.Type().IsRegular():
|
||||||
err = chownFile(root, entryName, uid, gid)
|
err = chownFile(root, entryName, uid, gid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,16 +149,14 @@ func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) {
|
|||||||
return dir.ReadDir(-1)
|
return dir.ReadDir(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chownFile gives name, a regular file or symbolic link inside root, to
|
// chownFile gives name, a regular file inside root, to uid and gid,
|
||||||
// uid and gid, unless it has a second name (hard link): all the names
|
// unless it has a second name (hard link): all the names of a file
|
||||||
// of a file share its owner, and the other could be outside root. It
|
// share its owner, and the other could be outside root. It opens name
|
||||||
// opens name through root with O_PATH, which reads nothing from the
|
// through root, reads the number of names from the open file and gives
|
||||||
// file; os.Root adds O_NOFOLLOW, and the two together open a symbolic
|
// that file to them, so a hard link swapped in at name is caught too.
|
||||||
// link itself, never what it points to. It reads the number of names
|
// It logs a file it leaves.
|
||||||
// from the open file and gives that file to them, so a hard link
|
|
||||||
// swapped in at name is caught too. It logs a file it leaves.
|
|
||||||
func chownFile(root *os.Root, name string, uid, gid int) error {
|
func chownFile(root *os.Root, name string, uid, gid int) error {
|
||||||
file, err := root.OpenFile(name, unix.O_PATH, 0)
|
file, err := root.Open(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,12 +168,9 @@ func chownFile(root *os.Root, name string, uid, gid int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// File.Chown would fail: fchown refuses a file opened with O_PATH.
|
|
||||||
// Fchownat with an empty name and AT_EMPTY_PATH acts on the open
|
|
||||||
// file instead.
|
|
||||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
if ok && stat.Nlink == 1 {
|
if ok && stat.Nlink == 1 {
|
||||||
return unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH)
|
return file.Chown(uid, gid)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Warn("owner not changed: file has a second name (hard link)",
|
slog.Warn("owner not changed: file has a second name (hard link)",
|
||||||
|
|||||||
@@ -254,12 +254,11 @@ func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
|
|||||||
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPrepareDataDirGivesVolumeToOwner gives the directories, regular
|
// TestPrepareDataDirGivesVolumeToOwner gives the directories and
|
||||||
// files and symbolic links in the volume to a uid and gid that own
|
// regular files in the volume to a uid and gid that own nothing, which
|
||||||
// nothing, which only root can do. A symbolic link in the volume to a
|
// only root can do. A symbolic link in the volume to a directory
|
||||||
// directory outside it is given to them itself; what it points to is
|
// outside it, and a hard link to a file outside it, are left as they
|
||||||
// left as it was. A hard link to a file outside the volume, and one to
|
// were, and so is what they lead to.
|
||||||
// a symbolic link outside it, are left as they were.
|
|
||||||
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -296,19 +295,6 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// os.Link does not follow a symbolic link: it gives the link itself
|
|
||||||
// a second name.
|
|
||||||
err = os.Symlink("f", filepath.Join(outside, "link"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Link(filepath.Join(outside, "link"),
|
|
||||||
filepath.Join(old, "hardlink-to-link"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
||||||
&user.User{Uid: "4242", Gid: "4343"})
|
&user.User{Uid: "4242", Gid: "4343"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,14 +303,11 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|||||||
|
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
|
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
|
||||||
filepath.Join(old, "link"),
|
|
||||||
} {
|
} {
|
||||||
requireOwner(t, path, 4242, 4343)
|
requireOwner(t, path, 4242, 4343)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range []string{
|
requireOwner(t, filepath.Join(old, "link"), 0, 0)
|
||||||
outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"),
|
requireOwner(t, outside, 0, 0)
|
||||||
} {
|
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
||||||
requireOwner(t, path, 0, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
// Package reportbuf accumulates telemetry reports in memory
|
// Package reportbuf accumulates telemetry reports in memory
|
||||||
// and periodically flushes them to zstd-compressed JSONL files,
|
// and periodically flushes them to zstd-compressed JSONL files,
|
||||||
// deleting the oldest files to keep them under a size cap. Before the
|
// deleting the oldest files to keep them under a size cap.
|
||||||
// server starts, PrepareDataDir gets the directory those files are
|
|
||||||
// kept in, DATA_DIR, ready for the user the server runs as: it creates
|
|
||||||
// it and gives it and the volume it is on to that user.
|
|
||||||
package reportbuf
|
package reportbuf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
measureLatency,
|
measureLatency,
|
||||||
Reporter,
|
Reporter,
|
||||||
tick,
|
tick,
|
||||||
updateSummary,
|
|
||||||
} from "../../web/src/main.js";
|
} from "../../web/src/main.js";
|
||||||
|
|
||||||
// There is no page here, so the tests stand in for it. The debug log looks
|
// There is no page here, so the tests stand in for it. The debug log looks
|
||||||
@@ -655,52 +654,6 @@ test("the summary's min, max, median and average latency over the WAN targets' l
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// In the next two tests each element of the summary that updateSummary
|
|
||||||
// writes into is a plain object, made the first time it is looked up and
|
|
||||||
// kept in elements under "#" and its id.
|
|
||||||
|
|
||||||
// The interval menu resets each target and counts the rounds from 0 again,
|
|
||||||
// then updates the summary, before any check since the change is recorded.
|
|
||||||
test("after an interval change, the summary's Reachable slot reads --/-- in white, as at page load", (t) => {
|
|
||||||
t.mock.method(
|
|
||||||
globalThis.document,
|
|
||||||
"getElementById",
|
|
||||||
(id) => (elements[`#${id}`] ??= {}),
|
|
||||||
);
|
|
||||||
const state = new AppState([]);
|
|
||||||
for (const host of state.wan) {
|
|
||||||
host.pushSample(Date.now(), { latency: 30, error: null });
|
|
||||||
}
|
|
||||||
state.tickCount = 5;
|
|
||||||
|
|
||||||
for (const host of state.allHosts) host.reset();
|
|
||||||
state.tickCount = 0;
|
|
||||||
updateSummary(state);
|
|
||||||
|
|
||||||
assert.equal(elements["#summary-reachable"].textContent, "--/--");
|
|
||||||
assert.equal(elements["#summary-reachable"].className, "text-white");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("once every WAN target's check has failed, the summary's Reachable slot reads 0 of them, in red", (t) => {
|
|
||||||
t.mock.method(
|
|
||||||
globalThis.document,
|
|
||||||
"getElementById",
|
|
||||||
(id) => (elements[`#${id}`] ??= {}),
|
|
||||||
);
|
|
||||||
const state = new AppState([]);
|
|
||||||
for (const host of state.wan) {
|
|
||||||
host.pushSample(Date.now(), { latency: null, error: "timeout" });
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSummary(state);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
elements["#summary-reachable"].textContent,
|
|
||||||
`0/${state.wan.length}`,
|
|
||||||
);
|
|
||||||
assert.equal(elements["#summary-reachable"].className, "text-red-400");
|
|
||||||
});
|
|
||||||
|
|
||||||
// An app state in which, of the WAN targets, the first timedOut timed out,
|
// An app state in which, of the WAN targets, the first timedOut timed out,
|
||||||
// the next unreachable were found unreachable, the next answered answered
|
// the next unreachable were found unreachable, the next answered answered
|
||||||
// after latency ms, and the rest have not been checked yet.
|
// after latency ms, and the rest have not been checked yet.
|
||||||
|
|||||||
+8
-15
@@ -995,7 +995,7 @@ function updateHostRow(host, index) {
|
|||||||
SparklineRenderer.draw(canvas, host.history);
|
SparklineRenderer.draw(canvas, host.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSummary(state) {
|
function updateSummary(state) {
|
||||||
const stats = state.wanStats();
|
const stats = state.wanStats();
|
||||||
const hstats = state.wanHistoryStats();
|
const hstats = state.wanHistoryStats();
|
||||||
|
|
||||||
@@ -1014,20 +1014,13 @@ export function updateSummary(state) {
|
|||||||
const hmaxUnitEl = document.getElementById("summary-hmax-unit");
|
const hmaxUnitEl = document.getElementById("summary-hmax-unit");
|
||||||
if (!reachableEl) return;
|
if (!reachableEl) return;
|
||||||
|
|
||||||
// Before any WAN host's check is recorded, as after an interval change,
|
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
|
||||||
// no host has answered or failed, so the slot reads as at page load.
|
reachableEl.className =
|
||||||
if (state.wan.every((h) => h.status === "pending")) {
|
stats.reachable === stats.total
|
||||||
reachableEl.textContent = "--/--";
|
? "text-green-400"
|
||||||
reachableEl.className = "text-white";
|
: stats.reachable === 0
|
||||||
} else {
|
? "text-red-400"
|
||||||
reachableEl.textContent = `${stats.reachable}/${stats.total}`;
|
: "text-yellow-400";
|
||||||
reachableEl.className =
|
|
||||||
stats.reachable === stats.total
|
|
||||||
? "text-green-400"
|
|
||||||
: stats.reachable === 0
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-yellow-400";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.min !== null) {
|
if (stats.min !== null) {
|
||||||
minEl.textContent = `${stats.min}`;
|
minEl.textContent = `${stats.min}`;
|
||||||
|
|||||||
Reference in new issue
Block a user