filed

Job queue using FUSE

git clone git://mccd.space/filed

commit 68a5a76c61a06226febaa9e5d15953863983f191
parent d2f777f1bf1ecc7196d87f25a38268070cc0aa9c
Author: Marc Coquand <marc@coquand.email>
Date:   Fri, 19 Dec 2025 12:51:17 +0100

Add landlock to file

Diffstat:
MREADME.md | 12++++++------
Mcmd/filed-launch.go | 3+++
Mfiled.5.scd | 42++++++++++++++++++++++++++++++------------
Mmain.go | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mmanager.go | 44+++++++++++++++++++++++++++++++++++++++-----
5 files changed, 200 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
@@ -1,10 +1,10 @@
 # File d'attente
 
-*File d'attente* (queue in French) is a concurrent file-based job queue, written in Go.
+*File d'attente* (queue in French) is a concurrent, file-based job queue, written in Go.
 
 File d'attente uses files and directories for queue manipulation. Create a job with  "`printf cmd > /pending/$id`", view running jobs with "`ls /active`", and restart a failed job with "`mv /failed/$id /pending`".
 
-The tool is intended for trusted, single-server workloads, as a companion queue to another application. File d'attente comes with automatic retries, timeout, and backoff built-in.
+The tool is intended for single-server workloads, as a companion queue to another application. File d'attente comes with sandboxing, automatic retries, timeout, and backoff built-in.
 
 ## Installation
 
@@ -36,10 +36,10 @@ It is recommended to read the [man pages] for more complete documentation and se
 
 ```sh
 $ mkdir /tmp/filed-jobs
-$ filed /tmp/filed-jobs
+$ filed -rof "/usr/bin/echo" -ro "/lib" /tmp/filed-jobs
 ```
 
-`filed` mounts the directory `filed-jobs` and exposes a few files and directories.
+`filed` mounts the directory `filed-jobs` and exposes a few files and directories. With the above script, each job will launch in a sandboxed-mode and only have access to `echo` and `lib`.
 
 A job can then be added by creating a file in the newly available pending directory:
 
@@ -92,9 +92,9 @@ I was inspired by 9p, and files proved to be a great abstraction since directori
 - [x] State is configured via environment variable
 - [x] Customizable backoff and timeout before retries
 - [x] Last modified and created at are correctly rendered for jobs
-- [ ] "Landlock"-mode, or sandboxed jobs - Requires a design
+- [x] "Landlock"-mode for sandboxing
 	- [x] Add filed-launch - a script that can be used to restrict command access
-	- [ ] Add command arguments to filed to lock it down, but still allow it access to state files, and remove that access in filed-launch
+	- [x] Add command arguments to filed to lock it down, but still allow it access to state files, and remove that access in filed-launch
 - [ ] A reusable systemd unit file
 - [ ] Notification on failure. Unfortunately [inotify does not work with fuse], which would have been elegant otherwise.
 - [ ] Notify forget and other updates.
diff --git a/cmd/filed-launch.go b/cmd/filed-launch.go
@@ -4,6 +4,7 @@ import (
 	"flag"
 	"fmt"
 	"log"
+	"log/slog"
 	"os"
 	"os/exec"
 	"syscall"
@@ -60,6 +61,8 @@ func main() {
 		}
 	}
 
