filed

Job queue using FUSE

git clone git://mccd.space/filed

commit 61b5e55411a834a8338c72dc1944dd0e5e3786a3
parent abe3ee2a86df367934e1768b52303efcd7c8d551
Author: Marc Coquand <marc@coquand.email>
Date:   Thu, 18 Dec 2025 14:14:51 +0100

Add initial sandboxing support functions

Diffstat:
M.gitignore | 2++
MREADME.md | 10+++++++---
Acmd/filed-launch.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afiled-launch.1.scd | 19+++++++++++++++++++
Mfiled.5.scd | 8+++++++-
Mgo.mod | 2++
Mgo.sum | 4++++
Mmanager.go | 40+++++-----------------------------------
Mstore/jobs.go | 2+-
9 files changed, 124 insertions(+), 40 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,4 +1,6 @@
 filed
 filed.[1-8]
 filed.config.[1-8]
+filed-launch
+filed-launch.[1-8]
  
diff --git a/README.md b/README.md
@@ -13,17 +13,19 @@ File d'attente is built in Go and depends on sqlite and fuse (make sure fusermou
 ```sh
 $ git clone https://sr.ht/~marcc/filed/
 $ cd filed
-$ go build
 $ go install
+$ go install cmd/filed-launch.go
 ```
 
 To build the docs you need [scdoc]
 
 ```sh
-$ scdoc < filed.5.scd > filed.5
-$ scdoc < filed.config.5.scd > filed.config.5
+$ for f in filed*.scd; do
+    scdoc < "$f" > "${f%.scd}"
+done
 # mv filed.5 /usr/local/man/man5
 # mv filed.config.5 /usr/local/man/man5
+# mv filed-launch.1 /usr/local/man/man1
 ```
 
 ## Getting started
@@ -91,6 +93,8 @@ I was inspired by 9p, and files proved to be a great abstraction since directori
 - [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] 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
 - [ ] 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
@@ -0,0 +1,77 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"syscall"
+
+	"github.com/landlock-lsm/go-landlock/landlock"
+)
+
+func main() {
+	var roPaths, roFilePaths, rwFilePaths, rwPaths []string
+	flag.Func("ro", "Read-only path", func(s string) error {
+		roPaths = append(roPaths, s)
+		return nil
+	})
+
+	flag.Func("rof", "Read-only file", func(s string) error {
+		roFilePaths = append(roFilePaths, s)
+		return nil
+	})
+
+	flag.Func("rwf", "Read-write file", func(s string) error {
+		rwFilePaths = append(rwFilePaths, s)
+		return nil
+	})
+
+	flag.Func("rw", "Read-write path", func(s string) error {
+		rwPaths = append(rwPaths, s)
+		return nil
+	})
+	flag.Parse()
+
+	if flag.NArg() < 1 {
+		fmt.Fprintf(os.Stderr, "Usage: %s [flags] -- command [args...]\n", os.Args[0])
+		os.Exit(1)
+	}
+	target := flag.Arg(0)
+	args := flag.Args()[1:]
+
+	var rules []landlock.Rule
+	if len(roPaths) > 0 {
+		rules = append(rules, landlock.RODirs(roPaths...))
+	}
+	if len(roFilePaths) > 0 {
+		rules = append(rules, landlock.ROFiles(roFilePaths...))
+	}
+	if len(rwPaths) > 0 {
+		rules = append(rules, landlock.RWDirs(rwPaths...))
+	}
+	if len(rwFilePaths) > 0 {
+		rules = append(rules, landlock.RWFiles(rwFilePaths...))
+	}
+	if len(rules) > 0 {
+		if err := landlock.V5.BestEffort().RestrictPaths(rules...); err != nil {
+			log.Fatalf("failed to apply landlock: %v", err)
+		}
+	}
+
+	if len(rules) > 0 {
+		if err := landlock.V5.BestEffort().RestrictPaths(rules...); err != nil {
+			log.Fatalf("failed to apply landlock: %v", err)
+		}
+	}
+
+	fullPath, err := exec.LookPath(target)
+	if err != nil {
+		log.Fatalf("command not found: %v", err)
+	}
+
+	if err := syscall.Exec(fullPath, append([]string{target}, args...), os.Environ()); err != nil {
+		log.Fatalf("failed to exec target: %v", err)
+	}
+}
diff --git a/filed-launch.1.scd b/filed-launch.1.scd
@@ -0,0 +1,19 @@
+FILED-LAUNCH(1)
+
+# NAME
+
+filed-launch - launch programs with restricted access
+
+# SYNOPSIS
+
+*filed-launch* [-rw _dir_] [-ro _dir_] [-rwf _file_] [-rof _file_] -- _executable_
+
+# DESCRIPTION
+
+*filed-launch* is used by *filed*(5) for launching programs with restricted file
+access using *landlock*(7).
+
+If no arguments are applied, the _executable_ is launched with full access
+to the file system.
+
+
diff --git a/filed.5.scd b/filed.5.scd
@@ -14,6 +14,9 @@ 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.
+
 ## Overview
 
 Underneath _mdir_, there are the following groups of files and subdirectories:
