Browse code

Merge pull request #7810 from smarterclayton/secure_external_ip

Merged by openshift-bot

OpenShift Bot authored on 2016/03/17 12:34:30
Showing 8 changed files
... ...
@@ -370,6 +370,11 @@ type MasterNetworkConfig struct {
370 370
 	ClusterNetworkCIDR string
371 371
 	HostSubnetLength   uint
372 372
 	ServiceNetworkCIDR string
373
+	// ExternalIPNetworkCIDRs controls what values are acceptable for the service external IP field. If empty, no externalIP
374
+	// may be set. It may contain a list of CIDRs which are checked for access. If a CIDR is prefixed with !, IPs in that
375
+	// CIDR will be rejected. Rejections will be applied first, then the IP checked against one of the allowed CIDRs. You
376
+	// should ensure this range does not overlap with your nodes, pods, or service CIDRs for security reasons.
377
+	ExternalIPNetworkCIDRs []string `json:"externalIPNetworkCIDRs"`
373 378
 }
374 379
 
375 380
 type ImageConfig struct {
... ...
@@ -406,11 +406,12 @@ func (MasterConfig) SwaggerDoc() map[string]string {
406 406
 }
407 407
 
408 408
 var map_MasterNetworkConfig = map[string]string{
409
-	"":                   "MasterNetworkConfig to be passed to the compiled in network plugin",
410
-	"networkPluginName":  "NetworkPluginName is the name of the network plugin to use",
411
-	"clusterNetworkCIDR": "ClusterNetworkCIDR is the CIDR string to specify the global overlay network's L3 space",
412
-	"hostSubnetLength":   "HostSubnetLength is the number of bits to allocate to each host's subnet e.g. 8 would mean a /24 network on the host",
413
-	"serviceNetworkCIDR": "ServiceNetwork is the CIDR string to specify the service networks",
409
+	"":                       "MasterNetworkConfig to be passed to the compiled in network plugin",
410
+	"networkPluginName":      "NetworkPluginName is the name of the network plugin to use",
411
+	"clusterNetworkCIDR":     "ClusterNetworkCIDR is the CIDR string to specify the global overlay network's L3 space",
412
+	"hostSubnetLength":       "HostSubnetLength is the number of bits to allocate to each host's subnet e.g. 8 would mean a /24 network on the host",
413
+	"serviceNetworkCIDR":     "ServiceNetwork is the CIDR string to specify the service networks",
414
+	"externalIPNetworkCIDRs": "ExternalIPNetworkCIDRs controls what values are acceptable for the service external IP field. If empty, no externalIP may be set. It may contain a list of CIDRs which are checked for access. If a CIDR is prefixed with !, IPs in that CIDR will be rejected. Rejections will be applied first, then the IP checked against one of the allowed CIDRs. You should ensure this range does not overlap with your nodes, pods, or service CIDRs for security reasons.",
414 415
 }
415 416
 
416 417
 func (MasterNetworkConfig) SwaggerDoc() map[string]string {
... ...
@@ -330,6 +330,11 @@ type MasterNetworkConfig struct {
330 330
 	HostSubnetLength uint `json:"hostSubnetLength"`
331 331
 	// ServiceNetwork is the CIDR string to specify the service networks
332 332
 	ServiceNetworkCIDR string `json:"serviceNetworkCIDR"`
333
+	// ExternalIPNetworkCIDRs controls what values are acceptable for the service external IP field. If empty, no externalIP
334
+	// may be set. It may contain a list of CIDRs which are checked for access. If a CIDR is prefixed with !, IPs in that
335
+	// CIDR will be rejected. Rejections will be applied first, then the IP checked against one of the allowed CIDRs. You
336
+	// should ensure this range does not overlap with your nodes, pods, or service CIDRs for security reasons.
337
+	ExternalIPNetworkCIDRs []string `json:"externalIPNetworkCIDRs"`
333 338
 }
334 339
 
335 340
 // ImageConfig holds the necessary configuration options for building image names for system components
... ...
@@ -178,6 +178,7 @@ masterClients:
178 178
 masterPublicURL: ""
179 179
 networkConfig:
180 180
   clusterNetworkCIDR: ""
181
+  externalIPNetworkCIDRs: null
181 182
   hostSubnetLength: 0
182 183
   networkPluginName: ""
183 184
   serviceNetworkCIDR: ""
... ...
@@ -147,6 +147,16 @@ func ValidateMasterConfig(config *api.MasterConfig, fldPath *field.Path) Validat
147 147
 			validationResults.AddErrors(field.Invalid(fldPath.Child("networkConfig", "serviceNetworkCIDR"), config.NetworkConfig.ServiceNetworkCIDR, fmt.Sprintf("must match kubernetesMasterConfig.servicesSubnet value of %q", config.KubernetesMasterConfig.ServicesSubnet)))
148 148
 		}
149 149
 	}
