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 examplehttps://kubyl.tail1234.ts.net:8443. Bind the server to that interface with--listen 100.x.y.z:8443when 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
ClusterIPService, reachable inside the cluster only (use your VPN's Kubernetes operator).service.type: NodePortorLoadBalancer,ingressandgatewayAPI.tlsRouteare off by default and make the server reachable from outside at your own risk. They must pass TLS through (ingress-nginxssl-passthrough, a Gateway APITLSRoute).tls.certManager(ortls.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
localhostor 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=Strictcookie (__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 matchingOrigin. 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}insettings.jsonand everybody, local browser included, uses the admin token.