Initial commit
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.retry
|
||||
@@ -0,0 +1,27 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, Eric Meehan
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Luanti WASM Kubernetes role
|
||||
|
||||
Deploy the **browser client**, not a Luanti game server. This follows the neighboring
|
||||
Owncast role's controller-local, `kubernetes.core.k8s`, role defaults, Jinja manifest,
|
||||
and deployment-playbook conventions. There is no Helm release, PVC, database,
|
||||
game-server Deployment, or mutable server configuration.
|
||||
|
||||
## Architecture and prerequisites
|
||||
|
||||
- A stateless Deployment puts the nginx **web runtime target** and **lwproxy
|
||||
sidecar in the same pod**. nginx serves TCP 8080 and forwards `/proxy` WebSocket
|
||||
requests to `http://127.0.0.1:7777`. Browser game traffic becomes UDP to the
|
||||
explicitly allowed external game destination.
|
||||
- One **ClusterIP-only Service**, with **only TCP 8080**. Port 7777 is never
|
||||
exposed by a Service, hostPort, NodePort, LoadBalancer, or Ingress.
|
||||
- The proxy listens on `0.0.0.0:7777` so kubelet TCP startup/readiness/liveness
|
||||
probes can reach the pod IP. Binding only to localhost would make those probes
|
||||
fail. The mandatory NetworkPolicy permits ingress **only to TCP 8080**;
|
||||
normal kubelet/node health-probe traffic is exempt from Kubernetes network
|
||||
isolation. Confirm your CNI's node-probe behavior in staging.
|
||||
- Kubernetes with `apps/v1`, `networking.k8s.io/v1` and a **NetworkPolicy-enforcing
|
||||
CNI** is required. The role cannot detect a CNI that silently ignores policy;
|
||||
there is deliberately **no policy-disable or unsupported-CNI bypass**.
|
||||
- Use a Linux Ansible controller (including WSL), Python 3.10+ and ansible-core
|
||||
2.16–2.19. Ansible 2.19 needs Python 3.11+ on the controller. Install
|
||||
[requirements.txt](requirements.txt) in your controller environment and the
|
||||
collection in [requirements.yml](requirements.yml). The version is pinned to
|
||||
match the neighboring role; the small CIDR filter uses only Python's standard
|
||||
library, not `ansible.utils` or `netaddr`.
|
||||
- The controller must have a working kubeconfig or `K8S_AUTH_*` settings and
|
||||
permissions to manage namespaces, ConfigMaps, Deployments, Services, Ingresses,
|
||||
and NetworkPolicies. No kubeconfig, credentials, RBAC binding, or service-account
|
||||
token is placed in the workload. Kubernetes API certificate verification stays
|
||||
enabled; use your normal trusted controller configuration.
|
||||
- Optional ingress needs a WebSocket-capable ingress controller, DNS, and a
|
||||
**browser-trusted TLS certificate**. cert-manager is optional. With no issuer,
|
||||
create the named TLS Secret yourself in the workload namespace. A private
|
||||
`ca-issuer` requires distributing that CA's trust to every browser.
|
||||
|
||||
## Build and supply real images
|
||||
|
||||
There are **no default image names**: the application CI does not publish these
|
||||
runtime images. Build, scan, and push both images to a registry you control. The
|
||||
following commands are conceptual, run **from your Luanti application checkout**;
|
||||
set `REGISTRY`, `RELEASE`, and `GAME_HOST` to your actual values first:
|
||||
|
||||
```sh
|
||||
docker build --target web \
|
||||
--build-arg LUANTI_SERVER_ADDRESS="$GAME_HOST" \
|
||||
--build-arg LUANTI_SERVER_PORT=30000 \
|
||||
-t "$REGISTRY/luanti-web:$RELEASE" .
|
||||
docker build -f docker/proxy.Dockerfile -t "$REGISTRY/lwproxy:$RELEASE" .
|
||||
docker push "$REGISTRY/luanti-web:$RELEASE"
|
||||
docker push "$REGISTRY/lwproxy:$RELEASE"
|
||||
```
|
||||
|
||||
Use the application's `web` target, **not** its `build` or `artifacts` target.
|
||||
The confirmed image contracts are:
|
||||
|
||||
| Image | Runtime contract |
|
||||
| --- | --- |
|
||||
| web | nginx entrypoint; `/usr/share/nginx/html`; `/etc/nginx/nginx.conf`; numeric UID/GID 101; TCP 8080 |
|
||||
| proxy | `lwproxy --config /etc/lwproxy.toml`; numeric UID/GID 65532; TCP 7777 |
|
||||
|
||||
ConfigMap mounts replace exactly those two config paths. Preserve these contracts
|
||||
in custom images. Root filesystems are read-only, nginx writes only to a bounded
|
||||
memory-backed `/tmp`, and the proxy requires no writable volume.
|
||||
|
||||
The game server **hostname and port are embedded at image build time** through
|
||||
`LUANTI_SERVER_ADDRESS` / `LUANTI_SERVER_PORT`. The role does not edit or mount
|
||||
`server.conf`, and changing role variables does **not** change the embedded
|
||||
destination. Rebuild the web image to change it. Keep the DNS A/AAAA addresses,
|
||||
`luanti_wasm_game_cidrs`, and `luanti_wasm_game_port` aligned with the destination
|
||||
and address families your proxy actually uses. The IPv4 proxy listener can still
|
||||
serve a dual-stack pod via nginx loopback; egress IPv6 requires cluster support.
|
||||
|
||||
Both image variables require a non-`latest` explicit tag or a complete
|
||||
`@sha256:` digest (64 lowercase hex characters). Mutable non-latest tags are
|
||||
accepted, but **digests are recommended** for reproducibility and supply-chain
|
||||
verification. Use admission signature/provenance policies and scan your builds.
|
||||
Reusing a mutable tag alone does not trigger a rollout. Image pull credentials
|
||||
must be pre-created Kubernetes Secrets, referenced by name, never embedded here.
|
||||
|
||||
## Deploy
|
||||
|
||||
From the role checkout, install dependencies and make the parent directory a
|
||||
role search path:
|
||||
|
||||
```sh
|
||||
python -m pip install -r requirements.txt
|
||||
ansible-galaxy collection install -r requirements.yml
|
||||
export ANSIBLE_ROLES_PATH="$(dirname "$PWD")${ANSIBLE_ROLES_PATH:+:$ANSIBLE_ROLES_PATH}"
|
||||
ansible-playbook -i localhost, playbooks/deploy-eom.yml -e @deployment-vars.yml
|
||||
```
|
||||
|
||||
Create your own deployment variables file. This example contains intentionally
|
||||
nonexistent registry names and documentation-only IPs; **replace every fixture
|
||||
with your pushed images and resolved game destination**:
|
||||
|
||||
```yaml
|
||||
eom_luanti_wasm_web_image: registry.example.invalid/your-team/luanti-web:your-release
|
||||
eom_luanti_wasm_proxy_image: registry.example.invalid/your-team/lwproxy:your-release
|
||||
eom_luanti_wasm_game_cidrs:
|
||||
- 192.0.2.15/32
|
||||
- 2001:db8::15/128
|
||||
# Optional; only approved content-service addresses, never 0.0.0.0/0 or ::/0.
|
||||
eom_luanti_wasm_https_cidrs: []
|
||||
```
|
||||
|
||||
[playbooks/deploy-eom.yml](playbooks/deploy-eom.yml) requires the three
|
||||
`eom_luanti_wasm_*` image/game inputs before any Kubernetes operation. It enables
|
||||
TLS ingress at **luanti.eom.dev**, ingress class **nginx**, using the
|
||||
**ca-issuer ClusterIssuer** and the **luanti-wasm-tls** Secret. It assumes the
|
||||
image was built for your intended server (the app currently defaults to
|
||||
luanti.eom.dev:30000); it does not create that server. If sharing this hostname,
|
||||
ensure its web ingress and external game UDP routing resolve as intended.
|
||||
|
||||
For another site, invoke the role directly:
|
||||
|
||||
```yaml
|
||||
- name: Deploy browser game client
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
vars:
|
||||
luanti_wasm_web_image: "{{ my_pushed_web_image }}"
|
||||
luanti_wasm_proxy_image: "{{ my_pushed_proxy_image }}"
|
||||
luanti_wasm_game_cidrs: "{{ my_game_server_cidrs }}"
|
||||
luanti_wasm_ingress_enabled: true
|
||||
luanti_wasm_hostname: play.example.org
|
||||
luanti_wasm_ingress_class: nginx
|
||||
luanti_wasm_tls_secret_name: browser-game-tls
|
||||
# Empty issuer: supply the browser-trusted TLS Secret externally.
|
||||
luanti_wasm_cluster_issuer: ""
|
||||
luanti_wasm_ingress_peers:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: ingress-nginx
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/component: controller
|
||||
roles:
|
||||
- ansible-role-luanti-wasm
|
||||
```
|
||||
|
||||
Inspect your controller labels before using the example ingress selectors.
|
||||
`namespaceSelector` and `podSelector` in **one peer** are ANDed; separate peer
|
||||
entries are ORed. A podSelector alone selects pods in the workload namespace.
|
||||
Ingress peers may also use validated `ipBlock` CIDRs with contained `except`
|
||||
networks. An empty peer list omits `from`, allowing any source to TCP 8080 only.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
All settings are in [defaults/main.yml](defaults/main.yml).
|
||||
|
||||
| Variable(s), prefixed `luanti_wasm_` | Default / purpose |
|
||||
| --- | --- |
|
||||
| `namespace`, `name` | `luanti-wasm`; DNS labels, at most 53 characters to reserve suffix space; name starts with a letter |
|
||||
| `web_image`, `proxy_image` | Empty, **required**; distinct runtime image inputs |
|
||||
| `image_pull_policy`, `image_pull_secrets` | `IfNotPresent`, `[]`; secrets are `{name: existing-secret}` objects |
|
||||
| `replicas` | Integer `1`; may scale to a positive integer, but see WebSocket caveats below |
|
||||
| `web_resources`, `proxy_resources` | Each requests `100m` CPU / `64Mi`; limits `500m` / `256Mi`; require both requests and limits |
|
||||
| `tmp_size_limit` | `64Mi`; positive whole `Ki`, `Mi`, or `Gi` memory-backed nginx scratch space, charged to pod/container memory usage |
|
||||
| `node_selector`, `tolerations` | `{}`, `[]` |
|
||||
| `wait`, `wait_timeout` | `true`, `300` seconds; wait for Deployment readiness, configurable for GitOps/slow starts |
|
||||
| `ingress_enabled`, `hostname`, `ingress_class` | `false`, empty, empty; disabled ingress is removed on subsequent runs |
|
||||
| `tls_enabled`, `tls_secret_name` | `true`, name plus `-tls`; **TLS must be true whenever ingress is enabled** |
|
||||
| `cluster_issuer` | Empty; optional cert-manager ClusterIssuer name |
|
||||
| `ingress_annotations` | nginx read/send timeout `3600` seconds and SSL redirect enabled; replace/extend the dictionary for your controller |
|
||||
| `websocket_timeout_seconds` | Integer `3600`; nginx side of the long-lived WebSocket; also adjust ingress timeouts to match |
|
||||
| `ingress_peers` | `[]`, any source on **8080 only**; restrict to your ingress controller in production |
|
||||
| `game_cidrs`, `game_port` | Empty, **required nonempty**, and UDP `30000`; canonical IPv4/IPv6 CIDRs only |
|
||||
| `https_cidrs` | `[]`, no external TCP 443 by default |
|
||||
| `http_allowed_hosts` | Exact `content.luanti.org`, `servers.luanti.org`; JSON-escaped array written as valid TOML |
|
||||
|
||||
Resource quantities are ultimately validated by the Kubernetes API; tune limits
|
||||
for actual traffic and account for nginx tmpfs. Requests must not exceed limits.
|
||||
Role assertions validate identities, image references, scalars, collection/peer
|
||||
shapes, ingress hostname/TLS, exact proxy hostnames, and canonical CIDRs. Scoped
|
||||
IPv6, host bits, malformed addresses, and `/0` wildcard networks are rejected.
|
||||
Use narrow `/32` and `/128` destination rules where possible. The role does not
|
||||
make DNS lookups to silently broaden or update an allowlist.
|
||||
|
||||
## Security and operational boundaries
|
||||
|
||||
- NetworkPolicy is applied **before the Deployment**, selecting its pod labels.
|
||||
Ingress permits TCP 8080 only; egress permits **only** DNS TCP/UDP 53 to
|
||||
`kube-system` pods labeled `k8s-app: kube-dns`, game UDP to the explicit CIDRs
|
||||
and port, and optional TCP 443 to explicit HTTPS CIDRs. There are no empty
|
||||
egress peers or wildcard destinations. This assumes CoreDNS/kube-dns with those
|
||||
labels. NodeLocal DNSCache, custom DNS namespaces, NAT, host-network ingress,
|
||||
and CNI-specific service-IP handling require operator review and testing;
|
||||
do not work around mismatches by opening arbitrary egress.
|
||||
- Kubernetes NetworkPolicies are **additive**: another policy can widen access.
|
||||
Audit all policies selecting these pods, node firewall rules, and namespace
|
||||
access. NetworkPolicy is pod-wide, not per-container; nginx and lwproxy share
|
||||
the same narrow egress permissions. It does not protect against privileged
|
||||
node access or enforce loopback isolation.
|
||||
- Kubernetes standard NetworkPolicy **does not support FQDN egress**. The proxy
|
||||
host allowlist is application-layer filtering, not protection from **DNS
|
||||
rebinding**. Keep CIDRs narrow; allowlisted hosts resolving outside them will
|
||||
fail closed. Never add pod, service, node, metadata, or internal control-plane
|
||||
networks to external content allowlists. Content/CDN addresses change; maintain
|
||||
approved CIDRs yourself, or use a separately reviewed CNI-specific FQDN/egress
|
||||
gateway policy outside this role. Do not use `0.0.0.0/0` or `::/0` as a shortcut.
|
||||
- Without HTTPS CIDRs, content/server-list HTTP proxy requests are intentionally
|
||||
blocked even though those hostnames appear in TOML. DNS and game traffic still
|
||||
work. Redirects, CDN aliases, extra ports, or new hostnames require explicit
|
||||
review; no automatic unrestricted egress is added. Browser-direct external
|
||||
fetches are not governed by pod NetworkPolicy and must satisfy COEP/CORS too.
|
||||
- Web and proxy run as numeric non-root users **101** and **65532**, drop **ALL**
|
||||
capabilities, disallow privilege escalation, use read-only roots, and inherit
|
||||
`seccompProfile: RuntimeDefault`. The pod never mounts an API token. There are
|
||||
no embedded credentials or certificates: reference existing pull/TLS Secrets
|
||||
and authenticate only on the controller to avoid leaking secrets into source.
|
||||
- TLS terminates at ingress; keep the in-cluster HTTP hop within a trusted,
|
||||
isolated cluster network. Game UDP is the native Luanti protocol, not added
|
||||
TLS. Configure cluster ingress TLS policy/ciphers and certificate renewal in
|
||||
your ingress infrastructure. SharedArrayBuffer/threaded WASM needs a **secure
|
||||
context** and the preserved COOP `same-origin`, COEP `require-corp`, and
|
||||
`X-Content-Type-Options: nosniff` headers on pages/workers. Do not strip them at
|
||||
ingress. If ingress is disabled, provide trusted HTTPS elsewhere (or use
|
||||
localhost port-forwarding for local testing), not remote plain HTTP.
|
||||
- Non-release paths use `no-cache`; only paths matching the app's immutable
|
||||
**12-lowercase-hex release directory** regex get one-year immutable caching.
|
||||
A SHA256 checksum of the full rendered ConfigMap changes the pod template
|
||||
whenever either runtime config changes, because `subPath` mounts do not
|
||||
update in-place. Upstream runtime/image contract changes need revalidation.
|
||||
- Existing WebSocket sessions belong to one proxy pod. Scaling is supported for
|
||||
new sessions, not session migration; rolling updates/restarts disconnect active
|
||||
games. A startup/readiness TCP probe proves the listener is reachable, not
|
||||
successful game UDP connectivity. Deployment wait does not prove ingress TLS,
|
||||
NetworkPolicy enforcement, content access, or an actual browser game session.
|
||||
|
||||
## Offline tests and staging validation
|
||||
|
||||
With dependencies installed, run from the role checkout:
|
||||
|
||||
```sh
|
||||
sh tests/run.sh
|
||||
```
|
||||
|
||||
[tests/test.yml](tests/test.yml) includes only `tasks_from: validate.yml` (with
|
||||
`public: true`) and `tasks_from: render.yml`, never the deployment tasks. It uses
|
||||
documentation-network and nonexistent-registry fixtures, renders and parses real
|
||||
YAML, and checks:
|
||||
|
||||
- same-pod layout, ClusterIP 8080-only routing, actual image/config mount contracts;
|
||||
- headers, immutable-cache regex, loopback WebSocket upstream and proxy bind;
|
||||
- security contexts, probes, tmpfs limits, resources, and absence of PVCs;
|
||||
- default-deny port isolation and exact DNS/game/optional HTTPS destinations;
|
||||
- deterministic config checksum and config-triggered rollout changes;
|
||||
- actual EOM playbook variables, TLS ingress, optional cert-manager and annotations;
|
||||
- rejection of empty/unversioned/latest images, malformed/wildcard CIDRs, bad
|
||||
names/hostnames, peer shapes, missing TLS, invalid resources/types and timeouts.
|
||||
|
||||
[tests/run.sh](tests/run.sh) also syntax-checks the deployment playbook and runs
|
||||
its missing-input preflight, verifying that it fails before namespace creation.
|
||||
It runs standard-library unit tests in [tests/test_network.py](tests/test_network.py)
|
||||
for canonical IPv4/IPv6 parsing and strict ingress CIDR exception containment.
|
||||
Negative test assertion failures are **expected and rescued**; the final recap
|
||||
must still show `failed=0` and the runner's success message. No cluster, image
|
||||
pull, container build, or WASM compiler is required. Run in a network-disabled
|
||||
sandbox for extra assurance.
|
||||
|
||||
Before production, separately verify admission/server-side schema acceptance,
|
||||
actual image startup with read-only roots, CNI drop behavior on 7777 and unwanted
|
||||
egress, allowed UDP game connectivity, trusted TLS/HTTPS redirects, and browser
|
||||
`crossOriginIsolated` plus WebSocket game join. These runtime properties cannot
|
||||
be established by offline manifest rendering alone.
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
# Uses the controller's kubeconfig / K8S_AUTH_* authentication; never disables TLS verification.
|
||||
luanti_wasm_namespace: luanti-wasm
|
||||
luanti_wasm_name: luanti-wasm
|
||||
# Build and publish your own images. Upstream does not publish these runtime images.
|
||||
luanti_wasm_web_image: ""
|
||||
luanti_wasm_proxy_image: ""
|
||||
luanti_wasm_image_pull_policy: IfNotPresent
|
||||
luanti_wasm_image_pull_secrets: []
|
||||
luanti_wasm_replicas: 1
|
||||
luanti_wasm_web_resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
luanti_wasm_proxy_resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 256Mi
|
||||
luanti_wasm_tmp_size_limit: 64Mi
|
||||
luanti_wasm_node_selector: {}
|
||||
luanti_wasm_tolerations: []
|
||||
luanti_wasm_wait: true
|
||||
luanti_wasm_wait_timeout: 300
|
||||
|
||||
luanti_wasm_ingress_enabled: false
|
||||
luanti_wasm_hostname: ""
|
||||
luanti_wasm_ingress_class: ""
|
||||
luanti_wasm_ingress_annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
luanti_wasm_tls_enabled: true
|
||||
luanti_wasm_tls_secret_name: "{{ luanti_wasm_name }}-tls"
|
||||
# Empty means a pre-existing, externally managed TLS Secret.
|
||||
luanti_wasm_cluster_issuer: ""
|
||||
luanti_wasm_websocket_timeout_seconds: 3600
|
||||
|
||||
# NetworkPolicy is unconditional, with no unsupported-CNI bypass.
|
||||
# [] omits 'from', allowing traffic from any source, but only to TCP 8080.
|
||||
luanti_wasm_ingress_peers: []
|
||||
# REQUIRED: literal, canonical, non-/0 IPv4/IPv6 networks for the built-in game destination.
|
||||
luanti_wasm_game_cidrs: []
|
||||
luanti_wasm_game_port: 30000
|
||||
# Empty disables external HTTPS egress; DNS + game UDP remain the only egress rules.
|
||||
luanti_wasm_https_cidrs: []
|
||||
luanti_wasm_http_allowed_hosts:
|
||||
- content.luanti.org
|
||||
- servers.luanti.org
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
"""Strict CIDR checks without the ansible.utils / netaddr dependency."""
|
||||
|
||||
from ipaddress import ip_network
|
||||
|
||||
|
||||
def valid_cidr(value: object) -> bool:
|
||||
"""Accept canonical IPv4/IPv6 networks, never a wildcard or scoped address."""
|
||||
if not isinstance(value, str) or "/" not in value or "%" in value:
|
||||
return False
|
||||
try:
|
||||
network = ip_network(value, strict=True)
|
||||
except ValueError:
|
||||
return False
|
||||
return network.prefixlen > 0 and str(network) == value
|
||||
|
||||
|
||||
def valid_ip_block(value: object) -> bool:
|
||||
"""Validate a Kubernetes ipBlock, including contained exception networks."""
|
||||
if not isinstance(value, dict) or set(value) - {"cidr", "except"}:
|
||||
return False
|
||||
if not valid_cidr(value.get("cidr")):
|
||||
return False
|
||||
exceptions = value.get("except", [])
|
||||
if not isinstance(exceptions, list):
|
||||
return False
|
||||
parent = ip_network(value["cidr"])
|
||||
for exception in exceptions:
|
||||
if not valid_cidr(exception):
|
||||
return False
|
||||
child = ip_network(exception)
|
||||
if (
|
||||
child.version != parent.version
|
||||
or child == parent
|
||||
or int(child.network_address) < int(parent.network_address)
|
||||
or int(child.broadcast_address) > int(parent.broadcast_address)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class FilterModule:
|
||||
"""Expose validation predicates to Ansible templates and assertions."""
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
"luanti_wasm_valid_cidr": valid_cidr,
|
||||
"luanti_wasm_valid_ip_block": valid_ip_block,
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: luanti_wasm
|
||||
author: Eric Meehan
|
||||
description: Deploy a stateless Luanti WASM web client and isolated lwproxy sidecar on Kubernetes
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.16"
|
||||
dependencies: []
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
# Uses the controller's kubeconfig. Required inputs have no invented image defaults.
|
||||
- name: Deploy eom.dev Luanti WASM client
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
vars:
|
||||
luanti_wasm_namespace: luanti-wasm
|
||||
luanti_wasm_name: luanti-wasm
|
||||
luanti_wasm_web_image: "{{ eom_luanti_wasm_web_image | default('') }}"
|
||||
luanti_wasm_proxy_image: "{{ eom_luanti_wasm_proxy_image | default('') }}"
|
||||
luanti_wasm_game_cidrs: "{{ eom_luanti_wasm_game_cidrs | default([]) }}"
|
||||
luanti_wasm_https_cidrs: "{{ eom_luanti_wasm_https_cidrs | default([]) }}"
|
||||
luanti_wasm_ingress_enabled: true
|
||||
luanti_wasm_hostname: luanti.eom.dev
|
||||
luanti_wasm_ingress_class: nginx
|
||||
luanti_wasm_tls_enabled: true
|
||||
luanti_wasm_tls_secret_name: luanti-wasm-tls
|
||||
luanti_wasm_cluster_issuer: ca-issuer
|
||||
pre_tasks:
|
||||
- name: Require explicit eom build images and destination networks
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- eom_luanti_wasm_web_image | default('') | length > 0
|
||||
- eom_luanti_wasm_proxy_image | default('') | length > 0
|
||||
- eom_luanti_wasm_game_cidrs | default([]) | length > 0
|
||||
fail_msg: Supply eom_luanti_wasm_web_image, eom_luanti_wasm_proxy_image, and eom_luanti_wasm_game_cidrs.
|
||||
roles:
|
||||
- ansible-role-luanti-wasm
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
ansible-core>=2.16,<2.20
|
||||
kubernetes>=24.2.0,<35
|
||||
PyYAML>=6.0,<7
|
||||
jsonpatch>=1.32,<2
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
---
|
||||
collections:
|
||||
- name: kubernetes.core
|
||||
version: 6.0.0
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
---
|
||||
- name: Validate Luanti configuration before contacting Kubernetes
|
||||
ansible.builtin.import_tasks: validate.yml
|
||||
|
||||
- name: Render Luanti resources before contacting Kubernetes
|
||||
ansible.builtin.import_tasks: render.yml
|
||||
|
||||
- name: Create Luanti namespace
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: "{{ luanti_wasm_namespace }}"
|
||||
|
||||
# Apply isolation before creating any pods. Enforcement requires a capable CNI.
|
||||
- name: Apply mandatory Luanti network policy and configuration
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition: "{{ _luanti_wasm_manifests[item] }}"
|
||||
loop:
|
||||
- networkpolicy
|
||||
- configmap
|
||||
|
||||
- name: Deploy Luanti web and proxy sidecar
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition: "{{ _luanti_wasm_manifests.deployment }}"
|
||||
wait: "{{ luanti_wasm_wait }}"
|
||||
wait_timeout: "{{ luanti_wasm_wait_timeout }}"
|
||||
|
||||
- name: Create Luanti private web service
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition: "{{ _luanti_wasm_manifests.service }}"
|
||||
|
||||
- name: Reconcile optional Luanti TLS ingress
|
||||
kubernetes.core.k8s:
|
||||
state: "{{ 'present' if luanti_wasm_ingress_enabled else 'absent' }}"
|
||||
definition: "{{ _luanti_wasm_manifests.ingress }}"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# Also used by the offline regression suite; no Kubernetes API access.
|
||||
- name: Render Luanti Kubernetes resources
|
||||
ansible.builtin.set_fact:
|
||||
_luanti_wasm_manifests:
|
||||
configmap: "{{ lookup('ansible.builtin.template', 'configmap.yml.j2') | from_yaml }}"
|
||||
deployment: "{{ lookup('ansible.builtin.template', 'deployment.yml.j2') | from_yaml }}"
|
||||
service: "{{ lookup('ansible.builtin.template', 'service.yml.j2') | from_yaml }}"
|
||||
networkpolicy: "{{ lookup('ansible.builtin.template', 'networkpolicy.yml.j2') | from_yaml }}"
|
||||
ingress: "{{ lookup('ansible.builtin.template', 'ingress.yml.j2') | from_yaml }}"
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
- name: Validate Luanti identity and scalar settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- luanti_wasm_name is string
|
||||
- luanti_wasm_name is match('^[a-z]([-a-z0-9]*[a-z0-9])?$')
|
||||
- luanti_wasm_name | length <= 53
|
||||
- luanti_wasm_namespace is string
|
||||
- luanti_wasm_namespace is match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')
|
||||
- luanti_wasm_namespace | length <= 53
|
||||
- luanti_wasm_replicas is integer and luanti_wasm_replicas is not boolean
|
||||
- luanti_wasm_replicas >= 1
|
||||
- luanti_wasm_game_port is integer and luanti_wasm_game_port is not boolean
|
||||
- luanti_wasm_game_port >= 1 and luanti_wasm_game_port <= 65535
|
||||
- luanti_wasm_websocket_timeout_seconds is integer and luanti_wasm_websocket_timeout_seconds is not boolean
|
||||
- luanti_wasm_websocket_timeout_seconds > 0
|
||||
- luanti_wasm_wait_timeout is integer and luanti_wasm_wait_timeout is not boolean
|
||||
- luanti_wasm_wait_timeout > 0
|
||||
- luanti_wasm_wait is boolean
|
||||
- luanti_wasm_ingress_enabled is boolean
|
||||
- luanti_wasm_tls_enabled is boolean
|
||||
- luanti_wasm_image_pull_policy in ['Always', 'IfNotPresent', 'Never']
|
||||
- luanti_wasm_tmp_size_limit is string
|
||||
- luanti_wasm_tmp_size_limit is match('^[1-9][0-9]*(Ki|Mi|Gi)$')
|
||||
- luanti_wasm_node_selector is mapping
|
||||
- luanti_wasm_ingress_annotations is mapping
|
||||
fail_msg: Invalid Luanti identity, integer/boolean settings, image pull policy, or tmp size.
|
||||
|
||||
- name: Validate explicit versioned Luanti images
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is string
|
||||
- item | length > 0
|
||||
- item is match('^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$')
|
||||
- item is search(':[A-Za-z0-9_][A-Za-z0-9_.-]*$') or item is search('@sha256:[a-f0-9]{64}$')
|
||||
- item is not search('(?i):latest(?:@|$)')
|
||||
fail_msg: Supply separate web/proxy image references with an explicit non-latest tag or sha256 digest.
|
||||
loop: "{{ [luanti_wasm_web_image, luanti_wasm_proxy_image] }}"
|
||||
|
||||
- name: Validate Luanti list settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is sequence and item is not string and item is not mapping
|
||||
loop: "{{ [luanti_wasm_image_pull_secrets, luanti_wasm_tolerations, luanti_wasm_ingress_peers, luanti_wasm_game_cidrs, luanti_wasm_https_cidrs, luanti_wasm_http_allowed_hosts] }}"
|
||||
|
||||
- name: Require an explicit game destination
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- luanti_wasm_game_cidrs | length > 0
|
||||
fail_msg: Supply nonempty luanti_wasm_game_cidrs matching the game destination built into the web image.
|
||||
|
||||
- name: Validate literal Luanti egress CIDRs
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item | luanti_wasm_valid_cidr
|
||||
fail_msg: Egress requires canonical IPv4/IPv6 CIDRs with no host bits, zone IDs, or /0 wildcard.
|
||||
loop: "{{ luanti_wasm_game_cidrs + luanti_wasm_https_cidrs }}"
|
||||
|
||||
- name: Validate Luanti ingress peers
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is mapping
|
||||
- item | length > 0
|
||||
- item.keys() | difference(['namespaceSelector', 'podSelector', 'ipBlock']) | length == 0
|
||||
- "'namespaceSelector' not in item or item.namespaceSelector is mapping"
|
||||
- "'podSelector' not in item or item.podSelector is mapping"
|
||||
- "'ipBlock' not in item or (item | length == 1 and item.ipBlock | luanti_wasm_valid_ip_block)"
|
||||
loop: "{{ luanti_wasm_ingress_peers }}"
|
||||
|
||||
- name: Validate Luanti resources
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is mapping
|
||||
- item.requests is defined
|
||||
- item.limits is defined
|
||||
- item.requests is mapping
|
||||
- item.limits is mapping
|
||||
- item.requests.cpu is defined and item.requests.memory is defined
|
||||
- item.limits.cpu is defined and item.limits.memory is defined
|
||||
loop: "{{ [luanti_wasm_web_resources, luanti_wasm_proxy_resources] }}"
|
||||
|
||||
- name: Validate pull secret references
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is mapping
|
||||
- item.name is defined
|
||||
- item.name is string
|
||||
- item.name | length <= 253
|
||||
- item.name is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||
loop: "{{ luanti_wasm_image_pull_secrets }}"
|
||||
|
||||
- name: Validate toleration objects
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is mapping
|
||||
loop: "{{ luanti_wasm_tolerations }}"
|
||||
|
||||
- name: Validate Luanti annotations
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.key is string
|
||||
- item.value is string
|
||||
loop: "{{ luanti_wasm_ingress_annotations | dict2items }}"
|
||||
|
||||
- name: Validate proxy HTTP host allowlist
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item is string
|
||||
- item | length <= 253
|
||||
- item is match('^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$')
|
||||
fail_msg: HTTP proxy allowlist accepts exact DNS names, not URLs or wildcards.
|
||||
loop: "{{ luanti_wasm_http_allowed_hosts }}"
|
||||
|
||||
- name: Validate Luanti ingress TLS settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- luanti_wasm_tls_enabled
|
||||
- luanti_wasm_hostname is string
|
||||
- luanti_wasm_hostname is match('^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$')
|
||||
- luanti_wasm_tls_secret_name is string
|
||||
- luanti_wasm_tls_secret_name | length <= 253
|
||||
- luanti_wasm_tls_secret_name is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||
- luanti_wasm_ingress_class is string
|
||||
- luanti_wasm_ingress_class | length == 0 or luanti_wasm_ingress_class is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||
- luanti_wasm_cluster_issuer is string
|
||||
- luanti_wasm_cluster_issuer | length <= 253
|
||||
- luanti_wasm_cluster_issuer | length == 0 or luanti_wasm_cluster_issuer is match('^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$')
|
||||
fail_msg: Ingress requires a valid DNS hostname and TLS secret; TLS cannot be disabled for WASM secure contexts.
|
||||
when: luanti_wasm_ingress_enabled
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ (luanti_wasm_name ~ '-config') | to_json }}
|
||||
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||
data:
|
||||
nginx.conf: {{ lookup('ansible.builtin.template', 'nginx.conf.j2') | to_json }}
|
||||
lwproxy.toml: {{ lookup('ansible.builtin.template', 'proxy.toml.j2') | to_json }}
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ luanti_wasm_name | to_json }}
|
||||
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||
spec:
|
||||
replicas: {{ luanti_wasm_replicas }}
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ luanti_wasm_name | to_json }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ luanti_wasm_name | to_json }}
|
||||
annotations:
|
||||
checksum/config: {{ lookup('ansible.builtin.template', 'configmap.yml.j2') | hash('sha256') | to_json }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
fsGroup: 101
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
imagePullSecrets: {{ luanti_wasm_image_pull_secrets | to_json }}
|
||||
nodeSelector: {{ luanti_wasm_node_selector | to_json }}
|
||||
tolerations: {{ luanti_wasm_tolerations | to_json }}
|
||||
containers:
|
||||
- name: web
|
||||
image: {{ luanti_wasm_web_image | to_json }}
|
||||
imagePullPolicy: {{ luanti_wasm_image_pull_policy | to_json }}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
resources: {{ luanti_wasm_web_resources | to_json }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx.conf
|
||||
readOnly: true
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: proxy
|
||||
image: {{ luanti_wasm_proxy_image | to_json }}
|
||||
imagePullPolicy: {{ luanti_wasm_image_pull_policy | to_json }}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
ports:
|
||||
- name: proxy
|
||||
containerPort: 7777
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: proxy
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: proxy
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: proxy
|
||||
periodSeconds: 20
|
||||
resources: {{ luanti_wasm_proxy_resources | to_json }}
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/lwproxy.toml
|
||||
subPath: lwproxy.toml
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ (luanti_wasm_name ~ '-config') | to_json }}
|
||||
defaultMode: 0444
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: {{ luanti_wasm_tmp_size_limit | to_json }}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ luanti_wasm_name | to_json }}
|
||||
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||
{% if luanti_wasm_ingress_enabled %}
|
||||
annotations: {{ (luanti_wasm_ingress_annotations | combine({'cert-manager.io/cluster-issuer': luanti_wasm_cluster_issuer} if luanti_wasm_cluster_issuer | length > 0 else {})) | to_json }}
|
||||
spec:
|
||||
{% if luanti_wasm_ingress_class | length > 0 %}
|
||||
ingressClassName: {{ luanti_wasm_ingress_class | to_json }}
|
||||
{% endif %}
|
||||
tls:
|
||||
- hosts: [{{ luanti_wasm_hostname | to_json }}]
|
||||
secretName: {{ luanti_wasm_tls_secret_name | to_json }}
|
||||
rules:
|
||||
- host: {{ luanti_wasm_hostname | to_json }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ luanti_wasm_name | to_json }}
|
||||
port:
|
||||
number: 8080
|
||||
{% endif %}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ luanti_wasm_name | to_json }}
|
||||
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: {{ luanti_wasm_name | to_json }}
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- ports:
|
||||
- port: 8080
|
||||
protocol: TCP
|
||||
{% if luanti_wasm_ingress_peers | length > 0 %}
|
||||
from: {{ luanti_wasm_ingress_peers | to_json }}
|
||||
{% endif %}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- port: 53
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
protocol: TCP
|
||||
- to:
|
||||
{% for cidr in luanti_wasm_game_cidrs %}
|
||||
- ipBlock:
|
||||
cidr: {{ cidr | to_json }}
|
||||
{% endfor %}
|
||||
ports:
|
||||
- port: {{ luanti_wasm_game_port }}
|
||||
protocol: UDP
|
||||
{% if luanti_wasm_https_cidrs | length > 0 %}
|
||||
- to:
|
||||
{% for cidr in luanti_wasm_https_cidrs %}
|
||||
- ipBlock:
|
||||
cidr: {{ cidr | to_json }}
|
||||
{% endfor %}
|
||||
ports:
|
||||
- port: 443
|
||||
protocol: TCP
|
||||
{% endif %}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
worker_processes auto;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
map $uri $cache_control {
|
||||
default "no-cache";
|
||||
"~^/[0-9a-f]{12}/" "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header Cross-Origin-Opener-Policy same-origin always;
|
||||
add_header Cross-Origin-Embedder-Policy require-corp always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Cache-Control $cache_control always;
|
||||
|
||||
location = /proxy {
|
||||
proxy_pass http://127.0.0.1:7777;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout {{ luanti_wasm_websocket_timeout_seconds }}s;
|
||||
proxy_send_timeout {{ luanti_wasm_websocket_timeout_seconds }}s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
[[serve]]
|
||||
protocol = "ws"
|
||||
# Pod-IP binding is required for kubelet TCP probes. NetworkPolicy denies ingress
|
||||
# to 7777; nginx talks to this sidecar through the shared pod loopback interface.
|
||||
bind_address = "0.0.0.0:7777"
|
||||
|
||||
[http_proxy]
|
||||
# JSON string arrays are valid TOML, including escaping untrusted strings.
|
||||
allowed_hosts = {{ luanti_wasm_http_allowed_hosts | to_json }}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ luanti_wasm_name | to_json }}
|
||||
namespace: {{ luanti_wasm_namespace | to_json }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: {{ luanti_wasm_name | to_json }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
---
|
||||
- name: Apply invalid test settings
|
||||
ansible.builtin.set_fact:
|
||||
"{{ setting.key }}": "{{ setting.value }}"
|
||||
loop: "{{ invalid_settings | dict2items }}"
|
||||
loop_control:
|
||||
loop_var: setting
|
||||
|
||||
- name: Verify validation rejects the input
|
||||
block:
|
||||
- name: Run role preflight with invalid settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: validate.yml
|
||||
- name: Fail if invalid configuration was accepted
|
||||
ansible.builtin.fail:
|
||||
msg: "Invalid configuration was accepted: {{ invalid_settings }}"
|
||||
rescue:
|
||||
- name: Require a real assertion failure, not a template error or test sentinel
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_failed_task.action == 'ansible.builtin.assert'
|
||||
- ansible_failed_task.name is match('Validate|Require an explicit')
|
||||
- (ansible_failed_result.results | default([ansible_failed_result]) | selectattr('failed', 'defined') | selectattr('failed') | list | length) > 0
|
||||
- name: Check each failed assertion, including loop result envelopes
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- rejection.assertion is defined
|
||||
- rejection.evaluated_to is defined
|
||||
- not rejection.evaluated_to
|
||||
loop: "{{ ansible_failed_result.results | default([ansible_failed_result]) | selectattr('failed', 'defined') | selectattr('failed') | list }}"
|
||||
loop_control:
|
||||
loop_var: rejection
|
||||
always:
|
||||
- name: Restore valid settings
|
||||
ansible.builtin.set_fact:
|
||||
"{{ setting.key }}": "{{ _luanti_baseline[setting.key] }}"
|
||||
loop: "{{ invalid_settings | dict2items }}"
|
||||
loop_control:
|
||||
loop_var: setting
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
export ANSIBLE_ROLES_PATH="$(dirname "$PWD")${ANSIBLE_ROLES_PATH:+:$ANSIBLE_ROLES_PATH}"
|
||||
python3 -m unittest discover -s tests -p test_network.py
|
||||
ansible-playbook -i localhost, playbooks/deploy-eom.yml --syntax-check
|
||||
ansible-playbook -i localhost, tests/test.yml
|
||||
|
||||
# This real deployment entrypoint must fail before any Kubernetes task is run.
|
||||
log=$(mktemp)
|
||||
trap 'rm -f "$log"' EXIT HUP INT TERM
|
||||
if ansible-playbook -i localhost, playbooks/deploy-eom.yml > "$log" 2>&1; then
|
||||
echo 'Deployment accepted missing required inputs' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Supply eom_luanti_wasm_web_image' "$log"
|
||||
if grep -q 'TASK \[ansible-role-luanti-wasm : Create Luanti namespace\]' "$log"; then
|
||||
echo 'Missing inputs reached Kubernetes tasks' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'Luanti WASM offline checks passed'
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
---
|
||||
- name: Validate Luanti WASM role without a cluster
|
||||
hosts: localhost
|
||||
connection: local
|
||||
gather_facts: false
|
||||
vars:
|
||||
# Non-routable documentation fixtures only; never pulled or deployed.
|
||||
luanti_wasm_web_image: registry.invalid/fixture/luanti-web:test-1
|
||||
luanti_wasm_proxy_image: "registry.invalid/fixture/lwproxy@sha256:{{ 'a' * 64 }}"
|
||||
luanti_wasm_game_cidrs: [192.0.2.15/32, '2001:db8::15/128']
|
||||
tasks:
|
||||
- name: Load defaults and validate fixture settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: validate.yml
|
||||
public: true
|
||||
|
||||
- name: Render the actual role templates
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: render.yml
|
||||
|
||||
- name: Check private stateless defaults and service isolation
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not luanti_wasm_ingress_enabled
|
||||
- _luanti_wasm_manifests.deployment.apiVersion == 'apps/v1'
|
||||
- _luanti_wasm_manifests.deployment.spec.replicas == 1
|
||||
- _luanti_wasm_manifests.deployment.spec.template.spec.containers | length == 2
|
||||
- _luanti_wasm_manifests.service.spec.type == 'ClusterIP'
|
||||
- _luanti_wasm_manifests.service.spec.ports | map(attribute='port') | list == [8080]
|
||||
- _luanti_wasm_manifests.service.spec.ports[0].targetPort == 'http'
|
||||
- _luanti_wasm_manifests.service.spec.selector == _luanti_wasm_manifests.deployment.spec.template.metadata.labels
|
||||
- "'hostNetwork' not in _luanti_wasm_manifests.deployment.spec.template.spec"
|
||||
- "'spec' not in _luanti_wasm_manifests.ingress"
|
||||
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes | length == 2
|
||||
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes | selectattr('persistentVolumeClaim', 'defined') | list | length == 0
|
||||
|
||||
- name: Check pod security, bounded memory temporary volume, and probes
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not pod.automountServiceAccountToken
|
||||
- pod.securityContext.runAsNonRoot
|
||||
- pod.securityContext.seccompProfile.type == 'RuntimeDefault'
|
||||
- pod.securityContext.fsGroup == 101
|
||||
- pod.volumes[1].emptyDir == {'medium':'Memory', 'sizeLimit':'64Mi'}
|
||||
- pod.containers[0].securityContext.runAsUser == 101
|
||||
- pod.containers[1].securityContext.runAsUser == 65532
|
||||
- pod.containers[0].readinessProbe.httpGet == {'path':'/', 'port':'http'}
|
||||
- pod.containers[0].livenessProbe.httpGet.port == 'http'
|
||||
- pod.containers[1].readinessProbe.tcpSocket.port == 'proxy'
|
||||
- pod.containers[1].livenessProbe.tcpSocket.port == 'proxy'
|
||||
- pod.containers[0].image == luanti_wasm_web_image
|
||||
- pod.containers[1].image == luanti_wasm_proxy_image
|
||||
- pod.containers[0].volumeMounts[0].mountPath == '/etc/nginx/nginx.conf'
|
||||
- pod.containers[1].volumeMounts[0].mountPath == '/etc/lwproxy.toml'
|
||||
- pod.containers[1].volumeMounts[0].subPath == 'lwproxy.toml'
|
||||
vars:
|
||||
pod: "{{ _luanti_wasm_manifests.deployment.spec.template.spec }}"
|
||||
|
||||
- name: Check both containers are hardened and resource bounded
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.securityContext.runAsNonRoot
|
||||
- item.securityContext.readOnlyRootFilesystem
|
||||
- not item.securityContext.allowPrivilegeEscalation
|
||||
- item.securityContext.capabilities.drop == ['ALL']
|
||||
- item.resources.requests == {'cpu':'100m', 'memory':'64Mi'}
|
||||
- item.resources.limits.memory == '256Mi'
|
||||
- item.volumeMounts[0].readOnly
|
||||
- item.startupProbe.failureThreshold == 30
|
||||
- item.ports | selectattr('hostPort', 'defined') | list | length == 0
|
||||
loop: "{{ _luanti_wasm_manifests.deployment.spec.template.spec.containers }}"
|
||||
|
||||
- name: Check WASM headers, release cache contract, and localhost routing
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'add_header Cross-Origin-Opener-Policy same-origin always;' in nginx"
|
||||
- "'add_header Cross-Origin-Embedder-Policy require-corp always;' in nginx"
|
||||
- "'add_header X-Content-Type-Options nosniff always;' in nginx"
|
||||
- "'~^/[0-9a-f]{12}/' in nginx"
|
||||
- "'public, max-age=31536000, immutable' in nginx"
|
||||
- "'default \"no-cache\";' in nginx"
|
||||
- "'proxy_pass http://127.0.0.1:7777;' in nginx"
|
||||
- "'proxy_set_header Upgrade $http_upgrade;' in nginx"
|
||||
- "'proxy_read_timeout 3600s;' in nginx"
|
||||
- "'proxy_buffering off;' in nginx"
|
||||
- "'bind_address = \"0.0.0.0:7777\"' in proxy"
|
||||
- "'allowed_hosts = [\"content.luanti.org\", \"servers.luanti.org\"]' in proxy"
|
||||
- _luanti_wasm_manifests.configmap.metadata.name == 'luanti-wasm-config'
|
||||
- _luanti_wasm_manifests.deployment.spec.template.spec.volumes[0].configMap.name == _luanti_wasm_manifests.configmap.metadata.name
|
||||
vars:
|
||||
nginx: "{{ _luanti_wasm_manifests.configmap.data['nginx.conf'] }}"
|
||||
proxy: "{{ _luanti_wasm_manifests.configmap.data['lwproxy.toml'] }}"
|
||||
|
||||
- name: Check mandatory policy and absence of wildcard egress
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- policy.policyTypes == ['Ingress', 'Egress']
|
||||
- policy.podSelector.matchLabels == _luanti_wasm_manifests.service.spec.selector
|
||||
- policy.ingress == [{'ports':[{'port':8080, 'protocol':'TCP'}]}]
|
||||
- policy.egress | length == 2
|
||||
- policy.egress[0].to == [{'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':'kube-system'}}, 'podSelector':{'matchLabels':{'k8s-app':'kube-dns'}}}]
|
||||
- policy.egress[0].ports == [{'port':53, 'protocol':'UDP'}, {'port':53, 'protocol':'TCP'}]
|
||||
- policy.egress[1].to | map(attribute='ipBlock.cidr') | list == luanti_wasm_game_cidrs
|
||||
- policy.egress[1].ports == [{'port':30000, 'protocol':'UDP'}]
|
||||
vars:
|
||||
policy: "{{ _luanti_wasm_manifests.networkpolicy.spec }}"
|
||||
|
||||
- name: Preserve initial checksum
|
||||
ansible.builtin.set_fact:
|
||||
_initial_checksum: "{{ _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config'] }}"
|
||||
|
||||
- name: Re-render identical settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: render.yml
|
||||
|
||||
- name: Check deterministic SHA256 checksum
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _initial_checksum is match('^[a-f0-9]{64}$')
|
||||
- _initial_checksum == _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config']
|
||||
|
||||
- name: Configure optional egress and ingress restrictions
|
||||
ansible.builtin.set_fact:
|
||||
luanti_wasm_https_cidrs: [198.51.100.0/24, '2001:db8:1::/64']
|
||||
luanti_wasm_ingress_peers:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: ingress-nginx
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/component: controller
|
||||
- ipBlock:
|
||||
cidr: 203.0.113.0/24
|
||||
except: [203.0.113.1/32]
|
||||
luanti_wasm_http_allowed_hosts: [content.luanti.org]
|
||||
luanti_wasm_websocket_timeout_seconds: 7200
|
||||
luanti_wasm_replicas: 2
|
||||
|
||||
- name: Validate optional settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: validate.yml
|
||||
|
||||
- name: Render optional settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: render.yml
|
||||
|
||||
- name: Check optional rules never widen ports or open arbitrary egress
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _luanti_wasm_manifests.deployment.spec.replicas == 2
|
||||
- _luanti_wasm_manifests.networkpolicy.spec.ingress[0]['from'] == luanti_wasm_ingress_peers
|
||||
- _luanti_wasm_manifests.networkpolicy.spec.ingress[0].ports == [{'port':8080, 'protocol':'TCP'}]
|
||||
- _luanti_wasm_manifests.networkpolicy.spec.egress | length == 3
|
||||
- _luanti_wasm_manifests.networkpolicy.spec.egress[2].ports == [{'port':443, 'protocol':'TCP'}]
|
||||
- _luanti_wasm_manifests.networkpolicy.spec.egress[2].to | map(attribute='ipBlock.cidr') | list == luanti_wasm_https_cidrs
|
||||
- _initial_checksum != _luanti_wasm_manifests.deployment.spec.template.metadata.annotations['checksum/config']
|
||||
- "'proxy_read_timeout 7200s;' in _luanti_wasm_manifests.configmap.data['nginx.conf']"
|
||||
|
||||
- name: Supply fixture eom inputs
|
||||
ansible.builtin.set_fact:
|
||||
eom_luanti_wasm_web_image: registry.invalid/fixture/eom-web:release-1
|
||||
eom_luanti_wasm_proxy_image: registry.invalid/fixture/eom-proxy:release-1
|
||||
eom_luanti_wasm_game_cidrs: [192.0.2.15/32]
|
||||
|
||||
- name: Load real eom playbook variables
|
||||
ansible.builtin.set_fact:
|
||||
# Rendering an entire YAML file quotes its embedded list expressions;
|
||||
# restore those two list types, as native playbook evaluation does.
|
||||
"{{ item.key }}": "{{ (item.value | from_yaml) if item.key in ['luanti_wasm_game_cidrs', 'luanti_wasm_https_cidrs'] else item.value }}"
|
||||
loop: "{{ (lookup('ansible.builtin.template', '../playbooks/deploy-eom.yml') | from_yaml)[0].vars | dict2items }}"
|
||||
|
||||
- name: Validate eom deployment settings without deploying
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: validate.yml
|
||||
|
||||
- name: Render eom deployment settings
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: render.yml
|
||||
|
||||
- name: Check TLS ingress and eom integration
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _luanti_wasm_manifests.ingress.apiVersion == 'networking.k8s.io/v1'
|
||||
- _luanti_wasm_manifests.ingress.spec.ingressClassName == 'nginx'
|
||||
- _luanti_wasm_manifests.ingress.spec.rules[0].host == 'luanti.eom.dev'
|
||||
- _luanti_wasm_manifests.ingress.spec.tls == [{'hosts':['luanti.eom.dev'], 'secretName':'luanti-wasm-tls'}]
|
||||
- _luanti_wasm_manifests.ingress.spec.rules[0].http.paths[0].backend.service.port.number == 8080
|
||||
- _luanti_wasm_manifests.ingress.metadata.annotations['cert-manager.io/cluster-issuer'] == 'ca-issuer'
|
||||
- _luanti_wasm_manifests.ingress.metadata.annotations['nginx.ingress.kubernetes.io/proxy-read-timeout'] == '3600'
|
||||
- _luanti_wasm_manifests.deployment.spec.template.spec.containers[0].image == eom_luanti_wasm_web_image
|
||||
|
||||
- name: Exercise external TLS and configurable annotations
|
||||
ansible.builtin.set_fact:
|
||||
luanti_wasm_cluster_issuer: ""
|
||||
luanti_wasm_ingress_annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "7200"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "7200"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
|
||||
- name: Render external TLS configuration
|
||||
ansible.builtin.include_role:
|
||||
name: ansible-role-luanti-wasm
|
||||
tasks_from: render.yml
|
||||
|
||||
- name: Check cert-manager is optional but TLS is not
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'cert-manager.io/cluster-issuer' not in _luanti_wasm_manifests.ingress.metadata.annotations"
|
||||
- _luanti_wasm_manifests.ingress.spec.tls | length == 1
|
||||
- _luanti_wasm_manifests.ingress.metadata.annotations['nginx.ingress.kubernetes.io/proxy-read-timeout'] == '7200'
|
||||
|
||||
- name: Capture valid baseline for rejection tests
|
||||
ansible.builtin.set_fact:
|
||||
_luanti_baseline: "{{ vars | dict2items | selectattr('key', 'match', '^luanti_wasm_') | items2dict }}"
|
||||
|
||||
- name: Reject invalid settings at role validation, never at Kubernetes
|
||||
ansible.builtin.include_tasks: invalid.yml
|
||||
loop:
|
||||
- {luanti_wasm_web_image: ""}
|
||||
- {luanti_wasm_proxy_image: ""}
|
||||
- {luanti_wasm_web_image: 'registry.invalid/team/web'}
|
||||
- {luanti_wasm_proxy_image: 'registry.invalid/team/proxy:latest'}
|
||||
- {luanti_wasm_web_image: 'registry.invalid/team/web:LaTeSt'}
|
||||
- {luanti_wasm_proxy_image: 'registry.invalid/team/proxy@sha256:abc'}
|
||||
- {luanti_wasm_web_image: 'https://registry.invalid/team/web:1'}
|
||||
- {luanti_wasm_game_cidrs: []}
|
||||
- {luanti_wasm_game_cidrs: ['0.0.0.0/0']}
|
||||
- {luanti_wasm_https_cidrs: ['::/0']}
|
||||
- {luanti_wasm_game_cidrs: ['192.0.2.999/32']}
|
||||
- {luanti_wasm_game_cidrs: ['192.0.2.1/24']}
|
||||
- {luanti_wasm_game_cidrs: ['2001:db8:::/64']}
|
||||
- {luanti_wasm_game_cidrs: ['2001:db8::/129']}
|
||||
- {luanti_wasm_game_cidrs: ['luanti.eom.dev/32']}
|
||||
- {luanti_wasm_game_cidrs: '192.0.2.0/24'}
|
||||
- {luanti_wasm_ingress_peers: [{}]}
|
||||
- {luanti_wasm_ingress_peers: ['not-an-object']}
|
||||
- {luanti_wasm_ingress_peers: [{namespaceSelector: 'not-an-object'}]}
|
||||
- {luanti_wasm_ingress_peers: [{ports: [7777]}]}
|
||||
- {luanti_wasm_ingress_peers: [{ipBlock: {cidr: '0.0.0.0/0'}}]}
|
||||
- {luanti_wasm_ingress_peers: [{ipBlock: {cidr: '192.0.2.0/24', except: ['198.51.100.0/24']}}]}
|
||||
- {luanti_wasm_tls_enabled: false}
|
||||
- {luanti_wasm_tls_secret_name: ""}
|
||||
- {luanti_wasm_hostname: 'https://luanti.eom.dev/'}
|
||||
- {luanti_wasm_hostname: '*.eom.dev'}
|
||||
- {luanti_wasm_name: 'Invalid_Name'}
|
||||
- {luanti_wasm_name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}
|
||||
- {luanti_wasm_namespace: 'bad.namespace'}
|
||||
- {luanti_wasm_replicas: 0}
|
||||
- {luanti_wasm_replicas: '2'}
|
||||
- {luanti_wasm_replicas: true}
|
||||
- {luanti_wasm_game_port: 65536}
|
||||
- {luanti_wasm_websocket_timeout_seconds: '3600; bad'}
|
||||
- {luanti_wasm_ingress_enabled: 'true'}
|
||||
- {luanti_wasm_image_pull_policy: Sometimes}
|
||||
- {luanti_wasm_ingress_annotations: {example.org/key: false}}
|
||||
- {luanti_wasm_http_allowed_hosts: ['*.luanti.org']}
|
||||
- {luanti_wasm_web_resources: {requests: {cpu: 100m}}}
|
||||
- {luanti_wasm_tmp_size_limit: unbounded}
|
||||
loop_control:
|
||||
loop_var: invalid_settings
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
"""Standard-library regression tests for the role's CIDR predicates."""
|
||||
|
||||
import unittest
|
||||
|
||||
from filter_plugins.luanti_wasm_network import valid_cidr, valid_ip_block
|
||||
|
||||
|
||||
class NetworkValidationTests(unittest.TestCase):
|
||||
def test_canonical_ipv4_and_ipv6(self):
|
||||
for cidr in ("192.0.2.0/24", "192.0.2.1/32", "2001:db8::/64", "2001:db8::1/128"):
|
||||
with self.subTest(cidr=cidr):
|
||||
self.assertTrue(valid_cidr(cidr))
|
||||
|
||||
def test_rejects_malformed_or_unbounded_cidrs(self):
|
||||
for value in (
|
||||
None, True, 123, [], {}, "", "192.0.2.1", "192.0.2.1/24",
|
||||
"999.0.0.0/8", "192.0.2.0/33", "0.0.0.0/0", "::/0",
|
||||
"2001:db8:::/64", "2001:db8::/129", "fe80::%eth0/64",
|
||||
"2001:DB8::/64", "host.example/32", "192.0.2.0/255.255.255.0",
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertFalse(valid_cidr(value))
|
||||
|
||||
def test_contained_exceptions(self):
|
||||
self.assertTrue(valid_ip_block({"cidr": "192.0.2.0/24"}))
|
||||
self.assertTrue(valid_ip_block({"cidr": "192.0.2.0/24", "except": ["192.0.2.1/32"]}))
|
||||
self.assertTrue(valid_ip_block({"cidr": "2001:db8::/64", "except": ["2001:db8::1/128"]}))
|
||||
|
||||
def test_rejects_invalid_ip_blocks(self):
|
||||
for value in (
|
||||
None, [], {}, {"cidr": "::/0"}, {"cidr": "192.0.2.0/24", "extra": True},
|
||||
{"cidr": "192.0.2.0/24", "except": "192.0.2.1/32"},
|
||||
{"cidr": "192.0.2.0/24", "except": ["192.0.2.0/24"]},
|
||||
{"cidr": "192.0.2.0/24", "except": ["198.51.100.0/24"]},
|
||||
{"cidr": "192.0.2.0/24", "except": ["2001:db8::/64"]},
|
||||
{"cidr": "2001:db8::/64", "except": ["2001:db8::/48"]},
|
||||
{"cidr": "2001:db8::/64", "except": ["2001:db8:1::/64"]},
|
||||
{"cidr": "2001:db8::/64", "except": ["192.0.2.1/32"]},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertFalse(valid_ip_block(value))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user