diff --git a/Gopkg.lock b/Gopkg.lock index 6ccada9bd4..99f92f232e 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1008,6 +1008,8 @@ "k8s.io/apimachinery/pkg/util/errors", "k8s.io/apimachinery/pkg/util/net", "k8s.io/apimachinery/pkg/util/runtime", + "k8s.io/apimachinery/pkg/util/sets", + "k8s.io/apimachinery/pkg/util/validation", "k8s.io/apimachinery/pkg/util/yaml", "k8s.io/client-go/discovery", "k8s.io/client-go/kubernetes/scheme", diff --git a/pkg/controller/add_networkconfig.go b/pkg/controller/add_networkconfig.go index dd25ef145c..b1cfe0d34f 100644 --- a/pkg/controller/add_networkconfig.go +++ b/pkg/controller/add_networkconfig.go @@ -3,11 +3,13 @@ package controller import ( "github.com/openshift/cluster-network-operator/pkg/controller/clusterconfig" "github.com/openshift/cluster-network-operator/pkg/controller/operconfig" + "github.com/openshift/cluster-network-operator/pkg/controller/proxyconfig" ) func init() { // AddToManagerFuncs is a list of functions to create controllers and add them to a manager. AddToManagerFuncs = append(AddToManagerFuncs, + proxyconfig.Add, operconfig.Add, clusterconfig.Add, operconfig.AddConfigMapReconciler, diff --git a/pkg/controller/proxyconfig/controller.go b/pkg/controller/proxyconfig/controller.go new file mode 100644 index 0000000000..8c7e7ec8e8 --- /dev/null +++ b/pkg/controller/proxyconfig/controller.go @@ -0,0 +1,156 @@ +package proxyconfig + +import ( + "context" + "fmt" + "log" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/cluster-network-operator/pkg/controller/statusmanager" + "github.com/openshift/cluster-network-operator/pkg/names" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// and Start it when the Manager is Started. +func Add(mgr manager.Manager, status *statusmanager.StatusManager) error { + reconciler := newReconciler(mgr, status) + if reconciler == nil { + return fmt.Errorf("failed to create reconciler") + } + + return add(mgr, reconciler) +} + +// newReconciler returns a new reconcile.Reconciler +func newReconciler(mgr manager.Manager, status *statusmanager.StatusManager) reconcile.Reconciler { + if err := configv1.Install(mgr.GetScheme()); err != nil { + return &ReconcileProxyConfig{} + } + + return &ReconcileProxyConfig{client: mgr.GetClient(), scheme: mgr.GetScheme(), status: status} +} + +// add adds a new Controller to mgr with r as the reconcile.Reconciler +func add(mgr manager.Manager, r reconcile.Reconciler) error { + // Create a new controller + c, err := controller.New("proxyconfig-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to the proxy resource. + err = c.Watch(&source.Kind{Type: &configv1.Proxy{}}, &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + + return nil +} + +// ReconcileProxyConfig reconciles a Proxy object +type ReconcileProxyConfig struct { + // This client, initialized using mgr.Client() above, is a split client + // that reads objects from the cache and writes to the apiserver. + client client.Client + scheme *runtime.Scheme + status *statusmanager.StatusManager +} + +// Reconcile expects request to refer to a proxy object named "cluster" +// in the default namespace and will ensure the proxy object is in the +// desired state. +func (r *ReconcileProxyConfig) Reconcile(request reconcile.Request) (reconcile.Result, error) { + log.Printf("Reconciling proxy '%s'", request.Name) + proxyConfig := &configv1.Proxy{} + err := r.client.Get(context.TODO(), request.NamespacedName, proxyConfig) + if err != nil { + if apierrors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + // Return and don't requeue + log.Printf("proxy '%s' not found; reconciliation will be skipped", request.Name) + return reconcile.Result{}, nil + } + // Error reading the object - requeue the request. + return reconcile.Result{}, fmt.Errorf("failed to get proxy '%s': %v", request.Name, err) + } + + // A nil proxy is generated by upgrades and installs not requiring a proxy. + if !isSpecHTTPProxySet(&proxyConfig.Spec) && + !isSpecHTTPSProxySet(&proxyConfig.Spec) && + !isSpecNoProxySet(&proxyConfig.Spec) { + log.Printf("httpProxy, httpsProxy and noProxy not defined for proxy '%s'; validation will be skipped", + request.Name) + } + + // Only proceed if the required config objects can be collected. + infraConfig := &configv1.Infrastructure{} + netConfig := &configv1.Network{} + clusterCfgMap := &corev1.ConfigMap{} + if err := r.client.Get(context.TODO(), types.NamespacedName{Name: names.CLUSTER_CONFIG}, infraConfig); err != nil { + log.Printf("failed to get infrastructure config '%s': %v", names.CLUSTER_CONFIG, err) + r.status.SetDegraded(statusmanager.ProxyConfig, "InfraConfigError", + fmt.Sprintf("Error getting infrastructure config %s: %v.", names.CLUSTER_CONFIG, err)) + return reconcile.Result{}, nil + } + if err := r.client.Get(context.TODO(), types.NamespacedName{Name: names.CLUSTER_CONFIG}, netConfig); err != nil { + log.Printf("failed to get network config '%s': %v", names.CLUSTER_CONFIG, err) + r.status.SetDegraded(statusmanager.ProxyConfig, "NetworkConfigError", + fmt.Sprintf("Error getting network config '%s': %v.", names.CLUSTER_CONFIG, err)) + return reconcile.Result{}, nil + } + if err := r.client.Get(context.TODO(), types.NamespacedName{Name: "cluster-config-v1", Namespace: "kube-system"}, + clusterCfgMap); err != nil { + log.Printf("failed to get configmap '%s/%s': %v", clusterCfgMap.Namespace, clusterCfgMap.Name, err) + r.status.SetDegraded(statusmanager.ProxyConfig, "ClusterConfigError", + fmt.Sprintf("Error getting cluster config configmap '%s/%s': %v.", clusterCfgMap.Namespace, + clusterCfgMap.Name, err)) + return reconcile.Result{}, nil + } + if err := r.ValidateProxyConfig(&proxyConfig.Spec); err != nil { + log.Printf("Failed to validate proxy '%s': %v", proxyConfig.Name, err) + r.status.SetDegraded(statusmanager.ProxyConfig, "InvalidProxyConfig", + fmt.Sprintf("The configuration is invalid for proxy '%s' (%v). "+ + "Use 'oc edit proxy.config.openshift.io %s' to fix.", proxyConfig.Name, proxyConfig.Name, err)) + return reconcile.Result{}, nil + } + + // Update proxy status. + if err := r.syncProxyStatus(proxyConfig, infraConfig, netConfig, clusterCfgMap); err != nil { + log.Printf("Could not sync proxy '%s' status: %v", proxyConfig.Name, err) + r.status.SetDegraded(statusmanager.ProxyConfig, "StatusError", + fmt.Sprintf("Could not update proxy '%s' status: %v", proxyConfig.Name, err)) + return reconcile.Result{}, err + } + log.Printf("Reconciling proxy '%s' complete", request.Name) + + r.status.SetNotDegraded(statusmanager.ProxyConfig) + + return reconcile.Result{}, nil +} + +// isSpecHTTPProxySet returns true if spec.httpProxy of +// proxyConfig is set. +func isSpecHTTPProxySet(proxyConfig *configv1.ProxySpec) bool { + return len(proxyConfig.HTTPProxy) > 0 +} + +// isSpecHTTPSProxySet returns true if spec.httpsProxy of +// proxyConfig is set. +func isSpecHTTPSProxySet(proxyConfig *configv1.ProxySpec) bool { + return len(proxyConfig.HTTPSProxy) > 0 +} + +// isSpecNoProxySet returns true if spec.NoProxy of proxyConfig is set. +func isSpecNoProxySet(proxyConfig *configv1.ProxySpec) bool { + return len(proxyConfig.NoProxy) > 0 +} diff --git a/pkg/controller/proxyconfig/status.go b/pkg/controller/proxyconfig/status.go new file mode 100644 index 0000000000..8e21679489 --- /dev/null +++ b/pkg/controller/proxyconfig/status.go @@ -0,0 +1,124 @@ +package proxyconfig + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/ghodss/yaml" + + configv1 "github.com/openshift/api/config/v1" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +// syncProxyStatus computes the current status of proxy and +// updates status of any changes since last sync. +func (r *ReconcileProxyConfig) syncProxyStatus(proxy *configv1.Proxy, infra *configv1.Infrastructure, network *configv1.Network, cluster *corev1.ConfigMap) error { + var err error + var noProxy string + updated := proxy.DeepCopy() + + if isSpecNoProxySet(&proxy.Spec) { + if proxy.Spec.NoProxy == noProxyWildcard { + noProxy = proxy.Spec.NoProxy + } else { + noProxy, err = mergeUserSystemNoProxy(proxy, infra, network, cluster) + if err != nil { + return fmt.Errorf("failed to merge user/system noProxy settings: %v", err) + } + } + } + + updated.Status.HTTPProxy = proxy.Spec.HTTPProxy + updated.Status.HTTPSProxy = proxy.Spec.HTTPSProxy + updated.Status.NoProxy = noProxy + + if !proxyStatusesEqual(proxy.Status, updated.Status) { + if err := r.client.Status().Update(context.TODO(), updated); err != nil { + return fmt.Errorf("failed to update proxy status: %v", err) + } + } + + return nil +} + +// mergeUserSystemNoProxy merges user-supplied noProxy settings from proxy +// with cluster-wide noProxy settings, returning a merged, comma-separated +// string of noProxy settings. +func mergeUserSystemNoProxy(proxy *configv1.Proxy, infra *configv1.Infrastructure, network *configv1.Network, cluster *corev1.ConfigMap) (string, error) { + apiServerURL, err := url.Parse(infra.Status.APIServerURL) + if err != nil { + return "", fmt.Errorf("failed to parse API server URL") + } + + internalAPIServer, err := url.Parse(infra.Status.APIServerInternalURL) + if err != nil { + return "", fmt.Errorf("failed to parse API server internal URL") + } + + set := sets.NewString( + "127.0.0.1", + "localhost", + network.Status.ServiceNetwork[0], + apiServerURL.Hostname(), + internalAPIServer.Hostname(), + ) + + // TODO: This will be flexible when master machine management is more dynamic. + type installConfig struct { + ControlPlane struct { + Replicas string `json:"replicas"` + } `json:"controlPlane"` + Networking struct { + MachineCIDR string `json:"machineCIDR"` + } `json:"networking"` + } + var ic installConfig + data, ok := cluster.Data["install-config"] + if !ok { + return "", fmt.Errorf("missing install-config in configmap") + } + if err := yaml.Unmarshal([]byte(data), &ic); err != nil { + return "", fmt.Errorf("invalid install-config: %v\njson:\n%s", err, data) + } + + switch infra.Status.PlatformStatus.Type { + case configv1.AWSPlatformType, configv1.GCPPlatformType, configv1.AzurePlatformType, configv1.OpenStackPlatformType: + set.Insert("169.254.169.254", ic.Networking.MachineCIDR) + } + + replicas, err := strconv.Atoi(ic.ControlPlane.Replicas) + if err != nil { + return "", fmt.Errorf("failed to parse install config replicas: %v", err) + } + + for i := int64(0); i < int64(replicas); i++ { + etcdHost := fmt.Sprintf("etcd-%d.%s", i, infra.Status.EtcdDiscoveryDomain) + set.Insert(etcdHost) + } + + for _, clusterNetwork := range network.Status.ClusterNetwork { + set.Insert(clusterNetwork.CIDR) + } + + for _, userValue := range strings.Split(proxy.Spec.NoProxy, ",") { + set.Insert(userValue) + } + + return strings.Join(set.List(), ","), nil +} + +// proxyStatusesEqual compares two ProxyStatus values. Returns true if the +// provided values should be considered equal for the purpose of determining +// whether an update is necessary, false otherwise. +func proxyStatusesEqual(a, b configv1.ProxyStatus) bool { + if a.HTTPProxy != b.HTTPProxy || a.HTTPSProxy != b.HTTPSProxy || a.NoProxy != b.NoProxy { + return false + } + + return true +} diff --git a/pkg/controller/proxyconfig/validation.go b/pkg/controller/proxyconfig/validation.go new file mode 100644 index 0000000000..8548aa7a79 --- /dev/null +++ b/pkg/controller/proxyconfig/validation.go @@ -0,0 +1,56 @@ +package proxyconfig + +import ( + "fmt" + "net" + "strings" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/cluster-network-operator/pkg/util/validation" +) + +const ( + proxyHTTPScheme = "http" + proxyHTTPSScheme = "https" + // noProxyWildcard is the string used to as a wildcard attached to a + // domain suffix in proxy.spec.noProxy to bypass proxying. + noProxyWildcard = "*" +) + +// ValidateProxyConfig ensures that proxyConfig is valid. +func (r *ReconcileProxyConfig) ValidateProxyConfig(proxyConfig *configv1.ProxySpec) error { + if isSpecHTTPProxySet(proxyConfig) { + scheme, err := validation.URI(proxyConfig.HTTPProxy) + if err != nil { + return fmt.Errorf("invalid httpProxy URI: %v", err) + } + if scheme != proxyHTTPScheme { + return fmt.Errorf("httpProxy requires an %q URI scheme", proxyHTTPScheme) + } + } + + if isSpecHTTPSProxySet(proxyConfig) { + scheme, err := validation.URI(proxyConfig.HTTPSProxy) + if err != nil { + return fmt.Errorf("invalid httpsProxy URI: %v", err) + } + if scheme != proxyHTTPScheme && scheme != proxyHTTPSScheme { + return fmt.Errorf("httpsProxy requires a %q or %s URI scheme", proxyHTTPScheme, proxyHTTPSScheme) + } + } + + if isSpecNoProxySet(proxyConfig) { + if proxyConfig.NoProxy != noProxyWildcard { + for _, v := range strings.Split(proxyConfig.NoProxy, ",") { + v = strings.TrimSpace(v) + errDomain := validation.DomainName(v, false) + _, _, errCIDR := net.ParseCIDR(v) + if errDomain != nil && errCIDR != nil { + return fmt.Errorf("invalid noProxy: %v", v) + } + } + } + } + + return nil +} diff --git a/pkg/controller/statusmanager/status_manager.go b/pkg/controller/statusmanager/status_manager.go index 00fcc930da..9786dc470d 100644 --- a/pkg/controller/statusmanager/status_manager.go +++ b/pkg/controller/statusmanager/status_manager.go @@ -26,6 +26,7 @@ type StatusLevel int const ( ClusterConfig StatusLevel = iota OperatorConfig StatusLevel = iota + ProxyConfig StatusLevel = iota PodDeployment StatusLevel = iota maxStatusLevel StatusLevel = iota ) diff --git a/pkg/names/names.go b/pkg/names/names.go index bd68838958..e278639099 100644 --- a/pkg/names/names.go +++ b/pkg/names/names.go @@ -1,5 +1,7 @@ package names +import "k8s.io/apimachinery/pkg/types" + // some names // OperatorConfig is the name of the CRD that defines the complete @@ -10,6 +12,9 @@ const OPERATOR_CONFIG = "cluster" // and status object. const CLUSTER_CONFIG = "cluster" +// PROXY_CONFIG is the name of the default proxy object. +const PROXY_CONFIG = "cluster" + // APPLIED_PREFIX is the prefix applied to the config maps // where we store previously applied configuration const APPLIED_PREFIX = "applied-" @@ -31,3 +36,11 @@ const SERVICE_CA_CONFIGMAP = "openshift-service-ca" // MULTUS_VALIDATING_WEBHOOK is the name of the ValidatingWebhookConfiguration for multus-admission-controller // that is used in multus admission controller deployment const MULTUS_VALIDATING_WEBHOOK = "multus.openshift.io" + +// Proxy returns the namespaced name "cluster" in the +// default namespace. +func Proxy() types.NamespacedName { + return types.NamespacedName{ + Name: PROXY_CONFIG, + } +} diff --git a/pkg/network/proxy.go b/pkg/network/kube_proxy.go similarity index 100% rename from pkg/network/proxy.go rename to pkg/network/kube_proxy.go diff --git a/pkg/network/proxy_test.go b/pkg/network/kube_proxy_test.go similarity index 100% rename from pkg/network/proxy_test.go rename to pkg/network/kube_proxy_test.go diff --git a/pkg/util/validation/network.go b/pkg/util/validation/network.go new file mode 100644 index 0000000000..92de1684b7 --- /dev/null +++ b/pkg/util/validation/network.go @@ -0,0 +1,84 @@ +package validation + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + k8serrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/validation" +) + +// DomainName checks if the given string is a valid domain name. +func DomainName(v string, acceptTrailingDot bool) error { + v = strings.TrimPrefix(v, ".") + if acceptTrailingDot { + v = strings.TrimSuffix(v, ".") + } + + return Subdomain(v) +} + +// Subdomain checks if the given string is a valid subdomain name. +func Subdomain(v string) error { + validationMessages := validation.IsDNS1123Subdomain(v) + if len(validationMessages) == 0 { + return nil + } + + errs := make([]error, len(validationMessages)) + for i, m := range validationMessages { + errs[i] = errors.New(m) + } + + return k8serrors.NewAggregate(errs) +} + +// Host validates if host is a valid IP address or subdomain in DNS (RFC 1123). +func Host(host string) error { + errDomain := DomainName(host, false) + errIP := validation.IsValidIP(host) + if errDomain != nil && errIP != nil { + return fmt.Errorf("invalid host: %s", host) + } + + return nil +} + +// Port validates if port is a valid port number between 1-65535. +func Port(port int) error { + invalidPorts := validation.IsValidPortNum(port) + if invalidPorts != nil { + return fmt.Errorf("invalid port number: %d", port) + } + + return nil +} + +// URI validates uri as being a valid url and returns the url scheme. +func URI(uri string) (string, error) { + parsed, err := url.Parse(uri) + if err != nil { + return "", err + } + if !parsed.IsAbs() { + return "", fmt.Errorf("failed validating URI, no scheme for URI %q", uri) + } + host := parsed.Hostname() + if err := Host(host); err != nil { + return "", fmt.Errorf("failed validating URI %q: %v", uri, err) + } + if port := parsed.Port(); len(port) != 0 { + intPort, err := strconv.Atoi(port) + if err != nil { + return "", fmt.Errorf("failed converting port to integer for URI %q: %v", uri, err) + } + if err := Port(intPort); err != nil { + return "", fmt.Errorf("failed to validate port for URL %q: %v", uri, err) + } + } + + return parsed.Scheme, nil +}