150
+	if len(config.NetworkConfig.ExternalIPNetworkCIDRs) > 0 {
151
+		for i, s := range config.NetworkConfig.ExternalIPNetworkCIDRs {
152
+			if strings.HasPrefix(s, "!") {
153
+				s = s[1:]
154
+			}
155
+			if _, _, err := net.ParseCIDR(s); err != nil {
156
+				validationResults.AddErrors(field.Invalid(fldPath.Child("networkConfig", "externalIPNetworkCIDRs").Index(i), config.NetworkConfig.ExternalIPNetworkCIDRs[i], "must be a valid CIDR notation IP range (e.g. 172.30.0.0/16) with an optional leading !"))
157
+			}
158
+		}
159
+	}
150 160
 
151 161
 	validationResults.AddErrors(ValidateKubeConfig(config.MasterClients.OpenShiftLoopbackKubeConfig, fldPath.Child("masterClients", "openShiftLoopbackKubeConfig"))...)
152 162
 
... ...
@@ -41,10 +41,11 @@ import (
41 41
 	cmdflags "github.com/openshift/origin/pkg/cmd/util/flags"
42 42
 	"github.com/openshift/origin/pkg/cmd/util/pluginconfig"
43 43
 	overrideapi "github.com/openshift/origin/pkg/quota/admission/clusterresourceoverride/api"
44
+	serviceadmit "github.com/openshift/origin/pkg/service/admission"
44 45
 )
45 46
 
46 47
 // AdmissionPlugins is the full list of admission control plugins to enable in the order they must run
47
-var AdmissionPlugins = []string{"NamespaceLifecycle", "PodNodeConstraints", "OriginPodNodeEnvironment", overrideapi.PluginName, "LimitRanger", "ServiceAccount", "SecurityContextConstraint", "BuildDefaults", "BuildOverrides", "ResourceQuota", "SCCExecRestrictions"}
48
+var AdmissionPlugins = []string{"NamespaceLifecycle", "PodNodeConstraints", "OriginPodNodeEnvironment", overrideapi.PluginName, serviceadmit.ExternalIPPluginName, "LimitRanger", "ServiceAccount", "SecurityContextConstraint", "BuildDefaults", "BuildOverrides", "ResourceQuota", "SCCExecRestrictions"}
48 49
 
49 50
 // MasterConfig defines the required values to start a Kubernetes master
50 51
 type MasterConfig struct {
... ...
@@ -132,6 +133,14 @@ func BuildKubernetesMasterConfig(options configapi.MasterConfig, requestContextM
132 132
 	plugins := []admission.Interface{}
133 133
 	for _, pluginName := range strings.Split(server.AdmissionControl, ",") {
134 134
 		switch pluginName {
135
+		case serviceadmit.ExternalIPPluginName:
136
+			// this needs to be moved upstream to be part of core config
137
+			reject, admit, err := serviceadmit.ParseCIDRRules(options.NetworkConfig.ExternalIPNetworkCIDRs)
138
+			if err != nil {
139
+				// should have been caught with validation
140
+				return nil, err
141
+			}
142
+			plugins = append(plugins, serviceadmit.NewExternalIPRanger(reject, admit))
135 143
 		case saadmit.PluginName:
136 144
 			// we need to set some custom parameters on the service account admission controller, so create that one by hand
137 145
 			saAdmitter := saadmit.NewServiceAccount(internalclientset.FromUnversionedClient(kubeClient))
138 146
new file mode 100644
... ...
@@ -0,0 +1,110 @@
0
+package admission
1
+
2
+import (
3
+	"io"
4
+	"net"
5
+	"strings"
6
+
7
+	kadmission "k8s.io/kubernetes/pkg/admission"
8
+	kapi "k8s.io/kubernetes/pkg/api"
9
+	apierrs "k8s.io/kubernetes/pkg/api/errors"
10
+	clientset "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset"
11
+	"k8s.io/kubernetes/pkg/util/validation/field"
12
+)
13
+
14
+const ExternalIPPluginName = "ExternalIPRanger"
15
+
16
+func init() {
17
+	kadmission.RegisterPlugin("ExternalIPRanger", func(client clientset.Interface, config io.Reader) (kadmission.Interface, error) {
18
+		return NewExternalIPRanger(nil, nil), nil
19
+	})
20
+}
21
+
22
+type externalIPRanger struct {
23
+	*kadmission.Handler
24
+	reject []*net.IPNet
25
+	admit  []*net.IPNet
26
+}
27
+
28
+var _ kadmission.Interface = &externalIPRanger{}
29
+
30
+// ParseCIDRRules calculates a blacklist and whitelist from a list of string CIDR rules (treating
31
+// a leading ! as a negation). Returns an error if any rule is invalid.
32
+func ParseCIDRRules(rules []string) (reject, admit []*net.IPNet, err error) {
33
+	for _, s := range rules {
34
+		negate := false
35
+		if strings.HasPrefix(s, "!") {
36
+			negate = true
37
+			s = s[1:]
38
+		}
39
+		_, cidr, err := net.ParseCIDR(s)
40
+		if err != nil {
41
+			return nil, nil, err
42
+		}
43
+		if negate {
44
+			reject = append(reject, cidr)
45
+		} else {
46
+			admit = append(admit, cidr)
47
+		}
48
+	}
49
+	return reject, admit, nil
50
+}
51
+
52
+// NewConstraint creates a new SCC constraint admission plugin.
53
+func NewExternalIPRanger(reject, admit []*net.IPNet) *externalIPRanger {
54
+	return &externalIPRanger{
55
+		Handler: kadmission.NewHandler(kadmission.Create, kadmission.Update),
56
+		reject:  reject,
57
+		admit:   admit,
58
+	}
59
+}
60
+
61
+// networkSlice is a helper for checking whether an IP is contained in a range
62
+// of networks.
63
+type networkSlice []*net.IPNet
64
+
65
+func (s networkSlice) Contains(ip net.IP) bool {
66
+	for _, cidr := range s {
67
+		if cidr.Contains(ip) {
68
+			return true
69
+		}
70
+	}
71
+	return false
72
+}
73
+
74
+// Admit determines if the service should be admitted based on the configured network CIDR.
75
+func (r *externalIPRanger) Admit(a kadmission.Attributes) error {
76
+	if a.GetResource() != kapi.Resource("services") {
77
+		return nil
78
+	}
79
+
80
+	svc, ok := a.GetObject().(*kapi.Service)
81
+	// if we can't convert then we don't handle this object so just return
82
+	if !ok {
83
+		return nil
84
+	}
85
+
86
+	var errs field.ErrorList
87
+	switch {
88
+	// administrator disabled externalIPs
89
+	case len(svc.Spec.ExternalIPs) > 0 && len(r.admit) == 0:
90
+		errs = append(errs, field.Forbidden(field.NewPath("spec", "externalIPs"), "externalIPs have been disabled"))
91
+	// administrator has limited the range
92
+	case len(svc.Spec.ExternalIPs) > 0 && len(r.admit) > 0:
93
+		for i, s := range svc.Spec.ExternalIPs {
94
+			ip := net.ParseIP(s)
95
+			if ip == nil {
96
+				errs = append(errs, field.Forbidden(field.NewPath("spec", "externalIPs").Index(i), "externalIPs must be a valid address"))
97
+				continue
98
+			}
99
+			if networkSlice(r.reject).Contains(ip) || !networkSlice(r.admit).Contains(ip) {
100
+				errs = append(errs, field.Forbidden(field.NewPath("spec", "externalIPs").Index(i), "externalIP is not allowed"))
101
+				continue
102
+			}
103
+		}
104
+	}
105
+	if len(errs) > 0 {
106
+		return apierrs.NewInvalid(a.GetKind(), a.GetName(), errs)
107
+	}
108
+	return nil
109
+}
0 110
new file mode 100644
... ...
@@ -0,0 +1,188 @@
0
+package admission
1
+
2
+import (
3
+	"net"
4
+	"strings"
5
+	"testing"
6
+
7
+	"k8s.io/kubernetes/pkg/admission"
8
+	kapi "k8s.io/kubernetes/pkg/api"
9
+)
10
+
11
+// TestAdmission verifies various scenarios involving pod/project/global node label selectors
12
+func TestAdmission(t *testing.T) {
13
+	svc := &kapi.Service{
14
+		ObjectMeta: kapi.ObjectMeta{Name: "test"},
15
+	}
16
+
17
+	_, ipv4, err := net.ParseCIDR("172.0.0.0/16")
18
+	if err != nil {
19
+		t.Fatal(err)
20
+	}
21
+	_, ipv4subset, err := net.ParseCIDR("172.0.1.0/24")
22
+	if err != nil {
23
+		t.Fatal(err)
24
+	}
25
+	_, ipv4offset, err := net.ParseCIDR("172.200.0.0/24")
26
+	if err != nil {
27
+		t.Fatal(err)
28
+	}
29
+	_, none, err := net.ParseCIDR("0.0.0.0/32")
30
+	if err != nil {
31
+		t.Fatal(err)
32
+	}
33
+	_, all, err := net.ParseCIDR("0.0.0.0/0")
34
+	if err != nil {
35
+		t.Fatal(err)
36
+	}
37
+
38
+	tests := []struct {
39
+		testName        string
40
+		rejects, admits []*net.IPNet
41
+		op              admission.Operation
42
+		externalIPs     []string
43
+		admit           bool
44
+		errFn           func(err error) bool
45
+	}{
46
+		{
47
+			admit:    true,
48
+			op:       admission.Create,
49
+			testName: "No external IPs on create",
50
+		},
51
+		{
52
+			admit:    true,
53
+			op:       admission.Update,
54
+			testName: "No external IPs on update",
55
+		},
56
+		{
57
+			admit:       false,
58
+			externalIPs: []string{"1.2.3.4"},
59
+			op:          admission.Create,
60
+			testName:    "No external IPs allowed on create",
61
+			errFn:       func(err error) bool { return strings.Contains(err.Error(), "externalIPs have been disabled") },
62
+		},
63
+		{
64
+			admit:       false,
65
+			externalIPs: []string{"1.2.3.4"},
66
+			op:          admission.Update,
67
+			testName:    "No external IPs allowed on update",
68
+			errFn:       func(err error) bool { return strings.Contains(err.Error(), "externalIPs have been disabled") },
69
+		},
70
+		{
71
+			admit:       false,
72
+			admits:      []*net.IPNet{ipv4},
73
+			externalIPs: []string{"1.2.3.4"},
74
+			op:          admission.Create,
75
+			testName:    "IP out of range on create",
76
+			errFn: func(err error) bool {
77
+				return strings.Contains(err.Error(), "externalIP is not allowed") &&
78
+					strings.Contains(err.Error(), "spec.externalIPs[0]")
79
+			},
80
+		},
81
+		{
82
+			admit:       false,
83
+			admits:      []*net.IPNet{ipv4},
84
+			externalIPs: []string{"1.2.3.4"},
85
+			op:          admission.Update,
86
+			testName:    "IP out of range on update",
87
+			errFn: func(err error) bool {
88
+				return strings.Contains(err.Error(), "externalIP is not allowed") &&
89
+					strings.Contains(err.Error(), "spec.externalIPs[0]")
90
+			},
91
+		},
92
+		{
93
+			admit:       false,
94
+			admits:      []*net.IPNet{ipv4},
95
+			rejects:     []*net.IPNet{ipv4subset},
96
+			externalIPs: []string{"172.0.1.1"},
97
+			op:          admission.Update,
98
+			testName:    "IP out of range due to blacklist",
99
+			errFn: func(err error) bool {
100
+				return strings.Contains(err.Error(), "externalIP is not allowed") &&
101
+					strings.Contains(err.Error(), "spec.externalIPs[0]")
102
+			},
103
+		},
104
+		{
105
+			admit:       false,
106
+			admits:      []*net.IPNet{ipv4},
107
+			rejects:     []*net.IPNet{ipv4offset},
108
+			externalIPs: []string{"172.199.1.1"},
109
+			op:          admission.Update,
110
+			testName:    "IP not in reject or admit",
111
+			errFn: func(err error) bool {
112
+				return strings.Contains(err.Error(), "externalIP is not allowed") &&
113
+					strings.Contains(err.Error(), "spec.externalIPs[0]")
114
+			},
115
+		},
116
+		{
117
+			admit:       true,
118
+			admits:      []*net.IPNet{ipv4},
119
+			externalIPs: []string{"172.0.0.1"},
120
+			op:          admission.Create,
121
+			testName:    "IP in range on create",
122
+		},
123
+		{
124
+			admit:       true,
125
+			admits:      []*net.IPNet{ipv4},
126
+			externalIPs: []string{"172.0.0.1"},
127
+			op:          admission.Update,
128
+			testName:    "IP in range on update",
129
+		},
130
+
131
+		// other checks
132
+		{
133
+			admit:       false,
134
+			admits:      []*net.IPNet{ipv4},
135
+			externalIPs: []string{"abcd"},
136
+			op:          admission.Create,
137
+			testName:    "IP unparseable on create",
138
+			errFn: func(err error) bool {
139
+				return strings.Contains(err.Error(), "externalIPs must be a valid address") &&
140
+					strings.Contains(err.Error(), "spec.externalIPs[0]")
141
+			},
142
+		},
143
+		{
144
+			admit:       false,
145
+			admits:      []*net.IPNet{none},
146
+			externalIPs: []string{"1.2.3.4"},
147
+			op:          admission.Create,
148
+			testName:    "IP range is empty",
149
+		},
150
+		{
151
+			admit:       false,
152
+			rejects:     []*net.IPNet{all},
153
+			admits:      []*net.IPNet{all},
154
+			externalIPs: []string{"1.2.3.4"},
155
+			op:          admission.Create,
156
+			testName:    "rejections can cover the entire range",
157
+		},
158
+	}
159
+	for _, test := range tests {
160
+		svc.Spec.ExternalIPs = test.externalIPs
161
+		handler := NewExternalIPRanger(test.rejects, test.admits)
162
+
163
+		err := handler.Admit(admission.NewAttributesRecord(svc, kapi.Kind("Service"), "namespace", svc.ObjectMeta.Name, kapi.Resource("services"), "", test.op, nil))
164
+		if test.admit && err != nil {
165
+			t.Errorf("%s: expected no error but got: %s", test.testName, err)
166
+		} else if !test.admit && err == nil {
167
+			t.Errorf("%s: expected an error", test.testName)
168
+		}
169
+		if test.errFn != nil && !test.errFn(err) {
170
+			t.Errorf("%s: unexpected error: %v", test.testName, err)
171
+		}
172
+	}
173
+}
174
+
175
+func TestHandles(t *testing.T) {
176
+	for op, shouldHandle := range map[admission.Operation]bool{
177
+		admission.Create:  true,
178
+		admission.Update:  true,
179
+		admission.Connect: false,
180
+		admission.Delete:  false,
181
+	} {
182
+		ranger := NewExternalIPRanger(nil, nil)
183
+		if e, a := shouldHandle, ranger.Handles(op); e != a {
184
+			t.Errorf("%v: shouldHandle=%t, handles=%t", op, e, a)
185
+		}
186
+	}
187
+}