Initial commit

This commit is contained in:
2026-10-24 18:46:35 -04:00
commit 8b74548a4c
24 changed files with 1276 additions and 0 deletions
Executable
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
Executable
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.venv/
*.retry
Executable
+27
View File
@@ -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.
Executable
+270
View File
@@ -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.
+54
View File
@@ -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
+48
View File
@@ -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
View File
@@ -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: []
+29
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
ansible-core>=2.16,<2.20
kubernetes>=24.2.0,<35
PyYAML>=6.0,<7
jsonpatch>=1.32,<2
+4
View File
@@ -0,0 +1,4 @@
---
collections:
- name: kubernetes.core
version: 6.0.0
Executable
+41
View File
@@ -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 }}"
+10
View File
@@ -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 }}"
+129
View File
@@ -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
+8
View File
@@ -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 }}
+110
View File
@@ -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 }}
+26
View File
@@ -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 %}
+48
View File
@@ -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 %}
+58
View File
@@ -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;
}
}
}
+9
View File
@@ -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 }}
+14
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
+45
View File
@@ -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()