From 6cc3dd8d409e8d758382ea1ee57c896743a203b6 Mon Sep 17 00:00:00 2001 From: qwerty8811 <81417095+qwerty8811@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:26:07 +0300 Subject: [PATCH] Add reportNodeInternalIPs option to report node internal IPs in Ingress status --- .../configuration-options.md | 1 + .../kubernetes/kubernetes-ingress.md | 28 ++++++- .../ingress/fixtures/Node-Internal-IP.yml | 75 +++++++++++++++++++ pkg/provider/kubernetes/ingress/kubernetes.go | 41 +++++++++- .../kubernetes/ingress/kubernetes_test.go | 71 ++++++++++++++++++ 5 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress/fixtures/Node-Internal-IP.yml diff --git a/docs/content/reference/install-configuration/configuration-options.md b/docs/content/reference/install-configuration/configuration-options.md index 1076f643f..4f5ea989c 100644 --- a/docs/content/reference/install-configuration/configuration-options.md +++ b/docs/content/reference/install-configuration/configuration-options.md @@ -395,6 +395,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | providers.kubernetesingress.labelselector | Kubernetes Ingress label selector to use. | | | providers.kubernetesingress.namespaces | Kubernetes namespaces. | | | providers.kubernetesingress.nativelbbydefault | Defines whether to use Native Kubernetes load-balancing mode by default. | false | +| providers.kubernetesingress.reportnodeinternalips | Report node internal IPs in Ingress status. | false | | providers.kubernetesingress.strictprefixmatching | Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). | false | | providers.kubernetesingress.throttleduration | Ingress refresh throttle duration | 0 | | providers.kubernetesingress.token | Kubernetes bearer token (not needed for in-cluster client). It accepts either a token value or a file path to the token. | | diff --git a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md index 7a680e1a8..4f6ed384f 100644 --- a/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md +++ b/docs/content/reference/install-configuration/providers/kubernetes/kubernetes-ingress.md @@ -58,12 +58,13 @@ which in turn creates the resulting routers, services, handlers, etc. | `providers.kubernetesIngress.`
`ingressEndpoint.hostname`
| Hostname used for Kubernetes Ingress endpoints. | "" | No | | `providers.kubernetesIngress.`
`ingressEndpoint.ip`
| This IP will get copied to the Ingress `status.loadbalancer.ip`, and currently only supports one IP value (IPv4 or IPv6). | "" | No | | `providers.kubernetesIngress.`
`ingressEndpoint.publishedService`
| The Kubernetes service to copy status from.
More information [here](#ingressendpointpublishedservice). | "" | No | +| `providers.kubernetesIngress.reportNodeInternalIPs` | Report node internal IPs in Ingress status.
Incompatible with `ingressEndpoint` and `disableClusterScopeResources`.
More information [here](#reportnodeinternalips). | false | No | | `providers.kubernetesIngress.throttleDuration` | Minimum amount of time to wait between two Kubernetes events before producing a new configuration.
This prevents a Kubernetes cluster that updates many times per second from continuously changing your Traefik configuration.
If empty, every event is caught. | 0s | No | | `providers.kubernetesIngress.allowEmptyServices` | Allows creating a route to reach a service that has no endpoint available.
It allows Traefik to handle the requests and responses targeting this service (applying middleware or observability operations) before returning a `503` HTTP Status. | false | No | | `providers.kubernetesIngress.allowExternalNameServices` | Allows the `Ingress` to reference ExternalName services. | false | No | | `providers.kubernetesIngress.crossProviderNamespaces` | List of namespaces from which Ingresses or Services are allowed to use `traefik.ingress.kubernetes.io/router.middlewares`, `traefik.ingress.kubernetes.io/router.tls.options`, or `traefik.ingress.kubernetes.io/service.serverstransport` annotations.
When unset, all namespaces are allowed. When set to `[]`, every cross-provider reference is rejected. | [] | No | | `providers.kubernetesIngress.nativeLBByDefault` | Allow using the Kubernetes Service load balancing between the pods instead of the one provided by Traefik for every `Ingress` by default.
It can be overridden in the [`Service`](../../../../reference/routing-configuration/kubernetes/crd/http/service.md#opt-nativeLB) | false | No | -| `providers.kubernetesIngress.disableClusterScopeResources` | Prevent from discovering cluster scope resources (`IngressClass` and `Nodes`).
By doing so, it alleviates the requirement of giving Traefik the rights to look up for cluster resources.
Furthermore, Traefik will not handle Ingresses with IngressClass references, therefore such Ingresses will be ignored (please note that annotations are not affected by this option).
This will also prevent from using the `NodePortLB` options on services. | false | No | +| `providers.kubernetesIngress.disableClusterScopeResources` | Prevent from discovering cluster scope resources (`IngressClass` and `Nodes`).
By doing so, it alleviates the requirement of giving Traefik the rights to look up for cluster resources.
Furthermore, Traefik will not handle Ingresses with IngressClass references, therefore such Ingresses will be ignored (please note that annotations are not affected by this option).
This will also prevent from using the `NodePortLB` options on services and is incompatible with `reportNodeInternalIPs`. | false | No | | `providers.kubernetesIngress.strictPrefixMatching` | Make prefix matching strictly comply with the Kubernetes Ingress specification (path-element-wise matching instead of character-by-character string matching). For example, a PathPrefix of `/foo` will match `/foo`, `/foo/`, and `/foo/bar` but not `/foobar`. | false | No | @@ -138,6 +139,31 @@ providers: --providers.kubernetesingress.ingressendpoint.publishedservice=namespace/foo-service ``` +### `reportNodeInternalIPs` + +When set to `true`, Traefik reports the internal IPs of all nodes in the cluster into the `status.loadBalancer.ingress` field of each managed Ingress resource. + +This is the equivalent of ingress-nginx's `--report-node-internal-ip-address` flag and is the recommended approach for bare-metal Kubernetes deployments where Traefik runs as a DaemonSet without a cloud LoadBalancer or MetalLB. + +This option requires cluster-scope access to Node resources and is mutually exclusive with `ingressEndpoint` and `disableClusterScopeResources`. + +```yaml tab="File (YAML)" +providers: + kubernetesIngress: + reportNodeInternalIPs: true + # ... +``` + +```toml tab="File (TOML)" +[providers.kubernetesIngress] + reportNodeInternalIPs = true + # ... +``` + +```bash tab="CLI" +--providers.kubernetesingress.reportnodeinternalips=true +``` + ## Routing Configuration See the dedicated section in [routing](../../../../reference/routing-configuration/kubernetes/ingress.md). diff --git a/pkg/provider/kubernetes/ingress/fixtures/Node-Internal-IP.yml b/pkg/provider/kubernetes/ingress/fixtures/Node-Internal-IP.yml new file mode 100644 index 000000000..806783728 --- /dev/null +++ b/pkg/provider/kubernetes/ingress/fixtures/Node-Internal-IP.yml @@ -0,0 +1,75 @@ +--- +kind: Node +apiVersion: v1 +metadata: + name: node1 + +status: + addresses: + - type: InternalIP + address: 10.0.0.1 + - type: ExternalIP + address: 1.2.3.4 + +--- +kind: Node +apiVersion: v1 +metadata: + name: node2 + +status: + addresses: + - type: InternalIP + address: 10.0.0.2 + +--- +kind: Ingress +apiVersion: networking.k8s.io/v1 +metadata: + name: foo + namespace: default + +spec: + rules: + - host: "*.foo.com" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: service1 + port: + number: 80 + +--- +kind: Service +apiVersion: v1 +metadata: + name: service1 + namespace: default + +spec: + ports: + - port: 80 + + clusterIP: 10.0.0.1 + +--- +kind: EndpointSlice +apiVersion: discovery.k8s.io/v1 +metadata: + name: service1-abc + namespace: default + labels: + kubernetes.io/service-name: service1 + +addressType: IPv4 +ports: + - port: 8080 + name: "" +endpoints: + - addresses: + - 10.10.0.1 + conditions: + ready: true diff --git a/pkg/provider/kubernetes/ingress/kubernetes.go b/pkg/provider/kubernetes/ingress/kubernetes.go index 439941dd7..1e55adb9b 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes.go +++ b/pkg/provider/kubernetes/ingress/kubernetes.go @@ -48,6 +48,7 @@ type Provider struct { LabelSelector string `description:"Kubernetes Ingress label selector to use." json:"labelSelector,omitempty" toml:"labelSelector,omitempty" yaml:"labelSelector,omitempty" export:"true"` IngressClass string `description:"Value of kubernetes.io/ingress.class annotation or IngressClass name to watch for." json:"ingressClass,omitempty" toml:"ingressClass,omitempty" yaml:"ingressClass,omitempty" export:"true"` IngressEndpoint *EndpointIngress `description:"Kubernetes Ingress Endpoint." json:"ingressEndpoint,omitempty" toml:"ingressEndpoint,omitempty" yaml:"ingressEndpoint,omitempty" export:"true"` + ReportNodeInternalIPs bool `description:"Report node internal IPs in Ingress status." json:"reportNodeInternalIPs,omitempty" toml:"reportNodeInternalIPs,omitempty" yaml:"reportNodeInternalIPs,omitempty" export:"true"` ThrottleDuration ptypes.Duration `description:"Ingress refresh throttle duration" json:"throttleDuration,omitempty" toml:"throttleDuration,omitempty" yaml:"throttleDuration,omitempty" export:"true"` AllowEmptyServices bool `description:"Allow creation of services without endpoints." json:"allowEmptyServices,omitempty" toml:"allowEmptyServices,omitempty" yaml:"allowEmptyServices,omitempty" export:"true"` AllowExternalNameServices bool `description:"Allow ExternalName services." json:"allowExternalNameServices,omitempty" toml:"allowExternalNameServices,omitempty" yaml:"allowExternalNameServices,omitempty" export:"true"` @@ -72,6 +73,14 @@ func (p *Provider) SetRouterTransform(routerTransform k8s.RouterTransform) { // Init the provider. func (p *Provider) Init() error { + if p.ReportNodeInternalIPs && p.IngressEndpoint != nil { + return errors.New("reportNodeInternalIPs and ingressEndpoint are mutually exclusive") + } + + if p.ReportNodeInternalIPs && p.DisableClusterScopeResources { + return errors.New("reportNodeInternalIPs and disableClusterScopeResources are mutually exclusive") + } + return nil } @@ -246,6 +255,26 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl ingresses := client.GetIngresses() + var nodeIngressStatus []netv1.IngressLoadBalancerIngress + if p.ReportNodeInternalIPs { + nodes, _, err := client.GetNodes() + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("Error while getting nodes for ingress status") + } else { + for _, node := range nodes { + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + nodeIngressStatus = append(nodeIngressStatus, netv1.IngressLoadBalancerIngress{IP: address.Address}) + } + } + } + + if len(nodeIngressStatus) == 0 { + log.Ctx(ctx).Error().Msg("No nodes with internal IP address found for ingress status") + } + } + } + certConfigs := make(map[string]*tls.CertAndStores) for _, ingress := range ingresses { logger := log.Ctx(ctx).With().Str("ingress", ingress.Name).Str("namespace", ingress.Namespace).Logger() @@ -255,7 +284,7 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl continue } - if err := p.updateIngressStatus(ingress, client); err != nil { + if err := p.updateIngressStatus(ingress, client, nodeIngressStatus); err != nil { logger.Error().Err(err).Msg("Error while updating ingress status") } @@ -442,7 +471,11 @@ func (p *Provider) loadConfigurationFromIngresses(ctx context.Context, client Cl return conf } -func (p *Provider) updateIngressStatus(ing *netv1.Ingress, k8sClient Client) error { +func (p *Provider) updateIngressStatus(ing *netv1.Ingress, k8sClient Client, nodeIngressStatus []netv1.IngressLoadBalancerIngress) error { + if len(nodeIngressStatus) > 0 { + return k8sClient.UpdateIngressStatus(ing, nodeIngressStatus) + } + // Only process if an EndpointIngress has been configured. if p.IngressEndpoint == nil { return nil @@ -623,12 +656,12 @@ func (p *Provider) loadService(client Client, namespace string, backend netv1.In return nil, errors.New("nodes lookup is disabled") } - nodes, nodesExists, nodesErr := client.GetNodes() + nodes, _, nodesErr := client.GetNodes() if nodesErr != nil { return nil, nodesErr } - if !nodesExists || len(nodes) == 0 { + if len(nodes) == 0 { return nil, fmt.Errorf("nodes not found in namespace %s", namespace) } diff --git a/pkg/provider/kubernetes/ingress/kubernetes_test.go b/pkg/provider/kubernetes/ingress/kubernetes_test.go index 8adcf6d7a..7c6a036a6 100644 --- a/pkg/provider/kubernetes/ingress/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress/kubernetes_test.go @@ -3094,6 +3094,77 @@ func readResources(t *testing.T, paths []string) []runtime.Object { return k8sObjects } +func TestProviderInit(t *testing.T) { + p := Provider{ + ReportNodeInternalIPs: true, + IngressEndpoint: &EndpointIngress{IP: "1.2.3.4"}, + } + assert.EqualError(t, p.Init(), "reportNodeInternalIPs and ingressEndpoint are mutually exclusive") + + p2 := Provider{ + ReportNodeInternalIPs: true, + DisableClusterScopeResources: true, + } + assert.EqualError(t, p2.Init(), "reportNodeInternalIPs and disableClusterScopeResources are mutually exclusive") + + p3 := Provider{ReportNodeInternalIPs: true} + assert.NoError(t, p3.Init()) +} + +func TestReportNodeInternalIPs(t *testing.T) { + testCases := []struct { + desc string + client clientMock + expectedEmpty bool + }{ + { + desc: "nodes present", + client: newClientMock(generateTestFilename("Node Internal IP")), + }, + { + desc: "GetNodes API error", + client: clientMock{apiNodesError: errors.New("api nodes error")}, + expectedEmpty: true, + }, + { + desc: "no nodes found", + client: clientMock{nodes: []*corev1.Node{}}, + expectedEmpty: true, + }, + { + desc: "nodes exist but none have an internal IP", + client: clientMock{ + nodes: []*corev1.Node{ + { + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + }, + }, + expectedEmpty: true, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + p := Provider{ReportNodeInternalIPs: true} + conf := p.loadConfigurationFromIngresses(t.Context(), test.client) + if test.expectedEmpty { + assert.Empty(t, conf.HTTP.Routers) + assert.Empty(t, conf.HTTP.Services) + } else { + assert.NotEmpty(t, conf.HTTP.Routers) + assert.NotEmpty(t, conf.HTTP.Services) + } + }) + } +} + func TestStrictPrefixMatchingRule(t *testing.T) { tests := []struct { path string