diff --git a/cni-plugin/main.go b/cni-plugin/main.go index 1032a17d..7bcb9378 100644 --- a/cni-plugin/main.go +++ b/cni-plugin/main.go @@ -22,7 +22,9 @@ import ( "context" "encoding/json" "fmt" + "io" "os" + "path/filepath" "strconv" "strings" @@ -34,6 +36,7 @@ import ( "github.com/linkerd/linkerd2-proxy-init/proxy-init/cmd" "github.com/sirupsen/logrus" + "gopkg.in/natefinch/lumberjack.v2" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -80,9 +83,13 @@ type PluginConf struct { RawPrevResult *map[string]interface{} `json:"prevResult"` PrevResult *cniv1.Result `json:"-"` - LogLevel string `json:"log_level"` - ProxyInit ProxyInit `json:"linkerd"` - Kubernetes Kubernetes `json:"kubernetes"` + LogLevel string `json:"log_level"` + LogFile string `json:"log_file"` + LogFileMaxSizeMB int `json:"log_file_max_size_mb"` + LogFileMaxAgeDays int `json:"log_file_max_age_days"` + LogFileMaxCount int `json:"log_file_max_count"` + ProxyInit ProxyInit `json:"linkerd"` + Kubernetes Kubernetes `json:"kubernetes"` } func main() { @@ -99,7 +106,10 @@ func main() { ) } -func configureLoggingLevel(logLevel string) { +// configureLogging sets log level and configures outputs to both stderr and a file with rotation. +// If logFilePath is empty, a sensible default is used. +// maxSizeMB, maxAgeDays, and maxCount configure the log rotation behavior. +func configureLogging(logLevel, logFilePath string, maxSizeMB, maxAgeDays, maxCount int) { switch strings.ToLower(logLevel) { case "debug": logrus.SetLevel(logrus.DebugLevel) @@ -108,6 +118,40 @@ func configureLoggingLevel(logLevel string) { default: logrus.SetLevel(logrus.WarnLevel) } + + // Default log file path + if strings.TrimSpace(logFilePath) == "" { + logFilePath = "/var/log/linkerd-cni-plugin.log" + } + + // Default rotation parameters if not specified (matching Calico CNI defaults) + if maxSizeMB <= 0 { + maxSizeMB = 100 // 100 MB default + } + if maxAgeDays <= 0 { + maxAgeDays = 30 // 30 days default + } + if maxCount <= 0 { + maxCount = 10 // 10 files default + } + + // Ensure directory exists + if dir := filepath.Dir(logFilePath); dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0o755) + } + + // Configure log rotation with lumberjack + logger := &lumberjack.Logger{ + Filename: logFilePath, + MaxSize: maxSizeMB, // megabytes + MaxBackups: maxCount, // number of backups + MaxAge: maxAgeDays, // days + Compress: true, // compress rotated files + } + + // Tee logs to both stderr and the rotating log file + mw := io.MultiWriter(os.Stderr, logger) + logrus.SetOutput(mw) } // parseConfig parses the supplied configuration (and prevResult) from stdin. @@ -147,7 +191,8 @@ func cmdAdd(args *skel.CmdArgs) error { logrus.Errorf("error parsing config: %e", err) return err } - configureLoggingLevel(conf.LogLevel) + // Configure logging level and outputs with rotation + configureLogging(conf.LogLevel, conf.LogFile, conf.LogFileMaxSizeMB, conf.LogFileMaxAgeDays, conf.LogFileMaxCount) if conf.PrevResult != nil { logrus.WithFields(logrus.Fields{ diff --git a/go.mod b/go.mod index 1a630212..17635650 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect diff --git a/go.sum b/go.sum index 30b91586..b09ebf65 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/iptables/iptables.go b/pkg/iptables/iptables.go index 9cf9ff75..a3d0b900 100644 --- a/pkg/iptables/iptables.go +++ b/pkg/iptables/iptables.go @@ -67,8 +67,9 @@ type FirewallConfiguration struct { // https://github.com/istio/istio/blob/e83411e/pilot/docker/prepare_proxy.sh func ConfigureFirewall(firewallConfiguration FirewallConfiguration) error { log.Debugf("tracing script execution as [%s]", executionTraceID) - log.Debugf("using '%s' to set-up firewall rules", firewallConfiguration.BinPath) - log.Debugf("using '%s' to list all available rules", firewallConfiguration.SaveBinPath) + + // Before executing, ensure the configured iptables binaries exist; if not, attempt a fallback. + resolveBinFallback(&firewallConfiguration, exec.LookPath) existingRules, err := executeCommand(firewallConfiguration, firewallConfiguration.makeShowAllRules()) if err != nil { @@ -112,6 +113,9 @@ func CleanupFirewallConfig(firewallConfiguration FirewallConfiguration) error { log.Debugf("using '%s' to clean-up firewall rules", firewallConfiguration.BinPath) log.Debugf("using '%s' to list all available rules", firewallConfiguration.SaveBinPath) + // Ensure binaries exist before attempting cleanup as well + resolveBinFallback(&firewallConfiguration, exec.LookPath) + commands := make([]*exec.Cmd, 0) commands = firewallConfiguration.cleanupRules(commands) @@ -448,3 +452,61 @@ func asDestination(portRange util.PortRange) string { return fmt.Sprintf("%d:%d", portRange.LowerBound, portRange.UpperBound) } + +// resolveBinFallback ensures the configured BinPath and SaveBinPath exist on PATH; if not, it +// tries reasonable alternatives of the same family (ip6tables vs iptables). +func resolveBinFallback(fc *FirewallConfiguration, lookPath func(string) (string, error)) { + // helper to check presence + has := func(name string) bool { + _, err := lookPath(name) + return err == nil + } + + // Both present? nothing to do + if has(fc.BinPath) && has(fc.SaveBinPath) { + log.WithFields(log.Fields{ + "requestedBin": fc.BinPath, + "requestedSaveBin": fc.SaveBinPath, + }).Debug("iptables: using configured binaries") + return + } + + // Decide family based on current name + ipv6 := strings.Contains(fc.BinPath, "ip6tables") || strings.Contains(fc.SaveBinPath, "ip6tables") + + // Candidate orders: prefer nft, then plain, then legacy + var candidates [][2]string + if ipv6 { + candidates = [][2]string{ + {"ip6tables-nft", "ip6tables-nft-save"}, + {"ip6tables", "ip6tables-save"}, + {"ip6tables-legacy", "ip6tables-legacy-save"}, + } + } else { + candidates = [][2]string{ + {"iptables-nft", "iptables-nft-save"}, + {"iptables", "iptables-save"}, + {"iptables-legacy", "iptables-legacy-save"}, + } + } + + // Use first candidate where both exist + for _, pair := range candidates { + if has(pair[0]) && has(pair[1]) { + if pair[0] != fc.BinPath || pair[1] != fc.SaveBinPath { + log.WithFields(log.Fields{ + "requestedBin": fc.BinPath, + "requestedSaveBin": fc.SaveBinPath, + "fallbackBin": pair[0], + "fallbackSaveBin": pair[1], + }).Warn("iptables: configured binaries not found; applying fallback to available binaries") + } + fc.BinPath = pair[0] + fc.SaveBinPath = pair[1] + return + } + } + + // No candidates found; keep as-is and let execution fail with a clear error later + log.WithFields(log.Fields{"binPath": fc.BinPath, "saveBinPath": fc.SaveBinPath}).Error("iptables: no suitable binaries found on PATH; commands may fail") +} diff --git a/pkg/iptables/resolve_fallback_test.go b/pkg/iptables/resolve_fallback_test.go new file mode 100644 index 00000000..505af390 --- /dev/null +++ b/pkg/iptables/resolve_fallback_test.go @@ -0,0 +1,89 @@ +package iptables + +import ( + "errors" + "testing" +) + +// fakeLookPath returns a LookPath-like function backed by a set of available names. +func fakeLookPath(available []string) func(string) (string, error) { + return func(name string) (string, error) { + for _, a := range available { + if a == name { + return "/fake/" + name, nil + } + } + return "", errors.New("not found") + } +} + +func TestResolveBinFallback_KeepWhenPresent(t *testing.T) { + fc := &FirewallConfiguration{BinPath: "iptables-nft", SaveBinPath: "iptables-nft-save"} + lp := fakeLookPath([]string{ + "iptables-nft", + "iptables-nft-save", + }) + + resolveBinFallback(fc, lp) + + if fc.BinPath != "iptables-nft" || fc.SaveBinPath != "iptables-nft-save" { + t.Fatalf("expected to keep configured binaries, got bin=%q save=%q", fc.BinPath, fc.SaveBinPath) + } +} + +func TestResolveBinFallback_FallbackToNFT_IPv4(t *testing.T) { + fc := &FirewallConfiguration{BinPath: "iptables-notreal", SaveBinPath: "iptables-notreal-save"} + lp := fakeLookPath([]string{ + // Only nft pair is available + "iptables-nft", + "iptables-nft-save", + }) + + resolveBinFallback(fc, lp) + + if fc.BinPath != "iptables-nft" || fc.SaveBinPath != "iptables-nft-save" { + t.Fatalf("expected fallback to iptables-nft, got bin=%q save=%q", fc.BinPath, fc.SaveBinPath) + } +} + +func TestResolveBinFallback_FallbackOrder_Plain(t *testing.T) { + fc := &FirewallConfiguration{BinPath: "iptables-missing", SaveBinPath: "iptables-missing-save"} + lp := fakeLookPath([]string{ + // Only plain iptables present + "iptables", + "iptables-save", + }) + + resolveBinFallback(fc, lp) + + if fc.BinPath != "iptables" || fc.SaveBinPath != "iptables-save" { + t.Fatalf("expected fallback to iptables/iptable-save, got bin=%q save=%q", fc.BinPath, fc.SaveBinPath) + } +} + +func TestResolveBinFallback_IPv6_FallbackLegacy(t *testing.T) { + fc := &FirewallConfiguration{BinPath: "ip6tables-missing", SaveBinPath: "ip6tables-missing-save"} + lp := fakeLookPath([]string{ + // Only legacy pair present for IPv6 + "ip6tables-legacy", + "ip6tables-legacy-save", + }) + + resolveBinFallback(fc, lp) + + if fc.BinPath != "ip6tables-legacy" || fc.SaveBinPath != "ip6tables-legacy-save" { + t.Fatalf("expected fallback to ip6tables-legacy, got bin=%q save=%q", fc.BinPath, fc.SaveBinPath) + } +} + +func TestResolveBinFallback_NoCandidates(t *testing.T) { + origBin, origSave := "iptables-missing", "iptables-missing-save" + fc := &FirewallConfiguration{BinPath: origBin, SaveBinPath: origSave} + lp := fakeLookPath([]string{}) + + resolveBinFallback(fc, lp) + + if fc.BinPath != origBin || fc.SaveBinPath != origSave { + t.Fatalf("expected no change when no candidates found, got bin=%q save=%q", fc.BinPath, fc.SaveBinPath) + } +}