166 lines
4.7 KiB
Go
166 lines
4.7 KiB
Go
package firewall
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
func trimQuotes(s string) string {
|
|
return strings.Trim(s, "\"'")
|
|
}
|
|
|
|
// 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. 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() }()
|
|
scanner := bufio.NewScanner(fd)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
|
line = line[:ci]
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
k, v, found := strings.Cut(line, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(k) == key {
|
|
return trimQuotes(strings.TrimSpace(v)), nil
|
|
}
|
|
}
|
|
return "", scanner.Err()
|
|
}
|
|
|
|
// 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) {
|
|
cmd := exec.CommandContext(ctx, command, 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 {
|
|
stderr := strings.TrimSpace(stderrData.String())
|
|
if stderr != "" {
|
|
err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n"))
|
|
}
|
|
// 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
|
|
}
|