go-firewall/utils.go
2026-08-10 17:17:03 -05:00

238 lines
7.5 KiB
Go

package firewall
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
func trimQuotes(s string) string {
return strings.Trim(s, "\"'")
}
// stripUnquotedComment removes a trailing '#' comment from a config line,
// ignoring a '#' inside a quoted value so `KEY = "pre#fix"` is not truncated.
func stripUnquotedComment(line string) string {
var inQuote byte
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case inQuote != 0:
if c == inQuote {
inQuote = 0
}
case c == '"' || c == '\'':
inQuote = c
case c == '#':
return line[:i]
}
}
return line
}
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
// csf.conf) for key and returns its value, or "" if key is not set. The last
// assignment wins, matching how the shell sources these files. Used for
// one-shot flags read at construction time, not the per-rule list edits (which
// each backend's own EditConf/EditRulePort scan handles in place).
func readConfValue(path, key string) (string, error) {
fd, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = fd.Close() }()
value := ""
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
if line == "" {
continue
}
k, v, found := strings.Cut(line, "=")
if !found {
continue
}
if strings.TrimSpace(k) == key {
value = trimQuotes(strings.TrimSpace(v))
}
}
return value, scanner.Err()
}
// binSearchDirs are the directories searched for a firewall tool when PATH does
// not resolve it, covering the standard locations iptables, ip6tables, ipset, nft
// and the backend front-ends install into. The tools live in the sbin
// directories, and a process started from a service unit, a cron job or a login
// shell for an unprivileged user routinely gets a PATH without them.
var binSearchDirs = []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/local/bin"}
// resolvedBins memoizes successful lookups: every command run resolves its tool
// and an installed tool does not move while the process runs. A failed lookup is
// deliberately not cached, so a tool installed under a long-lived process is
// picked up on its next use.
var resolvedBins sync.Map
// resolveBinary returns the absolute path of a firewall tool and reports whether
// it was found. PATH wins; failing that the standard install directories are
// searched (binSearchDirs). A name that already carries a path separator is taken
// as given. An unresolved name is returned unchanged, leaving the caller to hand
// it to exec (for its own error) or to a shell.
func resolveBinary(name string) (string, bool) {
if name == "" {
return name, false
}
if strings.ContainsRune(name, os.PathSeparator) {
return name, true
}
if cached, ok := resolvedBins.Load(name); ok {
return cached.(string), true
}
if p, err := exec.LookPath(name); err == nil {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
resolvedBins.Store(name, p)
return p, true
}
// PATH missed it — the tool may still be installed where the tools live.
for _, dir := range binSearchDirs {
cand := filepath.Join(dir, name)
if fi, err := os.Stat(cand); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 {
resolvedBins.Store(name, cand)
return cand, true
}
}
return name, false
}
// runCommand runs command and returns its stdout and any error. The context
// bounds the command's lifetime: cancelling it kills the process.
func runCommand(ctx context.Context, command string, args ...string) (out []string, err error) {
return runCommandStdin(ctx, "", command, args...)
}
// runCommandStdin runs command with the provided string fed to stdin, returning its stdout and any error.
func runCommandStdin(ctx context.Context, stdin string, command string, args ...string) (out []string, err error) {
// Resolve the tool up front so a PATH without the sbin directories does not
// turn every backend call into an executable-not-found error.
bin, _ := resolveBinary(command)
cmd := exec.CommandContext(ctx, bin, args...)
// Force the C locale so the backend tools emit their canonical, English output.
// Several backends match tool output to drive control flow — ufw's "Invalid
// position"/"Could not delete non-existent rule" fallbacks, CSF/APF restart
// messages — and those strings are gettext-translated. Without a pinned locale a
// non-English host would break the idempotent-remove and insert-append fallbacks
// (leaving a rule removed-and-not-re-added, or a no-op remove turned into an
// error). LC_ALL wins over LANG/LC_* so appending it last is sufficient.
cmd.Env = append(os.Environ(), "LC_ALL=C")
// Feed stdin when provided.
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
// Get output pipes.
var stdout, stderr io.ReadCloser
stdout, err = cmd.StdoutPipe()
if err != nil {
return
}
stderr, err = cmd.StderrPipe()
if err != nil {
_ = stdout.Close()
return
}
// Start the command. Close the pipes on failure so their file descriptors do
// not leak; a started command's pipes are closed by Wait below.
err = cmd.Start()
if err != nil {
_ = stdout.Close()
_ = stderr.Close()
return
}
// Setup wait group to wait for buffers to fully read.
var wg sync.WaitGroup
wg.Add(2)
// The default bufio.Scanner token cap is 64 KB, but some backends emit a
// single very long line — notably `nft -j list sets`, whose entire JSON
// result is one line and can far exceed 64 KB for a large blocklist. Give
// each scanner a generous max so such a line is not silently truncated, and
// surface scanner.Err() so a line that still overflows fails loudly rather
// than returning partial output as success.
const maxLine = 64 * 1024 * 1024
var scanErr error
var scanMu sync.Mutex
recordScanErr := func(e error) {
if e == nil {
return
}
scanMu.Lock()
if scanErr == nil {
scanErr = e
}
scanMu.Unlock()
}
// Read stdout.
stdoutScanner := bufio.NewScanner(stdout)
stdoutScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
go func() {
for stdoutScanner.Scan() {
out = append(out, stdoutScanner.Text())
}
recordScanErr(stdoutScanner.Err())
wg.Done()
}()
// Read stderr.
var stderrData strings.Builder
stderrScanner := bufio.NewScanner(stderr)
stderrScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
go func() {
for stderrScanner.Scan() {
line := stderrScanner.Text()
stderrData.WriteString(line)
stderrData.WriteByte('\n')
}
recordScanErr(stderrScanner.Err())
wg.Done()
}()
// Wait for the stdout and stderr reader goroutines to drain before calling cmd.Wait.
wg.Wait()
// Wait for the command to finish.
err = cmd.Wait()
if err != nil {
// Keep the underlying error wrapped so its exit code stays reachable
// through errors.As, and only mention stdout when there is any.
stderrText := strings.TrimSpace(stderrData.String())
switch {
case stderrText != "" && len(out) > 0:
err = fmt.Errorf("%s (stdout %s): %w", stderrText, strings.Join(out, "\n"), err)
case stderrText != "":
err = fmt.Errorf("%s: %w", stderrText, err)
}
// A command can fail and also produce truncated output; surface both so a
// read error is never hidden behind the command's own failure.
if scanErr != nil {
err = fmt.Errorf("%v; scanner: %w", err, scanErr)
}
return
}
// The process succeeded; report a read/truncation error if one occurred so a
// caller never mistakes truncated output for a complete result.
if scanErr != nil {
err = scanErr
}
return
}