1 Commits
Author SHA1 Message Date
sneak 54e3e83888 prepare-data-dir leaves files with a second name to their owner (closes #143)
check / check (push) Canceled after 0s
A hard link in /data shares its owner with a file that can be anywhere
on the same filesystem, so chowning it gave that file to the netwatch
user. A regular file or symbolic link with more than one name now
keeps its owner and its path is logged.

Owners are now set on the opened directory, file or link after
checking it, as chmodDir sets modes, so a hard link swapped in
afterwards is caught. A symbolic link is opened without following it
and given to the netwatch user itself. Setting the owner on an open
file or link needs Linux, the image's system; elsewhere it returns an
error. Other files, such as devices, keep their owner.

Model: opus-5-5
2026-10-18 21:48:41 +00:00
6 changed files with 65 additions and 96 deletions

No files matched your search

+12 -12
View File
@@ -23,18 +23,18 @@ 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 file with a second ([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A regular file or
name (hard link) keeps its owner, and the start logs its path. Owners are now symbolic link with a second name (hard link) keeps its owner, and the start
set on the opened directory, file or link, never by name, so a hard link logs its path. Owners are now set on the opened directory, file or link, never
swapped in after the check is caught too; a symbolic link is opened without by name, so a hard link swapped in after the check is caught too; a symbolic
following it and given to the netwatch user itself, so the server can still link is opened without following it and given to the netwatch user itself, so
follow one in a sticky directory that anyone can write to. Devices, named the server can still follow one in a sticky directory that anyone can write
pipes and sockets keep their owner. Opening a file without following it and to. Other files that are neither directories, regular files nor symbolic links
setting the owner on the open file needs Linux, the system of the image; on keep their owner. Setting the owner on an open file or link needs Linux, the
any other system the package still builds, and `prepare-data-dir` fails at the system of the image; on any other system the package still builds, and
first file that is not a directory. The test that runs as root hard-links a `prepare-data-dir` fails at the first regular file or symbolic link. The test
file, a symbolic link and a named pipe from outside the volume into it and that runs as root hard-links a file from outside the volume into it and finds
finds their owners unchanged its owner unchanged
- 2026-10-18: after an interval change, the summary's Reachable slot reads - 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 `--/--` 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 ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
+5 -6
View File
@@ -67,12 +67,11 @@ done > /etc/nginx/trusted-proxies.conf
# 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, regular files and symbolic links
# in it to the netwatch user, so the host directory needs no preparing. # in it to the netwatch user, so the host directory needs no preparing,
# A file with a second name (hard link) keeps its owner, as that name # except a file with a second name (hard link), which keeps its owner,
# could be outside /data, and its path is logged. It stops the start, # as that name could be outside /data. It stops the start, naming
# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it # DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
# acts on nothing outside /data, whatever symbolic or hard links it # nothing outside /data, whatever symbolic or hard links it meets there.
# 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
+26 -23
View File
@@ -20,13 +20,13 @@ var ErrDataDirOutsideVolume = errors.New(
// /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, regular files and symbolic links in it to
// owner, and gives volume and dir the mode the server gives a directory // owner, and gives volume and dir the mode the server gives a directory
// it creates. It leaves a file with a second name (hard link) as it // it creates. It leaves as they are a regular file or symbolic link
// is, as that name could be outside volume, and logs its path; it // with a second name (hard link), as that name could be outside volume,
// leaves a device, named pipe or socket as it is too (see chownFile). // and any other file, such as a device (see chownAll). dir must be
// dir must be volume or a path below it, with no '.', '..', empty part // volume or a path below it, with no '.', '..', empty part or '/' at
// or '/' at the end. On any system but Linux, the system of the image, it // the end. It gives a regular file or symbolic link to owner only on
// fails at the first file in volume that is not a directory (see // Linux, the system of the image; on any other it fails at the first
// chownFile). // one (see chownFile).
// //
// 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,20 +85,21 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
} }
// 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, but not the // regular files and symbolic links in it to uid and gid. It opens each
// files chownFile leaves. It opens each through root and gives the // through root and gives the open file to them, as chmodDir sets a
// open file to them, as chmodDir sets a mode on the open directory. // mode on the open directory. Root.Lchown would not hold: it acts on
// Root.Lchown would not hold: it acts on whatever is at the name by // whatever is at the name by then, and a hard link swapped in there
// then, and a hard link swapped in there shares its owner with a file // shares its owner with a file that could be outside root. A symbolic
// that could be outside root. A symbolic link is given to them itself, // link is given to them itself, never what it points to, because in a
// never what it points to, because in a directory that anyone can // directory that anyone can write to and that has the sticky bit set,
// write to and that has the sticky bit set, as /tmp has, the kernel // as /tmp has, the kernel follows a link only for the link's owner or
// follows a link only for the link's owner or when the link and the // when the link and the directory have the same owner. chownAll goes
// directory have the same owner. chownAll goes into an entry only when // into an entry only when the read found a directory there, so it
// the read found a directory there, so it follows no link it finds; // follows no link it finds; one swapped in afterwards for a directory
// one swapped in afterwards for a directory is followed only as the // is followed only as the os.Root allows, and one swapped in for a
// os.Root allows, and one swapped in for any other file is not // regular file or symbolic link is not followed at all (see
// followed at all (see chownFile). // 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 {
@@ -107,9 +108,11 @@ func chownAll(root *os.Root, name string, uid, gid int) error {
for _, entry := range entries { for _, entry := range entries {
entryName := filepath.Join(name, entry.Name()) entryName := filepath.Join(name, entry.Name())
if entry.IsDir() {
switch {
case entry.IsDir():
err = chownAll(root, entryName, uid, gid) err = chownAll(root, entryName, uid, gid)
} else { case entry.Type().IsRegular(), entry.Type() == fs.ModeSymlink:
err = chownFile(root, entryName, uid, gid) err = chownFile(root, entryName, uid, gid)
} }
+14 -28
View File
@@ -3,7 +3,6 @@
package reportbuf package reportbuf
import ( import (
"io/fs"
"log/slog" "log/slog"
"os" "os"
"syscall" "syscall"
@@ -11,18 +10,14 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
// chownFile gives name, a file inside root that is not a directory, to // chownFile gives name, a regular file or symbolic link inside root, to
// uid and gid if it is a regular file or symbolic link with one name. // uid and gid, unless it has a second name (hard link): all the names
// It leaves, and logs, a file of any kind with a second name (hard // of a file share its owner, and the other could be outside root. It
// link): all the names of a file share its owner, and the other could // opens name through root with O_PATH, which reads nothing from the
// be outside root. It leaves any other file with one name, such as a // file; os.Root adds O_NOFOLLOW, and the two together open a symbolic
// device, named pipe or socket: the server needs to own none of them. // link itself, never what it points to. It reads the number of names
// It opens name through root with O_PATH, which reads nothing from the // from the open file and gives that file to them, so a hard link
// file, so opening a device or named pipe neither waits nor acts on // swapped in at name is caught too. It logs a file it leaves.
// it; os.Root adds O_NOFOLLOW, and the two together open a symbolic
// link itself, never what it points to. It reads the kind of file and
// its number of names from the open file and gives that file to them,
// so a hard link swapped in at name is caught too.
// //
// This file is built only on Linux, because O_PATH and AT_EMPTY_PATH // This file is built only on Linux, because O_PATH and AT_EMPTY_PATH
// exist only there; prepare-data-dir runs only in the Linux image. On // exist only there; prepare-data-dir runs only in the Linux image. On
@@ -40,25 +35,16 @@ func chownFile(root *os.Root, name string, uid, gid int) error {
return err return err
} }
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Nlink > 1 {
slog.Warn("owner not changed: file has a second name (hard link)",
"path", file.Name())
return nil
}
if !info.Mode().IsRegular() && info.Mode().Type() != fs.ModeSymlink {
return nil
}
// File.Chown would fail: fchown refuses a file opened with O_PATH. // 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 // Fchownat with an empty name and AT_EMPTY_PATH acts on the open
// file instead. // file instead.
err = unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH) stat, ok := info.Sys().(*syscall.Stat_t)
if err != nil { if ok && stat.Nlink == 1 {
return &fs.PathError{Op: "chown", Path: file.Name(), Err: err} return unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH)
} }
slog.Warn("owner not changed: file has a second name (hard link)",
"path", file.Name())
return nil return nil
} }
+6 -7
View File
@@ -8,13 +8,12 @@ import (
"os" "os"
) )
// chownFile returns an error on every system but Linux. Opening a file // chownFile returns an error on every system but Linux. Giving a
// that is not a directory without reading it or following it, and // regular file or symbolic link to a user on the open file, never by
// giving it to a user on the open file, never by name, needs O_PATH // name, needs O_PATH and AT_EMPTY_PATH, which only Linux has (see
// and AT_EMPTY_PATH, which only Linux has (see datadir_linux.go), and // datadir_linux.go), and prepare-data-dir runs only in the Linux
// prepare-data-dir runs only in the Linux image. This file lets the // image. This file lets the package build elsewhere, such as on macOS,
// package build elsewhere, such as on macOS, where a developer runs // where a developer runs make build and make run.
// make build and make run.
func chownFile(_ *os.Root, name string, _, _ int) error { func chownFile(_ *os.Root, name string, _, _ int) error {
return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported} return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported}
} }
+2 -20
View File
@@ -258,9 +258,8 @@ func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
// files and symbolic links in the volume to a uid and gid that own // files and symbolic links in the volume to a uid and gid that own
// nothing, which only root can do. A symbolic link in the volume to a // 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 // directory outside it is given to them itself; what it points to is
// left as it was. A hard link to a file outside the volume, one to a // left as it was. A hard link to a file outside the volume, and one to
// symbolic link outside it and one to a named pipe outside it are left // a symbolic link outside it, are left as they were.
// as they were, and so is a named pipe in the volume.
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) { func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Parallel() t.Parallel()
@@ -310,22 +309,6 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = syscall.Mkfifo(filepath.Join(outside, "fifo"), 0o600)
if err != nil {
t.Fatal(err)
}
err = os.Link(filepath.Join(outside, "fifo"),
filepath.Join(old, "hardlink-to-fifo"))
if err != nil {
t.Fatal(err)
}
err = syscall.Mkfifo(filepath.Join(old, "fifo"), 0o600)
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 {
@@ -341,7 +324,6 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
for _, path := range []string{ for _, path := range []string{
outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"), outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"),
filepath.Join(outside, "fifo"), filepath.Join(old, "fifo"),
} { } {
requireOwner(t, path, 0, 0) requireOwner(t, path, 0, 0)
} }