Compare commits
1
Commits
main
...
14e4ff908d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e4ff908d |
@@ -21,6 +21,16 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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 with a
|
||||||
|
second name (hard link) keeps its owner, and the start logs its path. Owners
|
||||||
|
are now set on the opened directory or file, never by name, so a hard link
|
||||||
|
swapped in after the check is caught too; symbolic links and other files that
|
||||||
|
are neither directories nor regular files keep their owner, as setting it by
|
||||||
|
name could reach a hard link swapped in at that name. The test that runs as
|
||||||
|
root hard-links a file from outside the volume into it and finds its owner
|
||||||
|
unchanged
|
||||||
- 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
-4
@@ -66,10 +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 everything in it to the netwatch user, so the
|
# and gives /data and the directories and regular files in it to the
|
||||||
# host directory needs no preparing. It stops the start, naming
|
# netwatch user, so the host directory needs no preparing. It stops the
|
||||||
# DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
|
# start, naming DATA_DIR, unless DATA_DIR is /data or a path below it,
|
||||||
# nothing outside /data, whatever symbolic links it meets there.
|
# and it acts on 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
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package reportbuf
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,9 +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 everything in it to owner, and gives volume and dir the mode the
|
// and the directories and regular files in it to owner, and gives
|
||||||
// server gives a directory it creates. dir must be volume or a path
|
// volume and dir the mode the server gives a directory it creates. It
|
||||||
// below it, with no '.', '..', empty part or '/' at the end.
|
// leaves as they are a regular file with a second name (hard link), as
|
||||||
|
// that name could be outside volume, and any file that is neither a
|
||||||
|
// directory nor a regular file, such as a symbolic link (see
|
||||||
|
// chownAll). 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
|
// 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
|
||||||
@@ -32,10 +37,9 @@ var ErrDataDirOutsideVolume = errors.New(
|
|||||||
// open, never following a link at the name it creates, and follows a
|
// 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
|
// link on the path only as the os.Root allows, so a relative link
|
||||||
// inside volume can lead it to create directories elsewhere inside
|
// inside volume can lead it to create directories elsewhere inside
|
||||||
// volume. Lchown never changes what a link points to, and the modes
|
// volume. The owners and modes are set on files already opened (see
|
||||||
// are set on directories already opened (see chmodDir), so the most
|
// chownAll and chmodDir), so the most that process can do is make a
|
||||||
// that process can do is make a step fail or wait, or act on
|
// step fail or wait, or act on something else inside volume.
|
||||||
// something else inside volume.
|
|
||||||
func PrepareDataDir(volume, dir string, owner *user.User) error {
|
func PrepareDataDir(volume, dir string, owner *user.User) error {
|
||||||
// rel is dir as a path from volume; IsLocal is false for one that
|
// rel is dir as a path from volume; IsLocal is false for one that
|
||||||
// leads out of it.
|
// leads out of it.
|
||||||
@@ -66,7 +70,7 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = lchownAll(root, ".", uid, gid)
|
err = chownAll(root, ".", uid, gid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -79,38 +83,32 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
|
|||||||
return chmodDir(root, rel)
|
return chmodDir(root, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// lchownAll gives name, a directory inside root, and everything in it
|
// chownAll gives name, a directory inside root, and the directories
|
||||||
// to uid and gid. It reads each directory opened through root, not
|
// and regular files in it to uid and gid. It opens each through root
|
||||||
// through root.FS(), which refuses a name that is not valid UTF-8, and
|
// and gives the open file to them, as chmodDir sets a mode on the open
|
||||||
// calls Lchown on every entry, which gives a symbolic link itself to
|
// directory. Root.Lchown would not hold: it acts on whatever is at the
|
||||||
// them, not what it points to. It goes into an entry only when the
|
// name by then, and a hard link swapped in there shares its owner with
|
||||||
// read found a directory there, so it follows no link it finds; one
|
// a file that could be outside root. chownAll goes into an entry only
|
||||||
// swapped in for that directory afterwards is followed only as the
|
// when the read found a directory there, so it follows no link it
|
||||||
// os.Root allows.
|
// finds; one swapped in afterwards for a directory or a regular file
|
||||||
func lchownAll(root *os.Root, name string, uid, gid int) error {
|
// is followed only as the os.Root allows. It leaves any file that is
|
||||||
err := root.Lchown(name, uid, gid)
|
// neither a directory nor a regular file, such as a symbolic link:
|
||||||
if err != nil {
|
// opening one would follow the link or open the device, Root.Lchown
|
||||||
return err
|
// would not hold, and the server needs to own none of them.
|
||||||
}
|
func chownAll(root *os.Root, name string, uid, gid int) error {
|
||||||
|
entries, err := chownDir(root, name, uid, gid)
|
||||||
dir, err := root.Open(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := dir.ReadDir(-1)
|
|
||||||
_ = dir.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
entryName := filepath.Join(name, entry.Name())
|
entryName := filepath.Join(name, entry.Name())
|
||||||
if entry.IsDir() {
|
|
||||||
err = lchownAll(root, entryName, uid, gid)
|
switch {
|
||||||
} else {
|
case entry.IsDir():
|
||||||
err = root.Lchown(entryName, uid, gid)
|
err = chownAll(root, entryName, uid, gid)
|
||||||
|
case entry.Type().IsRegular():
|
||||||
|
err = chownFile(root, entryName, uid, gid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,6 +119,66 @@ func lchownAll(root *os.Root, name string, uid, gid int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chownDir gives name, a directory inside root, to uid and gid, and
|
||||||
|
// returns what it holds. Like chmodDir, it opens name through root and
|
||||||
|
// refuses anything but a directory, which has no second name (hard
|
||||||
|
// link). It reads the open directory, not root.FS(), which refuses a
|
||||||
|
// name that is not valid UTF-8.
|
||||||
|
func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) {
|
||||||
|
dir, err := root.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = dir.Close() }()
|
||||||
|
|
||||||
|
info, err := dir.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, &fs.PathError{Op: "chown", Path: name, Err: syscall.ENOTDIR}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = dir.Chown(uid, gid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir.ReadDir(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chownFile gives name, a regular file 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, 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.
|
||||||
|
func chownFile(root *os.Root, name string, uid, gid int) error {
|
||||||
|
file, err := root.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if ok && stat.Nlink == 1 {
|
||||||
|
return file.Chown(uid, gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("owner not changed: file has a second name (hard link)",
|
||||||
|
"path", file.Name())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// chmodDir gives name, a directory inside root, the mode the server
|
// chmodDir gives name, a directory inside root, the mode the server
|
||||||
// gives a directory it creates. Root.Chmod would not hold: on Linux it
|
// 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,
|
// checks that name is not a symbolic link, then sets the mode by name,
|
||||||
|
|||||||
@@ -254,10 +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 everything in the volume
|
// TestPrepareDataDirGivesVolumeToOwner gives the directories and
|
||||||
// to a uid and gid that own nothing, which only root can do. A
|
// regular files in the volume to a uid and gid that own nothing, which
|
||||||
// symbolic link in the volume to a directory outside it is given to
|
// only root can do. A symbolic link in the volume to a directory
|
||||||
// them itself; what it points to is left as it was.
|
// outside it, and a hard link to a file outside it, are left as they
|
||||||
|
// were, and so is what they lead to.
|
||||||
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -289,6 +290,11 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = os.Link(filepath.Join(outside, "f"), filepath.Join(old, "hardlink"))
|
||||||
|
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 {
|
||||||
@@ -296,12 +302,12 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
volume, filepath.Join(volume, reports), old,
|
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
|
||||||
filepath.Join(old, "f"), filepath.Join(old, "link"),
|
|
||||||
} {
|
} {
|
||||||
requireOwner(t, path, 4242, 4343)
|
requireOwner(t, path, 4242, 4343)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requireOwner(t, filepath.Join(old, "link"), 0, 0)
|
||||||
requireOwner(t, outside, 0, 0)
|
requireOwner(t, outside, 0, 0)
|
||||||
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user