Skip to content

Commit 82be7bf

Browse files
committed
OSD-24275: Validate machineCIDR is contained in default ingresscontroller allowedSourceRanges
1 parent cba44ff commit 82be7bf

File tree

4 files changed

+586
-18
lines changed

4 files changed

+586
-18
lines changed

build/resources.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const (
3232
roleName string = "validation-webhook"
3333
prometheusRoleName string = "prometheus-k8s"
3434
repoName string = "managed-cluster-validating-webhooks"
35+
// Role and Binding for reading cluster-config-v1 config map...
36+
clusterConfigRole string = "config-v1-reader-wh"
37+
clusterConfigRoleBinding string = "validation-webhook-cluster-config-v1-reader"
3538
// Used to define what phase a resource should be deployed in by package-operator
3639
pkoPhaseAnnotation string = "package-operator.run/phase"
3740
// Defines the 'rbac' package-operator phase for any resources related to RBAC
@@ -211,6 +214,60 @@ func createClusterRoleBinding() *rbacv1.ClusterRoleBinding {
211214
}
212215
}
213216

217+
func createClusterConfigRole() *rbacv1.Role {
218+
return &rbacv1.Role{
219+
TypeMeta: metav1.TypeMeta{
220+
Kind: "Role",
221+
APIVersion: rbacv1.SchemeGroupVersion.String(),
222+
},
223+
ObjectMeta: metav1.ObjectMeta{
224+
Name: clusterConfigRole,
225+
Namespace: "kube-system",
226+
},
227+
Rules: []rbacv1.PolicyRule{
228+
{
229+
APIGroups: []string{
230+
"",
231+
},
232+
Resources: []string{
233+
"configmaps",
234+
},
235+
Verbs: []string{
236+
"get",
237+
},
238+
ResourceNames: []string{
239+
"cluster-config-v1",
240+
},
241+
},
242+
},
243+
}
244+
}
245+
246+
func createClusterConfigRoleBinding() *rbacv1.RoleBinding {
247+
return &rbacv1.RoleBinding{
248+
TypeMeta: metav1.TypeMeta{
249+
Kind: "RoleBinding",
250+
APIVersion: rbacv1.SchemeGroupVersion.String(),
251+
},
252+
ObjectMeta: metav1.ObjectMeta{
253+
Name: clusterConfigRoleBinding,
254+
Namespace: "kube-system",
255+
},
256+
Subjects: []rbacv1.Subject{
257+
{
258+
Kind: "ServiceAccount",
259+
Name: serviceAccountName,
260+
Namespace: *namespace,
261+
},
262+
},
263+
RoleRef: rbacv1.RoleRef{
264+
Name: clusterConfigRole,
265+
Kind: "Role",
266+
APIGroup: rbacv1.GroupName,
267+
},
268+
}
269+
}
270+
214271
func createPrometheusRole() *rbacv1.Role {
215272
return &rbacv1.Role{
216273
TypeMeta: metav1.TypeMeta{
@@ -851,6 +908,8 @@ func main() {
851908
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createClusterRoleBinding()})
852909
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createPrometheusRole()})
853910
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createPromethusRoleBinding()})
911+
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createClusterConfigRole()})
912+
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createClusterConfigRoleBinding()})
854913
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createServiceMonitor()})
855914
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createCACertConfigMap()})
856915
templateResources.Add(utils.DefaultLabelSelector(), runtime.RawExtension{Object: createService()})

build/selectorsyncset.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,35 @@ objects:
123123
- kind: ServiceAccount
124124
name: prometheus-k8s
125125
namespace: openshift-monitoring
126+
- apiVersion: rbac.authorization.k8s.io/v1
127+
kind: Role
128+
metadata:
129+
creationTimestamp: null
130+
name: config-v1-reader-wh
131+
namespace: kube-system
132+
rules:
133+
- apiGroups:
134+
- ""
135+
resourceNames:
136+
- cluster-config-v1
137+
resources:
138+
- configmaps
139+
verbs:
140+
- get
141+
- apiVersion: rbac.authorization.k8s.io/v1
142+
kind: RoleBinding
143+
metadata:
144+
creationTimestamp: null
145+
name: validation-webhook-cluster-config-v1-reader
146+
namespace: kube-system
147+
roleRef:
148+
apiGroup: rbac.authorization.k8s.io
149+
kind: Role
150+
name: config-v1-reader-wh
151+
subjects:
152+
- kind: ServiceAccount
153+
name: validation-webhook
154+
namespace: openshift-validation-webhook
126155
- apiVersion: monitoring.coreos.com/v1
127156
kind: ServiceMonitor
128157
metadata:

pkg/webhooks/ingresscontroller/ingresscontroller.go

Lines changed: 177 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
package ingresscontroller
22

33
import (
4+
"bytes"
5+
"context"
46
"fmt"
7+
"net"
58
"net/http"
9+
"os"
610
"slices"
711
"strings"
812

913
operatorv1 "github.com/openshift/api/operator/v1"
14+
"github.com/openshift/managed-cluster-validating-webhooks/pkg/k8sutil"
1015
"github.com/openshift/managed-cluster-validating-webhooks/pkg/webhooks/utils"
16+
"github.com/pkg/errors"
17+
admissionv1 "k8s.io/api/admission/v1"
1118
admissionregv1 "k8s.io/api/admissionregistration/v1"
19+
corev1 "k8s.io/api/core/v1"
1220
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1321
"k8s.io/apimachinery/pkg/runtime"
14-
admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
15-
22+
"k8s.io/apimachinery/pkg/util/yaml"
23+
"sigs.k8s.io/controller-runtime/pkg/client"
1624
logf "sigs.k8s.io/controller-runtime/pkg/log"
25+
admissionctl "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
1726
)
1827

1928
const (
2029
WebhookName string = "ingresscontroller-validation"
2130
docString string = `Managed OpenShift Customer may create IngressControllers without necessary taints. This can cause those workloads to be provisioned on master nodes.`
2231
legacyIngressSupportFeatureFlag = "ext-managed.openshift.io/legacy-ingress-support"
32+
installConfigMap = "cluster-config-v1"
33+
installConfigNamespace = "kube-system"
34+
installConfigKeyName = "install-config"
2335
)
2436

2537
var (
@@ -42,7 +54,22 @@ var (
4254
)
4355

4456
type IngressControllerWebhook struct {
45-
s runtime.Scheme
57+
s runtime.Scheme
58+
kubeClient client.Client
59+
// Allow caching install config and machineCIDR values...
60+
machineCIDRIP net.IP
61+
machineCIDRNet *net.IPNet
62+
}
63+
64+
// installConfig struct to load the min values needed from the install-config data.
65+
// Alternative would be to vendor all of github.com/openshift/installer/pkg/types to import the proper struct.
66+
// Required values: machineCidr info, anything else we gather from this? hcp vs classic, privatelink info, etc?
67+
type installConfig struct {
68+
metav1.TypeMeta `json:",inline"`
69+
metav1.ObjectMeta `json:"metadata"`
70+
Networking struct {
71+
MachineCIDR string `json:"machineCIDR"`
72+
} `json:"networking"`
4673
}
4774

4875
// ObjectSelector implements Webhook interface
@@ -149,12 +176,151 @@ func (wh *IngressControllerWebhook) authorized(request admissionctl.Request) adm
149176
}
150177
}
151178

152-
ret = admissionctl.Allowed("IngressController operation is allowed")
179+
/* TODO:
180+
* 1) ONLY check for privatelink clusters? How?
181+
* 2) HCP vs Classic, etc.. ...any other cluster type this should apply to? Is this handled by the
182+
* HypershiftEnabled vs ClassicEnabled flags set in this module? Currently HCP disabled.
183+
* If HCP is to be enabled for allowed source ranges, should this part of a 2nd ingress validator to
184+
* allow separation of validations between cluster install types? Is there a run time method available
185+
* to this validator to determine classic vs hcp?
186+
* 3) What other specifics should be checked here for this cidr check to be applicable?m
187+
*/
188+
// Only check for machine cidr in allowed ranges if creating or updating resource...
189+
reqOp := request.AdmissionRequest.Operation
190+
if reqOp == admissionv1.Create || reqOp == admissionv1.Update {
191+
//TODO: Will these need to iterate over more than just the default IngressController config?
192+
if ic.ObjectMeta.Name == "default" && ic.ObjectMeta.Namespace == "openshift-ingress-operator" {
193+
ret := wh.checkAllowsMachineCIDR(ic.Spec.EndpointPublishingStrategy.LoadBalancer.AllowedSourceRanges)
194+
ret.UID = request.AdmissionRequest.UID
195+
if !ret.Allowed {
196+
log.Info("Error checking minimum AllowedSourceRange", "err", ret.AdmissionResponse.String())
197+
}
198+
return ret
199+
}
200+
}
201+
log.Info("############# DEBUG LOG: IngressController operation is allowed ###########")
202+
ret = admissionctl.Allowed("IngressController operation is allowed, machineCIDR n/a")
153203
ret.UID = request.AdmissionRequest.UID
154204

