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