@@ -60,7 +63,9 @@ 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 drop privileges. 
+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.
 
 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.
@@ -99,6 +104,7 @@ Inspect a currently running job:
 # SEE ALSO
 
 - *filed.config*(5)
+- *filed-launch*(1)
 - 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*
diff --git a/go.mod b/go.mod
@@ -6,11 +6,13 @@ require (
 	bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
+	github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/ncruces/go-strftime v0.1.9 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/sys v0.36.0 // indirect
+	kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
 	modernc.org/libc v1.66.10 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
@@ -4,6 +4,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c h1:QcKqiunpt7hooa/xIx0iyepA6Cs2BgKexaYOxHvHNCs=
+github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c/go.mod h1:stwyhp9tfeEy3A4bRJLdOEvjW/CetRJg/vcijNG8M5A=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -17,6 +19,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
 golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
+kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
 modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
 modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
 modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
diff --git a/manager.go b/manager.go
@@ -4,16 +4,14 @@ import (
 	"bytes"
 	"context"
 	"filed/store"
+	"strings"
 
 	"fmt"
 	"log/slog"
 	"math"
-	"os"
 	"os/exec"
 	"sync"
-	"syscall"
 	"time"
-	"unsafe"
 )
 
 type JobManager struct {
@@ -58,37 +56,6 @@ func (jm *JobManager) StartWorker(ctx context.Context) {
 		}
 	}()
 }
-func (jm *JobManager) runBinaryFromMemory(id string, data []byte) ([]byte, error) {
-	// 1. Create a "Memory File" (Linux only)
-	// This creates a file descriptor that exists only in RAM.
-	fdName := "qj_bin_" + id
-	fd, _, err := syscall.Syscall(syscall.SYS_MEMFD_CREATE, uintptr(unsafe.Pointer(syscall.StringBytePtr(fdName))), 0, 0)
-	if err != 0 {
-		return nil, fmt.Errorf("memfd_create failed: %w", err)
-	}
-
-	// Convert FD to an *os.File
-	f := os.NewFile(fd, fdName)
-	defer f.Close()
-
-	// 2. Write the binary data to the memory file
-	if _, err := f.Write(data); err != nil {
-		return nil, fmt.Errorf("failed to write binary to memory: %w", err)
-	}
-
-	// 3. Execute it using the /proc/self/fd path
-	// Linux maps file descriptors into the filesystem under /proc/self/fd/
-	cmdPath := fmt.Sprintf("/proc/self/fd/%d", fd)
-	cmd := exec.Command(cmdPath)
-
-	// Capture output just like before
-	var output bytes.Buffer
-	cmd.Stdout = &output
-	cmd.Stderr = &output
-
-	errRun := cmd.Run()
-	return output.Bytes(), errRun
-}
 
 func (jm *JobManager) processPendingJobs() {
 	conf := jm.store.GetConfig()
@@ -147,7 +114,10 @@ func (jm *JobManager) runJob(id, commandStr string) {
 	jm.activeJobs.Store(id, active)
 	defer jm.activeJobs.Delete(id)
 
-	cmd := exec.CommandContext(ctx, "sh", "-c", commandStr)
+	args := strings.Fields(commandStr)
+
+	cmd := exec.CommandContext(ctx, "filed-launch", "--")
+	cmd.Args = append(cmd.Args, args...)
 
 	writer := &SafeBuffer{target: active}
 	cmd.Stdout = writer
diff --git a/store/jobs.go b/store/jobs.go
@@ -40,7 +40,7 @@ func NewStore(filepath string) (*Store, error) {
 		return nil, err
 	}
 
-	if _, err := db.Exec("PRAGMA journal_mode=WAL;PRAGMA busy_timeout=5000;"); err != nil {
+	if _, err := db.Exec("PRAGMA journal_mode=WAL;PRAGMA busy_timeout=20000;"); err != nil {
 		return nil, err
 	}