Skip to main content

Ingress Mode

Steward supports exposing Tenant Control Planes through an Ingress Controller, allowing multiple tenants to share a single IP address. This eliminates the need for individual LoadBalancer services per tenant, significantly reducing infrastructure costs and IP address consumption.

Overview

LoadBalancer Mode (Default)

In the default LoadBalancer mode, each Tenant Control Plane gets its own LoadBalancer Service with a dedicated external IP address.

LoadBalancer Mode

This approach is simple but has drawbacks:

  • IP address consumption: Each tenant requires a unique IP
  • Cost: Cloud LoadBalancers incur per-instance costs
  • IPv4 scarcity: Limited availability of public IPv4 addresses

Ingress Mode

Ingress mode routes traffic to multiple Tenant Control Planes through a single Ingress Controller using SNI (Server Name Indication) for TLS passthrough.

Ingress Mode

Benefits:

  • Single IP: All tenants share the Ingress Controller's IP
  • Cost effective: One LoadBalancer for unlimited tenants
  • Hostname-based routing: Each tenant gets a unique hostname

The Challenge

When tenant worker nodes try to access their API server via kubernetes.default.svc, the traffic goes through the CNI which operates at L4 (TCP/IP). This means:

  1. No Host header is sent (it's raw TCP, not HTTP)
  2. The Ingress Controller cannot route based on hostname
  3. The connection fails

The Solution: tcp-proxy

Steward solves this with the tcp-proxy addon, which runs inside each tenant cluster:

  1. tcp-proxy rewrites the kubernetes EndpointSlice to point to itself
  2. When pods connect to kubernetes.default.svc:443, traffic goes to tcp-proxy
  3. tcp-proxy terminates TLS and re-establishes a connection to the Ingress Controller with proper SNI
  4. The Ingress Controller routes to the correct Tenant Control Plane based on SNI

This is transparent to applications - they connect to kubernetes.default.svc as usual.

Supported Ingress Controllers

ControllerTypeTLS Passthrough Method
TraefiktraefikIngressRouteTCP CRD (native support)
HAProxyhaproxyhaproxy.org/ssl-passthrough annotation
NGINXnginxnginx.ingress.kubernetes.io/ssl-passthrough annotation
GenericgenericManual annotation configuration

Configuration

Complete Example

apiVersion: steward.butlerlabs.dev/v1alpha1
kind: TenantControlPlane
metadata:
name: tenant-01
namespace: tenants
spec:
kubernetes:
version: v1.31.0

networkProfile:
address: tenant-01.k8s.example.com
port: 443
certSANs:
- tenant-01.k8s.example.com
- tenant-01.konnectivity.example.com
podCidr: 10.244.0.0/16
serviceCidr: 10.96.0.0/16

controlPlane:
deployment:
replicas: 2

service:
serviceType: ClusterIP

ingress:
hostname: tenant-01.k8s.example.com
ingressClassName: traefik
controllerType: traefik

addons:
tcpProxy: {}

konnectivity:
agent:
extraArgs:
- --proxy-server-host=tenant-01.konnectivity.example.com
- --proxy-server-port=443

Required Settings

1. Service Type

Use ClusterIP instead of LoadBalancer:

spec:
controlPlane:
service:
serviceType: ClusterIP

2. Ingress Configuration

spec:
controlPlane:
ingress:
hostname: tenant-01.k8s.example.com
ingressClassName: traefik
controllerType: traefik # traefik, haproxy, nginx, or generic

3. tcp-proxy Addon

Enable the tcp-proxy addon:

spec:
addons:
tcpProxy: {}

For advanced configuration:

spec:
addons:
tcpProxy:
image: ghcr.io/butlerdotdev/steward-tcp-proxy:latest
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
hostAliases:
- ip: 10.0.0.50
hostnames:
- tenant-01.k8s.example.com
- tenant-01.konnectivity.example.com
internalEndpoint: 10.40.0.201 # Management cluster node IP reachable from workers
FieldDescription
imageCustom container image for tcp-proxy
resourcesCPU/memory requests and limits
hostAliasesHostname-to-IP mappings for bootstrap (before CoreDNS)
internalEndpointDirect IP to reach API server; used when LoadBalancer IP is unavailable

4. Certificate SANs

Include the Ingress hostnames in the certificate SANs:

spec:
networkProfile:
certSANs:
- tenant-01.k8s.example.com
- tenant-01.konnectivity.example.com # if using konnectivity

!!! note "Automatic SANs" Steward automatically includes the standard Kubernetes service names in the API server certificate: kubernetes, kubernetes.default, kubernetes.default.svc, kubernetes.default.svc.cluster.local

Infrastructure Requirements

DNS Configuration

Configure wildcard DNS records pointing to your Ingress Controller's IP. Each tenant gets a unique hostname (e.g., tenant-01.k8s.example.com), and wildcard DNS ensures all tenants resolve to the shared Ingress Controller.

*.k8s.example.com             A   <INGRESS_CONTROLLER_IP>
*.konnectivity.example.com A <INGRESS_CONTROLLER_IP>

Enterprise DNS Examples

=== "Windows Server DNS / Active Directory"

```powershell
# PowerShell - Create zone and wildcard record
Add-DnsServerPrimaryZone -Name "k8s.example.com" -ZoneFile "k8s.example.com.dns"
Add-DnsServerResourceRecordA -ZoneName "k8s.example.com" -Name "*" -IPv4Address "10.0.0.50"

# For Konnectivity (pod exec/logs)
Add-DnsServerPrimaryZone -Name "konnectivity.example.com" -ZoneFile "konnectivity.example.com.dns"
Add-DnsServerResourceRecordA -ZoneName "konnectivity.example.com" -Name "*" -IPv4Address "10.0.0.50"
```

=== "BIND"

```bash
# /etc/bind/zones/k8s.example.com
$TTL 86400
@ IN SOA ns1.example.com. admin.example.com. (
2024020901 ; Serial
3600 ; Refresh
1800 ; Retry
604800 ; Expire
86400 ) ; Minimum TTL

IN NS ns1.example.com.
* IN A 10.0.0.50
```

=== "Infoblox"

Create a Host Record with:

- **Name**: `*.k8s.example.com`
- **IP Address**: `<INGRESS_CONTROLLER_IP>`
- **Enable**: DNS Host Record

=== "dnsmasq / Pi-hole"

```bash
# Add to /etc/dnsmasq.d/butler.conf
address=/.k8s.example.com/10.0.0.50
address=/.konnectivity.example.com/10.0.0.50
```

=== "CoreDNS"

```yaml
# Corefile addition
k8s.example.com:53 {
template IN A {
answer "{{ .Name }} 60 IN A 10.0.0.50"
}
}
```

Cloud/Remote Access Options

For accessing tenant clusters from outside your network:

=== "Cloudflare Tunnel (Recommended)"

Cloudflare Tunnel exposes internal services without public IPs:

```yaml
# cloudflared config.yml
tunnel: <tunnel-id>
ingress:
- hostname: "*.k8s.example.com"
service: https://<INGRESS_CONTROLLER_IP>:443
originRequest:
noTLSVerify: true
- hostname: "*.konnectivity.example.com"
service: https://<INGRESS_CONTROLLER_IP>:443
originRequest:
noTLSVerify: true
- service: http_status:404
```

Then add CNAME records in Cloudflare DNS:

```
*.k8s.example.com CNAME <tunnel-id>.cfargotunnel.com
*.konnectivity.example.com CNAME <tunnel-id>.cfargotunnel.com
```

=== "Public IP + NAT"

If your Ingress has a public IP (via NAT/port forwarding):

```
*.k8s.example.com A <PUBLIC_IP>
```

Ensure port 443 is forwarded to your Ingress Controller.

=== "Cloudflare Zero Trust"

For team-based access with WARP client:

1. Create a private network in Cloudflare Zero Trust
2. Add Split Tunnel rule for `k8s.example.com`
3. Configure Private DNS: `*.k8s.example.com` → `<INGRESS_CONTROLLER_IP>`
4. Team members connect via WARP client

Testing DNS Resolution

# Verify wildcard resolution
nslookup tenant-01.k8s.example.com
dig +short any-tenant.k8s.example.com

# Quick test with /etc/hosts (development only)
echo "<INGRESS_IP> tenant-01.k8s.example.com" | sudo tee -a /etc/hosts

Ingress Controller Setup

Traefik

Traefik requires the IngressRouteTCP CRD for TLS passthrough. Steward automatically creates these when controllerType: traefik is specified.

Ensure Traefik is configured with an entrypoint on port 443:

# Traefik Helm values
ports:
websecure:
port: 8443
exposedPort: 443
expose: true
protocol: TCP

HAProxy

Ensure HAProxy Ingress Controller supports SSL passthrough:

# HAProxy Helm values
controller:
config:
ssl-passthrough: "true"

NGINX

Enable SSL passthrough in NGINX Ingress Controller:

# NGINX Helm values
controller:
extraArgs:
enable-ssl-passthrough: "true"

Konnectivity (Optional)

If using Konnectivity for API server to node communication, configure the agent to use the Ingress:

spec:
addons:
konnectivity:
agent:
extraArgs:
- --proxy-server-host=tenant-01.konnectivity.example.com
- --proxy-server-port=443

How It Works

Traffic Flow

  1. Pod in tenant cluster connects to kubernetes.default.svc:443
  2. kube-proxy routes to tcp-proxy pods (via rewritten EndpointSlice)
  3. tcp-proxy terminates TLS using the API server certificate
  4. tcp-proxy opens new TLS connection to Ingress with correct SNI hostname
  5. Ingress Controller routes based on SNI to the correct TCP service
  6. API Server processes the request

tcp-proxy Components

When enabled, Steward automatically:

  • Sets --endpoint-reconciler-type=none on the API server (prevents conflicts with tcp-proxy)
  • Deploys into the tenant cluster:
    • Deployment: 2 replicas with hostNetwork for bootstrap
    • Service: ClusterIP service in kube-system namespace
    • ServiceAccount/RBAC: Permissions to manage the kubernetes EndpointSlice
    • TLS Secret: Copy of API server certificate for TLS termination

Bootstrap Sequence

tcp-proxy is designed to work before CNI is ready:

  1. Uses hostNetwork: true to communicate without CNI
  2. Tolerates node.kubernetes.io/not-ready and node.cilium.io/agent-not-ready taints
  3. Uses hostAliases to resolve Ingress hostname before CoreDNS is available
  4. Priority class system-cluster-critical ensures scheduling priority

Air-Gapped Environments

The tcp-proxy image can be customized for private registries:

spec:
addons:
tcpProxy:
image: private.registry.example.com/steward-tcp-proxy:latest

Troubleshooting

Pods can't reach kubernetes.default.svc

  1. Check tcp-proxy is running:

    kubectl --kubeconfig=<tenant-kubeconfig> get pods -n kube-system -l app=steward-tcp-proxy
  2. Verify the kubernetes EndpointSlice:

    kubectl --kubeconfig=<tenant-kubeconfig> get endpointslice -n default kubernetes -o yaml
  3. Check tcp-proxy logs:

    kubectl --kubeconfig=<tenant-kubeconfig> logs -n kube-system -l app=steward-tcp-proxy

TLS/Certificate Errors

  1. Verify certificate SANs include kubernetes.default.svc.cluster.local:

    openssl s_client -connect <tcp-ip>:6443 </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
  2. Check the TLS secret exists in tenant cluster:

    kubectl --kubeconfig=<tenant-kubeconfig> get secret -n kube-system steward-tcp-proxy-tls

Ingress Not Routing

  1. For Traefik, verify IngressRouteTCP was created:

    kubectl get ingressroutetcp -n <tcp-namespace>
  2. For HAProxy/NGINX, verify Ingress and annotations:

    kubectl get ingress -n <tcp-namespace> -o yaml
  3. Check Ingress Controller logs for routing issues

Comparison: LoadBalancer vs Ingress Mode

AspectLoadBalancer ModeIngress Mode
IP addressesOne per tenantOne for all tenants
CostHigher (per-LB costs)Lower (single LB)
Setup complexitySimpleModerate
DNS requirementsPer-tenant A recordsWildcard A records
Ingress ControllerNot requiredRequired
tcp-proxyOptionalRequired