Security
This page describes how Calagopus defends its trust boundaries: what isolates an untrusted game server from the host, how the daemon avoids being coerced into unwanted actions despite usually running as root, how credentials are handled, and how abuse and denial-of-service are contained.
Calagopus is written in Rust, which removes whole classes of memory-safety bugs, but memory safety on its own does not protect a multi-tenant panel. That protection comes from isolation, authorization, credential handling, and resource bounding, which the rest of this page covers.
The source links below pin the implementations checked for this page. They describe current development code; older supported releases may not include every control.
Reporting a vulnerability
Do not report security issues through public GitHub issues or Discord.
- GitHub private advisory: use the Security Advisories tab on
calagopus/panelorcalagopus/wings. - Email:
security@calagopus.com. For sensitive reports, encrypt with our PGP key.
Please give us a reasonable window to fix an issue before public disclosure, do not access or modify data that is not yours, and act in good faith.
Supported versions
See the Panel security policy and Wings security policy for the versions receiving security updates.
Threat model
The core assumption: a game server is untrusted code. Anything a tenant runs inside their container is treated as potentially hostile, and everyone else's safety on the node depends on that container not gaining unauthorized access to the host or other tenants. The daemon (Wings) typically runs as root, so a second assumption follows: the daemon must never be tricked into acting outside a server's own directory or privileges, even when the request originates from an authenticated but malicious user.
Attacker goals defended against: container escape to the host, cross-tenant access, coercing the root daemon into touching paths outside a server root, privilege escalation inside the panel, credential exfiltration, and denial-of-service against the node or panel.
Containers share the host kernel. These controls reduce what a tenant can do; they do not turn a container into a virtual machine or protect it from a compromised host. Administrators, the Panel, Wings, and their configuration are trusted. Container network access is a separate boundary, covered below.
Daemon isolation (Wings)
Each game-server process runs in its own container. Wings applies the following controls to these runtime containers:
- No privilege escalation:
no-new-privilegesis always set. - Dropped capabilities:
setpcap,mknod,audit_write,net_raw,dac_override,fowner,fsetid,net_bind_service,sys_chroot,setfcap, andsys_ptrace. - Seccomp profile applied by default (configurable per installation).
- Read-only root filesystem, with a size-limited
/tmpmountednosuid. - AppArmor profile selectable when it is installed on the host.
- User-namespace remapping supported and configurable.
- Rootless mode supported: the daemon and container engine can run without host root. The configured uid/gid inside the container may still be
0in its user namespace; container root and host root are different in that setup. - cgroup resource limits: memory (plus reservation/swap), CPU (quota/period/shares/cpuset), PID limit, block-IO weight, and OOM controls.
The enabled isolation and resource controls are enforced by the kernel. Wings sets them up; it does not need to inspect each syscall for them to work. no-new-privileges prevents gaining privileges through a setuid executable, seccomp filters syscalls, and cgroups constrain the resources configured for the server. These controls still depend on host support: for example, an I/O weight has no effect without a supporting scheduler or I/O cost model.
Defaults (see the Wings configuration reference): no-new-privileges, the dropped-capability set, and the read-only rootfs are always applied. docker.container_apply_seccomp defaults to true (it can need disabling under Podman). docker.userns_mode is empty by default (remapping off until configured), and rootless mode (system.user.rootless.enabled) is opt-in.
Production setup
Keep seccomp enabled and use a host dedicated to untrusted workloads. Choose rootless mode or user-namespace remapping to suit the container engine. Check feature compatibility first: Wings server firewalls are not supported with a rootless container engine.
These settings describe runtime containers. Installer images and scripts use a separate container configuration and are trusted administrator-supplied code. Administrator-approved host mounts and device passthrough also widen the resources available to a container; review them as part of the server's privileges.
Sources: runtime container configuration, installer configuration, device passthrough.
Filesystem safety: keeping a root daemon in its lane
Because the daemon usually runs as root, path handling is a critical boundary. Ordinary server file operations use capability-scoped directory handles (cap-std), so traversal and symlink resolution are confined to the filesystem root the daemon opened. Client-supplied paths are resolved relative to that root. The protection comes from filesystem operations through that handle, not a blocklist of suspicious path strings.
Wings also supports mounted and virtual filesystems. The boundary is the set of filesystems authorized for that server, not a promise that every operation touches one physical directory. This confinement applies to Wings file operations; container access to bind mounts is controlled by the container and host.
Sources: capability root and path handling, filesystem implementation.
Disk quota enforcement
Wings tracks file growth in its own write paths and checks it against cached disk usage. Uploads, SFTP writes, extraction, and remote pulls can be stopped when that accounting rejects further growth. Uploads can also use a supplied total size for an early space check; that check is not a reservation.
The write-time limit comes from the disk backend. A game server writes to its volume directly, without passing through Wings' file writer. Choose a backend that covers those writes:
| Mode | Mechanism |
|---|---|
zfs_dataset | Per-server ZFS dataset quota. |
btrfs_subvolume | Per-server Btrfs subvolume quota. |
xfs_quota | XFS project quota. |
fuse_quota | A separate FUSE process in the filesystem write path. |
none (default) | Wings accounting and usage checks, without a write-time backend. |
ZFS, Btrfs, and XFS enforce their configured quota in the filesystem. FUSE routes writes through its quota process. These cover writes from the game server as well as Wings. The in-process counter is supplementary accounting, not a hard bound on physical disk usage.
Choose a quota backend for untrusted tenants
The default none backend does not stop a game server's writes at the quota. Periodic usage checks are reactive, and a process can fill disk between checks. Use ZFS, Btrfs, or XFS where supported, or fuse_quota otherwise.
Sources: quota backends and default, file accounting, upload preflight.
Backups of a running server
A file can grow or shrink while a backup reads it. Wings' fixed-length reader limits the output to the size captured for that file: growth is clipped, and early EOF is padded with zeroes. This prevents that length change from shifting later entries in an archive. Other I/O errors can still fail the operation.
That is a stream-format safeguard, not an application-consistent snapshot. A live backup can contain files from different moments, and a clipped or padded file may be unusable to the game. Quiesce the application or stop it when consistency matters, and test restores.
Sources: fixed-length readers.
Daemon authentication and direct access
The Panel controls Wings with a node bearer token. Treat that token as a privileged node credential and protect the connection with HTTPS. Browser file transfers and console access use signed, scoped tokens instead of exposing this administrative credential.
Wings checks token expiry, issue time, and the required operation scope. File-download tokens identify a server and file; WebSocket tokens carry server permissions, which are checked for actions such as sending commands or changing power state. Download-token reuse is limited by api.max_jwt_uses (default five); these are not single-use links.
Tokens issued before the current Wings process started are rejected, so users may need fresh direct-access links after a daemon restart. Anyone holding a valid bearer token can use its authority until it expires or is invalidated; token scope limits that authority, but does not make a leaked token harmless.
Sources: node API authentication, JWT validation and reuse, file-download claims, WebSocket action permissions.
Network boundaries
Outbound requests made by Wings
Remote file pulls and scheduled HTTP requests filter DNS answers through the resolver used for the connection. They also check literal IP addresses, including IPv4-mapped IPv6 addresses, against configured blocked CIDRs. Defaults cover private, loopback, link-local, and other special-use ranges. Both clients disable environment proxy settings.
Pulls check redirect targets and allow at most ten redirects. Scheduled HTTP requests do not follow redirects. Scheduled requests also have per-server request limits, timeouts, and a cap on captured response bodies.
These checks cover those Wings HTTP features. Tenant code can still make its own network connections. Add your own internal networks to the blocked CIDRs, especially services reachable through public IP addresses, and use host/network controls for restrictions that must also apply to containers.
Sources: address filtering, remote-pull client, scheduled HTTP requests, blocked-range defaults.
Outbound requests made by the Panel
The Panel's own HTTP client resolves every hostname through a filtering resolver that drops answers in blocked CIDRs, re-checks each redirect target, allows at most ten redirects, and ignores environment proxy settings. Most of what it fetches is admin-configured, so the URL is only as trustworthy as the admin who typed it.
The exception is the avatar import, where the URL is assembled from an OAuth provider's profile response. Placeholders that are not the whole template are percent-encoded so a value cannot rewrite the rest of the URL, the result must be an http or https URL under 2048 characters, and the download stops at 8 MB. A provider you point the Panel at can still choose which public URL it gets fetched, so treat the Avatar URL Template as trust placed in that provider.
Sources: outbound client, address filtering, avatar url resolution, avatar download.
Server firewalls
Wings can apply source-address, protocol, and destination-port rules with nftables or iptables, including through a helper container. If no usable backend is available, creating a runtime container with configured firewall rules fails. Explicitly setting docker.firewall.backend to disabled permits it to start with a warning and leaves those rules unapplied.
These rules filter traffic to server destinations. They are not a general host firewall or an outbound network sandbox. Established connections are retained, so changing a rule does not necessarily disconnect an existing client. Traffic between bridged containers also depends on the host's bridge netfilter settings. Server firewalls require Linux and are unsupported with rootless container engines. See the Wings configuration reference.
Sources: backend selection, unavailable-backend behavior, startup failure handling, stateful filtering, bridge netfilter check.
Denial-of-service protections
A multi-tenant node has many amplification points: a server can spam its console, an attacker can hammer SFTP or the login endpoint, and a client can request a huge file or listing. The following controls bound specific parts of that work. They run in userspace, where Wings accepts SFTP connections and reads console output, and where the Panel handles API requests. They do not provide protection against network traffic that saturates the host or its upstream connection.
Console output throttling
A game server that floods stdout could otherwise exhaust memory, saturate the websocket, and lock up every viewer's browser. Wings counts output lines and, once a configurable line count is exceeded within a reset interval, stops forwarding to the rate-limited console stream and emits a single "throttling" notice (config.throttles.{enabled,lines,line_reset_interval}; throttling is on by default, at 2000 lines per 100 ms). The unthrottled internal stream is preserved separately for logging and startup detection, so throttling never breaks state detection.
Sources: console throttling, console defaults.
SFTP / SSH limits
The SSH/SFTP server (built on russh) applies layered limits (application/src/ssh/limiter.rs):
- Per-IP authentication attempts, counted separately for password (default 3) and public key (default 20). Exceeding the cap disconnects the connection; counters decay after
authentication_cooldown(default 60s). - Per-user concurrent connections capped by
max_connections_per_user(default 10). - Open SFTP handle caps, both per channel (
max_handles_per_channel, default 32) and global (max_handles_total, default 1024), the latter enforced with an atomic guard, bounding how many file handles a client can hold open.
Defaults are in the Wings configuration reference.
Sources: SSH and SFTP limits.
WebSocket limits
Wings defaults to a 1 MiB maximum message and frame size, a 60-second deadline to authenticate, and at most 32 unauthenticated connections per client IP. These limits cover stalled handshakes and oversized messages.
The per-IP slot is released after authentication. The separate global connection cap, system.websocket.max_connections_total, defaults to 0 (unlimited); set it for the capacity of the node.
Sources: WebSocket defaults, connection accounting, message and frame limits.
Panel rate limiting
The panel applies per-endpoint, per-client rate limits backed by the shared cache (Redis-like), keyed as ratelimit::<endpoint>::<client-ip> over a fixed window of hits per window_seconds. Limits are configurable per endpoint, and sensitive endpoints are covered individually rather than by one blanket limit:
- Auth:
auth_register,auth_login,auth_login_checkpoint,auth_login_security_key,auth_password_forgot,auth_password_reset, and OAuth flows. - Client: a general
clientlimit plus dedicated limits for expensive actions such asclient_servers_backups_create,client_servers_files_pull, andclient_servers_files_pull_query. - Node/daemon:
remoteandremote_sftp_auth.
Dedicated limits for expensive actions supplement the general client budget. Those requests still count toward the shared client limit. Limits are keyed by client IP, so configure trusted proxies and the real client address correctly (see Reverse proxies).
Redis/Valkey coordinates limits across Panel instances. When Redis is unavailable or returns an error, the local fallback keeps counters in one process; it cannot provide a shared budget across replicas. Fixed windows also allow a burst around a window boundary, so these limits are abuse controls rather than exact traffic shaping.
Sources: client middleware, cache and rate-limit implementation, endpoint settings.
File reads and uploads
File-content reads accept a byte limit. Server uploads stream into the file writer instead of buffering the whole upload, and can use a total size for early rejection when one is supplied. Neither a declared length nor an early free-space check replaces quota enforcement during the transfer.
Sources: file-content reads, streaming upload route.
Compile-time panic resistance (lints)
In a root daemon that parses untrusted input (paths, archive contents, wire protocols), a panic is a denial-of-service: it takes down the task or the daemon. Wings denies several common panic patterns when Clippy is run. Both the application crate and pbs-client set these Clippy lints to deny:
unwrap_used,panic,unreachable,todo,unimplementedindexing_slicingandstring_slice, so a bad index or a non-char-boundary slice is caught by linting; code can use checked access (.get()) insteadunwrap_in_resultandpanic_in_result_fn, keeping fallible paths returning errors instead of panicking
missing_panics_doc is a warning, and the full clippy::all group runs at warn. This does not make the daemon panic-proof in an absolute sense (it does not cover, for example, arithmetic overflow or allocation failure). Dependencies and explicit lint exceptions also need review.
Sources: application lints, PBS client lints.
Panel authentication and secrets
Passwords, sessions, and MFA
Passwords use bcrypt at cost 12. Hashing and verification run in a blocking pool with a bounded number of concurrent bcrypt jobs. Session tokens and API keys are stored as hashes; credential resolution and model records are cached, so this is not a fresh database hash verification on every request.
Session cookies are HttpOnly and SameSite=Lax. The Secure flag is set when the configured Panel URL uses HTTPS. Set that URL correctly and serve the Panel over HTTPS, including when TLS terminates at a reverse proxy.
The Panel supports TOTP, email codes, and WebAuthn/security keys. MFA requirements can be configured for all users or administrators; role-specific requirements override the global policy. TOTP and security keys count by default. Email codes are opt-in and are not in the default accepted-method list. Email authentication also depends on the security of the user's mailbox.
Discoverable passkeys can be used to sign in. With WebAuthn enabled and a security key registered, users can disable password login after confirming their password. Operators can make the same choice panel-wide, which also closes local registration, self-service password resets, and SFTP password authentication. The Panel refuses that change unless an enabled OAuth provider exists and the administrator making it can still sign in without a password. CAPTCHA can also gate login. These are configurable account and operator choices; having an MFA feature does not mean every account has enabled it.
Sources: password hashing, credential cache, session cookie, MFA policy, MFA defaults, discoverable passkeys, password-login control.
Scoped permissions and API keys
User, administrator, and server permissions are checked separately. An API key's scopes cap the permissions available through that key: they are intersected with the user's effective grants, rather than granting access the user does not have. Keys can also have an expiry, be disabled, and restrict source IPs/CIDRs. For an integration with a fixed job, choose the smallest permissions it needs.
Sources: effective permissions and scope intersection, key expiry, key disablement, key address restrictions.
Encryption and cache trust
Node tokens, backup credentials, database passwords, and two-factor seeds use the Panel's encrypted secret storage, keyed by APP_ENCRYPTION_KEY. The running Panel must be able to decrypt these values to use them. Encryption therefore does not protect them from a compromised Panel process or an attacker who also obtains its key.
Two-factor seeds encrypted from 1.2.2
Two-factor seeds were stored in plaintext before 1.2.2. Upgrading encrypts new and changed seeds immediately, but an existing account's seed is only converted the next time that user verifies a code, so plaintext seeds linger in the database for accounts that never sign in again.
Optional decrypted-secret cache
APP_USE_DECRYPTION_CACHE is off by default. Enabling it allows the encrypted-secret helper to hold decrypted values for 30 seconds, in the Panel process's own memory and capped at 16384 entries - nothing decrypted is written to Redis/Valkey. Treat Redis/Valkey as trusted infrastructure regardless of this option: the Panel uses it for application and authentication state either way.
Keep the encryption key secret and backed up separately. Losing it makes the stored encrypted values unusable; changing it requires re-encrypting those values. See the Panel environment reference.
Sources: encrypted value type, encryption and decrypted cache, cache option default, two-factor seed encryption.
Supply chain and build integrity
The Panel and Wings CI image workflows configure:
- CycloneDX SBOMs generated from source per build.
- Signed container images using cosign / sigstore (GHCR and Docker Hub).
- Build provenance and SBOM attestations attached to the images, so a consumer can verify what was built and from what.
Signatures and provenance establish where an artifact came from; they do not prove its code or dependencies are free of vulnerabilities. Verification is a consumer step, not something implied by pulling an image tag.
Backend extensions are trusted code in the Panel process. Their entrypoints receive application state, including database, cache, and environment access. Installing one extends the trusted codebase; the tenant permission model does not sandbox it.
Sources: Panel image workflow, Wings image workflow, extension entrypoints, application state.
Known residual risks
The residual risks, listed directly:
- Shared kernel and trusted control plane. A host, Panel, or privileged node credential compromise reaches beyond a single game server. Runtime container restrictions do not sandbox administrators, installer code, or Panel extensions.
- Operator-dependent isolation. Rootless execution, user namespaces, AppArmor, mounts, and device access depend on configuration and host support. Rootless engines cannot use Wings' server firewall feature.
- Disk and connection defaults. The default
nonequota backend supplies no write-time bound. The global WebSocket connection cap is also off by default. Choose both limits for the workload before accepting untrusted tenants. - Network reachability. HTTP destination checks cover the specific daemon features above. They do not stop a game server from opening its own connections. Server firewall changes also retain established connections.
- Live-backup consistency. A structurally readable backup can still contain inconsistent application data. Restore testing and application quiescing matter.
- Secret and cache access. The running Panel has decryption authority. The optional decrypted-secret cache increases where those plaintext values live.