mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-25 16:07:24 +00:00
feat: add externalip to rules api (#2099)
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
This commit is contained in:
@@ -295,6 +295,19 @@ spec:
|
||||
services:
|
||||
description: Enforcement for Services.
|
||||
properties:
|
||||
externalIPs:
|
||||
description: ExternalIPs defines constraints for spec.externalIPs.
|
||||
properties:
|
||||
cidrs:
|
||||
description: |-
|
||||
CIDRs restricts spec.externalIPs. Individual IP addresses are treated as
|
||||
host CIDRs (/32 for IPv4 and /128 for IPv6).
|
||||
For deny rules, empty means all external IPs are denied. For allow and
|
||||
audit rules, empty means no external IP restriction.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
externalNames:
|
||||
description: ExternalNames defines additional constraints
|
||||
for Services of type ExternalName.
|
||||
@@ -972,6 +985,19 @@ spec:
|
||||
services:
|
||||
description: Enforcement for Services.
|
||||
properties:
|
||||
externalIPs:
|
||||
description: ExternalIPs defines constraints for spec.externalIPs.
|
||||
properties:
|
||||
cidrs:
|
||||
description: |-
|
||||
CIDRs restricts spec.externalIPs. Individual IP addresses are treated as
|
||||
host CIDRs (/32 for IPv4 and /128 for IPv6).
|
||||
For deny rules, empty means all external IPs are denied. For allow and
|
||||
audit rules, empty means no external IP restriction.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
externalNames:
|
||||
description: ExternalNames defines additional constraints
|
||||
for Services of type ExternalName.
|
||||
@@ -1588,6 +1614,19 @@ spec:
|
||||
services:
|
||||
description: Enforcement for Services.
|
||||
properties:
|
||||
externalIPs:
|
||||
description: ExternalIPs defines constraints for spec.externalIPs.
|
||||
properties:
|
||||
cidrs:
|
||||
description: |-
|
||||
CIDRs restricts spec.externalIPs. Individual IP addresses are treated as
|
||||
host CIDRs (/32 for IPv4 and /128 for IPv6).
|
||||
For deny rules, empty means all external IPs are denied. For allow and
|
||||
audit rules, empty means no external IP restriction.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
externalNames:
|
||||
description: ExternalNames defines additional constraints
|
||||
for Services of type ExternalName.
|
||||
|
||||
@@ -2752,6 +2752,19 @@ spec:
|
||||
services:
|
||||
description: Enforcement for Services.
|
||||
properties:
|
||||
externalIPs:
|
||||
description: ExternalIPs defines constraints for spec.externalIPs.
|
||||
properties:
|
||||
cidrs:
|
||||
description: |-
|
||||
CIDRs restricts spec.externalIPs. Individual IP addresses are treated as
|
||||
host CIDRs (/32 for IPv4 and /128 for IPv6).
|
||||
For deny rules, empty means all external IPs are denied. For allow and
|
||||
audit rules, empty means no external IP restriction.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
externalNames:
|
||||
description: ExternalNames defines additional constraints
|
||||
for Services of type ExternalName.
|
||||
|
||||
@@ -96,6 +96,21 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "
|
||||
}
|
||||
}
|
||||
|
||||
externalIPCIDRRule := func(action rules.ActionType, cidrs ...string) *rules.NamespaceRuleBodyTenant {
|
||||
return &rules.NamespaceRuleBodyTenant{
|
||||
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Action: action,
|
||||
Services: rules.NamespaceRuleEnforceServicesBody{
|
||||
ExternalIPs: &rules.ServiceExternalIPRule{
|
||||
CIDRs: cidrs,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
externalNameRule := func(action rules.ActionType, hostnames ...runtime.ExpressionMatch) *rules.NamespaceRuleBodyTenant {
|
||||
return &rules.NamespaceRuleBodyTenant{
|
||||
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
|
||||
@@ -260,6 +275,8 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "
|
||||
action rules.ActionType
|
||||
types []rules.ServiceType
|
||||
loadBalancerCIDRs []string
|
||||
externalIPs bool
|
||||
externalIPCIDRs []string
|
||||
nodePortRanges []rules.ServiceNodePortRange
|
||||
externalExpressions []string
|
||||
externalExact [][]string
|
||||
@@ -303,6 +320,13 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "
|
||||
g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(Equal(expected.loadBalancerCIDRs))
|
||||
}
|
||||
|
||||
if expected.externalIPs {
|
||||
g.Expect(got.Enforce.Services.ExternalIPs).NotTo(BeNil())
|
||||
g.Expect(got.Enforce.Services.ExternalIPs.CIDRs).To(Equal(expected.externalIPCIDRs))
|
||||
} else {
|
||||
g.Expect(got.Enforce.Services.ExternalIPs).To(BeNil())
|
||||
}
|
||||
|
||||
if len(expected.nodePortRanges) == 0 {
|
||||
if got.Enforce.Services.NodePorts != nil {
|
||||
g.Expect(got.Enforce.Services.NodePorts.Ports).To(BeEmpty())
|
||||
@@ -593,6 +617,13 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "
|
||||
}
|
||||
}
|
||||
|
||||
externalIPService := func(name string, externalIPs ...string) *corev1.Service {
|
||||
svc := clusterIPService(name)
|
||||
svc.Spec.ExternalIPs = externalIPs
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
nodePortService := func(name string, nodePort int32) *corev1.Service {
|
||||
port := servicePort()
|
||||
port.NodePort = nodePort
|
||||
@@ -859,6 +890,105 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "
|
||||
)
|
||||
})
|
||||
|
||||
It("allows only Service external IPs contained in configured CIDRs", func() {
|
||||
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
|
||||
serviceTypeRule(
|
||||
rules.ActionTypeAllow,
|
||||
rules.ServiceTypeClusterIP,
|
||||
),
|
||||
externalIPCIDRRule(
|
||||
rules.ActionTypeAllow,
|
||||
"10.20.0.0/16",
|
||||
"192.168.1.2",
|
||||
),
|
||||
})
|
||||
|
||||
ns := createNamespace(nil)
|
||||
|
||||
expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{
|
||||
{
|
||||
action: rules.ActionTypeAllow,
|
||||
types: []rules.ServiceType{
|
||||
rules.ServiceTypeClusterIP,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: rules.ActionTypeAllow,
|
||||
externalIPs: true,
|
||||
externalIPCIDRs: []string{
|
||||
"10.20.0.0/16",
|
||||
"192.168.1.2",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
|
||||
|
||||
createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("external-ip-omitted-allowed"))
|
||||
|
||||
allowed := externalIPService("external-ip-cidr-allowed", "10.20.1.44")
|
||||
createServiceAndExpectAllowed(cs, ns.Name, allowed)
|
||||
createServiceAndExpectAllowed(cs, ns.Name, externalIPService("external-ip-host-allowed", "192.168.1.2"))
|
||||
|
||||
createServiceAndExpectDenied(cs, ns.Name, externalIPService("external-ip-denied", "8.8.8.8"),
|
||||
"external IP",
|
||||
"8.8.8.8",
|
||||
"spec.externalIPs[0]",
|
||||
"not allowed",
|
||||
"Allowed CIDRs",
|
||||
"10.20.0.0/16",
|
||||
"192.168.1.2",
|
||||
)
|
||||
|
||||
updateServiceAndExpectDenied(
|
||||
cs,
|
||||
ns.Name,
|
||||
allowed.Name,
|
||||
func(svc *corev1.Service) {
|
||||
svc.Spec.ExternalIPs = []string{"8.8.4.4"}
|
||||
},
|
||||
"external IP",
|
||||
"8.8.4.4",
|
||||
"spec.externalIPs[0]",
|
||||
"not allowed",
|
||||
)
|
||||
})
|
||||
|
||||
It("denies every specified Service external IP when a deny rule has empty CIDRs", func() {
|
||||
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
|
||||
serviceTypeRule(
|
||||
rules.ActionTypeAllow,
|
||||
rules.ServiceTypeClusterIP,
|
||||
),
|
||||
externalIPCIDRRule(rules.ActionTypeDeny),
|
||||
})
|
||||
|
||||
ns := createNamespace(nil)
|
||||
|
||||
expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{
|
||||
{
|
||||
action: rules.ActionTypeAllow,
|
||||
types: []rules.ServiceType{
|
||||
rules.ServiceTypeClusterIP,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: rules.ActionTypeDeny,
|
||||
externalIPs: true,
|
||||
},
|
||||
})
|
||||
|
||||
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
|
||||
|
||||
createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("external-ip-empty-deny-omitted"))
|
||||
createServiceAndExpectDenied(cs, ns.Name, externalIPService("external-ip-empty-denied", "10.20.1.44"),
|
||||
"external IP",
|
||||
"10.20.1.44",
|
||||
"denied",
|
||||
"all external IPs",
|
||||
)
|
||||
})
|
||||
|
||||
It("allows exact, regex, and combined ExternalName hostname matchers", func() {
|
||||
ns := createNamespace(nil)
|
||||
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ type NamespaceRuleEnforceServicesBody struct {
|
||||
// +optional
|
||||
LoadBalancers *ServiceLoadBalancerRule `json:"loadBalancers,omitempty"`
|
||||
|
||||
// ExternalIPs defines constraints for spec.externalIPs.
|
||||
// +optional
|
||||
ExternalIPs *ServiceExternalIPRule `json:"externalIPs,omitempty"`
|
||||
|
||||
// ExternalNames defines additional constraints for Services of type ExternalName.
|
||||
// +optional
|
||||
ExternalNames *ServiceExternalNameRule `json:"externalNames,omitempty"`
|
||||
@@ -50,6 +54,16 @@ type ServiceLoadBalancerRule struct {
|
||||
CIDRs []string `json:"cidrs,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type ServiceExternalIPRule struct {
|
||||
// CIDRs restricts spec.externalIPs. Individual IP addresses are treated as
|
||||
// host CIDRs (/32 for IPv4 and /128 for IPv6).
|
||||
// For deny rules, empty means all external IPs are denied. For allow and
|
||||
// audit rules, empty means no external IP restriction.
|
||||
// +optional
|
||||
CIDRs []string `json:"cidrs,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:generate=true
|
||||
type ServiceExternalNameRule struct {
|
||||
// Hostnames restricts spec.externalName.
|
||||
|
||||
@@ -214,6 +214,11 @@ func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnfor
|
||||
*out = new(ServiceLoadBalancerRule)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ExternalIPs != nil {
|
||||
in, out := &in.ExternalIPs, &out.ExternalIPs
|
||||
*out = new(ServiceExternalIPRule)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ExternalNames != nil {
|
||||
in, out := &in.ExternalNames, &out.ExternalNames
|
||||
*out = new(ServiceExternalNameRule)
|
||||
@@ -375,6 +380,26 @@ func (in *ResourceQuotaRule) DeepCopy() *ResourceQuotaRule {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ServiceExternalIPRule) DeepCopyInto(out *ServiceExternalIPRule) {
|
||||
*out = *in
|
||||
if in.CIDRs != nil {
|
||||
in, out := &in.CIDRs, &out.CIDRs
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExternalIPRule.
|
||||
func (in *ServiceExternalIPRule) DeepCopy() *ServiceExternalIPRule {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(ServiceExternalIPRule)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) {
|
||||
*out = *in
|
||||
|
||||
@@ -415,6 +415,20 @@ func validateServiceRules(
|
||||
}
|
||||
}
|
||||
|
||||
if services.ExternalIPs != nil {
|
||||
for j, cidr := range services.ExternalIPs.CIDRs {
|
||||
if err := validateCIDR(cidr); err != nil {
|
||||
return fmt.Errorf(
|
||||
"rules[%d].enforce.services.externalIPs.cidrs[%d] %q is invalid: %w",
|
||||
ruleIndex,
|
||||
j,
|
||||
cidr,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if services.ExternalNames != nil {
|
||||
for j, hostname := range services.ExternalNames.Hostnames {
|
||||
if err := validateExpressionMatch(
|
||||
|
||||
@@ -647,6 +647,78 @@ func TestValidateRuleStatusBody(t *testing.T) {
|
||||
},
|
||||
wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`,
|
||||
},
|
||||
{
|
||||
name: "invalid external IP CIDR",
|
||||
mapper: mapper,
|
||||
bodies: []*rules.NamespaceRuleBodyNamespace{
|
||||
{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Services: rules.NamespaceRuleEnforceServicesBody{
|
||||
ExternalIPs: &rules.ServiceExternalIPRule{
|
||||
CIDRs: []string{
|
||||
"10.20.0.0/33",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: `rules[0].enforce.services.externalIPs.cidrs[0] "10.20.0.0/33" is invalid`,
|
||||
},
|
||||
{
|
||||
name: "empty external IP CIDR entry",
|
||||
mapper: mapper,
|
||||
bodies: []*rules.NamespaceRuleBodyNamespace{
|
||||
{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Services: rules.NamespaceRuleEnforceServicesBody{
|
||||
ExternalIPs: &rules.ServiceExternalIPRule{
|
||||
CIDRs: []string{
|
||||
"",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: `rules[0].enforce.services.externalIPs.cidrs[0] "" is invalid: CIDR is empty`,
|
||||
},
|
||||
{
|
||||
name: "external IP CIDRs are valid",
|
||||
mapper: mapper,
|
||||
bodies: []*rules.NamespaceRuleBodyNamespace{
|
||||
{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Action: rules.ActionTypeAllow,
|
||||
Services: rules.NamespaceRuleEnforceServicesBody{
|
||||
ExternalIPs: &rules.ServiceExternalIPRule{
|
||||
CIDRs: []string{
|
||||
"10.20.0.0/16",
|
||||
"192.168.1.2",
|
||||
"2001:db8::/32",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty external IP CIDR deny rule is valid",
|
||||
mapper: mapper,
|
||||
bodies: []*rules.NamespaceRuleBodyNamespace{
|
||||
{
|
||||
Enforce: &rules.NamespaceRuleEnforceBody{
|
||||
Action: rules.ActionTypeDeny,
|
||||
Services: rules.NamespaceRuleEnforceServicesBody{
|
||||
ExternalIPs: &rules.ServiceExternalIPRule{
|
||||
CIDRs: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid externalName hostname regex",
|
||||
mapper: mapper,
|
||||
|
||||
Reference in New Issue
Block a user