//go:build unix package hostguard import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "golang.org/x/sys/unix" ) type recordDir struct { snapshot *record root *RootAuthority fd int fsync func(int) error } func openDirectory(path string) (int, error) { fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if errors.Is(err, unix.ENOENT) { return -1, fmt.Errorf("%w: %s", ErrNotProvisioned, path) } if err != nil { return -1, err } var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { unix.Close(fd) return -1, err } if st.Mode&unix.S_IFMT != unix.S_IFDIR { unix.Close(fd) return -1, fmt.Errorf("hostguard: not a directory: %s", path) } return fd, nil } func openRecordDir(dataDir string) (*recordDir, error) { fd, err := openDirectory(filepath.Join(dataDir, "hostguard")) if err != nil { return nil, err } return &recordDir{fd: fd, fsync: unix.Fsync}, nil } func (d *recordDir) close() { _ = unix.Close(d.fd) } func (d *recordDir) openRecord(host string) (*os.File, error) { if err := validateFileHost(host); err != nil { return nil, err } // NONBLOCK prevents a planted FIFO from hanging before Fstat can refuse it. fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) if errors.Is(err, unix.ENOENT) { return nil, ErrNoRecord } if err != nil { return nil, err } f := os.NewFile(uintptr(fd), host+".json") var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { f.Close() return nil, err } if st.Mode&unix.S_IFMT != unix.S_IFREG { f.Close() return nil, ErrUnsafeFile } return f, nil } func (d *recordDir) read(host string) (record, error) { if d.snapshot != nil { if d.snapshot.Host != host { return record{}, ErrInvalidHost } return *d.snapshot, nil } if d.root != nil { return d.root.read(host) } f, err := d.openRecord(host) if err != nil { return record{}, err } defer f.Close() b, err := io.ReadAll(f) if err != nil { return record{}, err } return decodeRecord(b, host) } func (d *recordDir) sync(host string) error { if d.snapshot != nil { return ErrFenced } if d.root != nil { r, err := d.root.read(host) if err != nil { return err } return d.root.write(r) } f, err := d.openRecord(host) if err != nil { return err } defer f.Close() if err := d.fsync(int(f.Fd())); err != nil { return err } return d.fsync(d.fd) } func (d *recordDir) write(r record) error { if d.snapshot != nil { return ErrFenced } if d.root != nil { return d.root.write(r) } var nonce [16]byte if _, err := rand.Read(nonce[:]); err != nil { return err } return d.writeUsing(r, ".tmp-"+hex.EncodeToString(nonce[:]), unix.Renameat) } func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, int, string) error) error { if err := validateFileHost(r.Host); err != nil { return err } if err := r.validate(r.Host); err != nil { return err } if tmp == "" || tmp == "." || tmp == ".." || filepath.Base(tmp) != tmp || tmp == r.Host+".json" { return ErrUnsafeFile } // Refuse an existing symlink or unreadable record. Renameat itself never // follows the destination, including a symlink planted after this check. if _, err := d.read(r.Host); err != nil && !errors.Is(err, ErrNoRecord) { return err } b, err := json.Marshal(r) if err != nil { return err } fd, err := unix.Openat(d.fd, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660) if err != nil { return err } // Only remove a name this call successfully created. defer unix.Unlinkat(d.fd, tmp, 0) f := os.NewFile(uintptr(fd), tmp) defer f.Close() if err := unix.Fchmod(fd, 0660); err != nil { return err } if _, err := f.Write(append(b, '\n')); err != nil { return err } if err := d.fsync(fd); err != nil { return err } if err := f.Close(); err != nil { return err } if err := rename(d.fd, tmp, d.fd, r.Host+".json"); err != nil { return err } return d.fsync(d.fd) }