Skip to content

Security: CIYAhq/playkeeper

SECURITY.md

Security

Playkeeper operates game servers and world data on the user's host. Treat the web panel as a sensitive administrative interface, not a public demo.

Reporting a vulnerability

Report vulnerabilities privately through GitHub: on the repository's Security tab, choose Report a vulnerability (direct link). The report, the discussion and the fix stay private until an advisory is published. There is no security email address.

Please include the Playkeeper version (playkeeper version), what an attacker needs (network position, credentials) and what they gain, and steps to reproduce. Do not put working exploits, secrets, world backups, player identifiers or server addresses in public issues, pull requests or discussions.

Supported versions: only the latest release receives security fixes, as a new release. The one-line installer always installs the latest release, and from 0.2.0 on the dashboard offers it.

Design

Implementation constraints: authenticated HTTPS management, fail-closed authorization, local allowlisted privilege boundary, no public Docker socket/host shell/RCON, redacted logs, bounded player-data retention, explicit restore preview and rollback, safe installation on previously unmodified hosts. These properties are covered by the unit tests and the end-to-end jobs in CI.

Exposure review (v0.1.0, 2026-09-24; updated for 0.4.0)

Network. The panel listens on 8443, and each Minecraft server on its own game port (25565 for the first, then the next free port from 25566), published by Docker, with voice chat's UDP port on a server that has it (see Voice chat) and crossplay's on one with crossplay (see Crossplay). The panel itself is served only over TLS. In v0.1.0 plain HTTP on its port got 400 Client sent an HTTP request to an HTTPS server; from 0.4.0 it gets a 308 redirect to HTTPS (400 when its Host header is missing or not a valid host), except under /resource-packs/: players' games refuse the panel's self-signed certificate, so that path serves over plain HTTP, without a sign-in, only the resource packs a server offers every player, by their SHA-1 (/resource-packs/<sha1>.zip, GET and HEAD), answers 404 to everything else, and limits each address to 60 requests a minute and 8 open downloads. A pack's link uses https:// on the same port instead while the panel serves a publicly trusted certificate for exactly the link's host with at least a week left, as the agent checks at each server start; either way the game checks the download against the pack's SHA-1. RCON (25575) stays inside the container on a private bridge; the Docker API is only the local Unix socket; apart from the two listeners below, the agent has no TCP listener. Checked with nmap -p- from another host during a rehearsal install. From 0.4.0 the agent also listens on port 80 for the seconds Let's Encrypt checks a certificate for an own domain, at issue and renewal, and answers only GET and HEAD for its pending /.well-known/acme-challenge/ tokens (anything else gets 404 or 405). If the panel is moved off 8443, it still listens on 8443 for the names service's liveness check (below) and answers nothing else there. From 0.4.0 the panel's port also carries /mcp, which answers only to API tokens (see AI agents), and the connections of machines joined to this dashboard (see Other machines); a joined machine opens no port for its link. While a server sleeps, the agent answers on its game port instead (see Sleeping servers). From 0.4.3 the panel also answers on ports 443 and 80, with the public server page and nothing else, while nothing else on the machine uses or claims them (see Public server page), and from 0.4.11 port 443 answers the machine's name with the dashboard while Serve the dashboard on the standard HTTPS port is on (see Dashboard on the standard HTTPS port).

Shared map (from 0.4.0). Apart from signing in, first-time setup, the health checks (/healthz, and /api/health, which gives the version) and the dashboard's own files, the panel answers without a sign-in only through its public route group: the resource packs above, the names service's liveness check (see Free names), the friends' pack page (see Friends' pack page), invite links (see Invite links), a server's shared map, Whop's webhook and Sign in with Whop's way there and back, from 0.4.11 the check that a browser reaches the dashboard on port 443 (see Dashboard on the standard HTTPS port), and from 0.4.12 Playkeeper Cloud's plans for its page on playkeeper.io: GET /api/public/cloud/plans lists the paid plans of the store the dashboard connects with its key, each with its name, its memory and whether it's available, never how many are left, from what the dashboard last read from or wrote to Whop, so no request reaches Whop or a machine. Any site may read it (Access-Control-Allow-Origin: *, with no cookies), caches may keep it a minute, and each address may ask 30 times a minute. /mcp answers only to API tokens (see AI agents), and joined machines connect with their keys (see Other machines). The map is at /map/<link code>, with the calls its page makes under /api/public/map/<link code>/, over HTTPS and to GET and HEAD only. The link code is 22 random letters and digits (about 131 bits) and is made new each time sharing is switched on, so switching it off and on again retires every earlier link; the agent compares codes in constant time. The agent answers only while the map is on and shared and its server runs with squaremap loaded, and then only with the server's name and icon, the drawn worlds and their tiles, and, while Show players on the shared map is on, the names and positions of the players online, whose faces come from the panel, so viewers never contact Mojang. The page itself is the same for every code. For everything else, from an unknown, old or malformed code to a stopped server or an agent that doesn't answer, its calls get one 404 that names no server, sent no sooner than 200 ms after the request. Each address (an IPv4 address or an IPv6 /64, taken from the connection, never from headers) may make 60 page requests and 600 map requests a minute, with 4 and 24 open at once; answers are no-store, and the request log shows only /map/… or /api/public/map/…, never the code or the viewer's address. squaremap's web server listens on port 25580 inside the server's container and isn't published, and containers on Playkeeper's private network can't reach each other, so only the host reaches it. Within Playkeeper only the agent asks it anything, and only for the worlds, the players and PNG tiles, within size limits, rebuilding the JSON from the fields the page uses, so squaremap's own page and scripts never reach a viewer.

Public server page (from 0.4.3). Opening the machine's name (a free name or an own domain) in a browser, on port 443 or 80, shows a page of its servers. Each server is on it until its owner turns off Settings › Public page › Show this server; Show who's playing is off until the owner turns it on. For each server the page shows its name, its description (MOTD) and whether it has an icon, whether it is online, starting, asleep or offline, how many are online of how many it lets in, its Minecraft version, server software and modpack, the address players type, whether only invited players get in, and the shared map's and friends' pack page's links while those are shared; with Show who's playing on, also the names of the players online and their faces, which come from the panel's cache, so visitors never contact Mojang. It never shows an IP address, a crash, a backup, the machine's resources or anyone's account. The name, description, icon, player counts and version are what Minecraft's own server list shows anyone who pings the game port.

  • The panel runs without CAP_NET_BIND_SERVICE. The agent opens ports 443 and 80, only while a server is on the page (from 0.4.14, a server on a joined machine at its name counts too, which only the panel knows, so it says so when it asks for them, and the agent logs each hand-over for that alone, which changes no setting and leaves no audit entry; see below) and the machine has an address, and passes the listening sockets to the panel over its Unix socket (SCM_RIGHTS, checked with SO_PEERCRED like every agent request); it keeps no copy, machine links don't offer the route, and it refuses to hand them over any other way. It never takes a port another program listens on, one a Docker container publishes or names in its settings, running or not, or ports 443 and 80 while nginx, Apache, Caddy, lighttpd, HAProxy, Traefik, OpenResty, Varnish or H2O is set to start with the machine, and not in the first three minutes after the machine starts. A port found busy isn't tried again until the agent restarts or someone who manages servers selects Try again. The panel closes both ports as soon as no server is on the page, or the machine has no address. From 0.4.16, while the machine has no address, the agent hands over port 80 alone, never 443, for the panel to send a browser at the machine's IP address to the dashboard (pointer in the hand-over, which the agent logs), with the same checks; the agent's state names what wants port 80 meanwhile (httpClaimed), a Docker container that publishes or names it or a web server set to start with the machine, and the panel gives the port back at its next look, within a minute, as it does once the machine has an address with no server on the page.
  • On those ports the panel serves only the page: GET and HEAD of /, the page's scripts and styles under /assets/, its data under /api/public/server-page (with the servers' icons and the faces it lists), and on port 80 Let's Encrypt's /.well-known/acme-challenge/ tokens, which it passes on to the agent. The dashboard, its API, /mcp, the other public routes and machine links don't answer there, but for the dashboard at the machine's name on port 443 from 0.4.11, while its owner has it served there (see Dashboard on the standard HTTPS port). A Host header other than the machine's name, a server's own address or the name of a server on a joined machine that is on the page gets one plain 404, and so does anything else, but for an IP address on port 80: from 0.4.16 that is redirected (302, no-store) to the dashboard at the same address on its own port, https://<ip>:8443/, path and query kept, which a browser given the bare address never tries. A name the page doesn't answer keeps its 404, as it may not be the machine's at all. Let's Encrypt's checks on port 80 are the exception: they reach the agent whatever name they carry, as they do when the agent answers on port 80 itself, so a certificate for a new address doesn't wait for the page to follow it. The agent answers only its own pending checks.
  • Port 443 serves only the certificate the agent got for the name from Let's Encrypt, never the self-signed one, so a browser without a certificate to trust tries port 80 instead. Port 80 redirects to HTTPS while port 443 is served with that certificate.
  • The page's routes are a public group of their own: each address (an IPv4 address or an IPv6 /64, from the connection) may make 60 page requests and 300 data or asset requests a minute, with 8 or 16 open at once, and each port holds at most 1024 connections. The agent is asked about the page at most once every 5 seconds, however many people have it open. Answers are no-store (the built assets are cached for a year, as on the dashboard), the page carries the dashboard's Content Security Policy, with only the two stream players below added as frame-src from 0.4.4, and noindex, and the log shows only a route's prefix, never a path or a visitor's address. What the owner typed reaches the page's title and link previews only escaped.
  • A server's own address (from 0.4.4). Under an own domain, an owner or an admin of every server can give a server an address of its own, like alex.example.com: the agent refuses the machine's name, another server's address and any name the plan can't give records, and nothing changes until one is set. Its page shows only that server, and only while it's on the page. Ports 443 and 80 answer its name too, each Host checked exactly as for the machine's name, and port 443 hands out only its own certificate from Let's Encrypt. The agent asks for at most 3 certificates a day for servers' own addresses, first ones and renewals together, since each counts against Let's Encrypt's weekly limit for the domain, and forgets an address's certificate when the address is cleared or its server deleted. From 0.4.5, An address for each server (the same people only) gives every server without one <slug>.<domain>, through one wildcard record the owner adds; each still gets its own certificate over HTTP-01, after a lookup shows its name pointing here, within the same 3 a day. The agent logs each request for a server's own address, so the day's count holds whatever becomes of the names: turning the switch off, changing or clearing an address, deleting a server or giving up the domain doesn't reset it. A server keeps an address it was given, no server can be given another's, and under playkeeper.me a server whose slug the names service reserves (login, admin and the like) gets none, so that no page there passes for Playkeeper's own. Turning it off keeps the certificates, so turning it on again asks for none; giving a server an address of its own under another name, or deleting it, forgets its certificate, and moving the machine to another domain or stopping its own domain forgets them all, whether the switch is on or off.
  • A server on a joined machine (from 0.4.14). A joined machine serves no page. Once the dashboard answers DNS for its domain, the name the zone gives a server on a joined machine, like alex.beta.playkeeper.me, points at the dashboard's machine; only its SRV record points at the machine, whose name there is <machine id>.m. Java players who type the name as the dashboard shows it, without a port, follow the SRV record to the machine, and Bedrock players join at the machine's IP address, which the dashboard shows them. Anything that doesn't follow the SRV record reaches the dashboard's machine instead, at whatever port it uses: a Java player who adds a port, a Bedrock player who tries the name, or a status site that reads only A records gets the dashboard's machine's server on that port, if it has one. Ports 443 and 80 answer the name with that server's page alone, and only while both the dashboard's own record and the machine say the server is on the page; otherwise the name gets the same 404 as a name nobody has. Every change to that switch goes through the dashboard, which records it (public_pages): an off before it asks the machine, so a machine that refuses it, or answers with an error, can't keep its page up, and an on once the machine takes it. It records what a move carries over too; for a server whose switch it has never set, it asks the machine once (GET /v1/servers/{id}/public-page) and keeps that answer as its record. The dashboard asks only the machine it knows runs the server, not while the server moves or two machines claim it, for what the page shows (GET /v1/servers/{id}/public-page/shown, over the machine's link), at most once every 5 seconds, and reads at most 64 KiB of the answer. Each machine's questions, and its servers' share cards, wait only for that machine's, and the port keeper asks the machines side by side for at most 10 seconds. A machine that doesn't answer by then, or answers with an error, says nothing about its servers' pages the dashboard's record has on: the look keeps what the page holds, as when the agent doesn't answer, and only a page off on the dashboard's record or the machine's word gives the ports back. So a slow or hostile machine holds up only its own servers' pages and never closes the ports. A server the record doesn't have on counts as off then, as its page can't be served without that answer, so a machine that never answers can't keep the ports open for the page's 404. The machine is trusted for what the page shows and nothing else: the server's address and slug on the page are the dashboard's, the page has no Bedrock address, which would be the machine's, and the shared map's and friends' pack page's links are under the dashboard's own address, and only for a token the dashboard saw that machine make for that server. What else it says is held to what the agent itself allows: a name of up to 32 characters and a description of up to 59, without control characters or formatting codes; About and the status board within their bounds above; up to 100 characters for the Minecraft version and the modpack's name and version; a server type and state the page knows; players only while the server is online, with no more names than are playing; and a stream only as a Twitch or YouTube channel's page. The name answers the zone's server ahead of a copy a move is making or left on the dashboard's machine. That machine's own page leaves such a copy out, and every server there the dashboard's record has off, so the name doesn't bring the copy back once the zone no longer gives it: the dashboard names them to its agent on every read of the page (?hidden=). The machine's answer never holds an address of its own, and a server's Settings show its page at that name, never at an address its machine has. Without the domain's wildcard certificate, which comes with An address for each server, no certificate is ordered for a joined server's name, so its page answers over plain HTTP on port 80 only, where anyone on the network path could change what it shows; the dashboard's cookies are Secure, and never sent there. Turn on An address for each server for HTTPS. Until then a server's Settings link its page over HTTP, and say that port 443 has no certificate for its address.
  • Let's Encrypt's HTTP-01 checks for an own domain keep working while the panel holds port 80: when the agent can't listen on port 80 itself, it asks over the loopback address whether port 80 answers its pending check, and goes ahead only then.
  • What the owner adds (from 0.4.4). Someone who manages servers can give the page an About text and a live stream's link in Settings. About is plain text of up to 600 characters on at most 12 lines: tabs and other spaces become plain spaces, control and direction-changing characters are refused, and the page shows it as text, never as HTML or links. The stream's link must be a Twitch channel (twitch.tv/<name>) or a YouTube channel by its ID (youtube.com/channel/UC…), and is kept as that channel's link written one way; anything else is refused. The page loads nothing from Twitch or YouTube until a visitor selects Watch live, then frames only Twitch's player (player.twitch.tv) or YouTube's cookie-less one (www.youtube-nocookie.com), sending only the page's origin as the referrer. Only the page's ports allow those frames; the dashboard's policy on its own port doesn't change.
  • The status board (from 0.4.4). The owner's tools post it with PUT /api/servers/{id}/public-page/board (a session and its CSRF token, with the right to start and stop the server, which moderators have) or the MCP tool set_status_board (a Moderator or Admin token), and clear it with DELETE on the same route or the tool's clear. Each post replaces the last. It holds a headline of up to 80 characters, whether a session is live, when the next starts (from a day ago to 31 days ahead), up to 6 numbers (labels up to 20 characters, values up to 16) and up to 24 checklist items (up to 40 characters), each one line of printable text; a board over any of those limits is refused whole. Playkeeper fetches nothing for it. The audit log records a board's first post and each clearing, not every update, since tools post as often as things change. Anyone can read it, which the tool's description tells agents.
  • The share card (from 0.4.4). Link previews show a 1200×630 PNG from /api/public/server-page/card.png, which the panel draws itself (Go's image packages, a pixel font and pictures built into the binary, nothing fetched) from what the page shows anyway: the server's name, its status and player count, the board's headline and the address, never a player's name. It counts toward the data requests' limits, and each different card is drawn at most once every 5 seconds.

Dashboard on the standard HTTPS port (from 0.4.11). Machine settings › Serve the dashboard on the standard HTTPS port (443) has the dashboard answer at the machine's name without a port. A new install turns it on when nothing uses or claims port 443; an update leaves it off.

  • Nothing about what the panel may do changes: it still runs without CAP_NET_BIND_SERVICE, and the agent opens port 443 (and port 80, which sends browsers on) through the public page's hand-over, with the same checks, and passes the listening sockets over its socket. That hand-over was chosen over the alternatives because each of them widens something: CAP_NET_BIND_SERVICE on the panel would let the internet-facing process open any port below 1024; lowering net.ipv4.ip_unprivileged_port_start would let every program on the machine open port 443; a REDIRECT or DNAT rule would change the firewall, take port 443 even from a program listening there, and need its own IPv6 rule; and a systemd socket unit would hold the port whenever it's enabled, even with the switch off. The hand-over opens only ports 443 and 80, only while nothing else uses or claims them, keeps no copy, and machine links don't carry it.
  • The agent keeps the switch (dashboard_443 in its database). Only people who may manage the machine can change it, the route isn't offered to joined machines, and each change is in the audit log (dashboard_443.on, dashboard_443.off). Turning it on is refused, with the holder named, while a program listens on port 443, a Docker container publishes or names it, or a web server set to start with the machine would take it; while the panel already holds the port for the public page, only the claims are checked. A port found busy later isn't tried again until the agent restarts or someone who manages the machine selects Try again.
  • While it's on, port 443 answers the machine's name with the dashboard: the same handler as port 8443, so every route keeps its sign-in, CSRF and rate limits, and the same headers and Content Security Policy, over TLS 1.2 or later with HTTP/2. It serves only the name's certificate from Let's Encrypt, never the self-signed one, so the IP address and any other name get no dashboard there. The root, /, shows the public page (with the page's policy and limits, no-store and Vary: Cookie) to someone without a valid session while a server is on the page, unless customers sign in there with Whop; its only addition is a link to /login. Servers' own addresses and every other name answer as before, and machine links never answer on port 443. The session cookie (__Host-playkeeper) is the same on both ports, as cookies don't tell ports apart; each request's Origin is still checked against its own Host.
  • The dashboard's address loses its port only once a browser from outside the machine has reached port 443 at the name: a request there from a public address, which the agent checks isn't the machine's own, so a request the machine makes to itself, one from a private network, shared address space (carrier NAT, Tailscale) or loopback doesn't count. The agent keeps the name it was reached at (dashboard_443_reached, audited once as dashboard_443.reached) and forgets it when the switch goes off. To check sooner, the dashboard's pages may connect to https://<name>, the machine's own name on port 443 (the Content Security Policy's connect-src gets exactly that origin, checked to be a DNS name), and ask GET /api/public/reach, a public route that answers a transparent 43-byte GIF with Cross-Origin-Resource-Policy: cross-origin and holds nothing, within 30 requests a minute and 4 at once from an address. While another program has port 443, the address keeps its port again.
  • Port 8443 keeps answering everything. Once the address has lost its port, and while the panel holds port 443, a GET or HEAD of one of the dashboard's pages at the machine's name there gets a 307 to the same path on port 443, no-store and never permanent, so turning the switch off doesn't leave browsers sent to a port that stopped answering. Its API, /mcp, Whop's webhook, Sign in with Whop's return, the names service's check, resource packs, built files, a friends' pack's files, the IP address, requests whose Sec-Fetch-Dest isn't document and machine links are never sent away.
  • What the dashboard hands out then has no port: Whop's webhook and the address it marks the store's products with, the ready and deletion messages customers get, invite links, shared map and friends' pack links, Discord's links and AI agents' address. Sign in with Whop sends Whop the new redirect URL only while Whop takes it, and until the app lists it, the one at port 8443, which keeps reaching the dashboard: the panel asks Whop's authorize endpoint the way a browser leaving to sign in would, with a throwaway state and PKCE challenge and no secret, so nobody signs in, and keeps Whop's answer for 10 minutes when it takes the URL and a minute when it doesn't. Each sign-in keeps the redirect URL it left with, and trades its code with that one. Turn on refuses an app that lists neither. Machine settings lists what keeps the old address outside Playkeeper, with the change each needs; only the owner sees Whop's.

World uploads (from 0.4.0). Only an admin of a server can upload a world into it, and only an admin of all servers can upload one for a new server, with the session's CSRF token like every other change. The panel streams the bytes to the agent, which checks every name, size and byte. An upload holds up to 16 .zip, .tar.gz or .tar archives, recognised by their content; open uploads together may announce no more than half the free disk space, less a reserve, and at most 64 GiB, and each file must arrive in order, from where the last piece stopped, and no longer than announced. The audit log records each file's name, size and SHA-256. The agent reads the archives without unpacking them and refuses links, sparse files, absolute paths, .., names that appear twice, more than 500,000 entries, an archive (or a file over 1 MB in it) that expands to more than 100 times its compressed size, and a level.dat that decodes to more than 16 MB. While it writes, it counts the bytes it actually unpacks against those limits and against the space the import may use, whatever an archive's headers say. Nothing is written until the admin confirms the preview. Then the chosen world goes into a new folder in the agent's staging area, as new files at clean paths inside it, each checked against what was read before, and is moved into the server's data folder; a world folder there that is a link stops the import, and the server's server.properties goes through internal/gamefiles (see below). From the old server only the seed, game mode, difficulty and hardcore setting are carried over, never RCON, ports, online mode, the whitelist or the other settings that control access. The dashboard doesn't copy the old server's plugins or mods, which would run on the server, or its operators and player lists.

Files tab. Only admins see or change a server's files, and every change needs the session's CSRF token: the files hold what the dashboard keeps from moderators and viewers (the RCON password in server.properties, plugins' tokens and database passwords, logs with players' addresses), and a changed or uploaded file, like a plugin's jar, runs code in the game. The agent keeps every path inside the server's data folder the way it handles its own files there (internal/gamefiles, see below): a path must be relative and without dot segments, every folder on the way must be a real folder, so a link there is refused wherever it leads, and a link or special file at the name is listed as what it is, never opened; moving or deleting one moves or deletes it, never what it leads to, and a zip download leaves them out. A move, an upload or a new file takes its name only if the name is still free when it is renamed into place (RENAME_NOREPLACE), so a file a plugin writes in between is kept, not replaced. The editor opens and saves at most 2 MB of UTF-8 text, and of two saves of the version it opened, the second is refused. An upload may announce no more than the space uploads may use, one server keeps at most 4 of the 8 unfinished uploads a machine allows, each file arrives in order from where the last piece stopped and no longer than announced, and it goes in place only as a regular file, over another only when asked. While the game runs its world folders can't change, nor any other folder at the top with a level.dat, such as a plugin's world, so a world the game is writing is never overwritten half-way; and a change holds off the server's starts (a wake for a player who joins too), backups and restores until it is done, so none of them begins half-way through a long delete or move. A zip is counted before it starts and refused above 100,000 files and folders, since it keeps a record of each in memory until it ends, or with folders more than 64 deep, which only something planting them makes, and its names are made safe for any unpacker: a backslash, a colon, a trailing dot or a Windows device name in a name the game chose can't write outside the folder it is unpacked into. The panel types every download application/octet-stream, with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, and names it itself with control characters, quotes, slashes and backslashes replaced, so a file from a server never renders as a page of the dashboard. The audit log records each change and download with its paths.

Privilege boundary.

  • playkeeper panel runs as the playkeeper user (not in the docker group) under a systemd sandbox that only allows writes to /var/lib/playkeeper/panel.
  • playkeeper agent runs as root with 7 capabilities (CAP_CHOWN, CAP_FOWNER, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, from 0.4.0 CAP_NET_BIND_SERVICE for Let's Encrypt's checks on port 80, and from 0.4.3 to open ports 443 and 80 for the public server page, and from 0.4.11 for the dashboard on the standard HTTPS port, whose listening sockets it passes to the panel, and from 0.4.5 CAP_NET_ADMIN and CAP_NET_RAW, with netlink sockets, for the iptables it runs for the network guard; its Docker socket gives it more than these already) and a read-only view of the host except /var/lib/playkeeper and /run/playkeeper. It serves a closed route table on /run/playkeeper/agent.sock (0660 root:playkeeper), checks the caller's UID with SO_PEERCRED, rejects unknown fields and trailing data, and never runs a shell. Console commands go to Minecraft over RCON as literal text. A backup made while players are online sends only save-off, save-all flush and save-on, and a command already written is never sent again.
  • A server's data folder is the game's: Minecraft and its plugins and mods can put anything there, including links to Playkeeper's own files. From 0.3.1 the agent reads and writes the files it manages there (server.properties, the allowlist and operators, the server icon, Paper's bStats setting and the jar it checks; from 0.4.0 also the ban list, the crash reports, Java's error reports and GC log that crash help and memory advice read, the headers of the world's region files it reads to count chunks, the mods folder whose jars size a mod loader's memory, and the folders it lists for them) only through internal/gamefiles. It keeps every path inside the folder (os.OpenRoot); refuses links, named pipes, sockets and devices, on the way to a file and at it; opens without waiting and checks the open file again; caps reads (1 MB for server.properties, the player lists, a crash report, Java's error report and each read of the GC log, 64 KB or less for the icon, 4 KB of each region file) and folder listings (1,000 to 20,000 entries, depending on the folder) and hashes a jar only up to 256 MB, stopping with its operation; writes a new temporary file and renames it over the old one; and gives the game's user only the folders and files it has just made, through their own handles. From 0.4.0 the one exception is the logs folder Java writes its GC log to: every start gives it to the game's user again, through a handle opened without following a link, and leaves a link or anything but a folder there alone. Where a refused file is needed, the action stops with a message naming it: a link where the bStats setting is written stops the start. Hard links are not checked: the game's container sees only its data folder, so it cannot link to anything outside it. From 0.4.0 the data packs in a world's datapacks folder, and Chunky's jar, settings and saved tasks for pre-generating the map, go through it too: a link on the way to them stops the action with a message naming it, and a linked data pack or jar is left out. So do the server's server.properties when a world is imported, where a link stops the import before it replaces anything, and the server icon on the shared map's page and for a sleeping server's stand-in. From 0.4.8 so does the crash help's new level.dat, which an admin asks for when both of a world's level.dat files are damaged: it reads that world's level.dat, level.dat_old and world_gen_settings.dat, 16 MB at most each, and only after a verified backup writes the seed, when it found it, to server.properties and deletes the two level.dat files, refusing a link or anything but a regular file there. So does its fix for an entity that crashes the server each time it's ticked, which an admin asks for: it reads the region files that may save what the crash report names, in the folders the server's type uses, 1 GB at most each, until one does, and only after a verified backup writes that one back, as a new file renamed over the old one, without that entity or block's data, refusing a link or anything but a regular file there.
  • From 0.4.0, when Docker refuses to start a server because its game port is taken, the agent asks Docker which running container publishes that port, through Docker's read-only list of containers, and names it unless Playkeeper made it; a name Docker wouldn't allow, or one over 64 characters, is left out. With no container on the port, it looks for the program listening on it: the socket in /proc/net/tcp and tcp6, then the process with that socket open. Without CAP_SYS_PTRACE the kernel only shows it the open files of root processes with no capabilities beyond its own, so most root services and other users' programs stay unnamed. It keeps only a printable name of at most 15 characters and the process id, and it never signals, stops or removes the program or the container.
  • Each Minecraft server has its own container, running as playkeeper-mc with all capabilities dropped, no-new-privileges, a memory limit equal to the chosen budget, a PID limit, only the game port published (and voice chat's and crossplay's UDP ports on a server that has them), and no Docker socket mount. It is on Playkeeper's private network, where containers can't reach each other and, from 0.4.5, can't reach link-local addresses, nor the machine while its owner keeps servers away from it (see Network guard).
  • Docker access is root-equivalent; that is why only the agent has it.
  • playkeeper-update.service (from 0.2.0) runs as root only when the agent leaves an update request in /var/lib/playkeeper/agent/update/, or an update it was installing was interrupted; the playkeeper-update.path unit watches for both. It runs the installed binary, not the downloaded one, and checks the staged release again with the installed version's keys. Its sandbox makes /usr, /boot, /efi and /etc read-only apart from /usr/local/bin, /etc/systemd/system and /etc/playkeeper, hides home directories and gives it a private /tmp; the rest of the host, /var included, stays writable to it.
  • From 0.4.0 schedules run in the agent, so they also run while the panel is down. A scheduled console command may only be say, save-all, weather, time set, time add, difficulty, gamerule, setidletimeout or kill @e[type=item], with its arguments checked, and goes to Minecraft over RCON once, audited like the Console with schedule:<id> as the actor.
  • A machine joined to another dashboard (from 0.4.0) runs no panel. Its agent runs as above, and its link to the dashboard runs as the playkeeper user in a systemd sandbox with no capabilities, reaching the agent only through its socket, which checks the link's user id as it checks the panel's (see Other machines).

Authentication. The first admin needs a one-time setup code printed by the installer (stored only as a SHA-256 hash, 24-hour expiry, deleted on use; setup is disabled once an admin exists). There is no open sign-up: every other account comes from a team invite (see Invite links). Passwords use argon2id. Session tokens are random, stored hashed, sent in a __Host- cookie with Secure; HttpOnly; SameSite=Strict, and expire after 12 hours idle or 7 days. Every cookie the dashboard sets is a __Host- one like it, Secure with Path=/ and no Domain, which browsers take only from the dashboard's own name. So no other name under its domain can plant one the dashboard reads: not a joined machine's, nor, for a dashboard with a free name, any other free name, which browsers count as part of the same site while playkeeper.me isn't on the Public Suffix List (see services/names/README.md). Sign in with Whop's cookie holds a random secret that never leaves the browser, and a sign-in on its way through Whop is kept by the hash of its state with that secret, so the link back from Whop signs someone in only in the browser that left with it. A link back that matches no sign-in leaves the cookie, which may be another tab's, so it can't end a sign-in on its way. Every state-changing request is refused from other sites: it needs a same-origin Origin or, without one, Sec-Fetch-Site absent or same-origin. One made with a session also needs the session's CSRF token; sign-in, setup, the second sign-in step and the invite page's calls, which have none, need a same-origin marker header instead, and /mcp takes only API tokens (see AI agents). Sign-in is rate-limited per address (10 attempts in 15 minutes, an IPv6 client by its /64) and per account (30 failures an hour from all addresses together), and 5 wrong passwords in a row lock the account out from that address for a minute, doubling up to 16 minutes; control actions are rate-limited per session. Responses carry a strict CSP and anti-framing headers.

Two-factor sign-in (from 0.4.0). Optional for the owner and required for every other admin, with an authenticator app: TOTP with HMAC-SHA1, 6 digits and 30-second steps, accepting one step either side; once a code is accepted, no code from its step or an earlier one works again. Turning it on needs the password, and the setup belongs to the session that started it and expires after 15 minutes; making new recovery codes and turning it off need the password and a current code or a recovery code. After a correct password the panel sets a separate __Host-playkeeper-2fa cookie for the second step, backed by a hashed row in its own pending_logins table, so it can never pass for a session; it allows 10 codes within 5 minutes, then the password is needed again. Wrong codes are counted inside a write transaction, so guesses sent in parallel all count: 5 in a row pause app codes for a minute, doubling up to 16 minutes, and 100 block them until a recovery code is used or root runs playkeeper reset-2fa <user>, so someone who knows the password gets at most 100 guesses. Each set has ten recovery codes of 80 random bits, stored only as SHA-256 hashes and used once each; they work while app codes are paused or blocked, and wrong ones are counted on their own and lock nothing, so they cannot be used to block anyone's app codes. After signing in, the dashboard tells each person about wrong codes entered for their account since their last sign-in, leaving out their own tries in that browser, and how many recovery codes are left when one was used or few remain. An admin other than the owner has Moderator rights until they turn it on, which the invite page offers right after they choose their password, and the owner or an admin of all their servers confirms that setup with one click; turning it off, or on again, needs a new confirmation. With Discord connected, every change to a team member's two-factor sign-in, and every admin confirmed by someone other than the owner, is posted there whichever alerts are chosen. API tokens for AI agents are separate and ask for no code (see AI agents).

Friends' pack page (from 0.4.0). The admin can switch on a public page for a modded server that shows friends what to install: the server's name and icon, its join address, its Minecraft and loader versions, its modpack, the mods friends need or may add, and a .mrpack file that holds only names, versions, file paths, hashes, sizes and Modrinth download links, never mods, configs or other files. Server-only mods are left out of both. The page is at /packs/<token>: the token is 22 random letters and digits (about 131 bits), never the server's name, made when sharing is switched on, replaced when it is switched off and on again, and compared in constant time. An unknown or old token, sharing switched off, a stopped server and a machine that can't answer all get the same 404, and the page itself is the same for every link. Its routes are in the panel's public group, limited per connection address (forwarded headers are ignored), no-store and noindex, never look at a sign-in, and appear in the log as /packs/…. The page loads nothing from Modrinth or CurseForge.

Certificates and HSTS (from 0.4.0). Without an address the panel keeps its self-signed certificate. With a free name or an own domain (Machine settings › Address), the agent gets a certificate for that one name from Let's Encrypt: through a DNS-01 check for a free name, whose TXT record the names service adds, and through an HTTP-01 check on port 80 for an own domain. The ACME account key is made on the host (/var/lib/playkeeper/agent/acme-account.key, 0600 root) and the account carries no email address unless root adds one as acmeEmail in /etc/playkeeper/config.json. Certificates and their keys are saved in /var/lib/playkeeper/certs/ (0640 root:playkeeper, so the panel can read them but not change them) and renewed when two thirds of their lifetime have passed (30 days before a 90-day certificate runs out). The panel chooses the certificate by the name the browser asks for: the name gets the Let's Encrypt certificate, while the IP address and any other name keep the self-signed one, so the dashboard stays reachable at the IP address if the name or its certificate breaks. HSTS lasts a day on names (max-age=86400), so a lapsed certificate locks a browser out of the self-signed fallback for at most a day after its last visit; on the IP address it is max-age=31536000, which browsers ignore for IP addresses. Sessions do not cross over: __Host- cookies belong to one host, so the name needs its own sign-in. An own domain's records are checked through public DNS-over-HTTPS, to see what Let's Encrypt and players see.

Free names (from 0.4.0). Free yourname.playkeeper.me addresses (yourname.playkeeper.io for installs from before the move) come from the names service at names.playkeeper.io, run by the Playkeeper project (code in cmd/playkeeper-names and internal/names/service; deployment, limits and operation in services/names). Each install makes its own Ed25519 key when it first needs one (/var/lib/playkeeper/agent/names.key, 0600 root). Every change is a request signed with that key, bound to the method, path, body, a timestamp within 5 minutes and a one-time nonce, and the key is the only proof of owning a name. The service points a name at the public address the claim or refresh came from, so a name can only point at the machine that asks, and adds an SRV record for each server on another port and, for a few minutes, the TXT records Let's Encrypt checks. One name per install; a released name is held for 30 days so nobody else takes over its players. The agent sends the name, its servers' short names and ports and the challenge values, and the service sees the machine's IP address; nothing about players, worlds or dashboard accounts. To check that a name still reaches its machine, the service asks https://<name>.playkeeper.me:8443/.well-known/playkeeper-names/<nonce> every 6 hours, and again when a lapsed name is refreshed or claimed. The panel serves that path without sign-in in its public route group, like the resource packs: at most 20 requests a minute and 2 at once from one address (the connection's, an IPv6 client by its /64), short deadlines, no caching, and one 404 for every failure (a malformed nonce gets 400); the agent answers only for the name this install holds or is claiming, with a signature of the name and nonce by the install's key. A name that hasn't answered for 7 days loses its records, and a name gets server addresses only 3 days after its claim and once it has answered. The service can change where every free name points, so a free name means trusting it and its operator, as with any DNS provider. It changes the playkeeper.me zone at Cloudflare with an API token for that zone alone, which can edit every record in it, because Cloudflare cannot limit a token to some records. The zone holds nothing but free names and the two records that keep anyone from sending email as the domain: the website, names.playkeeper.io and the /install script the one-line installer runs are in playkeeper.io, which the token cannot reach. The service's own code only touches the records it made under claimed names; the risk is the token leaking. It only works from the service's server, lives only in the hosting environment's variables (not in the image or the repository), never appears in logs, and Cloudflare's audit log lists every change made with it. An own domain or the IP address does not depend on the service, and the GitHub URL of the installer does not depend on playkeeper.io.

Invite links (from 0.4.0). An invite link's page (/join/<code>) and the calls it makes (/api/public/join/…) answer without a sign-in. Codes are 22 random letters and digits (about 131 bits), looked up by their SHA-256 hash; a friend link's code is also kept so the Players tab can copy it again, a team invite's is shown once. A friend link lets in a set number of people, up to 100 (the panel's API also allows no limit, which the dashboard doesn't offer), and runs out after a day, a week or a month, or only when it is turned off; a team invite lets in one person and runs out after a week. A turned-off link reads like an unknown one. The calls need the same-origin marker and count against the address (60 in 10 minutes) and, for failures that cost a Mojang lookup, against the link. Both paths are in the panel's public route group, which adds its own per-address limits (60 page loads and 120 calls a minute, 8 open at once), deadlines and Cache-Control: no-store, and logs only the path's prefix, never a code.

Creators (from 0.4.5). Only the owner can invite a creator (Settings › Team › Invite a creator). The link works once, for 7 days, and only the owner sees it on the Team page or can turn it off. The account it makes is an Admin with no servers and an allowance: how many servers it may create, and how much memory they may use between them. Like every admin, it needs two-factor sign-in, and the owner confirms its Admin rights. A creator creates servers only on their machine: the dashboard's own or, for a customer, the one placement gave them (see Other machines). Each one joins their servers. The panel checks every create, memory change and restore of a creator's against the allowance, one at a time. A server the owner also gave them keeps its memory, and a creator deletes only the servers they created. World imports and restores into a new server stay with admins of every server. The Team page doesn't change a creator's role or servers; the owner can remove them instead. A creator sees only the servers they have and the team members who share one, never the machines (from 0.4.9, and a customer the same, paused or not). No page or API answer gives them a machine's name, details, events or settings, how many machines there are, or any machine but the one their servers go on: its id is in the paths their pages use, and a joined one's IP is where their servers' join addresses lead anyway. A path that names any other machine isn't found, their pages show their plan's memory and disk instead of a machine's, and a machine that can't be reached is, to them and their AI agents, a server that can't be reached. From 0.4.12 their servers' names are unique among their own servers rather than the machine's: creating, renaming, restoring or moving one in is never refused over another customer's server of the same name, nor numbered after it, and a new server's address that another server has gets a few random letters after it, never a number that counts them. The sign-in page names no machine once customers sign in there with Whop.

Network guard (from 0.4.5). A server runs the plugins and mods its admins install, and on a machine shared with creators that is someone else's code. Docker keeps servers' containers from each other (inter-container traffic is off on Playkeeper's network), and the agent's firewall rules keep them from the cloud's metadata service on every machine: a server can't open a TCP connection to a link-local address (169.254.0.0/16), where clouds answer with the machine's metadata and, on some, its cloud-init data. With Keep servers away from this machine on (Machine settings; the owner, or an admin of every server), they are kept from the machine too: a server can't open a TCP or UDP connection to any port the machine listens on, whether through the bridge's gateway or one of the machine's addresses, ports a cloud firewall closes to the internet included. It is off unless turned on, so that a plugin can keep using a database the machine runs; the dashboard turns it on before it makes a creator invite, makes no invite when it can't, and refuses to turn it off while there are creators or creator invites that still work. On a joined machine, it turns it on before the machine takes customers, and refuses to turn it off while the machine takes customers or has any. Both leave DNS alone, since Google Cloud's and Oracle Cloud's resolver is the metadata address, and ICMP, which IPv6 needs; the replies to the machine's own connections to a server, such as RCON and the map, get through. The rules are iptables rules for the network's bridge, tagged playkeeper-guard: one at the top of DOCKER-USER, or of FORWARD where Docker has no such chain, and with the switch on two at the top of INPUT, also in ip6tables when the network has IPv6. The agent puts them in place before a server's container starts and looks at them every minute; when Docker, ufw or firewalld reloading their own rules removed or buried them, it puts them back at the top in one iptables-restore, so there is no moment without them, and it leaves every other rule alone. sudo playkeeper status says whether they are in place, and the uninstall takes them out. Where they can't be put in place, as on a machine without the iptables command, the agent logs why and tries again every minute; servers still start, except while Keep servers away from this machine is on. Players reach servers through their published ports as before.

Discord (from 0.4.0). An admin of all servers can connect Discord in Settings › Discord by pasting a channel webhook's URL; there is no bot account and no OAuth. Playkeeper accepts only an https webhook URL on discord.com (or canary.discord.com, ptb.discord.com and the old discordapp.com), always sends to https://discord.com/api/v10 and follows no redirects. The URL's last part lets anyone who has it post in the channel, so the URL stays in the agent's database (0600 root), and from 0.4.14 in those of the joined machines the dashboard confirmed, which post only that the dashboard can't be reached and that it's reachable again (see Other machines): the browser only sees the webhook's name, and messages and logs never repeat it. The dashboard machine's agent does the posting, so alerts about crashes go out while the panel is down, and servers on joined machines post nothing. Every message sets Discord's allowed_mentions to none, so names from the game can't ping anyone. Messages name what they are about: servers with their state, join address and Minecraft version, a link to the server in the dashboard, and players, in the live status and in alerts about joins, leaves and join requests. From 0.4.12 a dashboard with a store or joined machines also posts the fleet's room and health, whatever the alert switches say: a joined machine off the dashboard for 5 minutes and back, room for fewer than 2 of the smallest paid plan's servers across every store with how many customers wait, a disk past 75% full, a machine's busiest hour past 70% CPU three days running, a server lagging with players on, and from 0.4.13 a machine whose customers' plans set aside more memory than it has. They name the machine, the server and how many play, and nothing about customers; the dashboard only posts them, and never buys a machine. Once Discord answers that a webhook is gone or refused, nothing more is sent to it until a new URL is saved. PLAYKEEPER_E2E_DISCORD_URL_UNSAFE, for the end-to-end tests, can point the agent at a fake webhook on the same machine only, and the agent logs a warning while it is set.

Data. Player IP addresses are never shown (log-ips=false plus redaction) and never stored, except while a join request from an invite page waits for an answer: the panel keeps the network it came from (the IPv4 address or the IPv6 /64) to limit requests, and clears it when the request is answered or the link is turned off. Player names, UUIDs and session times are kept for 180 days, samples for 30 days, 15-minute summaries of Java's memory log for 14 days and audit, which names the players an action concerned, for 365 days, with row caps. How a player got in (their name, UUID and invite) is kept while they stay on the allowlist, answered join requests until their server is removed, who woke a sleeping server for 90 days, and each player's face, under their name, with no time limit. To explain a crash, the agent reads the end of the container's log, the newest crash report or Java's error report, the names of plugin and mod files, the free disk space and the list of backups, and keeps what it found in memory; the log lines it shows are redacted like the console's. Secrets are generated on the host: an RCON password per server (/var/lib/playkeeper/agent/servers/<id>/rcon.secret, 0600 root, plus a read-only copy for the game user; a server from before 0.3.0 keeps /var/lib/playkeeper/agent/rcon.secret), TLS key (0600 panel user), session tokens, and from 0.4.0 the ACME account key, the names key, the Let's Encrypt certificates' keys, each account's two-factor secret and recovery-code hashes (see above), the machine link keys (see Other machines), the keys that encrypt copies of backups and the SSH key for SFTP copies (see Copies somewhere else) and the hashes of API tokens (see AI agents). Secrets pasted into the dashboard stay on the machine and are never sent back to the browser in full: a Discord webhook URL, a CurseForge key, a Hetzner API token and the storage credentials for copies. A server's own AI key (from 0.4.9, the OpenRouter key the AI Build Battle plugin builds with) is never sent back at all, not even in part: the dashboard only learns whether one is set, and its field is masked without being a password field, so browsers and password managers don't offer to keep it. The key is kept in the server's secrets folder beside its data folder (/var/lib/playkeeper/servers/<id>/secrets/openrouter_api_key, 0400 in a 0500 folder, both the game user's), which only that server's container mounts, read-only at /run/playkeeper/secrets; the Files tab, backups and their copies, world copies and the template export never reach it. Only admins may save or remove one, with the Files tab's rights, since a plugin they add could read it anyway; the agent checks a key without quoting it in any error or log line, the audit log names only the provider (ai_key.saved, ai_key.removed), and deleting the server deletes the folder first. The Hetzner token (Settings › Machines › Hetzner stock, the owner's alone, from 0.4.7) is sent only to api.hetzner.cloud, once a minute, to read one server type's stock, and never along a redirect; the dashboard asks for a token with Read permission, and stops asking once Hetzner refuses it. Backups strip rcon.password and management-server-secret from server.properties and never include files holding the RCON password. Audit rows record actor, action, target and result, never secret values.

Outbound connections. Playkeeper itself contacts Docker Hub (pulling the runtime images, one per Java version, each pinned by digest), PaperMC (fill.papermc.io: the list of versions and builds with their checksums, fetched for onboarding, Settings, restores and template imports and reused for 30 minutes, the last list kept on disk, and the Paper download in a setup-only container after the EULA is accepted, from fill-data.papermc.io by the jar's pinned checksum; while the list can't be fetched, the machine offers the last one it kept, or else the list built into the release, and asks PaperMC again a minute later; the machine check sends a HEAD request to each of fill.papermc.io, fill-data.papermc.io and piston-meta.mojang.com), Mojang (piston-meta.mojang.com, for when each Minecraft version came out, also reused for 30 minutes) playkeeper.io (from 0.4.9: https://playkeeper.io/releases/latest/playkeeper-release.json.sig, and the manifest beside it only when the signature changed, when the dashboard's agent starts and about every 30 minutes after, unless Check for updates automatically is off in Settings; a request names the last copy's ETag and Last-Modified, so an unchanged release is answered 304; site/nginx.conf keeps no log of them) and GitHub (the same two files of the latest release when playkeeper.io doesn't answer with them, before 0.4.9 a minute after the agent starts and then every 12 hours, and the release tarball only when the admin installs an update; the requests to both carry the User-Agent playkeeper-updater and nothing else about the server). A machine joined to another dashboard doesn't look by itself: its dashboard updates it to its own version. From 0.4.0 the agent also contacts the add-on libraries Modrinth (api.modrinth.com, with files and icons from cdn.modrinth.com) and Hangar (hangar.papermc.io, with files and icons from hangarcdn.papermc.io), only for a server's Plugins or Mods tab and the add-ons it suggests, for modpacks and templates (see Server types, modpacks and templates), for the friends' pack page, for pre-generating the map, for turning on the Map and for AI agents' add-on tools: searches (results reused for 2 minutes), a project's details and versions, the files to install or update with the dependencies they need, each checked against the checksum its library publishes before it goes into the server's folder, Chunky the first time the map is pre-generated, squaremap when the Map is turned on, and, while the Plugins or Mods tab is open, a check at most every 15 minutes for newer versions, which sends Modrinth the SHA-512 of each file in the plugins or mods folder so it can recognise ones added by hand. Files and icons come only from those two CDN hosts, redirects included, but for Floodgate and Geyser listed on Hangar (see Crossplay); the requests carry the User-Agent CIYAhq/playkeeper/<version> (https://github.com/CIYAhq/playkeeper), as Modrinth asks, and nothing else about the server, and browsers get icons through the panel, never from the libraries (the agent keeps them 24 hours). From 0.4.0, only while the admin sets up an address in Machine settings and while one is in use, the agent also contacts Let's Encrypt (acme-v02.api.letsencrypt.org) to get and renew the certificate; for a free name, the names service (names.playkeeper.io: whether the name being typed, or a few alternatives to a taken one, is free, then signed requests to claim it, refresh it daily, publish server addresses and challenge records, and check on records being published) and, before Let's Encrypt checks, the playkeeper.me nameservers for the challenge record; for an own domain, public DNS-over-HTTPS (cloudflare-dns.com, then dns.google) with the domain's names, every minute until the records are right and every 6 hours after. The panel shows each player's face from their own skin: for a player it hasn't seen recently, it asks Mojang for the skin (api.minecraftservices.com with the name, sessionserver.mojang.com with the UUID) and downloads it from textures.minecraft.net, sending nothing else, and keeps just the face in its database, looking again after 12 hours (6 for default skins or a name with no account, 10 minutes after a failed lookup). The same name lookup checks the names typed on invite pages. Browsers get faces from the panel, never from Mojang. The Paper server contacts Mojang (the matching vanilla jar from piston-data.mojang.com on its first start, Mojang services for keys and, in online mode, player authentication and allowlist name lookups) and PaperMC's version check when it starts. Playkeeper turns off two defaults that would send data elsewhere: Paper's bStats usage statistics (plugins/bStats/config.yml is written with enabled: false before every start, including after a restore) and the runtime image's download of default config files from a third-party GitHub repository (SKIP_DOWNLOAD_DEFAULTS). squaremap, which turning on the Map downloads, is set not to check for updates or fetch player heads, and the bStats statistics of its Paper build are off with the rest of bStats. From 0.4.0 the agent also contacts the other server types' download sites, CurseForge, the hosts modpacks name and the websites a template's data packs come from when they are used (see Server types, modpacks and templates); it keeps each type's last version list and what it last looked up to install each version, so while a type's metadata can't be fetched it offers the kept list, or else the one built into the release, and installs from the type's download hosts, every file still checked against its hash, and, only once they are set up, Discord (discord.com, see Discord) and the storage that copies of backups go to (see Copies somewhere else). While Discord is connected, the agent fetches the version lists of the server types it runs a minute after it starts and then every 12 hours, to post about new Minecraft versions. A joined machine keeps one connection open to its dashboard (see Other machines). From 0.4.4 the installer and the agent send anonymous usage stats to stats.playkeeper.io unless they are turned off (see Usage stats), and from 0.4.18 the agent asks it to check a server's port from the internet: by itself while usage stats are on, otherwise only when someone asks (see Checking from the internet).

Server types, modpacks and templates (from 0.4.0). Vanilla, Fabric, Quilt, NeoForge, Forge and Purpur servers get their software from their projects' own hosts (piston-meta.mojang.com and piston-data.mojang.com, meta.fabricmc.net and maven.fabricmc.net, meta.quiltmc.org and maven.quiltmc.org, maven.neoforged.net, maven.minecraftforge.net and files.minecraftforge.net, api.purpurmc.org), refuse redirects to any other host, check every file against a checksum its project publishes before it is used, and write it through a handle confined to the server's folder, so a link swapped into that folder cannot lead a write outside it. Purpur publishes only an MD5, from the same server as its jar. NeoForge's and Forge's installers, once checked, run in a setup-only container and download the libraries their profile lists themselves; Playkeeper then checks each against the SHA-1 inside the verified installer. Nobody publishes a hash for the Minecraft jar NeoForge's installer patches, so Playkeeper records its hash after the install and checks it before every start; Forge's must match the SHA-1 inside its installer. Modpacks come from Modrinth and, once a CurseForge API key is added in Settings › Add-on sources (kept in /var/lib/playkeeper/agent/curseforge-key, root only) or the release carries one (the CURSEFORGE_API_KEY repository secret, built only into the release the Release check builds and a tag publishes), from CurseForge, with each file checked against the hash its source publishes. A Modrinth pack that ships Missing Mods Checker, a mod that asks players to download the pack's mods only CurseForge offers, gets those mods from CurseForge the same way instead, each checked against CurseForge's SHA-1, and the checker stays off the server. Packs often don't say which of their mods are for the game client only, so planning a pack sends Modrinth the SHA-1s of its mods, in one request, and leaves off the ones Modrinth lists as client-only unless another of its mods requires them; nothing else about the server goes with them. CurseForge is reached only at api.curseforge.com, with files only from edge.forgecdn.net and mediafilez.forgecdn.net; a Modrinth pack may also name files on github.com, raw.githubusercontent.com and gitlab.com, as its format allows, redirected at most to objects.githubusercontent.com or release-assets.githubusercontent.com. Every download is HTTPS to those hosts only, redirects included. A pack may put at most 20,000 files on the server (5,000 before 0.4.3), and each of them, like each add-on file, at most 512 MiB (256 MiB before 0.4.3). The key built into a release is readable by anyone who has the binary, so it tells CurseForge that a request comes from Playkeeper, not from which machine. Only an admin of all servers can add or remove a machine's own key through the panel (the dashboard offers it to the owner only), which replaces the built-in one; the dashboard shows only its last four characters, and an empty key file turns CurseForge off on that machine. A modpack may suggest gameplay settings, never the port, RCON, the allowlist, online mode, operator or function permission levels, or where the world is; the agent writes them into server.properties through internal/gamefiles, so a link planted there stops the install with a message naming the file and is never read or written through. A pack's default-server.properties, which the Default Server Properties mod would swap in for all of server.properties on the first start, is read the same way and never put on the server. For one already on a modded server, the agent puts its gameplay settings into server.properties before the first start and writes the mod's marker, so the mod leaves the console, the allowlist and Playkeeper's other settings alone; a server the mod had already started without them is restarted once after the update. A template holds names, versions, checksums, web addresses and settings for the new server, never files, code, worlds, players or secrets, the CurseForge API key included: its plugins and mods install through the add-on library; a modpack it names comes from Modrinth or, with the importing machine's own CurseForge key, from CurseForge, is created only when the template's pin (the SHA-512 of a .mrpack, the SHA-1 of a CurseForge zip) is the file its source offers for that version, and installs like any other pack, CurseForge's download rules included; and the data packs it names are downloaded before the new server's first start from whatever website the template names, but only over HTTPS to a public address on port 443. The address is checked after DNS resolution (loopback, private, link-local, CGNAT, cloud metadata, documentation and other reserved ranges are refused), never through a proxy, every redirect is held to the same rules, and a pack is kept only if it matches the template's checksum. A template's resource pack is left out. A template names no one: it carries only the day it was made, which is only shown, never trusted, since anyone can edit a file. A template's share link (https://playkeeper.io/t#…) carries it after #, which browsers don't send to playkeeper.io.

Voice chat (from 0.4.0). Installing Simple Voice Chat asks first, then publishes one UDP port from that server's container (24454, or the next port above it that no server or program on the machine uses) and writes it into the add-on's settings through a handle confined to the server's folder. Nothing else is published, RCON included. Docker's published ports bypass ufw, so the machine needs no firewall rule; a firewall at the hosting provider is left to the owner, which the dialog says. A template that lists it says so, with the port, before the server is created, and the new server gets the port at its first start. A modpack that brings it names the port in its preview, and the port opens only if that is accepted when the server is created. A backup records the port: restoring it keeps the port the server has, or gives it, or a new server, the backup's port or the next free one above it. Removing the add-on drops the port, so the container is made without it at the next start.

Crossplay (from 0.4.4). Off unless it's turned on, on a Paper or Purpur server. Turning Bedrock players on in a server's Settings asks first, then installs Geyser from Modrinth (GeyserMC marks every build there a beta, so it is installed and updated as one) and Floodgate from its Hangar listing, which links to GeyserMC's download server instead of a file: the agent asks GeyserMC's API (download.geysermc.org) for the newest build, pins the download to that build and checks the file against the SHA-256 GeyserMC publishes for it, and files from that host are accepted for Geyser's and Floodgate's Hangar listings only, redirects included. It publishes one UDP port from that server's container (19132, or the next port above it that no server or program on the machine uses, voice chat's included) and writes it into Geyser's config through a handle confined to the server's folder, again before every start. Geyser listens on every address inside the container and lets Bedrock players in only through Floodgate, whose key stays in the server's folder; it checks their Microsoft sign-in as it does by default, and is set not to log their IP addresses. Floodgate's usage statistics are off, like Paper's. Bedrock players have no chat signing key, so a server with crossplay has enforce-secure-profile off. The plugins contact GeyserMC themselves: Floodgate looks a gamertag up at api.geysermc.org when one is added to the allowlist, and Geyser and Floodgate use GeyserMC's global API for Bedrock skins and linked accounts and check for newer Geyser builds; Geyser also downloads the Minecraft client jar from Mojang to read its resources. Nothing else is published. A template leaves crossplay out. A backup records the port, and a restore gives it back the way voice chat's is. Turning crossplay off removes both plugins, keeping their settings and Floodgate's key, and closes the port.

Sleeping servers (from 0.4.0). Sleep is off by default. When it is on and a server has had no players for its idle time, the agent stops the server and listens on its game port itself, on every address, like the port Docker publishes. This stand-in answers Minecraft's status ping with the server's name, icon, version and player limit, and reads a join attempt only as far as the player's name, with at most 64 connections, 8 from one address (an IPv6 client by its /64), 10 seconds each and small size limits on every packet. A banned name never wakes the server; with the allowlist off any other name does, and with it on only a name on the allowlist or an operator's. If server.properties or the ban list can't be read, the allowlist rule holds. Wakes happen at most once in 2 minutes and 6 times an hour, and after a failed start the agent waits 5 minutes, doubling up to an hour. The stand-in can't check who a player is, so a name is only a claim; once the server runs, it checks players as usual.

One-line installer. https://playkeeper.io/install is a short script (site/install.sh), served by the nginx container in site/, that downloads https://github.com/CIYAhq/playkeeper/releases/latest/download/get.sh over HTTPS and runs it, telling it the install came through playkeeper.io (and the channel code of /install/<code>) for the usage stats; after each release the release workflow checks that it runs the new get.sh. Whoever controls that server or the domain's DNS controls what it runs, so the GitHub URL, which skips it, is the one to use if you would rather trust GitHub alone. get.sh refuses plain HTTP (redirects included) unless PLAYKEEPER_ALLOW_HTTP=1 is set for a local test mirror, refuses a .sha256 over 1 MB or a tarball over 200 MB, and runs nothing from the download until the tarball matches its published .sha256, which must name that tarball. Because the checksum comes from the same GitHub release as the tarball, this catches corrupted, truncated or wrong files, not a compromised repository or release. To guard against that, read the scripts before running them (curl -fsSL https://playkeeper.io/install, then get.sh at the address it names) and compare the tarball's SHA-256 with one you obtained another way, or use the tarball steps in the README.

Usage stats (from 0.4.4). The installer and each machine's agent send the anonymous reports README.md lists, field by field, to the project's stats service at stats.playkeeper.io (code in cmd/playkeeper-stats and internal/usage/service; deployment in services/stats): the installer as it installs, after it has said so and never for a plan that was declined, and the agent a minute after it starts, right after the dashboard's first account is made and right after the machine's first server comes online (each once, from 0.4.16), the first time someone plays on one of its servers and the first time a second, different player does (each once, from 0.4.18), and 12 hours to 12 and a half after each one. The root agent sends them, not the panel, whose PUT /api/usage-stats needs a session, its CSRF token and the rights to manage the machine, and passes only on or off to each machine's agent. It also tells the dashboard's own agent when setup makes the first account, over the agent's socket alone, which no machine link carries (POST /v1/usage-stats/first-account): that only has the heartbeat sent at once, the first time, and changes neither what it says, nor where it goes, nor whether it's sent. Where reports go comes only from stats.playkeeper.io or root's /etc/playkeeper/config.json (statsURL). Every field has a fixed form that the client checks before it sends and the service checks again (internal/usage), so no host name, path, address or other text can travel in one; requests go over HTTPS (plain HTTP only to this machine, for tests), follow no redirect, send no cookie, and wait at most 10 seconds. The install's ID is 128 random bits made for usage stats alone, unrelated to the install ID that backups and Docker labels carry. DO_NOT_TRACK=1 or PLAYKEEPER_USAGE_STATS=off when installing, or in the agent's own environment, turns them off, and root's choice outranks the switch in Settings; playkeeper dev never sends. The service keeps each ID with what its reports said, to the hour, and the days it ran, for 400 days; it uses a request's address only for its rate limits, in memory, keeps no access log and drops net/http's own log lines, and CI checks that its files and log hold no client address (scripts/stats-check.sh). It holds no credential but the token that opens its counts, and runs apart from the names service and its Cloudflare token.

Checking from the internet (from 0.4.18). A server's Overview shows whether friends can reach its port, which a provider's firewall decides and the machine can't see. The panel's POST /api/servers/{id}/internet-check needs a session, its CSRF token and the rights to start and stop the server; the server's agent then sends the stats service POST /v1/reach with the port alone, no ID, through the same client as the usage stats. The Overview asks by itself only while usage stats are on, and the agent then asks at most hourly; with them off, only someone's Check from the internet asks, at most every 15 seconds. A test install and playkeeper dev never ask stats.playkeeper.io. The service connects back to the address the request came from (the one its rate limits use, which only its trusted proxy can name), only when that is a public address, never a private, loopback, link-local, carrier-grade NAT or NAT64 one, and only on 25565 to 26564, the ports Playkeeper gives servers: a TCP connection within 5 seconds, then a Minecraft status request, of whose answer it reads only enough to see that a Minecraft server answered, so never the players or the description. It refuses a request with any other field, runs one check at a time for an address (5 at once, then 10 an hour) and 64 in all, and keeps nothing of a check; scripts/stats-check.sh checks in CI that it connects back to the address asking, refuses other ports, hosts and private addresses, and leaves no address in its files or log.

The RHEL family and Amazon Linux. AlmaLinux, Rocky Linux, Oracle Linux, RHEL and CentOS Stream ship Podman rather than Docker, so a missing Docker comes from Docker's own repository (download.docker.com, its CentOS build, or RHEL's on RHEL, for the system's major version). The installer adds it as /etc/yum.repos.d/playkeeper-docker-ce.repo with gpgcheck=1 and the repository's signing key (Docker Release (CE rpm), 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35) written from the installer itself to /etc/pki/rpm-gpg/playkeeper-docker-ce.gpg, so dnf installs only packages Docker signed and no key is downloaded; both files, and the key once dnf has imported it, go when Playkeeper removes Docker. Amazon Linux 2023 gets its own docker package. The installer won't put Docker next to podman-docker or an installed runc, which Docker's containerd.io would replace, or take a Docker socket that answers as Podman; it never lets dnf erase a package to make room, and removing Docker removes only the packages the install added that nothing else needs, which rpm checks first. With firewalld on, the installer opens its ports in the zone of the interface with the default route, saved and running, and records the ones it added; uninstall removes those, and the docker zone and docker-forwarding policy Docker adds to firewalld when it first starts. SELinux stays as it is: systemd runs Playkeeper's services, like any program in /usr/local/bin, as unconfined system services, and when Docker labels containers for SELinux (selinux-enabled) each server's two bind mounts (three for a server with a secrets folder, from 0.4.9) get :z, so Docker relabels only that server's data folder, its RCON password file and its secrets folder for containers.

Updates (from 0.2.0). Every release carries playkeeper-release.json: its version, date and notes, and for each tarball (x86_64 and 64-bit ARM) its size and SHA-256 and the SHA-256 of the playkeeper binary inside it. An installed Playkeeper takes the entry for its own CPU and ignores the others. The release workflow signs it with Ed25519 (playkeeper-release.json.sig) using the private key in the PLAYKEEPER_RELEASE_SIGNING_KEY repository secret, and stops unless that key's public half is in internal/update/release.pub, which is compiled into every build. An installed Playkeeper installs a release only if its manifest verifies against the keys compiled into the installed version, it is newer than the installed version, and the download matches the manifest; the binary is taken from the tarball by name and checked against its own SHA-256. The agent checks all of this before it hands the release to the updater, and the updater checks it again before installing. Nothing from a download runs before these checks pass. Unlike the one-line installer's checksum, the signature also protects against a changed release or download location: a forged update needs the signing key. From 0.4.9 installs learn of a release from playkeeper.io's copy of the latest manifest and signature (cmd/release-mirror, which serves them only once they verify), and the tarball still comes from GitHub, checked against the manifest GitHub serves: whoever controls playkeeper.io can keep an install from hearing of a release, or make it hear late, but can't get anything installed. The updater keeps a copy of the replaced version, including its databases (which hold the accounts' password hashes and, from 0.4.0, their two-factor secrets and the other secrets listed under Data), in /var/lib/playkeeper/agent/update/previous/ (root only) until the next update. The one-time upgrade from 0.1.0, which has no updater, goes through the one-line installer and its checksum.

Copies somewhere else (from 0.4.0). Backups leave the server only when someone who may hold backup keys turns copies on, to S3-compatible storage over HTTPS or to another machine over SFTP, and only encrypted on the server with age (X25519) to the server's own keys. The S3 secret key, the SFTP password or the SSH key Playkeeper made, and the keys that open the copies stay in the agent's database (0600 root); the browser only learns whether a secret is set. Copies never connect to link-local, multicast, unspecified or cloud metadata addresses, and an SFTP machine's host key is confirmed by its fingerprint once; if it changes, copies stop until someone who may hold backup keys looks at it. Host keys must be Ed25519, ECDSA or RSA of at least 2048 bits, and the SSH key Playkeeper makes is Ed25519, with a line for the other machine's authorized_keys that starts with restrict, which turns off forwarding and terminals. Only the owner, or an admin whose two-factor sign-in is on and confirmed, may change where copies go, delete copies, and see or download the recovery key; one function, mayHoldBackupKeys in internal/panel, decides all of these. Restoring on a new machine from a recovery key also needs every server, as creating a server does. Recovery key responses are sent with Cache-Control: no-store, and each download, and each refused request, is in the audit log. The recovery key file holds the server's private keys and the folder its copies go to, never the storage's credentials, and is also an age identity file, so the age tool opens copies with it. A new key encrypts new copies only, and the old keys are kept, so an old file still opens older copies: if a file was lost or seen by someone else, make a new key, copy the backups again and delete the older copies. Restoring on a new machine reads the copies with the key file the user picks and saves neither the key nor the storage details.

Other machines (from 0.4.0). An admin of all servers can connect more machines to a dashboard in Settings › Machines. The dashboard makes a join code (XXXX-XXXX, 40 random bits, working once for 30 minutes) and a command with its address, the code and its key's fingerprint (128 bits of SHA-256). It keeps only a hash of the code keyed with its private key, and after 5 wrong codes from one address (an IPv6 client by its /64), or 20 from everyone, in 15 minutes, it pauses joining. Each side makes its own Ed25519 key: the dashboard's is /var/lib/playkeeper/panel/link.key, a joined machine's /var/lib/playkeeper/link/machine.key, both 0600 in a folder only their user can open, and refused if other users can read or change them. The machine dials the dashboard's panel port with TLS 1.3 and the protocol name playkeeper-link/1, and checks the dashboard's key against the fingerprint before it sends anything, the code included; after that, each side accepts only the other's key, and certificate names and dates play no part. Over that connection the dashboard sends the machine's agent the same requests it sends its own, as HTTP/2: only routes in the agent's route table, with clean paths, checked on both sides, with the account, token or schedule that asked in a header the agent audits. The machine only answers: it can't ask the dashboard or other machines anything, and it opens no port for the link. Requests that aren't streams stop at 1 MiB, and their answers at 16 MiB or after 60 seconds without progress. The streams are logs, backup downloads, uploads other than a resource pack's, since packs stay with the dashboard's machine, and deleting an off-site copy, which waits up to two minutes on its store. If two machines say they run the same server, the dashboard sends requests about it to neither. Removing a machine stops its key working at once, and its servers keep running there, out of the dashboard's reach; playkeeper leave disconnects a machine from its side. A joined machine takes customers only once the owner confirms it's theirs on its page in Settings › Machines, which shows where it joined from and its fingerprint first, so a join code that leaked can't pull customers onto a stranger's machine. Confirming keeps servers away from the machine first (see Network guard) and fails when it can't; stopping it sends new customers elsewhere. Both are the owner's alone, and both go in the audit log and the machine's events. From 0.4.9, while the owner's read-only Hetzner token watches stock, a joined machine that is connected from one of the servers in their Hetzner project (its IPv4 address, or an address in its IPv6 /64, as Hetzner lists them) is confirmed by the dashboard the same way, the Hetzner server's name standing for the owner in the log and events. A machine the owner stopped is never confirmed that way again, and one connected from anywhere else waits for the owner. From 0.4.14 a confirmed joined machine, whether the owner or the Hetzner token confirmed it, gets the dashboard's Discord webhook over the machine link and stores it readable only by root, in its agent's database (0600 root). It uses it only to post that the dashboard can't be reached, once its link has been down for 5 minutes, and that it's reachable again. The dashboard clears it there when Discord is disconnected or the machine stops being confirmed, and a machine that leaves its dashboard, that the dashboard removes, or that joins a dashboard again forgets it, as it's kept with the join it came with. The dashboard's panel reads the webhook from its own agent's socket, which no machine link carries, only to pass it on. From 0.4.9, an agent gives a server an id it's told only when the dashboard moves a customer's server in from another machine: from an upload made as for any new server and checked the same way, never with an id one of its servers has or has files for, and only with the Minecraft EULA acceptance the server had there. None of the dashboard's pages reach that route, and a restore they apply always gets a new id and refuses a request naming one, so no account can give a server another server's id. A move carries the server's whole folder, so extra worlds, plugin data and uploads go with it: the machine it's on sends it only to the dashboard and only while the server is stopped, without the files holding the RCON password (server.properties loses its secret lines, as in every backup), the EULA file or what's downloaded again, and a restore refuses such an upload, so only a move-in takes it. What the agent keeps about the server goes the same way, from one machine through the dashboard to the other and never to a browser: its sleep setting, schedules, public page and links, its own address (left out when it doesn't fit the machine it goes to), and its copies somewhere else with their credentials and keys, so those still open. None of the dashboard's pages reach these routes either. Before any of a customer's servers stops, each is checked against what a move carries and for the room it takes on the other machine's disk, sparse files and links in full, and the move is refused naming the server or the room it lacks, so one server can't stop the rest part way or fill that disk. Only the owner moves a customer, and only to a machine that takes customers, has room for their plan and keeps servers away from itself; so only the owner removes a machine customers are on, which places them again. Each of their servers stops, its folder goes to that machine and is made into the server there only if it arrived unchanged, started only if it ran and its customer isn't paused or suspended, and counting against the customer's disk limit there before any request reaches it; then the machine it left deletes it, keeping its whole folder for 7 days as its final backup, or none when that machine hasn't the room, since the folder is where the server went. A customer whose plan ended is moved only while a move of theirs has stopped, and their servers are deleted only once they're on one machine, which keeps their final backups; one left on a removed machine is out of reach, and no final backup is promised for it. While a customer's servers are on two machines, they can't make a server or give one more memory, and each machine limits them to what their servers on the other leave of their plan's disk. While a server moves, no request reaches it, from any account, token or AI agent, and its customer is told only that it's being moved. The copy being made, and each copy a move leaves until it's deleted, never counts as the server in its machine's list, nor as that machine listing another machine's server, and gets no address without a port. A move that fails leaves the server where it was, and one a restart stopped carries on only with the copy it made. Updating a machine from the dashboard makes that machine install the latest release with its own checks and keys (see Updates).

AI agents (from 0.4.0). The panel serves Playkeeper's tools to AI agents at /mcp (the Model Context Protocol over HTTP). Only an API token in the Authorization: Bearer header counts there, never a cookie, and requests from web pages (with an Origin header) are refused, so a website can't use a signed-in browser to reach it. Each account makes its own tokens in Settings › AI agents: pk_mcp_ followed by 24 random bytes, shown once and stored only as a SHA-256 hash, with the rights of a Viewer, Moderator or Admin on every server or up to 100 of them, for 30, 60 (the default), 90 or 365 days, and at most 20 working tokens per account. A token never does more than its account may do now: a token for more is refused when it is made, and its rights are looked up again with its account's on every request and tool call. Removing an account from the team revokes all its tokens; lowering its role below the one it had when a token was made revokes that token, even one made for less; and a token for some servers is revoked once its account loses any of them, while one for every server follows the account's servers. A change on the Team page applies this at once and ends those tokens' sessions; otherwise the next check does, at the latest when the token is next used. Revoking a token ends its sessions at once, and resetting an account's password from the command line revokes all its tokens. The owner sees and can revoke everyone's tokens; others see their own.

A token can use 21 tools, each checked against its rights and servers. A Viewer token lists servers and reads their status, the console, who's online, the allowlist, backups, an operation's progress, lag reports and crash explanations, and searches Modrinth and Hangar for plugins and mods; a Moderator token also starts, stops and restarts servers, sends chat messages, edits the allowlist, makes backups and posts or clears the public page's status board (see Public server page); an Admin token also runs a console command (one printable line of up to 256 characters, on a running server), installs a plugin or mod from Modrinth or Hangar through the add-on library, with the same checks as the dashboard, and removes one that Playkeeper installed. No tool creates, deletes or restores servers, uploads worlds, changes settings, opens files or ports, or manages accounts, tokens, Discord, addresses or machines. Tools reach each machine's Playkeeper agent with the same allowlisted requests as the dashboard, and the audit log names the token as it names an account; calls a token's rights don't allow, or over its limits, are logged as refused. The endpoint allows 10 failed token checks a minute per address, 120 read-only and 30 other tool calls a minute per token and 256 KiB per message, keeps at most 16 sessions per token, and never logs arguments. Console lines, chat and player names come from players, so AI agents are told to treat them as data, never as instructions.

sudo playkeeper mcp serves the same tools over stdin and stdout, for an AI agent that reaches the machine over SSH. It has no sign-in of its own: as root it may do everything to that machine's servers, as root could anyway, so a sudo rule for it makes that account an owner. It then takes no arguments and reads nothing from the environment but SUDO_USER, to name the account in the activity log.

Supply chain. The runtime images (one per Java version) are pinned by digest; every Paper jar is checked before its first run against the SHA-256 that PaperMC's Fill API publishes for the build (servers created by 0.1.0 keep the checksums pinned in that release), and the other server types, add-ons, modpacks and a template's data packs against the hashes their sources publish (see Server types, modpacks and templates); releases are signed as described above; contributor toolchains are pinned by checksum; CI actions are pinned by commit SHA. Nothing proprietary is shipped (see docs/THIRD_PARTY.md).

Scans (2026-09-26). govulncheck v1.8.0 (Go 1.27.1): 0 vulnerabilities affecting Playkeeper (one advisory for the unmaintained golang.org/x/crypto/openpgp, which is not imported). npm audit for the UI: 0 vulnerabilities.

Known limitations.

  • At the IP address the certificate is self-signed. The installer tells people to click through the browser's warning, and prints the certificate's fingerprint for anyone who compares it on the first visit; whoever skips that comparison can't tell the dashboard from someone intercepting the connection. Only a name (a free one or an own domain, from 0.4.0) gets a publicly trusted certificate.
  • The two-factor secret is stored unencrypted in the panel database (/var/lib/playkeeper/panel/panel.db, readable by the panel user and root), since a key kept on the same host would not protect it: anyone who can read that file or a copy of it can make codes.
  • Free names rely on the names service and its operator, whose Cloudflare token can edit every free name in the playkeeper.me zone (see Free names above).
  • The RCON password is also present in the server's server.properties in its data folder (readable by the game user and root), because the Minecraft server reads it from there.
  • The Files tab shows admins every file in a server's folder, secrets included (the RCON password, plugins' tokens and passwords), and lets them upload plugins and mods, which run code on the server.
  • A server's AI key is readable by the game's user inside its container, as AI Build Battle must read it, so any plugin or mod on that server can read it too, and anyone who can play on the server can spend the owner's OpenRouter credits with /aibuild as the plugin allows. A spending limit on the key at OpenRouter bounds what that costs.
  • The Files tab knows a world by the server's level name or a level.dat in a folder at the top of the server's folder; a world a plugin keeps deeper, such as in a world container folder, can change while the game runs. On a file system that can't rename without replacing (RENAME_NOREPLACE), such as a network file system, a move or upload checks the name just before the rename instead, so a file a plugin writes in that instant can be replaced. Uploads of every server share the space uploads may use, so an admin of one server can use it up until their uploads finish or sit idle for an hour.
  • The network guard keeps servers from link-local addresses and, while its switch is on, from the machine they run on, and nothing more. A server can still open connections to other machines on the machine's private networks, such as a provider's private network or a home LAN, and to the internet.
  • PLAYKEEPER_E2E_OFFLINE_MODE_UNSAFE=1 lets anyone join under any name. It exists only for automated protocol-bot tests; the installer never sets it and the UI shows a permanent red warning when it is on.
  • Backups never contain a server's software (the Paper or Purpur jar, or a mod loader and its libraries), so a restore downloads it again, and NeoForge and Forge run their installer again: restoring needs outbound HTTPS to that type's hosts and Mojang.
  • Installed versions trust the release keys compiled into them, and there is no online revocation. Replacing the key takes two releases: one that lists both the old and the new key in internal/update/release.pub (signed with the old key), then one signed with the new key that drops the old one. A leaked key stays usable against every installation that has not installed that second release.
  • The Paper versions offered depend on PaperMC's Fill API: Playkeeper only offers builds that list a SHA-256, but it trusts PaperMC for which build is stable and for the checksum itself. The other types' versions and checksums come from their projects in the same way; Purpur publishes only an MD5, from the same server as its jar, and nobody publishes a hash for the Minecraft jar NeoForge's installer patches, so Playkeeper trusts the one built at install.
  • Anyone with a shared map's link sees the whole drawn world, builds included, until sharing is switched off; hiding players hides only where people are. Until the machine's name has its own certificate, friends who open the link see their browser's warning about the panel's self-signed certificate, with no easy way to check its fingerprint.
  • Automatic restarts give up after three failures in 15 minutes (crashes or failed starts) and say why; a start the user asked for that fails is not retried until they press Start again. From 0.4.9 they give up at the first failure whose cause the crash help knows repeats at every start: something in the world that crashes each time it's ticked, both of a world's level.dat files damaged, or a mod made for players' games. The count starts over when the agent restarts, for example after a reboot: a server that should be running is tried again, and if the cause is still there, Playkeeper gives up again and says why.
  • An API token works without a password or two-factor code, so anyone who has one can use it until it runs out or is revoked. An Admin token runs console commands, installs plugins and mods, which run code on the server, and removes those Playkeeper installed.
  • A dashboard controls the servers on every machine joined to it as it controls its own, so whoever takes over the dashboard can do to them whatever it can, though no more than each machine's agent allows.
  • The CurseForge key built into a release can be read from the binary by anyone. If CurseForge withdraws it, CurseForge modpacks stop working on machines without their own key.
  • Copies of backups open only with the server's keys: if the machine and the recovery key file are both lost, the copies can't be restored. Anyone who has the file and can read the storage can open them.
  • Anyone who knows the name of a player on a sleeping server's allowlist, or of one of its operators, can wake it, up to 6 times an hour; with the allowlist off, anyone whose name isn't banned can.
  • Anyone who has the Discord webhook URL can post in its channel, and everyone who can read the channel sees what Playkeeper posts there: server addresses and, while the live status or those alerts are on, player names.

There aren't any published security advisories