Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

knotserver/sandbox: add landlock sandbox backend

author
Anirudh Oppiliappan
date (Jun 12, 2026, 11:35 AM +0300) commit 19d42e3e parent 05862657 change-id qzkxovyw
+421 -1
+3 -1
go.mod
··· 47 47 github.com/hpcloud/tail v1.0.0 48 48 github.com/ipfs/go-cid v0.6.0 49 49 github.com/jackc/pgx/v5 v5.8.0 50 + github.com/landlock-lsm/go-landlock v0.8.1 50 51 github.com/mattn/go-sqlite3 v1.14.34 51 52 github.com/microcosm-cc/bluemonday v1.0.27 52 53 github.com/multiformats/go-multihash v0.2.3 ··· 70 71 golang.org/x/image v0.31.0 71 72 golang.org/x/net v0.50.0 72 73 golang.org/x/sync v0.19.0 74 + golang.org/x/sys v0.41.0 73 75 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 74 76 gopkg.in/yaml.v3 v3.0.1 75 77 ) ··· 274 276 go.uber.org/zap v1.27.1 // indirect 275 277 go.yaml.in/yaml/v2 v2.4.3 // indirect 276 278 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect 277 - golang.org/x/sys v0.41.0 // indirect 278 279 golang.org/x/text v0.34.0 // indirect 279 280 golang.org/x/time v0.12.0 // indirect 280 281 google.golang.org/protobuf v1.36.11 // indirect ··· 286 287 gorm.io/driver/sqlite v1.6.0 // indirect 287 288 gorm.io/gorm v1.31.1 // indirect 288 289 gotest.tools/v3 v3.5.2 // indirect 290 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect 289 291 lukechampine.com/blake3 v1.4.1 // indirect 290 292 ) 291 293
+4
go.sum
··· 494 494 github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= 495 495 github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= 496 496 github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM= 497 + github.com/landlock-lsm/go-landlock v0.8.1 h1:Krs1co16IzN7bQcFYIdtNF+BKwZem3geRBkVsZtlCKU= 498 + github.com/landlock-lsm/go-landlock v0.8.1/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= 497 499 github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 498 500 github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 499 501 github.com/libp2p/go-libp2p v0.47.0 h1:qQpBjSCWNQFF0hjBbKirMXE9RHLtSuzTDkTfr1rw0yc= ··· 951 953 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 952 954 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 953 955 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 956 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= 957 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 954 958 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 955 959 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 956 960 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc=
+102
knotserver/sandbox/repofs.go
··· 1 + package sandbox 2 + 3 + import ( 4 + "fmt" 5 + "io/fs" 6 + "os" 7 + "path/filepath" 8 + "sort" 9 + "strings" 10 + "syscall" 11 + ) 12 + 13 + // ChmodRepoTree sets directory modes to 0770 and file modes to 0660 under 14 + // root, preserving the executable bit on files (hook scripts need it). 15 + // Symlinks are skipped since their mode is not meaningful. 16 + // 17 + // The group bits exist so the knot service (running as the git user, which 18 + // is in the git group that owns the repos) can still read and write the 19 + // repo via group permissions even though the repo's UID owner is a virtual 20 + // UID. Sandbox subprocesses run with NoSetGroups: true so they don't gain 21 + // group access and cross-owner isolation still holds. 22 + func ChmodRepoTree(root string) error { 23 + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { 24 + if err != nil { 25 + return err 26 + } 27 + if d.Type()&fs.ModeSymlink != 0 { 28 + return nil 29 + } 30 + if d.IsDir() { 31 + return os.Chmod(path, 0770) 32 + } 33 + info, err := d.Info() 34 + if err != nil { 35 + return err 36 + } 37 + mode := fs.FileMode(0660) 38 + if info.Mode()&0100 != 0 { 39 + mode = 0770 40 + } 41 + return os.Chmod(path, mode) 42 + }) 43 + } 44 + 45 + // ChownRepoTree recursively chowns every entry under root to uid:gid. 46 + // Entries are processed deepest-first so a directory is only chowned after 47 + // its contents, preserving the calling process's access throughout the walk. 48 + // Call ChmodRepoTree first if you also want to tighten permissions; this 49 + // function only changes ownership. 50 + func ChownRepoTree(root string, uid int, gid int) error { 51 + type entry struct { 52 + path string 53 + depth int 54 + } 55 + var entries []entry 56 + if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { 57 + if err != nil { 58 + return err 59 + } 60 + depth := strings.Count(path, string(filepath.Separator)) 61 + entries = append(entries, entry{path, depth}) 62 + return nil 63 + }); err != nil { 64 + return err 65 + } 66 + 67 + sort.Slice(entries, func(i, j int) bool { 68 + return entries[i].depth > entries[j].depth 69 + }) 70 + 71 + for _, e := range entries { 72 + if err := os.Lchown(e.path, uid, gid); err != nil { 73 + return err 74 + } 75 + } 76 + return nil 77 + } 78 + 79 + // LookupUIDForRepoPath returns the owner UID and GID of the repo directory at 80 + // repoPath. scanPath is validated as a prefix to guard against directory escape. 81 + func LookupUIDForRepoPath(scanPath, repoPath string) (uid uint32, gid uint32, err error) { 82 + if !strings.HasPrefix(repoPath, scanPath) { 83 + return 0, 0, fmt.Errorf("repo path %q is outside scan path %q", repoPath, scanPath) 84 + } 85 + var stat syscall.Stat_t 86 + if err := syscall.Stat(repoPath, &stat); err != nil { 87 + return 0, 0, err 88 + } 89 + return stat.Uid, stat.Gid, nil 90 + } 91 + 92 + // ServiceGid returns the GID of scanPath, which is treated as the "service 93 + // group" that owns all repositories. Callers chown repo trees to 94 + // (virtualUID, ServiceGid(scanPath)) so the knot service (a member of this 95 + // group) retains read+write access via the group bits set by ChmodRepoTree. 96 + func ServiceGid(scanPath string) (uint32, error) { 97 + var stat syscall.Stat_t 98 + if err := syscall.Stat(scanPath, &stat); err != nil { 99 + return 0, fmt.Errorf("stat %s: %w", scanPath, err) 100 + } 101 + return stat.Gid, nil 102 + }
+45
knotserver/sandbox/sandbox.go
··· 1 + package sandbox 2 + 3 + import "os/exec" 4 + 5 + // Backend wraps git subprocesses in a filesystem sandbox. 6 + type Backend interface { 7 + Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) 8 + WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) 9 + Name() string 10 + } 11 + 12 + // NoopBackend passes commands through unchanged. 13 + type NoopBackend struct{} 14 + 15 + func (n *NoopBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) { 16 + cmd.Dir = repoPath 17 + return cmd, nil 18 + } 19 + 20 + func (n *NoopBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) { 21 + if len(paths) > 0 { 22 + cmd.Dir = paths[0] 23 + } 24 + return cmd, nil 25 + } 26 + 27 + func (n *NoopBackend) Name() string { return "noop" } 28 + 29 + // LookupUID resolves a repo path to its owner virtual UID. Used by the sandbox 30 + // to drop privileges before running git. Returning 0 (or any error) means 31 + // don't drop, i.e. the subprocess runs as the calling user. 32 + type LookupUID func(repoPath string) (uid uint32, gid uint32, err error) 33 + 34 + // New returns the best available sandboxing backend. If landlock is not 35 + // available, the warning string is non-empty and the backend falls back 36 + // to NoopBackend. lookup is optional; nil means subprocesses keep the 37 + // caller's UID/GID. 38 + func New(lookup LookupUID) (Backend, string) { 39 + return platformNew(lookup) 40 + } 41 + 42 + // Probe returns a human-readable description of sandbox capability on this host. 43 + func Probe() string { 44 + return platformProbe() 45 + }
+183
knotserver/sandbox/sandbox_linux.go
··· 1 + //go:build linux 2 + 3 + package sandbox 4 + 5 + import ( 6 + "errors" 7 + "fmt" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "syscall" 12 + "unsafe" 13 + 14 + "github.com/landlock-lsm/go-landlock/landlock" 15 + "golang.org/x/sys/unix" 16 + ) 17 + 18 + var ErrUnsupportedPlatform = errors.New("no sandbox backend available") 19 + 20 + // LandlockBackend uses the Linux Landlock LSM via a re-exec pattern. 21 + // landlock_restrict_self only affects the calling OS thread, so we re-exec 22 + // the binary as "sandbox-exec" which runs single-threaded before exec'ing git. 23 + type LandlockBackend struct { 24 + selfExe string 25 + lookup LookupUID 26 + } 27 + 28 + func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) { 29 + return l.WrapMulti([]string{repoPath}, cmd) 30 + } 31 + 32 + func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) { 33 + if len(paths) == 0 { 34 + return cmd, nil 35 + } 36 + 37 + // resolve the executable to an absolute path now, while $PATH is still 38 + // intact; the re-exec'd sandbox-exec subprocess inherits the env we pass 39 + // via cmd.Env, which may not include the wrappers that set up $PATH. 40 + args := cmd.Args 41 + if len(args) > 0 { 42 + if abs, err := exec.LookPath(args[0]); err == nil { 43 + args = append([]string{abs}, args[1:]...) 44 + } 45 + } 46 + 47 + var sandboxArgs []string 48 + sandboxArgs = append(sandboxArgs, "sandbox-exec") 49 + for _, p := range paths { 50 + sandboxArgs = append(sandboxArgs, "--repo-path="+p) 51 + } 52 + sandboxArgs = append(sandboxArgs, "--") 53 + sandboxArgs = append(sandboxArgs, args...) 54 + 55 + wrapped := exec.Command(l.selfExe, sandboxArgs...) 56 + wrapped.Env = cmd.Env 57 + wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve 58 + wrapped.Stdin = cmd.Stdin 59 + wrapped.Stdout = cmd.Stdout 60 + wrapped.Stderr = cmd.Stderr 61 + 62 + // drop to the virtual UID if we can resolve one. the kernel handles 63 + // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller. 64 + // 65 + // the primary GID is intentionally set to the virtual UID, NOT the 66 + // repo's group ownership. repo dirs are owned by virtualUID:gitGroup 67 + // with mode 0770 so the knot service (in gitGroup) can read them, but 68 + // sandbox subprocesses must not inherit gitGroup or they would gain 69 + // group access to every other repo and lose cross-owner isolation. 70 + if l.lookup != nil { 71 + if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 { 72 + wrapped.SysProcAttr = &syscall.SysProcAttr{ 73 + Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true}, 74 + } 75 + } 76 + } 77 + 78 + return wrapped, nil 79 + } 80 + 81 + func (l *LandlockBackend) Name() string { return "landlock" } 82 + 83 + // ApplyLandlock applies a Landlock ruleset to the current process then 84 + // exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand. 85 + func ApplyLandlock(repoPaths []string, gitArgs []string) error { 86 + if len(gitArgs) == 0 { 87 + return fmt.Errorf("sandbox-exec: no command specified") 88 + } 89 + 90 + // collect unique parent directories so git can read global config 91 + // under $HOME/.config/git/config. repo contents stay DAC-locked 92 + // (0700) so other repos can't actually be read. 93 + parents := map[string]struct{}{} 94 + for _, p := range repoPaths { 95 + parents[filepath.Dir(p)] = struct{}{} 96 + } 97 + parentSlice := make([]string, 0, len(parents)) 98 + for p := range parents { 99 + parentSlice = append(parentSlice, p) 100 + } 101 + 102 + // each repo gets full read/write plus REFER (needed for git's quarantine 103 + // rename in receive-pack, which moves objects across directories). 104 + repoRules := make([]landlock.Rule, len(repoPaths)) 105 + for i, p := range repoPaths { 106 + repoRules[i] = landlock.RWDirs(p).WithRefer() 107 + } 108 + 109 + rules := append([]landlock.Rule{ 110 + // system dirs: read + execute only, no writes 111 + landlock.RODirs("/usr", "/bin", "/lib", "/lib64", "/nix", "/etc").IgnoreIfMissing(), 112 + // /dev/null and friends: read/write files + ioctl (V5+ restricts ioctl 113 + // on device files; WithIoctlDev keeps /dev/null fully accessible) 114 + landlock.RWFiles("/dev").WithIoctlDev().IgnoreIfMissing(), 115 + // parent dirs: read + execute so git can traverse to the repo and read 116 + // global git config; 0700 DAC permissions prevent cross-repo reads 117 + landlock.RODirs(parentSlice...).IgnoreIfMissing(), 118 + // /tmp: read/write for temporary patch and object files 119 + landlock.RWDirs("/tmp").IgnoreIfMissing(), 120 + }, repoRules...) 121 + 122 + // V8.BestEffort enforces the strongest ruleset the running kernel supports, 123 + // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically. 124 + if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil { 125 + return fmt.Errorf("sandbox-exec: restrict paths: %w", err) 126 + } 127 + 128 + gitBin := gitArgs[0] 129 + if !filepath.IsAbs(gitBin) { 130 + return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin) 131 + } 132 + 133 + return unix.Exec(gitBin, gitArgs, os.Environ()) 134 + } 135 + 136 + func probeLandlock() bool { 137 + _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION) 138 + // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock. 139 + // Any other result (including EINVAL for the nil attr) means it's available. 140 + return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS) 141 + } 142 + 143 + func platformNew(lookup LookupUID) (Backend, string) { 144 + if probeLandlock() { 145 + selfExe, err := os.Readlink("/proc/self/exe") 146 + if err != nil { 147 + selfExe = "/proc/self/exe" 148 + } 149 + return &LandlockBackend{selfExe: selfExe, lookup: lookup}, "" 150 + } 151 + 152 + return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed" 153 + } 154 + 155 + func platformProbe() string { 156 + if probeLandlock() { 157 + return "landlock available (kernel >= 5.13)" 158 + } 159 + return "no sandbox backend available (kernel < 5.13)" 160 + } 161 + 162 + // landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall. 163 + // Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version. 164 + // Used only for the non-destructive probe in probeLandlock; all ruleset 165 + // construction is handled by go-landlock. 166 + func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) { 167 + var attrPtr unsafe.Pointer 168 + var attrSize uintptr 169 + if attr != nil { 170 + attrPtr = unsafe.Pointer(attr) 171 + attrSize = unsafe.Sizeof(*attr) 172 + } 173 + fd, _, errno := unix.Syscall( 174 + unix.SYS_LANDLOCK_CREATE_RULESET, 175 + uintptr(attrPtr), 176 + attrSize, 177 + uintptr(flags), 178 + ) 179 + if errno != 0 { 180 + return 0, errno 181 + } 182 + return int(fd), nil 183 + }
+15
knotserver/sandbox/sandbox_other.go
··· 1 + //go:build !linux 2 + 3 + package sandbox 4 + 5 + import "fmt" 6 + 7 + var ErrUnsupportedPlatform = fmt.Errorf("sandboxing is only supported on Linux") 8 + 9 + func platformNew(_ LookupUID) (Backend, string) { 10 + return &NoopBackend{}, "sandboxing is not supported on this platform (Linux only)" 11 + } 12 + 13 + func platformProbe() string { 14 + return "sandboxing not supported (Linux only)" 15 + }
+11
knotserver/sandbox/sandboxexec/exec_linux.go
··· 1 + //go:build linux 2 + 3 + package sandboxexec 4 + 5 + import ( 6 + "tangled.org/core/knotserver/sandbox" 7 + ) 8 + 9 + func applyAndExec(repoPaths, gitArgs []string) error { 10 + return sandbox.ApplyLandlock(repoPaths, gitArgs) 11 + }
+9
knotserver/sandbox/sandboxexec/exec_other.go
··· 1 + //go:build !linux 2 + 3 + package sandboxexec 4 + 5 + import "fmt" 6 + 7 + func applyAndExec(repoPaths, gitArgs []string) error { 8 + return fmt.Errorf("sandbox-exec is only supported on Linux") 9 + }
+43
knotserver/sandbox/sandboxexec/sandboxexec.go
··· 1 + package sandboxexec 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/urfave/cli/v3" 9 + ) 10 + 11 + // Command returns the hidden sandbox-exec subcommand used by LandlockBackend. 12 + // 13 + // landlock_restrict_self only restricts the calling OS thread, so it cannot be 14 + // called from a goroutine (the Go scheduler may migrate the goroutine across 15 + // threads). The workaround is to re-exec the knot binary with this subcommand, 16 + // which runs single-threaded before the Go runtime starts its thread pool, 17 + // applies the ruleset, then exec's into the target git process. 18 + func Command() *cli.Command { 19 + return &cli.Command{ 20 + Name: "sandbox-exec", 21 + Hidden: true, 22 + Usage: "apply landlock sandbox and exec into git (internal use only)", 23 + Action: Run, 24 + Flags: []cli.Flag{ 25 + &cli.StringSliceFlag{ 26 + Name: "repo-path", 27 + Usage: "repository path(s) to allow read/write access to", 28 + }, 29 + }, 30 + } 31 + } 32 + 33 + func Run(ctx context.Context, cmd *cli.Command) error { 34 + repoPaths := cmd.StringSlice("repo-path") 35 + gitArgs := cmd.Args().Slice() 36 + 37 + if len(gitArgs) == 0 { 38 + fmt.Fprintln(os.Stderr, "sandbox-exec: no command specified after --") 39 + os.Exit(1) 40 + } 41 + 42 + return applyAndExec(repoPaths, gitArgs) 43 + }
+6
nix/gomod2nix.toml
··· 548 548 [mod."github.com/labstack/gommon"] 549 549 version = "v0.4.1" 550 550 hash = "sha256-qfjV9jmtR8I7gC7/Hm02XDbuVLX8UkRNi3wPer8Jkm4=" 551 + [mod."github.com/landlock-lsm/go-landlock"] 552 + version = "v0.8.1" 553 + hash = "sha256-7H6/LBmv/d4vDIfZcJ0PeNiNU2PInkw29aQU1yHWxsU=" 551 554 [mod."github.com/lucasb-eyer/go-colorful"] 552 555 version = "v1.3.0" 553 556 hash = "sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=" ··· 846 849 [mod."gotest.tools/v3"] 847 850 version = "v3.5.2" 848 851 hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" 852 + [mod."kernel.org/pub/linux/libs/security/libcap/psx"] 853 + version = "v1.2.77" 854 + hash = "sha256-oqlAG5XMkQ4toFSIbqGg+2biuw+IyNrogcMralnmvfA=" 849 855 [mod."lukechampine.com/blake3"] 850 856 version = "v1.4.1" 851 857 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="