From 8b74548a4ce67a9a3391aeaf49f47ef229f849d2 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Sat, 24 Oct 2026 18:46:35 -0400 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 4 + LICENSE | 27 +++ README.md | 270 ++++++++++++++++++++++++++ defaults/main.yml | 54 ++++++ filter_plugins/luanti_wasm_network.py | 48 +++++ meta/main.yml | 8 + playbooks/deploy-eom.yml | 29 +++ requirements.txt | 4 + requirements.yml | 4 + tasks/main.yml | 41 ++++ tasks/render.yml | 10 + tasks/validate.yml | 129 ++++++++++++ templates/configmap.yml.j2 | 8 + templates/deployment.yml.j2 | 110 +++++++++++ templates/ingress.yml.j2 | 26 +++ templates/networkpolicy.yml.j2 | 48 +++++ templates/nginx.conf.j2 | 58 ++++++ templates/proxy.toml.j2 | 9 + templates/service.yml.j2 | 14 ++ tests/invalid.yml | 40 ++++ tests/run.sh | 21 ++ tests/test.yml | 267 +++++++++++++++++++++++++ tests/test_network.py | 45 +++++ 24 files changed, 1276 insertions(+) create mode 100755 .gitattributes create mode 100755 .gitignore create mode 100755 LICENSE create mode 100755 README.md create mode 100755 defaults/main.yml create mode 100755 filter_plugins/luanti_wasm_network.py create mode 100755 meta/main.yml create mode 100755 playbooks/deploy-eom.yml create mode 100755 requirements.txt create mode 100755 requirements.yml create mode 100755 tasks/main.yml create mode 100755 tasks/render.yml create mode 100755 tasks/validate.yml create mode 100755 templates/configmap.yml.j2 create mode 100755 templates/deployment.yml.j2 create mode 100755 templates/ingress.yml.j2 create mode 100755 templates/networkpolicy.yml.j2 create mode 100755 templates/nginx.conf.j2 create mode 100755 templates/proxy.toml.j2 create mode 100755 templates/service.yml.j2 create mode 100755 tests/invalid.yml create mode 100755 tests/run.sh create mode 100755 tests/test.yml create mode 100755 tests/test_network.py diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..efdba87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3c7b99e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +.venv/ +*.retry diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..f5456b3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..55b8ab5 --- /dev/null +++ b/README.md @@ -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. diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100755 index 0000000..6e49113 --- /dev/null +++ b/defaults/main.yml @@ -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 diff --git a/filter_plugins/luanti_wasm_network.py b/filter_plugins/luanti_wasm_network.py new file mode 100755 index 0000000..dec8375 --- /dev/null +++ b/filter_plugins/luanti_wasm_network.py @@ -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, + } diff --git a/meta/main.yml b/meta/main.yml new file mode 100755 index 0000000..b0b5b1a --- /dev/null +++ b/meta/main.yml @@ -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: [] diff --git a/playbooks/deploy-eom.yml b/playbooks/deploy-eom.yml new file mode 100755 index 0000000..73fc6a9 --- /dev/null +++ b/playbooks/deploy-eom.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..b1591ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ansible-core>=2.16,<2.20 +kubernetes>=24.2.0,<35 +PyYAML>=6.0,<7 +jsonpatch>=1.32,<2 diff --git a/requirements.yml b/requirements.yml new file mode 100755 index 0000000..0b61478 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: kubernetes.core + version: 6.0.0 diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100755 index 0000000..0852a59 --- /dev/null +++ b/tasks/main.yml @@ -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 }}" diff --git a/tasks/render.yml b/tasks/render.yml new file mode 100755 index 0000000..c5e3d76 --- /dev/null +++ b/tasks/render.yml @@ -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 }}" diff --git a/tasks/validate.yml b/tasks/validate.yml new file mode 100755 index 0000000..9a6093c --- /dev/null +++ b/tasks/validate.yml @@ -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 diff --git a/templates/configmap.yml.j2 b/templates/configmap.yml.j2 new file mode 100755 index 0000000..a0d9adf --- /dev/null +++ b/templates/configmap.yml.j2 @@ -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 }} diff --git a/templates/deployment.yml.j2 b/templates/deployment.yml.j2 new file mode 100755 index 0000000..e77577c --- /dev/null +++ b/templates/deployment.yml.j2 @@ -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 }} diff --git a/templates/ingress.yml.j2 b/templates/ingress.yml.j2 new file mode 100755 index 0000000..df0f6f6 --- /dev/null +++ b/templates/ingress.yml.j2 @@ -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 %} diff --git a/templates/networkpolicy.yml.j2 b/templates/networkpolicy.yml.j2 new file mode 100755 index 0000000..13107ba --- /dev/null +++ b/templates/networkpolicy.yml.j2 @@ -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 %} diff --git a/templates/nginx.conf.j2 b/templates/nginx.conf.j2 new file mode 100755 index 0000000..a7aac02 --- /dev/null +++ b/templates/nginx.conf.j2 @@ -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; + } + } +} diff --git a/templates/proxy.toml.j2 b/templates/proxy.toml.j2 new file mode 100755 index 0000000..3dc0cda --- /dev/null +++ b/templates/proxy.toml.j2 @@ -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 }} diff --git a/templates/service.yml.j2 b/templates/service.yml.j2 new file mode 100755 index 0000000..ea44397 --- /dev/null +++ b/templates/service.yml.j2 @@ -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 diff --git a/tests/invalid.yml b/tests/invalid.yml new file mode 100755 index 0000000..9bb1800 --- /dev/null +++ b/tests/invalid.yml @@ -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 diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..caf259c --- /dev/null +++ b/tests/run.sh @@ -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' diff --git a/tests/test.yml b/tests/test.yml new file mode 100755 index 0000000..f1bafaa --- /dev/null +++ b/tests/test.yml @@ -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 diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100755 index 0000000..0ff5179 --- /dev/null +++ b/tests/test_network.py @@ -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()