Component boundaries

BrowsersColloq appRuntime broker
Room A kernelRoom B kernel

In production the app runs as UID 1000 with no Docker socket and no Kubernetes credentials. Only the private runtime broker gets a namespaced service-account token, and it creates Pods and Services from a fixed template and a trusted catalog. The API doesn't accept an arbitrary Pod template or host path.

Each room gets its own Jupyter Pod, token and workspace subPath. If the runtime, the image or a policy is unavailable, nothing starts: there is no fallback to a shared server kernel.

Pod restrictions

A room Pod runs as UID/GID 1000 with no extra capabilities and no privilege escalation, with the RuntimeDefault seccomp profile and a read-only root filesystem. The service-account token is not mounted. Writable areas: the room directory, /tmp, /home/runner and /dev/shm.

Pod Security enforces the restricted profile. The broker's RBAC is limited to Pod and Service operations in the namespace; the only way it can change a running Pod is the pods/resize subresource, which it uses to change a room's memory. But RBAC can't restrict every field of the Pod being created, so the broker's code, its templates and the catalog are part of the trusted base.

Network: check the actual result

NetworkPolicy allows inbound connections to Jupyter only from the app and the broker. By default, rooms may send traffic out only to the DNS Pod in kube-system over TCP/UDP 53. To download external data you need a separately designed outbound proxy or profile.

If you need isolation from host services, add a CNI with host policy or a tested firewall policy that allows only the broker to reach the Kubernetes API. Before you let untrusted users in, run a real room-to-node connection test. Having a manifest is not proof.

Files and quotas

Linux production accesses files through trusted file descriptors, O_NOFOLLOW and /proc/self/fd. Symbolic links can't escape the room directory. If the protected mechanism is unavailable, production refuses to run.

An old workspace must not contain hard links between rooms. When migrating, copy regular files into separate directories and check owners and links. Path checks don't turn two names of an existing shared inode into independent data.

PVC size is scheduling metadata, not a filesystem quota. MAX_UPLOAD_MB and MAX_SESSION_MB limit what the app does and don't stop Python from writing whatever it wants. Use a separate partition and monitor free space.

Access, links and secrets

A room link gives access to the class; a teacher's sign-in link gives access to the panel. The setup token lets an owner regain control. Keep secrets and backups private; full access to the server's disk means access to its data and keys.

Separate answers in a Council don't give each student their own kernel. Action limits and browser bans don't verify identity. The system doesn't promise automatic grading, SSO or protected exam execution.

Deployment limits

Containers share the VM's Linux kernel. This is not VM-level isolation and doesn't protect against kernel vulnerabilities. One VM, one k3s node and local PVs give no HA. If the machine is lost, you need an external backup; updates interrupt work and lose Python memory.

Test your own load. There is no proven universal answer to “how many students can a server handle”: it depends on the computation, output, files, active rooms and network.

A class on your own computer

A local run — colloq start or make dev — listens on 127.0.0.1 only; the class is visible from outside only after an explicit colloq host. Room kernels are separate Docker containers, and the server manages them through the Docker daemon, which amounts to root on that machine. On macOS every path segment is opened with O_NOFOLLOW, but without the Linux /proc/self/fd mechanism a race window remains between the check and the operation.

Every room container gets the same hardened profile, however the class was started (colloq start, make dev, make run, make up, the vast.ai image): UID 1000, no Linux capabilities, no-new-privileges, Docker's default seccomp profile, a process limit (KERNEL_PIDS, 512 by default) and no IPv6. The room reaches the internet — pip install, datasets, APIs — but not local addresses: 10/8, 172.16/12, 192.168/16, 100.64/10, link-local 169.254/16 with cloud metadata, loopback, multicast, the computer itself and the other rooms. A connection there is refused at once. Under make up and in the vast.ai image rooms share a Docker network with the server, and traffic inside that network is filtered only when the host passes bridged traffic through iptables, as Docker normally arranges; otherwise the per-room Jupyter token still stands between rooms. Only DNS on port 53 stays open to any address, because on some machines Docker's resolver forwards queries from the room's own network.

The server installs this block itself: a short-lived privileged helper container adds rules to chains of its own, COLLOQ-ROOMS-*, reached from DOCKER-USER and INPUT, with the same iptables the Docker daemon uses. It is built for colima, Docker Desktop and Docker on Linux; rootless Docker, podman and Docker Desktop with Enhanced Container Isolation don't allow the helper. If the rules can't be installed, rooms don't start, and the message in the room says why. For a trusted class that needs the local network, the line COLLOQ_ROOM_NETWORK=open in .env lifts the block, and colloq doctor then points it out. A room container started by an older version keeps its old profile until it stops; the next start recreates it.

Unlike a production room, the root filesystem stays writable so that %pip install works, and nothing limits how much disk a room uses. So a local class is still meant for a trusted setting — “my class on my computer”; for an open audience, use a server install.