feat: add externalip to rules api (#2099)

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
Oliver Bähler
2026-08-24 08:37:50 +02:00
committed by GitHub
parent 7f56e957d6
commit 7b146871d6
10 changed files with 789 additions and 0 deletions
@@ -0,0 +1,131 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"fmt"
"net"
"strings"
corev1 "k8s.io/api/core/v1"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/events"
)
type externalIPCIDRRule struct {
CIDRs []string
MatchAll bool
}
func (h *serviceRules) validateExternalIPs(
svc *corev1.Service,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) (*ruleengine.Evaluation, error) {
return evaluateServiceRules[externalIPCIDRRule](
svc,
enforceBodies,
serviceRuleSet[externalIPCIDRRule]{
Name: "external IP",
EventReason: events.ReasonForbiddenExternalServiceIP,
Values: externalIPValues,
Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []externalIPCIDRRule {
if enforce == nil || enforce.Services.ExternalIPs == nil {
return nil
}
if len(enforce.Services.ExternalIPs.CIDRs) == 0 {
if enforce.Action.OrDefault() == apirules.ActionTypeDeny {
return []externalIPCIDRRule{
{
MatchAll: true,
},
}
}
return nil
}
cidrs := make([]string, 0, len(enforce.Services.ExternalIPs.CIDRs))
for _, cidr := range enforce.Services.ExternalIPs.CIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
cidrs = append(cidrs, cidr)
}
if len(cidrs) == 0 {
return nil
}
return []externalIPCIDRRule{
{
CIDRs: cidrs,
},
}
},
Matches: func(rule externalIPCIDRRule, value ruleengine.Value) (ruleengine.Match, error) {
if rule.MatchAll {
return ruleengine.Match{
Matched: true,
MatchedValue: "all external IPs",
Detail: "deny rule applies to all external IPs",
}, nil
}
ip := net.ParseIP(value.Value)
if ip == nil {
return ruleengine.Match{}, fmt.Errorf(
"%s contains invalid IP %q",
value.Path,
value.Value,
)
}
for _, rawCIDR := range rule.CIDRs {
allowedCIDR, err := parseCIDR(rawCIDR)
if err != nil {
return ruleengine.Match{}, fmt.Errorf("invalid external IP CIDR %q: %w", rawCIDR, err)
}
if !cidrContainsIP(allowedCIDR, ip) {
continue
}
return ruleengine.Match{
Matched: true,
MatchedValue: rawCIDR,
Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR),
}, nil
}
return ruleengine.Match{}, nil
},
RuleDescription: func(rule externalIPCIDRRule) string {
if rule.MatchAll {
return "all external IPs"
}
return strings.Join(rule.CIDRs, ", ")
},
AllowedDescription: "Allowed CIDRs",
},
)
}
func externalIPValues(svc *corev1.Service) []ruleengine.Value {
out := make([]ruleengine.Value, 0, len(svc.Spec.ExternalIPs))
for i, externalIP := range svc.Spec.ExternalIPs {
out = append(out, ruleengine.Value{
Value: externalIP,
Path: fmt.Sprintf("spec.externalIPs[%d]", i),
})
}
return out
}
@@ -0,0 +1,350 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/events"
)
func TestServiceRulesValidateExternalIPs(t *testing.T) {
tests := []struct {
name string
svc *corev1.Service
enforceBodies []*apirules.NamespaceRuleEnforceBody
wantNil bool
wantBlocking bool
wantFinal bool
wantAudits int
wantErr string
wantMessage []string
}{
{
name: "nil service returns nil evaluation",
svc: nil,
wantNil: true,
},
{
name: "service without external IPs remains unrestricted",
svc: externalIPServiceForTest(),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"),
},
},
{
name: "external IP without external IP rules remains unrestricted",
svc: externalIPServiceForTest("10.0.0.2"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
{Action: apirules.ActionTypeAllow},
},
},
{
name: "allows IPv4 address inside configured CIDR",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"),
},
wantFinal: true,
wantMessage: []string{
`external IP "10.20.1.44" at spec.externalIPs[0] is allowed by namespace rule`,
"10.20.1.44 is contained in 10.20.0.0/16",
},
},
{
name: "allows plain IPv4 rule as a host CIDR",
svc: externalIPServiceForTest("192.168.1.2"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "192.168.1.2"),
},
wantFinal: true,
},
{
name: "allows IPv6 address inside configured CIDR",
svc: externalIPServiceForTest("2001:db8::2"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "2001:db8::/32"),
},
wantFinal: true,
wantMessage: []string{
"2001:db8::2 is contained in 2001:db8::/32",
},
},
{
name: "allow miss denies address outside configured CIDRs",
svc: externalIPServiceForTest("8.8.8.8"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16", "192.168.1.2"),
},
wantBlocking: true,
wantMessage: []string{
`external IP "8.8.8.8" at spec.externalIPs[0] is not allowed by namespace rule`,
"Allowed CIDRs",
"10.20.0.0/16",
"192.168.1.2",
},
},
{
name: "all external IPs must match an allow rule",
svc: externalIPServiceForTest("10.20.1.44", "8.8.8.8"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"),
},
wantFinal: true,
wantBlocking: true,
wantMessage: []string{
`external IP "8.8.8.8" at spec.externalIPs[1] is not allowed by namespace rule`,
},
},
{
name: "matching deny rule blocks address",
svc: externalIPServiceForTest("10.20.66.4"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeDeny, "10.20.66.0/24"),
},
wantFinal: true,
wantBlocking: true,
wantMessage: []string{
`external IP "10.20.66.4" at spec.externalIPs[0] is denied by namespace rule`,
},
},
{
name: "deny with omitted CIDRs blocks every external IP",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeDeny),
},
wantFinal: true,
wantBlocking: true,
wantMessage: []string{
`external IP "10.20.1.44" at spec.externalIPs[0] is denied by namespace rule`,
"all external IPs",
"deny rule applies to all external IPs",
},
},
{
name: "deny with explicit empty CIDRs blocks every external IP",
svc: externalIPServiceForTest("2001:db8::2"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
{
Action: apirules.ActionTypeDeny,
Services: apirules.NamespaceRuleEnforceServicesBody{
ExternalIPs: &apirules.ServiceExternalIPRule{
CIDRs: []string{},
},
},
},
},
wantFinal: true,
wantBlocking: true,
},
{
name: "default deny action with empty CIDRs blocks every external IP",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(""),
},
wantFinal: true,
wantBlocking: true,
},
{
name: "deny with empty CIDRs does not require an external IP",
svc: externalIPServiceForTest(),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeDeny),
},
},
{
name: "later allow overrides earlier matching deny",
svc: externalIPServiceForTest("10.20.66.4"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeDeny, "10.20.0.0/16"),
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.66.0/24"),
},
wantFinal: true,
},
{
name: "matching audit rule is observational",
svc: externalIPServiceForTest("10.20.66.4"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAudit, "10.20.66.0/24"),
},
wantAudits: 1,
wantMessage: []string{
`external IP "10.20.66.4" at spec.externalIPs[0] matched audit namespace rule`,
},
},
{
name: "audit does not satisfy a non-matching allow rule",
svc: externalIPServiceForTest("10.20.66.4"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAudit, "10.20.66.0/24"),
externalIPEnforceForTest(apirules.ActionTypeAllow, "192.168.0.0/16"),
},
wantAudits: 1,
wantBlocking: true,
},
{
name: "blank configured CIDRs are ignored",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "", " "),
},
},
{
name: "invalid configured CIDR returns matcher error",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/33"),
},
wantErr: `external IP: invalid rule: invalid external IP CIDR "10.20.0.0/33"`,
},
{
name: "invalid requested external IP returns matcher error",
svc: externalIPServiceForTest("not-an-ip"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"),
},
wantErr: `external IP: invalid rule: spec.externalIPs[0] contains invalid IP "not-an-ip"`,
},
{
name: "nil enforce body is ignored",
svc: externalIPServiceForTest("10.20.1.44"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
nil,
externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"),
},
wantFinal: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := serviceRulesForTest()
evaluation, err := h.validateExternalIPs(tt.svc, tt.enforceBodies)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if tt.wantNil {
if evaluation != nil {
t.Fatalf("expected nil evaluation, got %#v", evaluation)
}
return
}
if evaluation == nil {
t.Fatalf("expected evaluation, got nil")
}
if (evaluation.Blocking != nil) != tt.wantBlocking {
t.Fatalf("blocking decision = %#v, want presence %t", evaluation.Blocking, tt.wantBlocking)
}
if (evaluation.Final != nil) != tt.wantFinal {
t.Fatalf("final decision = %#v, want presence %t", evaluation.Final, tt.wantFinal)
}
if len(evaluation.Audits) != tt.wantAudits {
t.Fatalf("audit decisions = %d, want %d", len(evaluation.Audits), tt.wantAudits)
}
message := externalIPDecisionMessagesForTest(evaluation)
for _, expected := range tt.wantMessage {
if !strings.Contains(message, expected) {
t.Fatalf("expected message %q to contain %q", message, expected)
}
}
if evaluation.Final != nil && evaluation.Final.EventReason != events.ReasonForbiddenExternalServiceIP {
t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenExternalServiceIP)
}
if evaluation.Blocking != nil && evaluation.Blocking.EventReason != events.ReasonForbiddenExternalServiceIP {
t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenExternalServiceIP)
}
for _, audit := range evaluation.Audits {
if audit.EventReason != events.ReasonForbiddenExternalServiceIP {
t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenExternalServiceIP)
}
}
})
}
}
func TestExternalIPValues(t *testing.T) {
values := externalIPValues(externalIPServiceForTest("10.20.1.44", "2001:db8::2"))
if len(values) != 2 {
t.Fatalf("expected 2 values, got %d", len(values))
}
if values[0].Value != "10.20.1.44" || values[0].Path != "spec.externalIPs[0]" {
t.Fatalf("unexpected first value: %#v", values[0])
}
if values[1].Value != "2001:db8::2" || values[1].Path != "spec.externalIPs[1]" {
t.Fatalf("unexpected second value: %#v", values[1])
}
}
func externalIPEnforceForTest(
action apirules.ActionType,
cidrs ...string,
) *apirules.NamespaceRuleEnforceBody {
return &apirules.NamespaceRuleEnforceBody{
Action: action,
Services: apirules.NamespaceRuleEnforceServicesBody{
ExternalIPs: &apirules.ServiceExternalIPRule{
CIDRs: cidrs,
},
},
}
}
func externalIPServiceForTest(externalIPs ...string) *corev1.Service {
return &corev1.Service{
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
ExternalIPs: externalIPs,
},
}
}
func externalIPDecisionMessagesForTest(evaluation *ruleengine.Evaluation) string {
messages := make([]string, 0, len(evaluation.Audits)+2)
for _, audit := range evaluation.Audits {
messages = append(messages, audit.Message)
}
if evaluation.Final != nil {
messages = append(messages, evaluation.Final.Message)
}
if evaluation.Blocking != nil && evaluation.Blocking != evaluation.Final {
messages = append(messages, evaluation.Blocking.Message)
}
return strings.Join(messages, "\n")
}
@@ -62,6 +62,7 @@ func ServiceRules(
h.rules = []serviceRuleValidator{
h.validateServiceTypes,
h.validateLoadBalancers,
h.validateExternalIPs,
h.validateExternalNames,
h.validateNodePorts,
}