mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
278 lines
7.8 KiB
Go
278 lines
7.8 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
//go:build linux
|
|
|
|
package sandbox
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/landlock-lsm/go-landlock/landlock"
|
|
"golang.org/x/sys/unix"
|
|
"quad4/reticulum-go/pkg/common"
|
|
"quad4/reticulum-go/pkg/debug"
|
|
)
|
|
|
|
func applyPlatform(cfg *common.ReticulumConfig) error {
|
|
strict := cfg != nil && cfg.SandboxStrict
|
|
|
|
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
|
|
debug.Log(debug.DebugError, "PR_SET_NO_NEW_PRIVS failed", "error", err)
|
|
if strict {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := applyLandlock(cfg); err != nil {
|
|
debug.Log(debug.DebugError, "Landlock failed", "error", err)
|
|
warnSoftUnavailable("landlock", err.Error())
|
|
if strict {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if os.Geteuid() == 0 {
|
|
if err := dropAllCapabilities(); err != nil {
|
|
debug.Log(debug.DebugError, "Capability drop failed", "error", err)
|
|
}
|
|
if err := unix.Unshare(unix.CLONE_NEWNS); err != nil {
|
|
debug.Log(debug.DebugError, "Unshare(CLONE_NEWNS) failed", "error", err)
|
|
} else {
|
|
_ = unix.Mount("none", "/", "", unix.MS_REC|unix.MS_PRIVATE, "")
|
|
}
|
|
} else {
|
|
debug.Log(debug.DebugVerbose, "Skipping privileged sandbox steps (not root)")
|
|
}
|
|
|
|
if err := setResourceLimits(); err != nil {
|
|
debug.Log(debug.DebugError, "Setrlimit failed", "error", err)
|
|
}
|
|
|
|
if err := applySeccomp(cfg); err != nil {
|
|
if strict {
|
|
return err
|
|
}
|
|
}
|
|
|
|
debug.Log(debug.DebugInfo, "Sandbox applied", "platform", "linux")
|
|
return nil
|
|
}
|
|
|
|
// applyLandlock restricts filesystem access and IPC scopes with Landlock ABI
|
|
// V9 via go-landlock. TCP port rules are intentionally omitted so the P2P
|
|
// mesh can bind and dial arbitrary peers. BestEffort downgrades on older
|
|
// kernels. Missing optional paths are ignored.
|
|
func applyLandlock(cfg *common.ReticulumConfig) error {
|
|
// Go AllThreadsSyscall (Landlock restrict without TSYNC) fatals under
|
|
// qemu-user when per-thread results diverge. Skip rather than crash.
|
|
if os.Getenv("RETICULUM_QEMU_USER") == "1" {
|
|
return fmt.Errorf("landlock skipped under qemu-user")
|
|
}
|
|
|
|
if err := probeLandlock(); err != nil {
|
|
return err
|
|
}
|
|
|
|
rules, err := landlockPathRules(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// RestrictPaths only. Do not call Restrict or RestrictNet: V4+ would
|
|
// deny TCP bind/connect unless every mesh port is allowlisted.
|
|
if err := landlock.V9.BestEffort().RestrictPaths(rules...); err != nil {
|
|
return fmt.Errorf("landlock restrict paths: %w", err)
|
|
}
|
|
|
|
// V6+ scopes abstract UNIX sockets and signals toward more privileged
|
|
// domains. Pathname UNIX sockets (session bus, journald) need
|
|
// WithResolveUnix on their path trees instead. GUI hosts skip this
|
|
// because WebKitGTK helpers use abstract sockets and signals.
|
|
if shouldRestrictScoped(cfg) {
|
|
if err := landlock.V9.BestEffort().RestrictScoped(); err != nil {
|
|
return fmt.Errorf("landlock restrict scoped: %w", err)
|
|
}
|
|
}
|
|
|
|
debug.Log(debug.DebugInfo, "Landlock sandbox applied", "abi", "V9")
|
|
return nil
|
|
}
|
|
|
|
func shouldRestrictScoped(cfg *common.ReticulumConfig) bool {
|
|
return cfg == nil || !cfg.SandboxSkipScoped
|
|
}
|
|
|
|
func probeLandlock() error {
|
|
_, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET,
|
|
0, 0, unix.LANDLOCK_CREATE_RULESET_VERSION)
|
|
if errno == unix.ENOSYS {
|
|
return fmt.Errorf("landlock not supported by kernel")
|
|
}
|
|
if errno == unix.EOPNOTSUPP {
|
|
return fmt.Errorf("landlock is currently disabled")
|
|
}
|
|
if errno != 0 {
|
|
return fmt.Errorf("landlock_create_ruleset version check: %w", errno)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func landlockPathRules(cfg *common.ReticulumConfig) ([]landlock.Rule, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
homeCfg := filepath.Join(home, ".reticulum-go")
|
|
|
|
rwDirs := []string{homeCfg, "/tmp", "/var/tmp"}
|
|
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
|
|
if runtimeDir == "" {
|
|
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
|
|
}
|
|
// ResolveUnix covers pathname session bus under XDG_RUNTIME_DIR (V9).
|
|
runtimeRule := landlock.RWDirs(runtimeDir).WithResolveUnix().IgnoreIfMissing()
|
|
|
|
if cfg != nil && cfg.ConfigPath != "" {
|
|
parent := filepath.Dir(cfg.ConfigPath)
|
|
if parent != "" && parent != "." && parent != homeCfg {
|
|
rwDirs = append(rwDirs, parent)
|
|
}
|
|
}
|
|
if cfg != nil && cfg.LogFile != "" {
|
|
logParent := filepath.Dir(cfg.LogFile)
|
|
if logParent != "" && logParent != "." {
|
|
rwDirs = append(rwDirs, logParent)
|
|
}
|
|
}
|
|
|
|
rules := []landlock.Rule{
|
|
landlock.RWDirs(rwDirs...).IgnoreIfMissing(),
|
|
runtimeRule,
|
|
// Journald pathname socket (V9 resolve unix). Read-only tree.
|
|
landlock.RODirs("/run/systemd").WithResolveUnix().IgnoreIfMissing(),
|
|
landlock.ROFiles(
|
|
"/etc/resolv.conf",
|
|
"/etc/hosts",
|
|
"/etc/ssl/cert.pem",
|
|
"/dev/null",
|
|
"/dev/urandom",
|
|
"/etc/localtime",
|
|
"/etc/protocols",
|
|
"/etc/services",
|
|
).IgnoreIfMissing(),
|
|
// Syslog pathname socket (often a symlink into /run).
|
|
landlock.ROFiles("/dev/log").WithResolveUnix().IgnoreIfMissing(),
|
|
landlock.RODirs(
|
|
"/etc/ssl/certs",
|
|
"/proc/self",
|
|
"/lib",
|
|
"/lib64",
|
|
"/usr/lib",
|
|
).IgnoreIfMissing(),
|
|
}
|
|
if !isRouterProfile(cfg) {
|
|
rules = append(rules, landlock.RODirs(
|
|
"/bin",
|
|
"/usr/bin",
|
|
"/usr/local/bin",
|
|
).IgnoreIfMissing())
|
|
}
|
|
rules = append(rules, extraLandlockRules(cfg)...)
|
|
return rules, nil
|
|
}
|
|
|
|
func extraLandlockRules(cfg *common.ReticulumConfig) []landlock.Rule {
|
|
var rules []landlock.Rule
|
|
for _, p := range collectExtraPaths(cfg) {
|
|
switch p.kind {
|
|
case pathRWDir:
|
|
rules = append(rules, landlock.RWDirs(p.path).IgnoreIfMissing())
|
|
case pathRODir:
|
|
rules = append(rules, landlock.RODirs(p.path).IgnoreIfMissing())
|
|
case pathROFile:
|
|
rules = append(rules, landlock.ROFiles(p.path).IgnoreIfMissing())
|
|
default:
|
|
rules = append(rules, landlock.RWFiles(p.path).WithIoctlDev().IgnoreIfMissing())
|
|
}
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func dropAllCapabilities() error {
|
|
lastCap, err := readCapLastCap()
|
|
if err != nil {
|
|
lastCap = 40
|
|
}
|
|
|
|
var dropped int
|
|
for capIdx := 0; capIdx <= lastCap; capIdx++ {
|
|
err := unix.Prctl(unix.PR_CAPBSET_DROP, uintptr(capIdx), 0, 0, 0)
|
|
if err == nil {
|
|
dropped++
|
|
}
|
|
}
|
|
|
|
if dropped == 0 && lastCap > 0 {
|
|
return fmt.Errorf("no capabilities dropped")
|
|
}
|
|
debug.Log(debug.DebugInfo, "Capabilities dropped", "count", dropped)
|
|
return nil
|
|
}
|
|
|
|
func readCapLastCap() (int, error) {
|
|
data, err := os.ReadFile("/proc/sys/kernel/cap_last_cap")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
v, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func setResourceLimits() error {
|
|
const maxFDs = 65536
|
|
if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &unix.Rlimit{Cur: maxFDs, Max: maxFDs}); err != nil {
|
|
debug.Log(debug.DebugError, "RLIMIT_NOFILE failed", "error", err)
|
|
}
|
|
|
|
// Do not set RLIMIT_AS. A 2GiB address-space cap aborts Go under normal
|
|
// mesh load (runtime: out of memory / unknown pc during GC).
|
|
|
|
if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{Cur: 0, Max: 0}); err != nil {
|
|
debug.Log(debug.DebugError, "RLIMIT_CORE failed", "error", err)
|
|
}
|
|
|
|
const stackLimit = 8 << 20 // 8 MiB
|
|
if err := unix.Setrlimit(unix.RLIMIT_STACK, &unix.Rlimit{Cur: stackLimit, Max: unix.RLIM_INFINITY}); err != nil {
|
|
debug.Log(debug.DebugError, "RLIMIT_STACK failed", "error", err)
|
|
}
|
|
|
|
const procLimit = 65536
|
|
if err := unix.Setrlimit(unix.RLIMIT_NPROC, &unix.Rlimit{Cur: procLimit, Max: procLimit}); err != nil {
|
|
debug.Log(debug.DebugError, "RLIMIT_NPROC failed", "error", err)
|
|
}
|
|
|
|
// Raise soft MEMLOCK so identity securemem pages can mlock (a few KB).
|
|
var memlock unix.Rlimit
|
|
if err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &memlock); err == nil {
|
|
const want = 64 << 10 // 64 KiB
|
|
if memlock.Cur < want {
|
|
memlock.Cur = want
|
|
if memlock.Max < want && memlock.Max != unix.RLIM_INFINITY {
|
|
memlock.Max = want
|
|
}
|
|
if err := unix.Setrlimit(unix.RLIMIT_MEMLOCK, &memlock); err != nil {
|
|
debug.Log(debug.DebugVerbose, "RLIMIT_MEMLOCK raise failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|