Skip to content
Docs · Documentation

Kubyl Server

Server operations

Networking with Tailscale or WireGuard, TLS and proxies, the Helm chart's exposure options, and the web UI.

Network: Tailscale, WireGuard

The server holds every cluster's credentials, so it should be reachable only from your own devices.

  • Put the server and your phone on a Tailscale tailnet or a WireGuard VPN and use the VPN address or name as KUBYL_SERVER_PUBLIC_URL, for example https://kubyl.tail1234.ts.net:8443. Bind the server to that interface with --listen 100.x.y.z:8443 when it runs on the host, or publish the container port only on it (-p 100.x.y.z:8443:8443).
  • Do not forward the port from the internet. The pairing codes and the device limit are no substitute for a private network.
  • Do not terminate TLS in front of the server (a reverse proxy, tailscale serve): the app pins the server's own certificate key, and a proxy presents another one. If you must, use a proxy that passes TLS through. If you give the server your own certificate (--tls-cert, --tls-key), a renewal that keeps the private key does not unpair devices; a new key does, so re-pair.
  • Helm chart: the default is a ClusterIP Service, reachable inside the cluster only (use your VPN's Kubernetes operator). service.type: NodePort or LoadBalancer, ingress and gatewayAPI.tlsRoute are off by default and make the server reachable from outside at your own risk. They must pass TLS through (ingress-nginx ssl-passthrough, a Gateway API TLSRoute). tls.certManager (or tls.existingSecret) gives the server a real CA certificate with a key that survives renewals; restart the pod after a renewal.
  • If you put a proxy in front of the web UI anyway: requests that carry forwarding headers (X-Forwarded-For, Forwarded, X-Real-IP…) are never treated as “on this machine”, so they need the admin token.

Hardening you get without doing anything

  • The server accepts at most 1024 connections, waits 30 seconds for a first request, and limits HTTP/2 to 256 streams and 64 KiB of headers per connection.
  • Pairing codes and refresh tokens are rate-limited per address; tokens are stored only as hashes and refresh tokens rotate, so a stolen copy that is used revokes the device.
  • Secret values are masked server-side everywhere. The only route to a clear-text value is an explicit reveal (Secrets, an OpenShift Route's TLS key, Helm values): rate-limited per device and audited without the value. Pod file access refuses Secret volumes and /proc.
  • Port-forward and web-view traffic (the bridge) on a read-only cluster allows only GET, HEAD and OPTIONS and no WebSocket. The bridge shares the server's web origin, so a page served by a pod is same-origin with the web UI; its cookies named kubyl_* are dropped. Treat the web UI session as less safe while a web view is open.
  • A terminal in a pod is not confirmed with the cluster name on PROD clusters (it is refused on read-only ones); node shells and every other write do ask.
  • Any paired device can list and revoke other devices and use the license screens (one user per server); pair only phones you trust.

Web UI

Open https://<server>:8443/ in a browser. Accept the certificate warning once, or compare its public key hash with the one on the Status page. It has these pages: Pair (QR code and link, new code), Devices (list, revoke), License (state, activate, apply a lease file, renew now, remove), Agents (see AI agents) and Status (version, clusters and their connection state).

  • A browser on the server's own machine (the address is localhost or an IP) gets in without a token.
  • Everywhere else you sign in with the admin token. It is made at the first start, shown once (in the console's first screen, or on stdout without a terminal) and only its hash is kept. In the console T makes a new one: the old one stops working, open sessions stay.
  • The session is an HTTP-only, Secure SameSite=Strict cookie (__Host-kubyl_session; 8 hours idle, 24 hours at most, 64 sessions at once), and every change needs a CSRF token, a JSON content type and a matching Origin. Failed sign-ins are limited per address (5 in 10 minutes). A tokenless local session stops working as soon as a request no longer looks local.
  • If something on the same host proxies or tunnels to the port without forwarding headers, its connections look local. Set "server": {"web_trust_loopback": false} in settings.json and everybody, local browser included, uses the admin token.

Something missing or wrong? Open an issue on GitHub.