155205
return ret
156206
}
157207

208+
func (wh *IngressControllerWebhook) getMachineCIDR() (net.IP, *net.IPNet, error) {
209+
if wh.machineCIDRIP == nil || wh.machineCIDRNet == nil {
210+
instConf, err := wh.getClusterConfig()
211+
if err != nil {
212+
log.Error(err, "Failed to fetch machineCIDR", "namespace", installConfigNamespace, "configmap", installConfigMap)
213+
return nil, nil, err
214+
}
215+
if instConf == nil {
216+
err := fmt.Errorf("can not fetch machineCIDR from empty '%s' install config", installConfigMap)
217+
log.Error(err, "getMachineCIDR failed to find CIDR value")
218+
return nil, nil, err
219+
}
220+
if len(instConf.Networking.MachineCIDR) <= 0 {
221+
err := fmt.Errorf("empty machineCIDR string value parsed from '%s' install config", installConfigMap)
222+
log.Error(err, "getMachineCIDR found empty CIDR value")
223+
return nil, nil, err
224+
}
225+
machIP, machNet, err := net.ParseCIDR(string(instConf.Networking.MachineCIDR))
226+
if err != nil {
227+
log.Error(err, "err parsing machineCIDR into network cidr", "machineCIDR", string(instConf.Networking.MachineCIDR))
228+
return nil, nil, err
229+
}
230+
if machIP == nil || machNet == nil {
231+
err := fmt.Errorf("failed to parse machineCIDR string:'%s' into network structures", string(instConf.Networking.MachineCIDR))
232+
log.Error(err, "failed to parse install-config machineCIDR")
233+
return nil, nil, err
234+
}
235+
// Successfully fetched, parsed, and converted the machineCIDR string into net structures...
236+
wh.machineCIDRIP = machIP
237+
wh.machineCIDRNet = machNet
238+
}
239+
return wh.machineCIDRIP, wh.machineCIDRNet, nil
240+
}
241+
242+
/* Fetch the install-config from the kube-system config map's data.
243+
* this requires proper role, rolebinding for this service account's get() request
244+
* to succeed. (see toplevel selectorsyncset). This config should not change during runtime so
245+
* this operation should cache this if possible.
246+
* TODO: Should it retry fetching the config if there are any failures/errors encountered while
247+
* parsing out the the desired values?
248+
*/
249+
func (wh *IngressControllerWebhook) getClusterConfig() (*installConfig, error) {
250+
var err error
251+
if wh.kubeClient == nil {
252+
wh.kubeClient, err = k8sutil.KubeClient(&wh.s)
253+
if err != nil {
254+
log.Error(err, "Fail creating KubeClient for IngressControllerWebhook")
255+
return nil, err
256+
}
257+
}
258+
clusterConfig := &corev1.ConfigMap{}
259+
err = wh.kubeClient.Get(context.Background(), client.ObjectKey{Name: installConfigMap, Namespace: installConfigNamespace}, clusterConfig)
260+
if err != nil {
261+
log.Error(err, "Failed to fetch configmap: 'cluster-config-v1' for cluster config")
262+
return nil, err
263+
}
264+
data, ok := clusterConfig.Data[installConfigKeyName]
265+
if !ok {
266+
return nil, fmt.Errorf("did not find key %s in configmap %s/%s", installConfigKeyName, installConfigNamespace, installConfigMap)
267+
}
268+
instConf := &installConfig{}
269+
270+
decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(data)), 4096)
271+
if err := decoder.Decode(instConf); err != nil {
272+
return nil, errors.Wrap(err, "failed to decode install config")
273+
}
274+
return instConf, nil
275+
}
276+
277+
func (wh *IngressControllerWebhook) checkAllowsMachineCIDR(ipRanges []operatorv1.CIDR) admissionctl.Response {
278+
// https://docs.openshift.com/container-platform/4.13/networking/configuring_ingress_cluster_traffic/configuring-ingress-cluster-traffic-load-balancer-allowed-source-ranges.html
279+
// Note: From docs it appears a missing ASR value/attr allows all. However...
280+
// once ASR values have been added to an ingresscontroller, later deleting all the ASRs can expose an issue
281+
// where the IGC will remaining in progressing state indefinitely.
282+
// For now return Allowed with a warning?
283+
if ipRanges == nil || len(ipRanges) <= 0 {
284+
return admissionctl.Allowed("Allowing empty 'AllowedSourceRanges'. Populate this value if operator remains in 'progressing' state")
285+
}
286+
machIP, machNet, err := wh.getMachineCIDR()
287+
if err != nil {
288+
// This represents a fault in either the webhook itself, webhook permissions, or install config.
289+
// Might be nice to have an env var etc we can set to allow proceeding w/o the immediate need to roll new code?
290+
return admissionctl.Errored(http.StatusInternalServerError, err)
291+
}
292+
machNetSize, machNetBits := machNet.Mask.Size()
293+
log.Info("Checking AllowedSourceRanges", "MachineCIDR", fmt.Sprintf("%s/%d", machIP.String(), machNetSize), "NetBits", machNetBits, "AllowedSourceRanges", ipRanges)
294+
for _, OpV1CIDR := range ipRanges {
295+
// Clean up the operatorV1.CIDR value into trimmed CIDR 'a.b.c.d/x' string
296+
ASRstring := strings.TrimSpace(string(OpV1CIDR))
297+
log.Info(fmt.Sprintf("Checking allowed source:'%s'", ASRstring))
298+
if len(ASRstring) <= 0 {
299+
continue
300+
}
301+
// Parse the Allowed Source Range Cidr entry into network structures...
302+
_, ASRNet, err := net.ParseCIDR(ASRstring)
303+
if err != nil {
304+
log.Info(fmt.Sprintf("failed to parse AllowedSourceRanges value: '%s'. Err: %s", string(ASRstring), err))
305+
return admissionctl.Errored(http.StatusBadRequest, fmt.Errorf("failed to parse AllowedSourceRanges value: '%s'. Err: %s", string(ASRstring), err))
306+
}
307+
// First check if this AlloweSourceRange entry network contains the machine cidr ip...
308+
if !ASRNet.Contains(machIP) {
309+
log.Info(fmt.Sprintf("AllowedSourceRange:'%s' does not contain machine CIDR:'%s/%d'", ASRstring, machIP.String(), machNetSize))
310+
continue
311+
}
312+
// Check if this AlloweSourceRange entry mask includes the network.
313+
ASRNetSize, ASRNetBits := ASRNet.Mask.Size()
314+
if machNetBits == ASRNetBits && ASRNetSize <= machNetSize {
315+
log.Info(fmt.Sprintf("Found machineCidr:'%s/%d' within AllowedSourceRange:'%s'", machIP.String(), machNetSize, ASRstring))
316+
return admissionctl.Allowed(fmt.Sprintf("Found machineCidr:'%s/%d' within AllowedSourceRange:'%s'", machIP.String(), machNetSize, ASRstring))
317+
//return admissionctl.Allowed("IngressController operation is allowed. Minimum AllowedSourceRanges are met.")
318+
}
319+
}
320+
log.Info(fmt.Sprintf("machineCidr:'%s/%d' not found within networks provided by AllowedSourceRanges:'%v'", machIP.String(), machNetSize, ipRanges))
321+
return admissionctl.Denied(fmt.Sprintf("At least one AllowedSourceRange must allow machine cidr:'%s/%d'", machIP.String(), machNetSize))
322+
}
323+
158324
// isAllowedUser checks if the user is allowed to perform the action
159325
func isAllowedUser(request admissionctl.Request) bool {
160326
log.Info(fmt.Sprintf("Checking username %s on whitelist", request.UserInfo.Username))
@@ -199,7 +365,13 @@ func (s *IngressControllerWebhook) HypershiftEnabled() bool { return false }
199365
// NewWebhook creates a new webhook
200366
func NewWebhook() *IngressControllerWebhook {
201367
scheme := runtime.NewScheme()
368+
err := corev1.AddToScheme(scheme)
369+
if err != nil {
370+
log.Error(err, "Fail adding corev1 scheme to IngressControllerWebhook")
371+
os.Exit(1)
372+
}
202373
return &IngressControllerWebhook{
203-
s: *scheme,
374+
s: *scheme,
375+
kubeClient: nil,
204376
}
205377
}

0 commit comments

Comments
 (0)