+	slog.Info("Landlock", "Restrictions", rules)
+
 	if len(rules) > 0 {
 		if err := landlock.V5.BestEffort().RestrictPaths(rules...); err != nil {
 			log.Fatalf("failed to apply landlock: %v", err)
diff --git a/filed.5.scd b/filed.5.scd
@@ -6,7 +6,7 @@ filed - queue jobs utility
 
 # SYNOPSIS
 
-*filed* _mdir_
+*filed* [_option_]... _mdir_
 
 # DESCRIPTION
 
@@ -14,8 +14,13 @@ filed (file d'attente) is a pseudo-filesystem which provides an inspectable
 job queue that operates on files. It mounts a directory _mdir_, which is
 where the user can add and inspect jobs.
 
-All jobs are executed using *filed-launch*(1), allowing you to restrict job
-accesses.
+All jobs are executed with *filed-launch*(1), allowing you to restrict job
+accesses. If an _option_ is supplied, filed will launch with *landlock*(7)
+sandbox, restrict itself to only the necessary directories and files to access
+fuse, processes, database, *filed-launch*(1) along with the supplied _option_s.
+Jobs thereafter will have their access further droppet to only access _option_s.
+
+If no _option_ is supplied, *filed* will launch with access unrestricted.
 
 ## Overview
 
@@ -47,6 +52,21 @@ _mdir_/config.json
 	Provides various settings. Changes made to this file are applied
 	immediately. See *filed.config*(5) for full list of options.  
 
+# OPTIONS
+
+Options for file restrictions can be used multiple times.
+
+*-rwf* _file_
+	Give read, execute and write access to file.
+
+*-rof* _file_
+	Give read and execute access to file.
+
+*-rw* _dir_
+	Give read and execute access to directory.
+
+*-ro* _dir_
+	Give read and execute access to directory.
 
 # SECURITY
 
@@ -62,10 +82,8 @@ principle of least access.
 
 Importantly, the system is intended for only trusted scripts: the job user
 has access to the state, and is thus able to rewrite access rights. It is
-recommended for the running scripts to use *namespaces*(7) or *Landlock*(7) to
-drop further privileges. You can also use *bwrap*(1) or *landrun* to run filed
-with dropped privileges, ensuring it can only access the desired files and
-directories.
+recommended to either use _option_s to restrict access, or alternatively
+*bwrap*(1) or similar tools to drop further privileges.
 
 Another aspect to be aware of is that File d'attente stores logs of all jobs.
 Care should be taken to ensure that no secrets are printed.
@@ -91,8 +109,9 @@ exist. Defaults to $XDG_DATA_HOME/filed.db, or exit(1) if XDG_DATA_HOME is unset
 
 # EXAMPLE
 
-*filed* /var/filed
-	Mount *filed* pseudo-filesystem to /var/filed.
+*filed* -rof "/usr/bin/cat" -ro "/lib" -ro "$HOME/some-dir" /var/filed
+	Mount *filed* pseudo-filesystem to /var/filed. Restrict scripts that
+	can be launched to cat and only allow jobs to access files in some-dir.
 
 printf "echo helloworld" > "/var/filed/pending/$(< /var/filed/new-id)"
 	Create a new job with a unique id sampled from _new-id_, that echoes hello world.
@@ -105,11 +124,10 @@ cat /var/filed/active/myjob
 
 # SEE ALSO
 
-- *filed.config*(5)
-- *filed-launch*(1)
+*filed.config*(5) *filed-launch*(1) *landlock*(7)
+ 
 - Periodic jobs can be set up using *cron*(8).
 - Monitoring failures can be done with *watch*(1)
-- Limiting job privileges can be done with *bwrap*(1) or *landrun*
 
 # LIMITATIONS
 
diff --git a/main.go b/main.go
@@ -9,11 +9,13 @@ import (
 	"log/slog"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"syscall"
 
 	"bazil.org/fuse"
 	"bazil.org/fuse/fs"
 	_ "bazil.org/fuse/fs/fstestutil"
+	"github.com/landlock-lsm/go-landlock/landlock"
 )
 
 func usage() {
@@ -21,29 +23,55 @@ func usage() {
 	flag.PrintDefaults()
 }
 
+type Restrictions struct {
+	rwFiles []string
+	roFiles []string
+	rwDir   []string
+	roDir   []string
+}
+
 func main() {
 	flag.Usage = usage
+	restrictions := Restrictions{}
+	shouldLandlock := false
+	flag.Func("ro", "Read-only path", func(s string) error {
+		restrictions.roDir = append(restrictions.roDir, s)
+		shouldLandlock = true
+		return nil
+	})
+
+	flag.Func("rof", "Read-only file", func(s string) error {
+		restrictions.roFiles = append(restrictions.roFiles, s)
+		shouldLandlock = true
+		return nil
+	})
+
+	flag.Func("rwf", "Read-write file", func(s string) error {
+		restrictions.rwFiles = append(restrictions.rwFiles, s)
+
+		shouldLandlock = true
+		return nil
+	})
+
+	flag.Func("rw", "Read-write path", func(s string) error {
+		restrictions.rwDir = append(restrictions.rwDir, s)
+
+		shouldLandlock = true
+		return nil
+	})
 	flag.Parse()
-
 	if flag.NArg() != 1 {
 		usage()
 		os.Exit(2)
 	}
+
 	filedLaunchExecutable, err := exec.LookPath("filed-launch")
 	if err != nil {
 		log.Fatalf("filed-launch needs to be available in $PATH: %v", err)
 	}
-
-	dbPath := os.Getenv("FILED_STATE_FILE")
-	if dbPath == "" {
-		xdg_home := os.Getenv("XDG_DATA_HOME")
-		if xdg_home == "" {
-			fmt.Fprintf(os.Stderr, "FILED_STATE_FILE environment variable needs to be set.\n")
-			fmt.Fprintf(os.Stderr, "For example: export FILED_STATE_FILE=$HOME/.local/share/filed.db")
-			usage()
-			os.Exit(1)
-		}
-		dbPath = fmt.Sprintf("%s/filed.db", xdg_home)
+	fusermountExecutable, err := exec.LookPath("fusermount")
+	if err != nil {
+		log.Fatalf("fusermount needs to be available in $PATH: %v", err)
 	}
 
 	userUid := uint32(os.Getuid())
@@ -52,7 +80,9 @@ func main() {
 		slog.Warn(warning)
 	}
 
+	dbPath := getDbPath()
 	mountpoint := flag.Arg(0)
+
 	if err := Unmount(mountpoint); err != nil {
 		slog.Debug("FUSE: Pre-start unmount failed (this is usually okay)", "error", err)
 	}
@@ -61,7 +91,6 @@ func main() {
 	if err != nil {
 		panic(err)
 	}
-
 	slog.Info("Mounting filesystem", "mountpoint", mountpoint)
 	c, err := fuse.Mount(
 		mountpoint,
@@ -74,7 +103,39 @@ func main() {
 	}
 	defer c.Close()
 
-	jobManager := NewJobManager(store, filedLaunchExecutable)
+	if shouldLandlock {
+		var rules []landlock.Rule
+
+		// For the filed daemon we need to append extra files and directories needed to operate.
+		// Later on, filed-launch will further landlock each process to make sure they can't
+		// access these files.
+
+		// fusermount and and filed-launcher are needed to launch applications
+		rwFilePathsForFiled := append(restrictions.rwFiles, dbPath)
+		rules = append(rules, landlock.RWFiles(rwFilePathsForFiled...))
+
+		// filed-launch are needed to launch applications
+		// fusermount for unmounting
+		roFilePathsForFiled := append(restrictions.roFiles, fusermountExecutable, filedLaunchExecutable)
+		rules = append(rules, landlock.ROFiles(roFilePathsForFiled...))
+
+		// /proc and /dev are needed to oversee the process and kill it
+		roDirPathsForFiled := append(restrictions.roDir, "/proc", "/dev")
+		rules = append(rules, landlock.RODirs(roDirPathsForFiled...))
+
+		// mountpoint might not technically be necessarily? Probably good to include either way...
+		rwPathsForFiled := append(restrictions.rwFiles, mountpoint)
+		rules = append(rules, landlock.RWDirs(rwPathsForFiled...))
+
+		err := landlock.V5.BestEffort().RestrictPaths(
+			rules...,
+		)
+		if err != nil {
+			log.Fatalf("Failed to landlock: %v", err)
+		}
+	}
+
+	jobManager := NewJobManager(store, filedLaunchExecutable, &restrictions)
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 	jobManager.StartWorker(ctx)
@@ -135,3 +196,50 @@ var rootEntries = []fuse.Dirent{
 func (RootDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
 	return rootEntries, nil
 }
+func getDbPath() string {
+	dbPath := os.Getenv("FILED_STATE_FILE")
+	if dbPath == "" {
+		xdg_home := os.Getenv("XDG_DATA_HOME")
+		if xdg_home == "" {
+			fmt.Fprintf(os.Stderr, "FILED_STATE_FILE environment variable needs to be set.\n")
+			fmt.Fprintf(os.Stderr, "For example: export FILED_STATE_FILE=$HOME/.local/share/filed.db")
+			usage()
+			os.Exit(1)
+		}
+		dbPath = filepath.Join(xdg_home, "filed.db")
+	}
+	return dbPath
+}
+
+// XXX Should be struct
+func getLandlockOptions() ([]string, []string, []string, []string, bool) {
+	var roPaths, roFilePaths, rwFilePaths, rwPaths []string
+	isSet := false
+	flag.Func("ro", "Read-only path", func(s string) error {
+		roPaths = append(roPaths, s)
+		isSet = true
+		return nil
+	})
+
+	flag.Func("rof", "Read-only file", func(s string) error {
+		roFilePaths = append(roFilePaths, s)
+		isSet = true
+		return nil
+	})
+
+	flag.Func("rwf", "Read-write file", func(s string) error {
+		rwFilePaths = append(rwFilePaths, s)
+
+		isSet = true
+		return nil
+	})
+
+	flag.Func("rw", "Read-write path", func(s string) error {
+		rwPaths = append(rwPaths, s)
+
+		isSet = true
+		return nil
+	})
+
+	return roPaths, roFilePaths, rwFilePaths, rwPaths, isSet
+}
diff --git a/manager.go b/manager.go
@@ -14,11 +14,19 @@ import (
 	"time"
 )
 
+type RestrictionsArg struct {
+	rwFilesArg []string
+	roFilesArg []string
+	rwDirArg   []string
+	roDirArg   []string
+}
+
 type JobManager struct {
 	store      *store.Store
 	activeJobs sync.Map
 	// Command to use for executing jobs in pending
-	execCmd string
+	execCmd      string
+	restrictions *RestrictionsArg
 }
 
 type ActiveJob struct {
@@ -27,9 +35,28 @@ type ActiveJob struct {
 	output bytes.Buffer
 }
 
-func NewJobManager(s *store.Store, filedLaunchExecutablePath string) *JobManager {
+func NewJobManager(s *store.Store, filedLaunchExecutablePath string, restrictions *Restrictions) *JobManager {
+
+	argRestrictions := RestrictionsArg{}
+	if restrictions != nil {
+		argRestrictions.roFilesArg = toArg(restrictions.roFiles, "-rof")
+		argRestrictions.roDirArg = toArg(restrictions.roDir, "-ro")
+		argRestrictions.rwFilesArg = toArg(restrictions.rwFiles, "-rwf")
+		argRestrictions.rwDirArg = toArg(restrictions.rwDir, "-rw")
+	}
+	slog.Info("restrictions", "rest", argRestrictions)
+
+	return &JobManager{store: s, execCmd: filedLaunchExecutablePath, restrictions: &argRestrictions}
+}
+
+func toArg(lst []string, argParam string) []string {
+	var result []string
 
-	return &JobManager{store: s, execCmd: filedLaunchExecutablePath}
+	for _, v := range lst {
+		result = append(result, argParam, v)
+	}
+
+	return result
 }
 
 func (jm *JobManager) StartWorker(ctx context.Context) {
@@ -46,7 +73,7 @@ func (jm *JobManager) StartWorker(ctx context.Context) {
 		}
 	}
 
-	ticker := time.NewTicker(2 * time.Second)
+	ticker := time.NewTicker(1 * time.Second)
 	go func() {
 		for {
 			select {
@@ -118,7 +145,14 @@ func (jm *JobManager) runJob(id, commandStr string) {
 
 	args := strings.Fields(commandStr)
 
-	cmd := exec.CommandContext(ctx, jm.execCmd, "--")
+	cmd := exec.CommandContext(ctx, jm.execCmd)
+	if jm.restrictions != nil {
+		cmd.Args = append(cmd.Args, jm.restrictions.roFilesArg...)
+		cmd.Args = append(cmd.Args, jm.restrictions.rwFilesArg...)
+		cmd.Args = append(cmd.Args, jm.restrictions.rwDirArg...)
+		cmd.Args = append(cmd.Args, jm.restrictions.roDirArg...)
+	}
+	cmd.Args = append(cmd.Args, "--")
 	cmd.Args = append(cmd.Args, args...)
 
 	writer := &SafeBuffer{target: active}