Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions docs/security/general.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,13 @@ that limits which files and network ports a process can access.
After parsing flags and loading certificates, Ghostunnel builds a minimal set
of Landlock rules based on the flags it was given:

- **File access**: Read-only access to certificate files, CA bundles, and OPA
policy bundles (and their parent directories, to support file rotation).
Read-write access to `/dev`, `/var/run`, `/tmp`, `/proc` for syslog and temp files.
- **Network access**: Bind access for `--listen` and `--status` ports. Connect
access for `--target`, `--metrics-graphite`, `--metrics-url`, and SPIFFE
Workload API ports. DNS (TCP/53) is always allowed.
- **File access**: Read-only access to files referenced by flags (certificates,
CA bundles, OPA policy bundles) including their parent directories so file
rotation works. Read-write access to a small set of system paths needed for
syslog, temporary files, and Go runtime state.
- **Network access**: Bind access for listening ports and connect access for
upstream targets, metrics endpoints, and other outbound destinations derived
from the configured flags. DNS resolution is always allowed.

### Best-Effort Mode

Expand Down
191 changes: 166 additions & 25 deletions landlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
Expand All @@ -33,27 +34,43 @@ import (

type portRuleFunc = func(port uint16) landlock.NetRule

var defaultReadWritePaths = []string{
"/dev", // /dev/log syslog socket, /dev/urandom, /dev/null
"/run", // sd_notify socket, journald socket, runtime state
"/var/run", // legacy alias of /run; some syslog daemons listen here
"/proc", // Go runtime: /proc/self/* for GC and scheduler
"/tmp", // temporary files (e.g. spooled writes, Go runtime)
}

var defaultReadOnlyPaths = []string{
"/sys", // Go runtime: cgroup info for GOMAXPROCS, CPU topology
"/etc", // DNS config, hosts, nsswitch, localtime, distro CA bundles
"/usr/share/zoneinfo", // tzdata, referenced via /etc/localtime symlink
"/usr/share/ca-certificates", // Debian/Ubuntu CA cert source files
"/usr/local/share/ca-certificates", // locally-installed CA certs (Debian/Ubuntu)
"/var/lib/ca-certificates", // openSUSE/SLES CA bundle location
}

// setupLandlock processes flags given to the process and generates an
// appropriate landlock rule configuration to limit our privileges.
func setupLandlock() error {
fsRules := []landlock.Rule{}
netRules := []landlock.Rule{}

// Extra RW paths registered by init() hooks (e.g. GOCOVERDIR for
// coverage-instrumented builds).
for _, path := range extraRWPaths {
fsRules = append(fsRules, landlock.RWDirs(path))
}

// Default net rules
netRules := []landlock.Rule{
// For DNS over TCP/53 (sometimes enabled for name resolution)
landlock.ConnectTCP(uint16(53)),
}
// DNS over TCP as a fallback path for Go's resolver when a UDP response
// is truncated. UDP DNS is not gated by landlock net rules.
netRules = append(netRules, landlock.ConnectTCP(53))

// Default RW FS rules. Some paths we need always accessible for syslog and
// for creating runtime/temporary files. Note that syslog can be in multiple
// places not just /dev/log, e.g. /var/run is an option.
for _, path := range []string{"/dev", "/var/run", "/tmp", "/proc"} {
for _, path := range defaultReadWritePaths {
fsRules = append(fsRules, landlock.RWDirs(path).IgnoreIfMissing())
}

Expand All @@ -62,10 +79,43 @@ func setupLandlock() error {
// that we could have chosen to limit ourselves to specific files (e.g.
// /etc/nsswitch.conf, /etc/gai.conf), but it's difficult to enumerate the
// exact set of files required in every conceivable situation.
for _, path := range []string{"/etc", "/usr/share/zoneinfo"} {
for _, path := range defaultReadOnlyPaths {
fsRules = append(fsRules, landlock.RODirs(path).IgnoreIfMissing())
}

// When ACME is enabled, certmagic persists certificates and keys to
// $XDG_DATA_HOME/certmagic (defaulting to $HOME/.local/share/certmagic).
// Grant RW on the parent dir so certmagic can create and populate the
// certmagic/ subdirectory on first use. The path is per-user so it's not
// safe to add unconditionally. Outbound access to the ACME CA URL (for
// directory/order/finalize and cert downloads) is granted via the
// ConnectTCP loop below using the configured --auto-acme-ca /
// --auto-acme-testca URLs, falling back to Let's Encrypt on tcp/443 when
// neither is set. TLS-ALPN-01 challenge traffic is inbound on the
// listener and is already covered by its BindTCP rule.
if serverAutoACMEFQDN != nil && *serverAutoACMEFQDN != "" {
fsRules = append(fsRules, landlock.RWDirs(filepath.Dir(certmagicDataDir())).IgnoreIfMissing())
if (serverAutoACMEProdCA == nil || *serverAutoACMEProdCA == "") &&
(serverAutoACMETestCA == nil || *serverAutoACMETestCA == "") {
netRules = append(netRules, landlock.ConnectTCP(443))
}
}

// SSL_CERT_FILE and SSL_CERT_DIR override Go's compiled-in CA bundle search
// paths (see crypto/x509/root_unix.go), so if an operator has set these the
// default RO paths above won't cover what Go actually reads.
if f := os.Getenv("SSL_CERT_FILE"); f != "" {
fsRules = append(fsRules, rulesFromFile(f)...)
}
if d := os.Getenv("SSL_CERT_DIR"); d != "" {
for _, dir := range strings.Split(d, ":") {
if dir == "" {
continue
}
fsRules = append(fsRules, rulesFromCertDir(dir)...)
}
}

// Process string flags containing addresses or URLs.
for _, addr := range []*string{
serverListenAddress,
Expand All @@ -91,6 +141,8 @@ func setupLandlock() error {
clientForwardAddress,
useWorkloadAPIAddr,
metricsURL,
serverAutoACMEProdCA,
serverAutoACMETestCA,
} {
if addr == nil || len(*addr) == 0 {
continue
Expand All @@ -105,9 +157,7 @@ func setupLandlock() error {
}
}

// Process string flags containing file paths. Since we need to able to
// reload these files even after the file was changed/rewritten, we need to
// add a RO rule on the entire parent directory.
// Process string flags containing file paths.
for _, path := range []*string{
serverAllowPolicy,
clientAllowPolicy,
Expand All @@ -119,18 +169,7 @@ func setupLandlock() error {
if path == nil || len(*path) == 0 {
continue
}

// Note: If one of these args is a symlink, we also need to add a rule for
// the target of the symlink.
fsRules = append(fsRules, landlock.RODirs(filepath.Dir(*path)).IgnoreIfMissing())

target, err := filepath.EvalSymlinks(*path)
if err != nil {
continue
}
if target != *path {
fsRules = append(fsRules, landlock.RODirs(filepath.Dir(target)).IgnoreIfMissing())
}
fsRules = append(fsRules, rulesFromFile(*path)...)
}

// Process net.TCPAddr flags.
Expand All @@ -148,6 +187,19 @@ func setupLandlock() error {
}
}

// Outbound HTTP from Go's net/http transport (certmagic ACME calls)
// honors the HTTP_PROXY / HTTPS_PROXY env vars at request time. If
// they're set, allow outbound connect to the proxy.
for _, u := range proxyURLsFromEnv() {
rule, err := ruleFromURL(u, landlock.ConnectTCP)
if err != nil {
return fmt.Errorf("error processing proxy env URL '%s' for landlock rule: %w", u, err)
}
if rule != nil {
netRules = append(netRules, rule)
}
}

// Process url.URL flags.
for _, url := range []**url.URL{clientProxy} {
if url == nil || *url == nil {
Expand Down Expand Up @@ -182,6 +234,10 @@ func setupLandlock() error {
return config.RestrictNet(netRules...)
}

// ruleFromStringAddress turns a flag-supplied address string into a landlock
// rule. Handles unix sockets (RW on the socket path), systemd/launchd socket
// activation (no rule needed; the FD is inherited), HTTP/HTTPS URLs, and
// host:port forms. ruleFromPort selects bind vs connect semantics.
func ruleFromStringAddress(addr string, ruleFromPort portRuleFunc) (landlock.Rule, error) {
if strings.HasPrefix(addr, "unix:") {
return landlock.RWFiles(addr[5:]), nil
Expand Down Expand Up @@ -211,21 +267,29 @@ func ruleFromStringAddress(addr string, ruleFromPort portRuleFunc) (landlock.Rul
return ruleFromPort(uint16(port)), nil
}

// ruleFromTCPAddress turns a *net.TCPAddr (from already-parsed flags like
// --metrics-graphite) into a port-based landlock rule. ruleFromPort selects
// bind vs connect semantics.
func ruleFromTCPAddress(addr *net.TCPAddr, ruleFromPort portRuleFunc) (landlock.Rule, error) {
if addr.Port == 0 {
return nil, errors.New("unable to extract port number from address")
}
return ruleFromPort(uint16(addr.Port)), nil
}

// ruleFromURL turns a *url.URL into a port-based landlock rule, defaulting
// the port by scheme when none is explicit: 80 for http, 443 for https,
// 1080 for socks5/socks5h. ruleFromPort selects bind vs connect semantics.
func ruleFromURL(u *url.URL, ruleFromPort portRuleFunc) (landlock.Rule, error) {
port := u.Port()
if len(port) == 0 {
if u.Scheme == "http" {
switch u.Scheme {
case "http":
return ruleFromPort(uint16(80)), nil
}
if u.Scheme == "https" {
case "https":
return ruleFromPort(uint16(443)), nil
case "socks5", "socks5h":
return ruleFromPort(uint16(1080)), nil
}
}
numericPort, err := strconv.ParseUint(port, 10, 16)
Expand All @@ -237,3 +301,80 @@ func ruleFromURL(u *url.URL, ruleFromPort portRuleFunc) (landlock.Rule, error) {
}
return ruleFromPort(uint16(numericPort)), nil
}

// proxyURLsFromEnv returns deduplicated proxy URLs that Go's net/http would
// honor via HTTP_PROXY / HTTPS_PROXY (and the lowercase variants) at request
// time. NO_PROXY is a bypass list, not a destination, so it needs no rule.
// Mirrors the fallback in golang.org/x/net/http/httpproxy: if the value isn't
// a URL with a recognized scheme, retry with "http://" prepended.
func proxyURLsFromEnv() []*url.URL {
var urls []*url.URL
seen := make(map[string]bool)
for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} {
v := os.Getenv(name)
if v == "" || seen[v] {
continue
}
seen[v] = true
u, err := url.Parse(v)
if err != nil || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https" &&
u.Scheme != "socks5" && u.Scheme != "socks5h") {
u, err = url.Parse("http://" + v)
if err != nil || u.Host == "" {
continue
}
}
urls = append(urls, u)
}
return urls
}

// rulesFromFile returns RO rules covering the parent directory of path, and
// (if path is a symlink) the parent directory of the symlink target. We grant
// the parent rather than the file itself so the file can be replaced
// atomically (write-new-then-rename) and reloaded without breaking the rule.
func rulesFromFile(path string) []landlock.Rule {
rules := []landlock.Rule{landlock.RODirs(filepath.Dir(path)).IgnoreIfMissing()}
if target, err := filepath.EvalSymlinks(path); err == nil && target != path {
rules = append(rules, landlock.RODirs(filepath.Dir(target)).IgnoreIfMissing())
}
return rules
}

// rulesFromCertDir returns an RO rule for dir, plus an RO rule for the parent
// directory of every readable entry whose symlinks resolve. Distros commonly
// populate cert directories (e.g. /etc/ssl/certs) with symlinks pointing into
// a vendor-specific bundle location, and Go's crypto/x509 follows those
// symlinks when scanning SSL_CERT_DIR — so we need rules covering the
// eventual targets too. Plain files and subdirs produce redundant rules,
// which landlock dedupes at the kernel level.
func rulesFromCertDir(dir string) []landlock.Rule {
rules := []landlock.Rule{landlock.RODirs(dir).IgnoreIfMissing()}
entries, err := os.ReadDir(dir)
if err != nil {
return rules
}
for _, entry := range entries {
target, err := filepath.EvalSymlinks(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
rules = append(rules, landlock.RODirs(filepath.Dir(target)).IgnoreIfMissing())
}
return rules
}

// certmagicDataDir mirrors the path resolution logic in certmagic's
// FileStorage so the landlock rule covers exactly what the library writes to.
// See vendor/github.com/caddyserver/certmagic/filestorage.go (dataDir).
func certmagicDataDir() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "certmagic")
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
home = "."
}
return filepath.Join(home, ".local", "share", "certmagic")
}
Loading
Loading