1
0
Fork 0

Add Kubernetes integration tests for EasyHAProxy examples

- Introduced a suite of pytest-based integration tests for Kubernetes using `kind`.
- Automated local installation of dependencies (`kind`, `kubectl`, and `helm`) when missing.
- Implemented fixtures for Kubernetes resource management and TLS secrets.
- Added end-to-end tests for HTTP and HTTPS ingress functionality.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-11 23:32:44 -05:00
parent 5767e55dea
commit 90b01b1f13
18 changed files with 2978 additions and 24 deletions

View file

@ -31,12 +31,22 @@ Protect APIs and services with JWT authentication without needing application-le
| `algorithm` | JWT signing algorithm | `RS256` |
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) |
| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) |
| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) |
| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) |
| `pubkey_path` | Path to public key file (priority 1: explicit file path) | (optional) |
| `pubkey` | Public key content as base64-encoded string (priority 2: inline content) | (optional) |
| `k8s_secret.pubkey` | Kubernetes secret containing public key (priority 3: Kubernetes only - see below) | (optional) |
| `paths` | List of paths that require JWT validation (optional) | (all paths) |
| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` |
| `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` |
### Public Key Configuration Priority
When multiple public key options are configured, they are evaluated in this order:
1. **`pubkey_path`** - Direct file path (explicit configuration)
2. **`pubkey`** - Base64-encoded key content (inline configuration)
3. **`k8s_secret.pubkey`** - Kubernetes secret (recommended for Kubernetes deployments)
The first configured option is used; others are ignored.
## Path Validation Logic
- **No paths configured:** ALL requests to the domain require JWT validation (default behavior)
@ -120,7 +130,70 @@ services:
# Invalid JWTs are rejected
```
### Kubernetes Annotations
### Kubernetes with Secrets (Recommended)
The recommended way to configure JWT public keys in Kubernetes is using Kubernetes Secrets with the `k8s_secret` pattern:
```yaml
---
# Create a secret with your JWT public key
apiVersion: v1
kind: Secret
metadata:
name: jwt-pubkey-secret
namespace: production
type: Opaque
stringData:
pubkey: |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
---
# Reference it in your ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
easyhaproxy.plugins: "jwt_validator"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Load public key from Kubernetes secret
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret"
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
easyhaproxy.plugin.jwt_validator.only_paths: "false"
spec:
ingressClassName: easyhaproxy
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080
```
**With explicit secret key name:**
```yaml
metadata:
annotations:
# Use custom key name from the secret
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret/rsa-public-key"
```
For complete details about the `k8s_secret` pattern, including auto-detect vs explicit key names, troubleshooting, and security considerations, see: [Loading Plugin Configuration from Kubernetes Secrets](../kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets)
### Kubernetes with pubkey_path (Legacy)
You can also mount the public key file using ConfigMaps or volumes:
```yaml
apiVersion: networking.k8s.io/v1
@ -135,11 +208,13 @@ metadata:
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
easyhaproxy.plugin.jwt_validator.only_paths: "false"
spec:
ingressClassName: easyhaproxy
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
@ -147,6 +222,8 @@ spec:
number: 8080
```
**Note:** This requires mounting the public key file into the EasyHAProxy pod using ConfigMaps or volumes. Using `k8s_secret.pubkey` is recommended as it's simpler and more secure.
### Static YAML Configuration
```yaml

View file

@ -245,6 +245,219 @@ env:
For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide.
## Loading Plugin Configuration from Kubernetes Secrets
EasyHAProxy supports loading sensitive plugin configuration values directly from Kubernetes Secrets using the `k8s_secret` pattern. This is a **generic, plugin-agnostic feature** that works with any plugin.
### Why Use Kubernetes Secrets?
- **Security**: Keep sensitive data (API keys, passwords, certificates) out of annotations
- **Best practices**: Follows Kubernetes conventions for managing sensitive data
- **Simplicity**: No need to mount volumes or ConfigMaps for secret data
- **Encryption**: Secrets are encrypted at rest in etcd
### Annotation Format
```yaml
# Auto-detect key (tries common variations):
easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name"
# Explicit key (no variations):
easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name/key_name"
```
### How It Works
1. **You create** a Kubernetes Secret with your sensitive data
2. **You reference** the secret in your ingress annotation using the `k8s_secret` pattern
3. **EasyHAProxy reads** the secret from the same namespace as the ingress
4. **EasyHAProxy transforms** the annotation to inject the secret value
5. **The plugin receives** the value as if it was provided directly in the annotation
**Example transformation:**
```yaml
# Input annotation:
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
# EasyHAProxy reads the secret and transforms to:
easyhaproxy.plugin.jwt_validator.pubkey: "<base64-encoded-content>"
```
### Auto-Detect vs Explicit Key
#### Auto-Detect Key Format
When you use `"secret_name"` (without `/`), EasyHAProxy tries to find the key automatically:
```yaml
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
```
EasyHAProxy will try these keys in order:
1. Exact match: `pubkey`
2. Common variations based on the config key name
**Auto-detect key variations:**
| Config Key | Tries (in order) |
|------------|---------------------------------------|
| `pubkey` | `pubkey`, `public-key`, `jwt.pub`, `tls.crt` |
| `password` | `password`, `pass`, `pwd` |
| `api_key` | `api_key`, `apikey`, `api-key`, `key` |
#### Explicit Key Format
When you use `"secret_name/key_name"` (with `/`), EasyHAProxy only tries the exact key name:
```yaml
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret/rsa-public-key"
```
EasyHAProxy will **only** try: `rsa-public-key` (no variations)
**Use explicit key when:**
- Your secret uses a non-standard key name
- You want to be explicit and avoid ambiguity
- Multiple keys exist in the secret
### Complete Example
```yaml
---
# 1. Create a secret with your JWT public key
apiVersion: v1
kind: Secret
metadata:
name: jwt-pubkey-secret
namespace: production
type: Opaque
stringData:
pubkey: |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
---
# 2. Reference it in your ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
easyhaproxy.plugins: "jwt_validator"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Load pubkey from Kubernetes secret (auto-detect key)
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret"
spec:
ingressClassName: easyhaproxy
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080
```
### Example with Explicit Key Name
```yaml
---
apiVersion: v1
kind: Secret
metadata:
name: app-credentials
namespace: production
type: Opaque
stringData:
# Custom key name
rsa-public-key: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
easyhaproxy.plugins: "jwt_validator"
# Use explicit key name after the slash
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "app-credentials/rsa-public-key"
spec:
ingressClassName: easyhaproxy
# ... rest of configuration
```
### Using with Any Plugin
The `k8s_secret` pattern works with **any plugin configuration**:
```yaml
# JWT Validator - load public key
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-secret"
# Hypothetical API auth plugin - load API key
easyhaproxy.plugin.api_auth.k8s_secret.api_key: "api-credentials/key"
# Hypothetical basic auth plugin - load password
easyhaproxy.plugin.basic_auth.k8s_secret.password: "auth-secret/pwd"
```
### Priority Order
When multiple configuration methods are used, this is the priority (highest to lowest):
1. **Explicit annotation** (e.g., `easyhaproxy.plugin.jwt_validator.pubkey: "value"`)
2. **k8s_secret annotation** (e.g., `easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret"`)
Explicit annotations always take precedence over `k8s_secret` annotations.
### Troubleshooting
**Secret not found:**
```
WARNING: Ingress production/api-ingress - Failed to process k8s_secret annotation
'easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey' with value 'jwt-secret': ...
```
- Verify the secret exists: `kubectl get secret jwt-secret -n production`
- Check the secret is in the same namespace as the ingress
**Key not found in secret:**
```
WARNING: Ingress production/api-ingress - Secret 'jwt-secret' found but no matching
key (tried: pubkey, public-key, jwt.pub, tls.crt)
```
- List secret keys: `kubectl get secret jwt-secret -n production -o jsonpath='{.data}'`
- Use explicit key format: `"jwt-secret/actual-key-name"`
**Check EasyHAProxy logs:**
```bash
kubectl logs -n easyhaproxy -l app=easyhaproxy --tail=100
```
Look for:
- `INFO: Loaded 'pubkey' from secret 'jwt-secret'` (success)
- `WARNING: Secret 'xyz' found but no matching key` (key not found)
### Security Considerations
- Secrets are read from the **same namespace** as the ingress (no cross-namespace access)
- EasyHAProxy needs RBAC permissions to read secrets (included in default deployment)
- Secrets are encrypted at rest in etcd
- Secret values are base64-encoded by Kubernetes automatically
- Use Kubernetes RBAC to control which service accounts can read which secrets
## Certbot / ACME / Letsencrypt
It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration:

9
examples/kubernetes/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
# kind installation directory
.kind/
# kubectl config
kubeconfig
# Test artifacts
*.log
service_tls_generated.yml

View file

@ -100,7 +100,8 @@ metadata:
# Allow specific IPs and networks
# UPDATE THIS with your actual office/VPN IPs!
easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42,10.0.0.0/8"
# For testing: includes localhost and Docker/Kubernetes private networks
easyhaproxy.plugin.ip_whitelist.allowed_ips: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,203.0.113.0/24,198.51.100.42"
# Status code to return for blocked IPs
easyhaproxy.plugin.ip_whitelist.status_code: "403"
@ -114,9 +115,10 @@ spec:
- host: admin.example.local
http:
paths:
- backend:
- path: /
pathType: Prefix
backend:
service:
name: admin-service
port:
number: 8080
pathType: ImplementationSpecific

View file

@ -0,0 +1,135 @@
# Example demonstrating JWT validator with Kubernetes secret
# This shows the recommended way to provide JWT public keys in Kubernetes
#
# IMPORTANT: Before applying this manifest, generate JWT keys by running:
# cd /path/to/examples && bash generate-keys.sh
#
# Then create the secrets with your generated keys:
# kubectl create secret generic jwt-pubkey-secret \
# --from-file=pubkey=docker/jwt_pubkey.pem -n default
# kubectl create secret generic jwt-custom-secret \
# --from-file=rsa-public-key=docker/jwt_pubkey.pem -n default
#
# TWO ANNOTATION FORMATS:
# 1. Auto-detect key (tries common variations):
# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret"
# Tries keys: pubkey, public-key, jwt.pub, tls.crt
#
# 2. Explicit key (no variations):
# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret/rsa-public-key"
# Only tries key: rsa-public-key
---
# NOTE: Secrets should be created separately using your generated JWT keys
# See instructions at the top of this file
# The test fixture creates these secrets automatically
---
# Deployment for API service
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: byjg/static-httpserver
ports:
- containerPort: 8080
env:
- name: TITLE
value: "Protected API - JWT Required"
---
# Service to be protected with JWT
apiVersion: v1
kind: Service
metadata:
name: api-service
namespace: default
spec:
selector:
app: api
ports:
- port: 8080
targetPort: 8080
---
# Ingress Example 1: Auto-detect key (uses standard key name "pubkey")
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress-jwt-auto
namespace: default
annotations:
# Enable JWT validator plugin
easyhaproxy.plugins: "jwt_validator"
# JWT validator configuration
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Auto-detect: tries pubkey, public-key, jwt.pub, tls.crt
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret"
spec:
ingressClassName: easyhaproxy
rules:
- host: api.example.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080
---
# Ingress Example 2: Explicit key (uses custom key name "rsa-public-key")
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress-jwt-explicit
namespace: default
annotations:
# Enable JWT validator plugin
easyhaproxy.plugins: "jwt_validator"
# JWT validator configuration
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Explicit key: only tries "rsa-public-key" from the secret
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-custom-secret/rsa-public-key"
# Optional: Protect only specific paths
# easyhaproxy.plugin.jwt_validator.paths: "/api,/admin"
# Optional: Allow anonymous access (JWT validated only if present)
# easyhaproxy.plugin.jwt_validator.allow_anonymous: "true"
spec:
ingressClassName: easyhaproxy
rules:
- host: api-custom.example.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080

View file

@ -2,6 +2,24 @@
# EXAMPLE: JWT Validator Plugin for Kubernetes
# ==============================================================================
#
# JWT PUBLIC KEY CONFIGURATION OPTIONS:
# There are three ways to provide the JWT public key:
#
# 1. pubkey_path - Mount a file and reference the path (requires ConfigMap or Volume)
# easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
#
# 2. k8s_secret.pubkey - Reference a Kubernetes secret (RECOMMENDED)
# Auto-detect key (tries common variations):
# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
# Explicit key (no variations):
# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret/custom-key-name"
# See jwt-validator-secret-example.yml for full example
#
# 3. pubkey - Inline base64-encoded key (for testing only, not recommended for production)
# easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..."
#
# This example shows option #1 (pubkey_path) for backward compatibility
#
# WHAT THIS DEMONSTRATES:
# - JWT token validation for API protection in Kubernetes
# - RS256 algorithm signature verification

View file

@ -66,21 +66,23 @@ spec:
- host: example.org
http:
paths:
- backend:
- path: /
pathType: Prefix
backend:
service:
name: container-example
port:
number: 8080
pathType: ImplementationSpecific
- host: www.example.org
http:
paths:
- backend:
- path: /
pathType: Prefix
backend:
service:
name: container-example
port:
number: 8080
pathType: ImplementationSpecific
---
apiVersion: v1

View file

@ -71,12 +71,13 @@ spec:
- host: host2.local
http:
paths:
- backend:
- path: /
pathType: Prefix
backend:
service:
name: tls-example
port:
number: 8080
pathType: ImplementationSpecific
---
apiVersion: v1

View file

@ -0,0 +1,169 @@
#!/bin/bash
set -e
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
CLUSTER_NAME="easyhaproxy-test"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${SCRIPT_DIR}/.kind"
KIND_BIN="${BIN_DIR}/kind"
KUBECTL_BIN="${BIN_DIR}/kubectl"
HELM_BIN="${BIN_DIR}/helm"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# Port configuration (matches test_kubernetes.py)
HTTP_PORT=10080
HTTPS_PORT=10443
STATS_PORT=11936
echo -e "${BLUE}[1/9] Setting up kind cluster '${CLUSTER_NAME}'...${NC}"
# Ensure kind is installed
if [ ! -f "${KIND_BIN}" ]; then
echo "Installing kind locally..."
mkdir -p "${BIN_DIR}"
curl -Lo "${KIND_BIN}" "https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64"
chmod +x "${KIND_BIN}"
echo -e "${GREEN}✓ kind installed to ${KIND_BIN}${NC}"
fi
# Ensure kubectl is installed
if ! command -v kubectl &> /dev/null; then
if [ ! -f "${KUBECTL_BIN}" ]; then
echo "Installing kubectl locally..."
mkdir -p "${BIN_DIR}"
VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt)
curl -Lo "${KUBECTL_BIN}" "https://dl.k8s.io/release/${VERSION}/bin/linux/amd64/kubectl"
chmod +x "${KUBECTL_BIN}"
echo -e "${GREEN}✓ kubectl installed to ${KUBECTL_BIN}${NC}"
fi
KUBECTL="${KUBECTL_BIN}"
else
KUBECTL="kubectl"
fi
# Ensure helm is installed
if ! command -v helm &> /dev/null; then
if [ ! -f "${HELM_BIN}" ]; then
echo "Installing helm locally..."
mkdir -p "${BIN_DIR}"
HELM_VERSION="v3.13.3"
HELM_TAR="${BIN_DIR}/helm.tar.gz"
curl -Lo "${HELM_TAR}" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"
tar -xzf "${HELM_TAR}" -C "${BIN_DIR}" --strip-components=1 linux-amd64/helm
rm "${HELM_TAR}"
chmod +x "${HELM_BIN}"
echo -e "${GREEN}✓ helm installed to ${HELM_BIN}${NC}"
fi
HELM="${HELM_BIN}"
else
HELM="helm"
fi
# Check if cluster already exists
echo -e "${BLUE}[1/9] Checking for existing cluster...${NC}"
if ${KIND_BIN} get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then
echo -e "${BLUE}Cluster '${CLUSTER_NAME}' already exists, deleting it first...${NC}"
${KIND_BIN} delete cluster --name "${CLUSTER_NAME}"
fi
# Create cluster config
echo -e "${BLUE}[1/9] Writing cluster config...${NC}"
CLUSTER_CONFIG="${BIN_DIR}/cluster-config.yaml"
mkdir -p "${BIN_DIR}"
cat > "${CLUSTER_CONFIG}" <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 80
hostPort: ${HTTP_PORT}
protocol: TCP
- containerPort: 443
hostPort: ${HTTPS_PORT}
protocol: TCP
- containerPort: 1936
hostPort: ${STATS_PORT}
protocol: TCP
EOF
# Create cluster
echo -e "${BLUE}[2/9] Creating kind cluster (this may take 1-2 minutes)...${NC}"
${KIND_BIN} create cluster --name "${CLUSTER_NAME}" --config "${CLUSTER_CONFIG}"
# Set kubectl context
echo -e "${BLUE}[3/9] Setting kubectl context...${NC}"
${KUBECTL} config use-context "kind-${CLUSTER_NAME}"
# Wait for nodes to be ready
echo -e "${BLUE}[3/9] Waiting for cluster nodes to be ready...${NC}"
${KUBECTL} wait --for=condition=Ready nodes --all --timeout=30s
echo -e "${GREEN}✓ kind cluster '${CLUSTER_NAME}' is ready${NC}"
# Build and load local EasyHAProxy image
echo -e "${BLUE}[4/9] Building local EasyHAProxy image (may take 30-60s)...${NC}"
docker build -t byjg/easy-haproxy:local \
-f "${PROJECT_ROOT}/build/Dockerfile" \
"${PROJECT_ROOT}"
echo -e "${BLUE}[5/9] Loading image into kind cluster (may take 10-20s)...${NC}"
${KIND_BIN} load docker-image byjg/easy-haproxy:local --name "${CLUSTER_NAME}"
# Generate EasyHAProxy manifest using Helm
echo -e "${BLUE}[6/9] Generating EasyHAProxy manifest from Helm...${NC}"
HELM_DIR="${PROJECT_ROOT}/helm"
MANIFEST_PATH="${BIN_DIR}/easyhaproxy-local.yml"
${HELM} template ingress "${HELM_DIR}/easyhaproxy" \
--namespace easyhaproxy \
--set service.create=false \
--set image.tag=local \
--set image.pullPolicy=Never \
> "${MANIFEST_PATH}"
# Install EasyHAProxy
echo -e "${BLUE}[7/9] Creating easyhaproxy namespace...${NC}"
${KUBECTL} create namespace easyhaproxy
echo -e "${BLUE}[7/9] Applying EasyHAProxy manifest...${NC}"
${KUBECTL} apply -f "${MANIFEST_PATH}"
# Label the control-plane node
echo -e "${BLUE}[8/9] Labeling control-plane node...${NC}"
${KUBECTL} label nodes "${CLUSTER_NAME}-control-plane" \
"easyhaproxy/node=master" --overwrite
# Wait for EasyHAProxy to be ready
echo -e "${BLUE}[9/9] Waiting for EasyHAProxy pods to be ready...${NC}"
if ${KUBECTL} wait --for=condition=Ready pods \
-n easyhaproxy -l "app.kubernetes.io/name=easyhaproxy" \
--timeout=30s 2>/dev/null; then
echo -e "${GREEN}✓ EasyHAProxy pods are ready${NC}"
else
echo -e "${RED}✗ Pods not ready within 30s. Checking status...${NC}"
${KUBECTL} get pods -n easyhaproxy -o wide
echo -e "\n${BLUE}Events:${NC}"
${KUBECTL} get events -n easyhaproxy --sort-by=.lastTimestamp
exit 1
fi
echo -e "${GREEN}✓ All setup complete! Cluster is ready.${NC}"
echo ""
echo -e "${BLUE}Cluster Information:${NC}"
echo -e " Cluster name: ${CLUSTER_NAME}"
echo -e " HTTP port: localhost:${HTTP_PORT}"
echo -e " HTTPS port: localhost:${HTTPS_PORT}"
echo -e " Stats port: localhost:${STATS_PORT}"
echo ""
echo -e "${BLUE}Useful commands:${NC}"
echo -e " Apply example ingress: ${KUBECTL} apply -f ${SCRIPT_DIR}/service.yml"
echo -e " Check EasyHAProxy logs: ${KUBECTL} logs -n easyhaproxy -l app.kubernetes.io/name=easyhaproxy -f"
echo -e " Test with curl: curl -H 'Host: example.org' http://localhost:${HTTP_PORT}"
echo -e " Delete cluster: ${SCRIPT_DIR}/teardown-cluster.sh"
echo ""

View file

@ -0,0 +1,39 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
NC='\033[0m' # No Color
CLUSTER_NAME="easyhaproxy-test"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${SCRIPT_DIR}/.kind"
KIND_BIN="${BIN_DIR}/kind"
# Check if kind binary exists
if [ ! -f "${KIND_BIN}" ]; then
# Try to use system kind
if command -v kind &> /dev/null; then
KIND_BIN="kind"
else
echo -e "${RED}✗ kind binary not found. Cannot delete cluster.${NC}"
echo " Cluster may not exist or kind is not installed."
exit 1
fi
fi
# Check if cluster exists
if ! ${KIND_BIN} get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then
echo -e "${BLUE}Cluster '${CLUSTER_NAME}' does not exist. Nothing to delete.${NC}"
exit 0
fi
echo -e "${BLUE}Deleting kind cluster '${CLUSTER_NAME}'...${NC}"
if ${KIND_BIN} delete cluster --name "${CLUSTER_NAME}"; then
echo -e "${GREEN}✓ Cluster deleted successfully${NC}"
else
echo -e "${RED}✗ Failed to delete cluster${NC}"
exit 1
fi

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,17 @@ packages = ["src/easymapping", "src/functions", "src/processor", "src/plugins",
addopts = "-v -p no:warnings"
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"kubernetes: marks tests as kubernetes integration tests (deselect with '-m \"not kubernetes\"')",
"ssl: marks tests for SSL/TLS functionality",
"jwt: marks tests for JWT validator plugin",
"loadbalancing: marks tests for load balancing functionality",
"php: marks tests for PHP-FPM FastCGI plugin",
"plugins: marks tests for combined plugin functionality",
"security: marks tests for security features (IP whitelist, etc.)",
"cloudflare: marks tests for Cloudflare IP restoration plugin",
"custom_label: marks tests for custom label prefix functionality",
]
[tool.ruff]
line-length = 120

View file

@ -5,7 +5,7 @@ import re
from jinja2 import Environment, FileSystemLoader
from functions import logger_easyhaproxy
from functions import Functions, logger_easyhaproxy
class DockerLabelHandler:
@ -289,6 +289,24 @@ class HaproxyConfigGenerator:
r.haproxy_config for r in domain_results if r.haproxy_config
]
# Write JWT public key files from metadata
for result in domain_results:
if result.metadata and "pubkey_content" in result.metadata and "pubkey_file" in result.metadata:
pubkey_file = result.metadata["pubkey_file"]
pubkey_content = result.metadata["pubkey_content"]
# Create jwt_keys directory if it doesn't exist (belt and suspenders)
import os
jwt_keys_dir = os.path.dirname(pubkey_file)
if jwt_keys_dir:
os.makedirs(jwt_keys_dir, exist_ok=True)
# Write the pubkey file
Functions.save(pubkey_file, pubkey_content)
logger_easyhaproxy.debug(
f"Wrote JWT public key to {pubkey_file} for domain {hostname}"
)
# Extract fcgi-app definitions from metadata and add to global configs
for result in domain_results:
if result.metadata and "fcgi_app_definition" in result.metadata:

View file

@ -202,6 +202,7 @@ class Consts:
custom_config_folder = "/etc/haproxy/conf.d"
certs_certbot = "/certs/certbot"
certs_haproxy = "/certs/haproxy"
jwt_keys = "/etc/haproxy/jwt_keys"
class DaemonizeHAProxy:

View file

@ -20,6 +20,7 @@ def start():
os.makedirs(Consts.certs_certbot, exist_ok=True)
os.makedirs(Consts.certs_haproxy, exist_ok=True)
os.makedirs(Consts.jwt_keys, exist_ok=True)
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)

View file

@ -9,12 +9,28 @@ Configuration:
- algorithm: JWT signing algorithm (default: RS256)
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
- audience: Expected JWT audience (optional, set to "none"/"null" to skip validation)
- pubkey_path: Path to public key file (required if pubkey not provided)
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided)
- pubkey_path: Path to public key file in container (priority: 1)
- pubkey: Public key content as base64-encoded string (priority: 2)
- k8s_secret.pubkey: Kubernetes secret containing public key (priority: 3, Kubernetes only)
- paths: List of paths that require JWT validation (optional, if not set ALL domain is protected)
- only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation
- allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header
Priority Order (first configured option wins):
1. pubkey_path - Direct file path (explicit configuration)
2. pubkey - Base64-encoded key content (inline configuration)
3. k8s_secret.pubkey - Kubernetes secret name (processed by K8s processor into pubkey)
Kubernetes Secret Pattern (Kubernetes only):
For Kubernetes deployments, you can load the public key from a Kubernetes Secret:
- Auto-detect key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name"
- Explicit key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name/key_name"
See documentation for details:
- General k8s_secret pattern: docs/kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets
- JWT Validator with Secrets: docs/Plugins/jwt-validator.md#kubernetes-with-secrets-recommended
Path Validation Logic:
- No paths configured: ALL requests to the domain require JWT validation (default behavior)
- Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through
@ -46,6 +62,16 @@ Example Container Label:
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
easyhaproxy.http.plugin.jwt_validator.only_paths: true
Example Kubernetes Annotations:
# Using k8s_secret pattern (recommended for Kubernetes):
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Using inline pubkey (for testing):
easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..."
HAProxy Config Generated:
# JWT Validator - Validate JWT tokens
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
@ -74,7 +100,7 @@ import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from functions import logger_easyhaproxy
from functions import Consts, logger_easyhaproxy
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
@ -178,7 +204,7 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey:
# Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem"
pubkey_file = f"{Consts.jwt_keys}/{domain_safe}_pubkey.pem"
else:
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult()

View file

@ -218,12 +218,17 @@ class Swarm(ProcessorInterface):
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None):
def __init__(self, filename=None, api_instance=None, v1=None):
self.parsed_object = None
# Only load config if API clients are not provided (allows dependency injection for testing)
if api_instance is None or v1 is None:
config.load_incluster_config()
config.verify_ssl = False
self.api_instance = client.CoreV1Api()
self.v1 = client.NetworkingV1Api()
# Use injected clients or create new ones (dependency injection pattern)
self.api_instance = api_instance or client.CoreV1Api()
self.v1 = v1 or client.NetworkingV1Api()
self.cert_cache = {}
self.deployment_mode_cache = None
self.ingress_addresses_cache = None
@ -485,11 +490,122 @@ class Kubernetes(ProcessorInterface):
if annotation_key.startswith("easyhaproxy.plugin."):
plugin_annotations[annotation_key] = annotation_value
# Get ingress name for logging
ingress_name = f"{ingress.metadata.namespace}/{ingress.metadata.name}"
# Generic k8s_secret annotation processing
# Pattern: easyhaproxy.plugin.X.k8s_secret.KEY: "secret_name" or "secret_name/key_name"
# Result: easyhaproxy.plugin.X.KEY: "<base64-encoded-content>"
k8s_secret_annotations = {}
for annotation_key, secret_value in list(plugin_annotations.items()):
# Check if this annotation contains k8s_secret pattern
if ".k8s_secret." in annotation_key:
try:
# Parse the annotation key
# Example: "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey" -> "pubkey"
parts = annotation_key.split(".k8s_secret.")
if len(parts) != 2:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Malformed k8s_secret annotation: {annotation_key}"
)
continue
prefix = parts[0] # "easyhaproxy.plugin.jwt_validator"
config_key = parts[1] # "pubkey"
target_annotation = f"{prefix}.{config_key}" # "easyhaproxy.plugin.jwt_validator.pubkey"
# Parse secret_value: can be "secret_name" or "secret_name/key_name"
if "/" in secret_value:
secret_name, explicit_key_name = secret_value.split("/", 1)
use_explicit_key = True
else:
secret_name = secret_value
explicit_key_name = None
use_explicit_key = False
# Read the secret
secret = self.api_instance.read_namespaced_secret(
secret_name,
ingress.metadata.namespace
)
# Try to find the key in the secret data
secret_data = None
tried_keys = []
if use_explicit_key:
# User specified exact key name - only try that one
tried_keys = [explicit_key_name]
if explicit_key_name in secret.data:
secret_data = secret.data[explicit_key_name]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found explicit secret key '{explicit_key_name}' "
f"in secret '{secret_name}'"
)
else:
# No explicit key - try config_key and common variations
tried_keys = [config_key]
if config_key in secret.data:
secret_data = secret.data[config_key]
else:
# Try common variations for the requested key
variations = []
if config_key == "pubkey":
variations = ["public-key", "jwt.pub", "tls.crt"]
elif config_key == "password":
variations = ["pass", "pwd"]
elif config_key == "api_key":
variations = ["apikey", "api-key", "key"]
for variation in variations:
tried_keys.append(variation)
if variation in secret.data:
secret_data = secret.data[variation]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found secret key '{variation}' "
f"for requested key '{config_key}'"
)
break
if secret_data:
# Decode from base64 (Kubernetes secrets are base64-encoded)
# Then re-encode to base64 for plugin (plugin expects base64-encoded)
decoded = base64.b64decode(secret_data).decode('ascii')
reencoded = base64.b64encode(decoded.encode('ascii')).decode('ascii')
# Store the processed annotation
k8s_secret_annotations[target_annotation] = reencoded
logger_easyhaproxy.info(
f"Ingress {ingress_name} - Loaded '{config_key}' from secret "
f"'{secret_name}' for annotation '{target_annotation}'"
)
else:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Secret '{secret_name}' found but "
f"no matching key (tried: {', '.join(tried_keys)})"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Failed to process k8s_secret annotation "
f"'{annotation_key}' with value '{secret_value}': {e}"
)
# Merge k8s_secret annotations into plugin_annotations
# k8s_secret annotations will NOT override existing explicit annotations (lower priority)
for key, value in k8s_secret_annotations.items():
if key not in plugin_annotations:
plugin_annotations[key] = value
else:
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Skipping k8s_secret annotation '{key}' "
f"because explicit annotation already exists"
)
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
ingress_name = ingress.metadata.namespace
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try:

View file

@ -0,0 +1,419 @@
"""
Tests for Kubernetes Processor - k8s_secret functionality
Tests the generic k8s_secret annotation pattern that allows loading
plugin configuration values from Kubernetes Secrets.
"""
import base64
import os
import sys
from unittest.mock import MagicMock, Mock
from types import SimpleNamespace
# Add src to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from processor import Kubernetes
class TestKubernetesSecretPattern:
"""Test cases for k8s_secret annotation pattern"""
def create_mock_secret(self, data):
"""Helper to create a mock Kubernetes secret"""
secret = Mock()
# Kubernetes stores secret data as base64-encoded strings
secret.data = {
key: base64.b64encode(value.encode('ascii')).decode('ascii')
for key, value in data.items()
}
return secret
def create_mock_ingress(self, annotations, namespace="default"):
"""Helper to create a mock Kubernetes ingress"""
ingress = Mock()
ingress.metadata = Mock()
ingress.metadata.namespace = namespace
ingress.metadata.name = "test-ingress"
ingress.metadata.annotations = annotations
ingress.metadata.creation_timestamp = Mock()
ingress.metadata.creation_timestamp.strftime = Mock(return_value="01/01/2024 00:00:00")
ingress.metadata.resource_version = "12345"
ingress.spec = Mock()
ingress.spec.tls = None
ingress.spec.ingress_class_name = "easyhaproxy"
# Create a proper rule with path and backend
rule = Mock()
rule.host = "test.example.com"
rule.http = Mock()
path = Mock()
path.path = "/"
path.path_type = "Prefix"
path.backend = Mock()
path.backend.service = Mock()
path.backend.service.name = "test-service"
path.backend.service.port = Mock()
path.backend.service.port.number = 8080
rule.http.paths = [path]
ingress.spec.rules = [rule]
return ingress
def test_k8s_secret_auto_detect_exact_match(self):
"""Test k8s_secret with auto-detect finds exact key match"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with exact key name "pubkey"
secret = self.create_mock_secret({"pubkey": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with k8s_secret annotation (auto-detect format)
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients (refresh is called automatically in __init__)
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify secret was read
mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default")
# Verify the annotation was transformed correctly
parsed = processor.get_parsed_object()
assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}"
# parsed_object is a dict with IP addresses as keys, get the first (and only) value
ingress_data = list(parsed.values())[0]
# The k8s_secret annotation should have been transformed and stored in the ingress data
# Format: easyhaproxy.{host}_{port}.plugin.{plugin_name}.{key}
# For test.example.com:8080 -> easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data
# The value should be base64-encoded (double encoding: K8s decodes, we re-encode for plugin)
assert ingress_data["easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey"] is not None
def test_k8s_secret_auto_detect_variation_match(self):
"""Test k8s_secret with auto-detect finds variation key"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with variation key name "public-key" instead of "pubkey"
secret = self.create_mock_secret({"public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with k8s_secret annotation (auto-detect format)
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients (refresh is called automatically in __init__)
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify secret was read
mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default")
# Verify the annotation was transformed correctly
parsed = processor.get_parsed_object()
assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}"
# parsed_object is a dict with IP addresses as keys, get the first (and only) value
ingress_data = list(parsed.values())[0]
# Should find the "public-key" variation
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data
def test_k8s_secret_explicit_key(self):
"""Test k8s_secret with explicit key name (secret_name/key_name format)"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with custom key name
secret = self.create_mock_secret({"rsa-public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with explicit key format
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret/rsa-public-key"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients (refresh is called automatically in __init__)
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify secret was read
mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default")
# Verify the annotation was transformed correctly
parsed = processor.get_parsed_object()
assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}"
# parsed_object is a dict with IP addresses as keys, get the first (and only) value
ingress_data = list(parsed.values())[0]
# Should use the explicit key
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data
def test_k8s_secret_explicit_key_no_variations(self):
"""Test k8s_secret with explicit key doesn't try variations"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with ONLY "public-key", not "custom-key"
secret = self.create_mock_secret({"public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with explicit key that doesn't exist
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret/custom-key"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients (refresh is called automatically in __init__)
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify secret was read
mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default")
# Verify the annotation was NOT created (explicit key not found, no variations tried)
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
# Should NOT have the pubkey annotation (explicit key not found)
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data
def test_k8s_secret_priority_explicit_annotation_wins(self):
"""Test that explicit annotation overrides k8s_secret annotation"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret
secret = self.create_mock_secret({"pubkey": "-----BEGIN PUBLIC KEY-----\nfrom-secret\n-----END PUBLIC KEY-----"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with BOTH explicit pubkey AND k8s_secret.pubkey
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.pubkey": base64.b64encode(b"explicit-value").decode('ascii'),
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify the annotation kept the explicit value (not replaced by secret)
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
# Should have the explicit annotation value, NOT the secret value
explicit_value = base64.b64encode(b"explicit-value").decode('ascii')
assert ingress_data.get("easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey") == explicit_value
def test_k8s_secret_secret_not_found(self):
"""Test k8s_secret handles secret not found gracefully"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Simulate secret not found
from kubernetes.client.rest import ApiException
mock_core_api.read_namespaced_secret.side_effect = ApiException(status=404, reason="Not Found")
# Create ingress with k8s_secret annotation
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "nonexistent-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Should not raise exception, just log warning
# Verify the annotation was NOT created
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
# Should NOT have pubkey annotation
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data
def test_k8s_secret_key_not_found(self):
"""Test k8s_secret handles key not found in secret gracefully"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with NO matching keys
secret = self.create_mock_secret({"some-other-key": "value"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress with k8s_secret annotation
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify the annotation was NOT created
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
# Should NOT have pubkey annotation (no matching keys)
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data
def test_k8s_secret_multiple_plugins(self):
"""Test k8s_secret works with multiple plugins"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create different secrets for different plugins
def get_secret(name, namespace):
if name == "jwt-secret":
return self.create_mock_secret({"pubkey": "jwt-public-key"})
elif name == "api-secret":
return self.create_mock_secret({"api_key": "secret-api-key"})
raise Exception("Secret not found")
mock_core_api.read_namespaced_secret.side_effect = get_secret
# Create ingress with multiple k8s_secret annotations
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator,api_auth",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "jwt-secret",
"easyhaproxy.plugin.api_auth.k8s_secret.api_key": "api-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify both secrets were read
assert mock_core_api.read_namespaced_secret.call_count == 2
# Verify both annotations were transformed
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data
assert "easyhaproxy.test-example-com_8080.plugin.api_auth.api_key" in ingress_data
def test_k8s_secret_namespace_isolation(self):
"""Test k8s_secret reads secrets from same namespace as ingress"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
secret = self.create_mock_secret({"pubkey": "test-key"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress in "production" namespace
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
"easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-secret"
}, namespace="production")
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify secret was read from correct namespace
mock_core_api.read_namespaced_secret.assert_called_once_with("my-secret", "production")
def test_k8s_secret_malformed_annotation(self):
"""Test k8s_secret handles malformed annotation gracefully"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create ingress with malformed k8s_secret annotation
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "jwt_validator",
# Malformed: multiple k8s_secret in the key
"easyhaproxy.plugin.k8s_secret.jwt_validator.k8s_secret.pubkey": "my-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Should not raise exception
# Verify no secret read was attempted
mock_core_api.read_namespaced_secret.assert_not_called()
def test_k8s_secret_password_variations(self):
"""Test k8s_secret auto-detect variations for password key"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with "pass" instead of "password"
secret = self.create_mock_secret({"pass": "secret-password"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress requesting "password" key (should find "pass" variation)
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "auth_plugin",
"easyhaproxy.plugin.auth_plugin.k8s_secret.password": "my-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify the annotation was created (variation found)
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
assert "easyhaproxy.test-example-com_8080.plugin.auth_plugin.password" in ingress_data
def test_k8s_secret_api_key_variations(self):
"""Test k8s_secret auto-detect variations for api_key"""
# Setup mocks
mock_core_api = MagicMock()
mock_networking_api = MagicMock()
# Create a secret with "apikey" instead of "api_key"
secret = self.create_mock_secret({"apikey": "secret-key-123"})
mock_core_api.read_namespaced_secret.return_value = secret
# Create ingress requesting "api_key" (should find "apikey" variation)
ingress = self.create_mock_ingress({
"easyhaproxy.plugins": "api_plugin",
"easyhaproxy.plugin.api_plugin.k8s_secret.api_key": "my-secret"
})
mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress])
# Create processor with mocked API clients
processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api)
# Verify the annotation was created (variation found)
parsed = processor.get_parsed_object()
assert len(parsed) == 1
ingress_data = list(parsed.values())[0]
assert "easyhaproxy.test-example-com_8080.plugin.api_plugin.api_key" in ingress_data