2026-10-26 18:56:39 -04:00
2026-10-26 13:18:46 -04:00
2026-10-24 18:46:35 -04:00
2026-10-24 18:46:35 -04:00
2026-10-24 18:46:35 -04:00
2026-10-26 18:56:39 -04:00
2026-10-26 13:18:46 -04:00
2026-10-24 18:46:35 -04:00
2026-10-26 13:18:46 -04:00
2026-10-24 18:46:35 -04:00
2026-10-24 18:46:35 -04:00
2026-10-24 18:46:35 -04:00
2026-10-26 13:18:46 -04:00
2026-10-24 18:46:35 -04:00
2026-10-24 18:46:35 -04:00

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 in your controller environment and the collection in 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:

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 an explicit tag (including latest) or a complete @sha256: digest (64 lowercase hex characters). Tags are mutable; digests are recommended for reproducibility and supply-chain verification. For latest, set luanti_wasm_image_pull_policy: Always so Kubernetes checks the registry on each container start; the role default is IfNotPresent, which can reuse a cached image. Reusing a mutable tag alone does not trigger a rollout. Use admission signature/provenance policies and scan your builds. 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:

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:

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 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:

- 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.

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 tests/run.sh

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;
  • acceptance of latest tags and rejection of empty/unversioned images, malformed/wildcard CIDRs, bad names/hostnames, peer shapes, missing TLS, invalid resources/types and timeouts.

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 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.

S
Description
Ansible role to deploy Luani WASM to Kubernetes
Readme BSD-3-Clause
63 KiB
Languages
Jinja 65%
Python 28.4%
Shell 6.6%