ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
2 changed files with 145336 additions and 0 deletions
627
services/oci-l4-relay-guest/cmd/smtp-deny/main.go
Normal file
627
services/oci-l4-relay-guest/cmd/smtp-deny/main.go
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tableName = "oci_l4_relay_smtp_deny"
|
||||
activePolicyStateFile = "active-state.json"
|
||||
maximumBundleBytes = 16 << 20
|
||||
absoluteMaxEntries = 100000
|
||||
absoluteMaxLife = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var transportReasons = map[string]struct{}{
|
||||
"connection-churn": {},
|
||||
"connection-rate": {},
|
||||
"syn-rate": {},
|
||||
}
|
||||
|
||||
type denyEntry struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reason string `json:"reason"`
|
||||
ObservationStarted time.Time `json:"observation_started"`
|
||||
ObservationEnded time.Time `json:"observation_ended"`
|
||||
ObservationCount uint64 `json:"observation_count"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type bundle struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DeniedIPv4 []denyEntry `json:"denied_ipv4"`
|
||||
DeniedIntervalCount int `json:"denied_interval_count"`
|
||||
DeniedAddressCount uint64 `json:"denied_address_count"`
|
||||
}
|
||||
|
||||
type parsedEntry struct {
|
||||
start uint32
|
||||
end uint32
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type validatedBundle struct {
|
||||
bundle
|
||||
entries []parsedEntry
|
||||
}
|
||||
|
||||
type activePolicyState struct {
|
||||
ActivatedAt time.Time `json:"activated_at"`
|
||||
Source string `json:"source"`
|
||||
BundleGeneratedAt time.Time `json:"bundle_generated_at,omitempty"`
|
||||
BundleExpiresAt time.Time `json:"bundle_expires_at,omitempty"`
|
||||
LastRemoteCurrentAt time.Time `json:"last_remote_current_at,omitempty"`
|
||||
}
|
||||
|
||||
type activationConfig struct {
|
||||
publicInterface string
|
||||
stateDirectory string
|
||||
nft string
|
||||
minimumObservations uint64
|
||||
maximumAddresses uint64
|
||||
maximumIntervals int
|
||||
maximumLifetime time.Duration
|
||||
maximumObservationWindow time.Duration
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fatal(errors.New("expected apply, empty, restore, validate, or render"))
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "apply":
|
||||
flags := flag.NewFlagSet("apply", flag.ExitOnError)
|
||||
configuration := activationConfig{}
|
||||
input := flags.String("input", "", "downloaded SMTP deny bundle")
|
||||
addActivationFlags(flags, &configuration)
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
if *input == "" {
|
||||
fatal(errors.New("input is required"))
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := activate(now, configuration, *input, true, "current"); err != nil {
|
||||
applyErr := err
|
||||
if restoreErr := restore(now, configuration); restoreErr != nil {
|
||||
if failOpenErr := failOpen(now, configuration); failOpenErr != nil {
|
||||
fatal(errors.Join(applyErr, restoreErr, failOpenErr))
|
||||
}
|
||||
fatal(errors.Join(applyErr, restoreErr))
|
||||
}
|
||||
fmt.Println("local_apply=false cache_restored=true")
|
||||
fatal(applyErr)
|
||||
}
|
||||
case "empty":
|
||||
flags := flag.NewFlagSet("empty", flag.ExitOnError)
|
||||
output := flags.String("output", "", "empty SMTP deny bundle output")
|
||||
lifetime := flags.Duration("lifetime", 24*time.Hour, "empty bundle lifetime")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
if *output == "" || *lifetime <= 0 || *lifetime > absoluteMaxLife {
|
||||
fatal(errors.New("output and a bounded lifetime are required"))
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
document := bundle{
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now,
|
||||
ExpiresAt: now.Add(*lifetime),
|
||||
DeniedIPv4: []denyEntry{},
|
||||
}
|
||||
if err := writeBundle(*output, document); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "restore":
|
||||
flags := flag.NewFlagSet("restore", flag.ExitOnError)
|
||||
configuration := activationConfig{}
|
||||
addActivationFlags(flags, &configuration)
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
now := time.Now().UTC()
|
||||
if err := restore(now, configuration); err != nil {
|
||||
if failOpenErr := failOpen(now, configuration); failOpenErr != nil {
|
||||
fatal(errors.Join(err, failOpenErr))
|
||||
}
|
||||
fatal(err)
|
||||
}
|
||||
case "validate", "render":
|
||||
flags := flag.NewFlagSet(os.Args[1], flag.ExitOnError)
|
||||
configuration := activationConfig{}
|
||||
input := flags.String("input", "", "SMTP deny bundle")
|
||||
previousInput := flags.String("previous-input", "", "previous SMTP deny bundle")
|
||||
summaryOutput := flags.String("summary-output", "", "sanitized validation summary")
|
||||
addActivationFlags(flags, &configuration)
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
now := time.Now().UTC()
|
||||
validated, err := readBundle(now, configuration, *input)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
removedIntervals := 0
|
||||
if *previousInput != "" {
|
||||
previous, err := readBundle(now, configuration, *previousInput)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
removedIntervals = removedIntervalCount(previous, validated)
|
||||
}
|
||||
if *summaryOutput != "" {
|
||||
summary := struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DeniedIntervalCount int `json:"denied_interval_count"`
|
||||
DeniedAddressCount uint64 `json:"denied_address_count"`
|
||||
RemovedIntervalCount int `json:"removed_interval_count"`
|
||||
}{
|
||||
GeneratedAt: validated.GeneratedAt,
|
||||
ExpiresAt: validated.ExpiresAt,
|
||||
DeniedIntervalCount: validated.DeniedIntervalCount,
|
||||
DeniedAddressCount: validated.DeniedAddressCount,
|
||||
RemovedIntervalCount: removedIntervals,
|
||||
}
|
||||
if err := writeJSONFile(*summaryOutput, summary); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
if os.Args[1] == "render" {
|
||||
if err := render(os.Stdout, now, validated, configuration.publicInterface); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
fatal(fmt.Errorf("unknown command %q", os.Args[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func writeBundle(path string, document bundle) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".bundle-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer func() { _ = os.Remove(name) }()
|
||||
writer := io.Writer(temporary)
|
||||
var compressed *gzip.Writer
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
compressed = gzip.NewWriter(temporary)
|
||||
compressed.ModTime = time.Time{}
|
||||
writer = compressed
|
||||
}
|
||||
encoder := json.NewEncoder(writer)
|
||||
if err := encoder.Encode(document); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if compressed != nil {
|
||||
if err := compressed.Close(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := temporary.Chmod(0600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
contents, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(contents, '\n'), 0644)
|
||||
}
|
||||
|
||||
func removedIntervalCount(previous, current validatedBundle) int {
|
||||
retained := make(map[[2]uint32]struct{}, len(current.entries))
|
||||
for _, entry := range current.entries {
|
||||
retained[[2]uint32{entry.start, entry.end}] = struct{}{}
|
||||
}
|
||||
removed := 0
|
||||
for _, entry := range previous.entries {
|
||||
if _, exists := retained[[2]uint32{entry.start, entry.end}]; !exists {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func addActivationFlags(flags *flag.FlagSet, configuration *activationConfig) {
|
||||
flags.StringVar(&configuration.publicInterface, "public-interface", "", "public network interface")
|
||||
flags.StringVar(&configuration.stateDirectory, "state-directory", "/var/lib/oci-l4-relay/smtp-deny", "state directory")
|
||||
flags.StringVar(&configuration.nft, "nft", "/usr/sbin/nft", "nft path")
|
||||
flags.Uint64Var(&configuration.minimumObservations, "minimum-observations", 1, "minimum observations for each entry")
|
||||
flags.Uint64Var(&configuration.maximumAddresses, "maximum-addresses", 1, "maximum covered IPv4 addresses")
|
||||
flags.IntVar(&configuration.maximumIntervals, "maximum-intervals", 1, "maximum IPv4 intervals")
|
||||
flags.DurationVar(&configuration.maximumLifetime, "maximum-lifetime", 24*time.Hour, "maximum entry and bundle lifetime")
|
||||
flags.DurationVar(&configuration.maximumObservationWindow, "maximum-observation-window", time.Hour, "maximum observation window")
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func activate(now time.Time, configuration activationConfig, bundlePath string, persist bool, source string) error {
|
||||
if err := writeActivePolicyState(configuration.stateDirectory, now, "transition", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
validated, err := readBundle(now, configuration, bundlePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryDirectory, err := os.MkdirTemp(configuration.stateDirectory, ".activate-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(temporaryDirectory) }()
|
||||
rulesetPath := filepath.Join(temporaryDirectory, "smtp-deny.nft")
|
||||
ruleset, err := os.OpenFile(rulesetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := render(ruleset, now, validated, configuration.publicInterface); err != nil {
|
||||
_ = ruleset.Close()
|
||||
return err
|
||||
}
|
||||
if err := ruleset.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTable(configuration.nft); err != nil {
|
||||
return err
|
||||
}
|
||||
if output, err := exec.Command(configuration.nft, "--check", "--file", rulesetPath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("validate rendered ruleset: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if output, err := exec.Command(configuration.nft, "--file", rulesetPath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("load rendered ruleset: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if persist {
|
||||
if err := replaceFile(bundlePath, filepath.Join(configuration.stateDirectory, "last-good.json.gz")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeActivePolicyState(configuration.stateDirectory, now, source, &validated); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("denied_intervals=%d denied_addresses=%d expires_at=%s\n", len(validated.entries), validated.DeniedAddressCount, validated.ExpiresAt.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
func restore(now time.Time, configuration activationConfig) error {
|
||||
if configuration.publicInterface == "" {
|
||||
return errors.New("public-interface is required")
|
||||
}
|
||||
return activate(now, configuration, filepath.Join(configuration.stateDirectory, "last-good.json.gz"), false, "cache")
|
||||
}
|
||||
|
||||
func failOpen(now time.Time, configuration activationConfig) error {
|
||||
if err := writeActivePolicyState(configuration.stateDirectory, now, "transition", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTable(configuration.nft); err != nil {
|
||||
return err
|
||||
}
|
||||
ruleset, err := os.CreateTemp("", "oci-l4-relay-smtp-deny-fail-open-*.nft")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := ruleset.Name()
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
if err := renderFailOpen(ruleset, configuration.publicInterface); err != nil {
|
||||
_ = ruleset.Close()
|
||||
return err
|
||||
}
|
||||
if err := ruleset.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if output, err := exec.Command(configuration.nft, "--check", "--file", path).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("validate fail-open ruleset: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if output, err := exec.Command(configuration.nft, "--file", path).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("load fail-open ruleset: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if err := writeActivePolicyState(configuration.stateDirectory, now, "fail-open", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("smtp_deny_fail_open=true")
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeActivePolicyState(directory string, now time.Time, source string, active *validatedBundle) error {
|
||||
state := activePolicyState{ActivatedAt: now, Source: source}
|
||||
path := filepath.Join(directory, activePolicyStateFile)
|
||||
if previous, err := readActivePolicyState(path); err == nil {
|
||||
state.LastRemoteCurrentAt = previous.LastRemoteCurrentAt
|
||||
}
|
||||
if active != nil {
|
||||
state.BundleGeneratedAt = active.GeneratedAt
|
||||
state.BundleExpiresAt = active.ExpiresAt
|
||||
}
|
||||
if source == "current" {
|
||||
state.LastRemoteCurrentAt = now
|
||||
}
|
||||
contents, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".active-state-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer func() { _ = os.Remove(temporaryPath) }()
|
||||
if err := temporary.Chmod(0600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(append(contents, '\n')); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, path)
|
||||
}
|
||||
|
||||
func readActivePolicyState(path string) (activePolicyState, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return activePolicyState{}, err
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(contents)))
|
||||
decoder.DisallowUnknownFields()
|
||||
state := activePolicyState{}
|
||||
if err := decoder.Decode(&state); err != nil {
|
||||
return activePolicyState{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return activePolicyState{}, errors.New("active policy state contains trailing data")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func ensureTable(nft string) error {
|
||||
if err := exec.Command(nft, "list", "table", "inet", tableName).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if output, err := exec.Command(nft, "add", "table", "inet", tableName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("create SMTP deny table: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceFile(source, destination string) error {
|
||||
contents, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(destination), ".last-good-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer func() { _ = os.Remove(name) }()
|
||||
if err := temporary.Chmod(0600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, destination)
|
||||
}
|
||||
|
||||
func readBundle(now time.Time, configuration activationConfig, path string) (validatedBundle, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return validatedBundle{}, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
reader := io.LimitReader(file, maximumBundleBytes+1)
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
compressed, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return validatedBundle{}, fmt.Errorf("open bundle gzip: %w", err)
|
||||
}
|
||||
defer func() { _ = compressed.Close() }()
|
||||
reader = io.LimitReader(compressed, maximumBundleBytes+1)
|
||||
}
|
||||
decoder := json.NewDecoder(reader)
|
||||
decoder.DisallowUnknownFields()
|
||||
document := bundle{}
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return validatedBundle{}, fmt.Errorf("decode bundle: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return validatedBundle{}, errors.New("bundle contains trailing data")
|
||||
}
|
||||
return validateBundle(now, configuration, document)
|
||||
}
|
||||
|
||||
func validateBundle(now time.Time, configuration activationConfig, document bundle) (validatedBundle, error) {
|
||||
if configuration.minimumObservations == 0 ||
|
||||
configuration.maximumAddresses == 0 ||
|
||||
configuration.maximumIntervals < 0 ||
|
||||
configuration.maximumIntervals > absoluteMaxEntries ||
|
||||
configuration.maximumLifetime <= 0 ||
|
||||
configuration.maximumLifetime > absoluteMaxLife ||
|
||||
configuration.maximumObservationWindow <= 0 ||
|
||||
configuration.maximumObservationWindow > configuration.maximumLifetime {
|
||||
return validatedBundle{}, errors.New("invalid policy bounds")
|
||||
}
|
||||
if document.SchemaVersion != 1 {
|
||||
return validatedBundle{}, fmt.Errorf("unsupported schema version %d", document.SchemaVersion)
|
||||
}
|
||||
if document.GeneratedAt.After(now.Add(5 * time.Minute)) {
|
||||
return validatedBundle{}, errors.New("bundle generation time is in the future")
|
||||
}
|
||||
if !document.ExpiresAt.After(now) ||
|
||||
!document.ExpiresAt.After(document.GeneratedAt) ||
|
||||
document.ExpiresAt.Sub(document.GeneratedAt) > configuration.maximumLifetime ||
|
||||
document.GeneratedAt.Before(now.Add(-configuration.maximumLifetime)) {
|
||||
return validatedBundle{}, errors.New("bundle lifetime or generation age exceeds bounds")
|
||||
}
|
||||
if len(document.DeniedIPv4) > configuration.maximumIntervals {
|
||||
return validatedBundle{}, errors.New("denied interval count exceeds configured bound")
|
||||
}
|
||||
entries := make([]parsedEntry, 0, len(document.DeniedIPv4))
|
||||
var addressCount uint64
|
||||
var previousEnd uint32
|
||||
for index, entry := range document.DeniedIPv4 {
|
||||
start, err := parseIPv4(entry.Start)
|
||||
if err != nil {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d start: %w", index, err)
|
||||
}
|
||||
end, err := parseIPv4(entry.End)
|
||||
if err != nil {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d end: %w", index, err)
|
||||
}
|
||||
if start > end {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d is reversed", index)
|
||||
}
|
||||
if index > 0 && start <= previousEnd {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d overlaps or is unsorted", index)
|
||||
}
|
||||
if _, exists := transportReasons[entry.Reason]; !exists {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d has invalid transport reason", index)
|
||||
}
|
||||
if entry.ObservationCount < configuration.minimumObservations {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d is below the observation threshold", index)
|
||||
}
|
||||
if entry.ObservationStarted.After(entry.ObservationEnded) ||
|
||||
entry.ObservationEnded.After(document.GeneratedAt.Add(5*time.Minute)) ||
|
||||
entry.ObservationEnded.Sub(entry.ObservationStarted) > configuration.maximumObservationWindow {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d has invalid observation window", index)
|
||||
}
|
||||
if !entry.ExpiresAt.After(entry.ObservationEnded) ||
|
||||
entry.ExpiresAt.After(document.ExpiresAt) ||
|
||||
entry.ExpiresAt.Sub(entry.ObservationEnded) > configuration.maximumLifetime {
|
||||
return validatedBundle{}, fmt.Errorf("entry %d has invalid expiry", index)
|
||||
}
|
||||
addressCount += uint64(end) - uint64(start) + 1
|
||||
if addressCount > configuration.maximumAddresses {
|
||||
return validatedBundle{}, errors.New("denied address count exceeds configured bound")
|
||||
}
|
||||
entries = append(entries, parsedEntry{start: start, end: end, expiresAt: entry.ExpiresAt})
|
||||
previousEnd = end
|
||||
}
|
||||
if document.DeniedIntervalCount != len(entries) || document.DeniedAddressCount != addressCount {
|
||||
return validatedBundle{}, errors.New("denied statistics do not match payload")
|
||||
}
|
||||
return validatedBundle{bundle: document, entries: entries}, nil
|
||||
}
|
||||
|
||||
func parseIPv4(value string) (uint32, error) {
|
||||
address, err := netip.ParseAddr(value)
|
||||
if err != nil || !address.Is4() || address.String() != value {
|
||||
return 0, fmt.Errorf("invalid canonical IPv4 address %q", value)
|
||||
}
|
||||
bytes := address.As4()
|
||||
return uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3]), nil
|
||||
}
|
||||
|
||||
func ipv4String(value uint32) string {
|
||||
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}).String()
|
||||
}
|
||||
|
||||
func render(output io.Writer, now time.Time, document validatedBundle, publicInterface string) error {
|
||||
if err := validateInterface(publicInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
writer := bufio.NewWriter(output)
|
||||
if _, err := fmt.Fprintf(writer, "delete table inet %s\ntable inet %s {\n", tableName, tableName); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(writer, " set denied_v4 {\n type ipv4_addr\n flags interval, timeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
active := make([]parsedEntry, 0, len(document.entries))
|
||||
for _, entry := range document.entries {
|
||||
if entry.expiresAt.Sub(now) >= time.Second {
|
||||
active = append(active, entry)
|
||||
}
|
||||
}
|
||||
if len(active) > 0 {
|
||||
if _, err := fmt.Fprintln(writer, " elements = {"); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, entry := range active {
|
||||
address := ipv4String(entry.start)
|
||||
if entry.start != entry.end {
|
||||
address += "-" + ipv4String(entry.end)
|
||||
}
|
||||
separator := ","
|
||||
if index == len(active)-1 {
|
||||
separator = ""
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, " %s timeout %ds%s\n", address, int64(entry.expiresAt.Sub(now)/time.Second), separator); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(writer, " }"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, " }\n counter smtp_denied {}\n chain ingress {\n type filter hook prerouting priority raw - 19; policy accept;\n iifname %s tcp dport 25 ip saddr @denied_v4 counter name smtp_denied drop\n }\n}\n", strconv.Quote(publicInterface)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func renderFailOpen(output io.Writer, publicInterface string) error {
|
||||
if err := validateInterface(publicInterface); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(output, "delete table inet %s\ntable inet %s {\n set denied_v4 {\n type ipv4_addr\n flags interval, timeout\n }\n counter smtp_denied {}\n chain ingress {\n type filter hook prerouting priority raw - 19; policy accept;\n iifname %s tcp dport 25 ip saddr @denied_v4 counter name smtp_denied drop\n }\n}\n", tableName, tableName, strconv.Quote(publicInterface))
|
||||
return err
|
||||
}
|
||||
|
||||
func validateInterface(value string) error {
|
||||
if value == "" || strings.ContainsAny(value, "\"\\\n\r\t ") {
|
||||
return errors.New("invalid public interface")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
400
services/oci-l4-relay-guest/cmd/smtp-deny/main_test.go
Normal file
400
services/oci-l4-relay-guest/cmd/smtp-deny/main_test.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func policy() activationConfig {
|
||||
return activationConfig{
|
||||
publicInterface: "ens3",
|
||||
minimumObservations: 20,
|
||||
maximumAddresses: 4096,
|
||||
maximumIntervals: 1024,
|
||||
maximumLifetime: 24 * time.Hour,
|
||||
maximumObservationWindow: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func validBundle(now time.Time) bundle {
|
||||
return bundle{
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
DeniedIPv4: []denyEntry{{
|
||||
Start: "192.0.2.1",
|
||||
End: "192.0.2.1",
|
||||
Reason: "connection-rate",
|
||||
ObservationStarted: now.Add(-10 * time.Minute),
|
||||
ObservationEnded: now,
|
||||
ObservationCount: 20,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
}},
|
||||
DeniedIntervalCount: 1,
|
||||
DeniedAddressCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndReadEmptyBundle(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
path := filepath.Join(t.TempDir(), "empty", "current.json.gz")
|
||||
document := bundle{
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: now,
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
DeniedIPv4: []denyEntry{},
|
||||
}
|
||||
if err := writeBundle(path, document); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validated, err := readBundle(now, policy(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validated.DeniedIntervalCount != 0 || validated.DeniedAddressCount != 0 || validated.DeniedIPv4 == nil {
|
||||
t.Fatal("empty bundle did not retain an explicit empty deny list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedIntervalCount(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
previous, err := validateBundle(now, policy(), validBundle(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentDocument := validBundle(now)
|
||||
currentDocument.DeniedIPv4 = []denyEntry{}
|
||||
currentDocument.DeniedIntervalCount = 0
|
||||
currentDocument.DeniedAddressCount = 0
|
||||
current, err := validateBundle(now, policy(), currentDocument)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removedIntervalCount(previous, current) != 1 {
|
||||
t.Fatal("omitted range was not recorded as removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONFileCreatesParentDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit", "summary.json")
|
||||
if err := writeJSONFile(path, map[string]int{"removed_interval_count": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != "{\"removed_interval_count\":1}\n" {
|
||||
t.Fatalf("unexpected summary %q", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteActivePolicyStatePreservesRemoteCurrentActivation(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
||||
directory := t.TempDir()
|
||||
validated, err := validateBundle(now, policy(), validBundle(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeActivePolicyState(directory, now, "current", &validated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cacheActivation := now.Add(time.Minute)
|
||||
if err := writeActivePolicyState(directory, cacheActivation, "cache", &validated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := readActivePolicyState(filepath.Join(directory, activePolicyStateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Source != "cache" || !state.ActivatedAt.Equal(cacheActivation) || !state.LastRemoteCurrentAt.Equal(now) {
|
||||
t.Fatalf("unexpected active state %#v", state)
|
||||
}
|
||||
if !state.BundleGeneratedAt.Equal(validated.GeneratedAt) || !state.BundleExpiresAt.Equal(validated.ExpiresAt) {
|
||||
t.Fatalf("unexpected bundle state %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteActivePolicyStateRecordsFailOpen(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
||||
directory := t.TempDir()
|
||||
if err := writeActivePolicyState(directory, now, "fail-open", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := readActivePolicyState(filepath.Join(directory, activePolicyStateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Source != "fail-open" || !state.BundleGeneratedAt.IsZero() || !state.BundleExpiresAt.IsZero() {
|
||||
t.Fatalf("unexpected fail-open state %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailOpenRecordsFinalStateAfterRulesLoad(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
||||
directory := t.TempDir()
|
||||
observedPath := filepath.Join(directory, "observed-state.json")
|
||||
nftPath := filepath.Join(directory, "nft")
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--file\" ]; then cp \"$STATE_PATH\" \"$OBSERVED_PATH\"; exit \"${NFT_LOAD_EXIT:-0}\"; fi\nexit 0\n"
|
||||
if err := os.WriteFile(nftPath, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("STATE_PATH", filepath.Join(directory, activePolicyStateFile))
|
||||
t.Setenv("OBSERVED_PATH", observedPath)
|
||||
configuration := policy()
|
||||
configuration.stateDirectory = directory
|
||||
configuration.nft = nftPath
|
||||
|
||||
if err := failOpen(now, configuration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observed, err := readActivePolicyState(observedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observed.Source != "transition" {
|
||||
t.Fatalf("rules loaded after source %q was reported", observed.Source)
|
||||
}
|
||||
final, err := readActivePolicyState(filepath.Join(directory, activePolicyStateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final.Source != "fail-open" {
|
||||
t.Fatalf("unexpected final source %q", final.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationRecordsFinalStateAfterRulesLoad(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
||||
directory := t.TempDir()
|
||||
bundlePath := filepath.Join(directory, "current.json.gz")
|
||||
if err := writeBundle(bundlePath, validBundle(now)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observedPath := filepath.Join(directory, "observed-state.json")
|
||||
nftPath := filepath.Join(directory, "nft")
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--file\" ]; then cp \"$STATE_PATH\" \"$OBSERVED_PATH\"; fi\nexit 0\n"
|
||||
if err := os.WriteFile(nftPath, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("STATE_PATH", filepath.Join(directory, activePolicyStateFile))
|
||||
t.Setenv("OBSERVED_PATH", observedPath)
|
||||
configuration := policy()
|
||||
configuration.stateDirectory = directory
|
||||
configuration.nft = nftPath
|
||||
|
||||
if err := activate(now, configuration, bundlePath, true, "current"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observed, err := readActivePolicyState(observedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observed.Source != "transition" {
|
||||
t.Fatalf("rules loaded after source %q was reported", observed.Source)
|
||||
}
|
||||
final, err := readActivePolicyState(filepath.Join(directory, activePolicyStateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final.Source != "current" {
|
||||
t.Fatalf("unexpected final source %q", final.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailOpenLoadFailureRemainsTransitional(t *testing.T) {
|
||||
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
||||
directory := t.TempDir()
|
||||
nftPath := filepath.Join(directory, "nft")
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--file\" ]; then exit 1; fi\nexit 0\n"
|
||||
if err := os.WriteFile(nftPath, []byte(script), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configuration := policy()
|
||||
configuration.stateDirectory = directory
|
||||
configuration.nft = nftPath
|
||||
|
||||
if err := failOpen(now, configuration); err == nil {
|
||||
t.Fatal("expected nftables load failure")
|
||||
}
|
||||
state, err := readActivePolicyState(filepath.Join(directory, activePolicyStateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Source != "transition" {
|
||||
t.Fatalf("failed rules load reported source %q", state.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndRender(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
validated, err := validateBundle(now, policy(), validBundle(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := render(&output, now, validated, "ens3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"table inet oci_l4_relay_smtp_deny",
|
||||
"flags interval, timeout",
|
||||
"192.0.2.1 timeout 86400s",
|
||||
`iifname "ens3" tcp dport 25`,
|
||||
"counter name smtp_denied drop",
|
||||
} {
|
||||
if !strings.Contains(output.String(), expected) {
|
||||
t.Errorf("rendered ruleset does not contain %q", expected)
|
||||
}
|
||||
}
|
||||
for _, excluded := range []string{"dport 465", "dport 587", "dport 993", "dport { 80, 443 }"} {
|
||||
if strings.Contains(output.String(), excluded) {
|
||||
t.Errorf("rendered SMTP deny ruleset contains %q", excluded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyBundleIsValidAndFailOpenIsEmpty(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
document := validBundle(now)
|
||||
document.DeniedIPv4 = nil
|
||||
document.DeniedIntervalCount = 0
|
||||
document.DeniedAddressCount = 0
|
||||
validated, err := validateBundle(now, policy(), document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := render(&rendered, now, validated, "ens3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var failOpen bytes.Buffer
|
||||
if err := renderFailOpen(&failOpen, "ens3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]string{"empty bundle": rendered.String(), "fail open": failOpen.String()} {
|
||||
if strings.Contains(value, "elements =") {
|
||||
t.Errorf("%s unexpectedly contains deny elements", name)
|
||||
}
|
||||
if !strings.Contains(value, "ip saddr @denied_v4") {
|
||||
t.Errorf("%s does not retain the empty deny lookup", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredEntryDecaysWithoutInvalidatingBundle(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
document := validBundle(now)
|
||||
document.DeniedIPv4[0].ObservationEnded = now.Add(-2 * time.Minute)
|
||||
document.DeniedIPv4[0].ExpiresAt = now.Add(-time.Minute)
|
||||
validated, err := validateBundle(now, policy(), document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := render(&output, now, validated, "ens3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(output.String(), "192.0.2.1") {
|
||||
t.Fatal("expired entry remained in the rendered deny set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyBoundaries(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
maximum := validBundle(now)
|
||||
maximum.DeniedIPv4[0].Start = "192.0.2.0"
|
||||
maximum.DeniedIPv4[0].End = "192.0.17.255"
|
||||
maximum.DeniedAddressCount = 4096
|
||||
if _, err := validateBundle(now, policy(), maximum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := map[string]func(*bundle){
|
||||
"observation threshold": func(value *bundle) {
|
||||
value.DeniedIPv4[0].ObservationCount = 19
|
||||
},
|
||||
"address cap": func(value *bundle) {
|
||||
value.DeniedIPv4[0].Start = "192.0.2.0"
|
||||
value.DeniedIPv4[0].End = "192.0.18.0"
|
||||
value.DeniedAddressCount = 4097
|
||||
},
|
||||
"lifetime": func(value *bundle) {
|
||||
value.ExpiresAt = now.Add(24*time.Hour + time.Second)
|
||||
value.DeniedIPv4[0].ExpiresAt = value.ExpiresAt
|
||||
},
|
||||
"observation window": func(value *bundle) {
|
||||
value.DeniedIPv4[0].ObservationStarted = now.Add(-10*time.Minute - time.Second)
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
value := validBundle(now)
|
||||
mutate(&value)
|
||||
if _, err := validateBundle(now, policy(), value); err == nil {
|
||||
t.Fatal("expected policy-bound failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
intervals := validBundle(now)
|
||||
intervals.DeniedIPv4 = make([]denyEntry, 1025)
|
||||
for index := range intervals.DeniedIPv4 {
|
||||
entry := validBundle(now).DeniedIPv4[0]
|
||||
entry.Start = ipv4String(uint32(index * 2))
|
||||
entry.End = entry.Start
|
||||
intervals.DeniedIPv4[index] = entry
|
||||
}
|
||||
intervals.DeniedIntervalCount = len(intervals.DeniedIPv4)
|
||||
intervals.DeniedAddressCount = uint64(len(intervals.DeniedIPv4))
|
||||
if _, err := validateBundle(now, policy(), intervals); err == nil {
|
||||
t.Fatal("expected interval-cap failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsNonTransportOrMalformedEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
tests := map[string]func(*bundle){
|
||||
"schema": func(value *bundle) { value.SchemaVersion = 2 },
|
||||
"expired": func(value *bundle) { value.ExpiresAt = now },
|
||||
"ipv6": func(value *bundle) { value.DeniedIPv4[0].Start = "2001:db8::1" },
|
||||
"reason": func(value *bundle) { value.DeniedIPv4[0].Reason = "spam-score" },
|
||||
"future window": func(value *bundle) {
|
||||
value.DeniedIPv4[0].ObservationEnded = now.Add(6 * time.Minute)
|
||||
},
|
||||
}
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
value := validBundle(now)
|
||||
mutate(&value)
|
||||
if _, err := validateBundle(now, policy(), value); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBundleRejectsContentEvidence(t *testing.T) {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
contents, err := json.Marshal(validBundle(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = bytes.Replace(contents, []byte(`"reason":"connection-rate"`), []byte(`"reason":"connection-rate","spam_score":9`), 1)
|
||||
path := filepath.Join(t.TempDir(), "bundle.json")
|
||||
if err := os.WriteFile(path, contents, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readBundle(now, policy(), path); err == nil {
|
||||
t.Fatal("expected content evidence to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsUnsafeInterface(t *testing.T) {
|
||||
if err := renderFailOpen(&bytes.Buffer{}, "ens3\nflush ruleset"); err == nil {
|
||||
t.Fatal("expected unsafe interface rejection")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue