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:
parent
5767e55dea
commit
90b01b1f13
18 changed files with 2978 additions and 24 deletions
|
|
@ -31,12 +31,22 @@ Protect APIs and services with JWT authentication without needing application-le
|
||||||
| `algorithm` | JWT signing algorithm | `RS256` |
|
| `algorithm` | JWT signing algorithm | `RS256` |
|
||||||
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) |
|
| `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) |
|
| `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_path` | Path to public key file (priority 1: explicit file path) | (optional) |
|
||||||
| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (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) |
|
| `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` |
|
| `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` |
|
| `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
|
## Path Validation Logic
|
||||||
|
|
||||||
- **No paths configured:** ALL requests to the domain require JWT validation (default behavior)
|
- **No paths configured:** ALL requests to the domain require JWT validation (default behavior)
|
||||||
|
|
@ -120,7 +130,70 @@ services:
|
||||||
# Invalid JWTs are rejected
|
# 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
|
```yaml
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
|
|
@ -135,11 +208,13 @@ metadata:
|
||||||
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
|
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
|
||||||
easyhaproxy.plugin.jwt_validator.only_paths: "false"
|
easyhaproxy.plugin.jwt_validator.only_paths: "false"
|
||||||
spec:
|
spec:
|
||||||
|
ingressClassName: easyhaproxy
|
||||||
rules:
|
rules:
|
||||||
- host: api.example.com
|
- host: api.example.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: api-service
|
name: api-service
|
||||||
|
|
@ -147,6 +222,8 @@ spec:
|
||||||
number: 8080
|
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
|
### Static YAML Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,219 @@ env:
|
||||||
|
|
||||||
For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide.
|
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
|
## Certbot / ACME / Letsencrypt
|
||||||
|
|
||||||
It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration:
|
It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration:
|
||||||
|
|
|
||||||
9
examples/kubernetes/.gitignore
vendored
Normal file
9
examples/kubernetes/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# kind installation directory
|
||||||
|
.kind/
|
||||||
|
|
||||||
|
# kubectl config
|
||||||
|
kubeconfig
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
*.log
|
||||||
|
service_tls_generated.yml
|
||||||
|
|
@ -100,7 +100,8 @@ metadata:
|
||||||
|
|
||||||
# Allow specific IPs and networks
|
# Allow specific IPs and networks
|
||||||
# UPDATE THIS with your actual office/VPN IPs!
|
# 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
|
# Status code to return for blocked IPs
|
||||||
easyhaproxy.plugin.ip_whitelist.status_code: "403"
|
easyhaproxy.plugin.ip_whitelist.status_code: "403"
|
||||||
|
|
@ -114,9 +115,10 @@ spec:
|
||||||
- host: admin.example.local
|
- host: admin.example.local
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- backend:
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
service:
|
service:
|
||||||
name: admin-service
|
name: admin-service
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
pathType: ImplementationSpecific
|
|
||||||
|
|
|
||||||
135
examples/kubernetes/jwt-validator-secret-example.yml
Normal file
135
examples/kubernetes/jwt-validator-secret-example.yml
Normal 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
|
||||||
|
|
@ -2,6 +2,24 @@
|
||||||
# EXAMPLE: JWT Validator Plugin for Kubernetes
|
# 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:
|
# WHAT THIS DEMONSTRATES:
|
||||||
# - JWT token validation for API protection in Kubernetes
|
# - JWT token validation for API protection in Kubernetes
|
||||||
# - RS256 algorithm signature verification
|
# - RS256 algorithm signature verification
|
||||||
|
|
|
||||||
|
|
@ -66,21 +66,23 @@ spec:
|
||||||
- host: example.org
|
- host: example.org
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- backend:
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
service:
|
service:
|
||||||
name: container-example
|
name: container-example
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
pathType: ImplementationSpecific
|
|
||||||
- host: www.example.org
|
- host: www.example.org
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- backend:
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
service:
|
service:
|
||||||
name: container-example
|
name: container-example
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
pathType: ImplementationSpecific
|
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,13 @@ spec:
|
||||||
- host: host2.local
|
- host: host2.local
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- backend:
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
service:
|
service:
|
||||||
name: tls-example
|
name: tls-example
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
pathType: ImplementationSpecific
|
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|
|
||||||
169
examples/kubernetes/setup-cluster.sh
Executable file
169
examples/kubernetes/setup-cluster.sh
Executable 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 ""
|
||||||
39
examples/kubernetes/teardown-cluster.sh
Executable file
39
examples/kubernetes/teardown-cluster.sh
Executable 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
|
||||||
1697
examples/kubernetes/test_kubernetes.py
Normal file
1697
examples/kubernetes/test_kubernetes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -56,6 +56,17 @@ packages = ["src/easymapping", "src/functions", "src/processor", "src/plugins",
|
||||||
addopts = "-v -p no:warnings"
|
addopts = "-v -p no:warnings"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
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]
|
[tool.ruff]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import re
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
from functions import logger_easyhaproxy
|
from functions import Functions, logger_easyhaproxy
|
||||||
|
|
||||||
|
|
||||||
class DockerLabelHandler:
|
class DockerLabelHandler:
|
||||||
|
|
@ -289,6 +289,24 @@ class HaproxyConfigGenerator:
|
||||||
r.haproxy_config for r in domain_results if r.haproxy_config
|
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
|
# Extract fcgi-app definitions from metadata and add to global configs
|
||||||
for result in domain_results:
|
for result in domain_results:
|
||||||
if result.metadata and "fcgi_app_definition" in result.metadata:
|
if result.metadata and "fcgi_app_definition" in result.metadata:
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ class Consts:
|
||||||
custom_config_folder = "/etc/haproxy/conf.d"
|
custom_config_folder = "/etc/haproxy/conf.d"
|
||||||
certs_certbot = "/certs/certbot"
|
certs_certbot = "/certs/certbot"
|
||||||
certs_haproxy = "/certs/haproxy"
|
certs_haproxy = "/certs/haproxy"
|
||||||
|
jwt_keys = "/etc/haproxy/jwt_keys"
|
||||||
|
|
||||||
|
|
||||||
class DaemonizeHAProxy:
|
class DaemonizeHAProxy:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ def start():
|
||||||
|
|
||||||
os.makedirs(Consts.certs_certbot, exist_ok=True)
|
os.makedirs(Consts.certs_certbot, exist_ok=True)
|
||||||
os.makedirs(Consts.certs_haproxy, 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_config(Consts.haproxy_config)
|
||||||
processor_obj.save_certs(Consts.certs_haproxy)
|
processor_obj.save_certs(Consts.certs_haproxy)
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,28 @@ Configuration:
|
||||||
- algorithm: JWT signing algorithm (default: RS256)
|
- algorithm: JWT signing algorithm (default: RS256)
|
||||||
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
|
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
|
||||||
- audience: Expected JWT audience (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_path: Path to public key file in container (priority: 1)
|
||||||
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided)
|
- 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)
|
- 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
|
- 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
|
- 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:
|
Path Validation Logic:
|
||||||
- No paths configured: ALL requests to the domain require JWT validation (default behavior)
|
- 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
|
- 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.paths: /api/admin,/api/sensitive
|
||||||
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
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:
|
HAProxy Config Generated:
|
||||||
# JWT Validator - Validate JWT tokens
|
# JWT Validator - Validate JWT tokens
|
||||||
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
|
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
|
# Add parent directory to path for imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
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
|
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -178,7 +204,7 @@ class JwtValidatorPlugin(PluginInterface):
|
||||||
elif self.pubkey:
|
elif self.pubkey:
|
||||||
# Generate path for pubkey based on domain
|
# Generate path for pubkey based on domain
|
||||||
domain_safe = context.domain.replace(".", "_").replace(":", "_")
|
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:
|
else:
|
||||||
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
|
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
|
||||||
return PluginResult()
|
return PluginResult()
|
||||||
|
|
|
||||||
|
|
@ -218,12 +218,17 @@ class Swarm(ProcessorInterface):
|
||||||
|
|
||||||
|
|
||||||
class Kubernetes(ProcessorInterface):
|
class Kubernetes(ProcessorInterface):
|
||||||
def __init__(self, filename=None):
|
def __init__(self, filename=None, api_instance=None, v1=None):
|
||||||
self.parsed_object = 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.load_incluster_config()
|
||||||
config.verify_ssl = False
|
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.cert_cache = {}
|
||||||
self.deployment_mode_cache = None
|
self.deployment_mode_cache = None
|
||||||
self.ingress_addresses_cache = None
|
self.ingress_addresses_cache = None
|
||||||
|
|
@ -485,11 +490,122 @@ class Kubernetes(ProcessorInterface):
|
||||||
if annotation_key.startswith("easyhaproxy.plugin."):
|
if annotation_key.startswith("easyhaproxy.plugin."):
|
||||||
plugin_annotations[annotation_key] = annotation_value
|
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"),
|
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
|
||||||
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
|
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
|
||||||
|
|
||||||
ingress_name = ingress.metadata.namespace
|
|
||||||
|
|
||||||
if ingress.spec.tls is not None:
|
if ingress.spec.tls is not None:
|
||||||
for tls in ingress.spec.tls:
|
for tls in ingress.spec.tls:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
419
tests/test_kubernetes_processor.py
Normal file
419
tests/test_kubernetes_processor.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue