Self-hosted multiplayer for Blocksmith, a from-scratch Minecraft-like for the Nintendo 3DS. Everything runs on your own box — there is no third-party game server, and no third-party service ever sees game traffic in the clear.
Security is the design centre of this repository, not an afterthought. Every choice below — no inbound port, default-deny egress, a stateless cookie in front of any crypto work, a sandboxed systemd unit with an empty capability set — exists because this is meant to sit on a home network and be forgotten about, which is exactly the box that becomes a problem if it isn't locked down first.
Something has to be reachable from the internet for remote friends to connect. The usual answer is a router port forward — exposing your home IP, hoping CGNAT doesn't get in the way, and leaving a hole open indefinitely.
This does the opposite. The playit.gg agent runs
inside the container and dials outbound to playit's infrastructure.
bsgate, the process that actually terminates game traffic, binds
127.0.0.1 only — it has no public address at all. The consequence:
- No port forward. Your router configuration never changes.
- No DMZ.
- CGNAT is irrelevant. Nothing is inbound, so it doesn't matter whether your ISP's WAN address is shared.
- Your home IP is never exposed. Friends connect to a playit-assigned address; traffic terminates at playit's edge and only reaches your house because the agent inside the container dialed out to fetch it — the same direction any browser tab on your LAN already talks.
3DS client (Wi-Fi)
|
| UDP — Noise XX encrypted, Blocksmith wire
| protocol (proto/bs_proto.h)
v
playit.gg edge / relay
^
| outbound tunnel, DIALED FROM INSIDE the LXC —
| nothing ever dials in
|
+-------------- Proxmox LXC (unprivileged) -------------------+
| |
| playit agent (systemd: playit) |
| | |
| | loopback UDP, PROXY protocol v2 header prepended |
| | (carries the player's real address through) |
| v |
| bsgate (systemd: bsgate) |
| binds 127.0.0.1:<game port>/udp — no public address |
| | |
| | plaintext application bytes, /run/bsgate/game.sock|
| | (unix datagram socket) |
| v |
| bsgame (systemd: bsgame) |
| authoritative game logic — validates every edit and |
| position update, rebroadcasts at 10 Hz |
| |
+---------------------------------------------------------------+
bsgate does no game logic; bsgame never touches a network socket. That
split means the only process facing the network is small enough to read end
to end, and a bug in game code can never be reached from the internet
directly — it can only be reached through bytes bsgate already decrypted,
authenticated, and matched to a known friend's key.
- A Proxmox VE host, with
pctandpvesmavailable, run as root. - A Debian 12/13 container template (the installer downloads one via
pveamif none is cached). - A network bridge for the container (default
vmbr0). - Outbound internet access from the container for: Debian's package
mirrors,
github.com(the pinned libhydrogen commit is fetched at build time — seegateway/Makefile), and the playit.gg apt repo + tunnel service. - A free playit.gg account, to approve the agent claim in a browser.
On the Proxmox host, as root, from inside this server/ directory:
./install/proxmox-lxc-install.sh --ip 192.168.1.50/24 --gw 192.168.1.1Other flags (all optional beyond --ip/--gw, which are strongly advised
over DHCP):
| Flag | Default | Meaning |
|---|---|---|
--nameserver ADDR |
host's resolvers, else --gw |
DNS for the container — see below |
--ctid N |
next free id | container id |
--hostname NAME |
blocksmith-gw |
container hostname |
--storage NAME |
autodetected | rootfs storage |
--bridge NAME |
vmbr0 |
network bridge |
--disk GB |
4 |
root disk size |
--memory MB |
512 |
RAM |
--cores N |
1 |
CPU cores |
--port N |
41234 |
game UDP port |
--skip-playit |
off | don't install/claim playit; bsgate stays LAN-reachable only |
--no-start |
off | create but don't start the container |
A static --ip gives the container an address and a route and nothing else —
no resolver. Proxmox only copies the host's DNS settings when the host has
usable ones to copy, and a node whose /etc/resolv.conf points at a local stub
(127.0.0.53, systemd-resolved, dnsmasq) has nothing meaningful to hand
over. The container then boots with no DNS at all.
The installer now works one out for you — the host's own non-loopback resolvers
first, the --gw address second — and verifies it before doing anything that
depends on it. If your setup needs something else:
./install/proxmox-lxc-install.sh --ip 192.168.1.50/24 --gw 192.168.1.1 --nameserver 1.1.1.1This is called out because of how the failure used to present. Provisioning runs
apt-get with -qq and its output discarded, so a container with no DNS showed
a bare -> installing packages line and then nothing for minutes, eventually
followed by W: Failed to fetch http://deb.debian.org/... Temporary failure resolving. It reads like a hung install rather than a missing setting. Both
halves are now checked explicitly: the host-side script refuses to continue if
the container can't resolve deb.debian.org, and the container-side script
refuses to continue if apt can't see build-essential after an update.
This creates an unprivileged Debian LXC (--unprivileged 1, nesting=0,
no device passthrough), copies the source in, and runs
install/container-provision.sh inside it, which:
- Installs packages (
build-essential git ca-certificates nftables curl gnupg unattended-upgrades apt-listchanges) and enables unattended security upgrades. - Installs and starts the playit.gg agent (unless
--skip-playit). It does not claim it — see below. - Creates the
bsgateandbsgameservice accounts and the sharedbsgamegroup. - Builds
bsgateandbsgame, runs both test suites, and refuses to install either if its suite fails (make -C gateway test,make -C game test). - Runs
make -C gateway install-checkandmake -C game install-checkto confirm the built binaries actually have PIE, RELRO/BIND_NOW, and a non-executable stack — not just that the compiler flags were accepted. - Installs both as sandboxed systemd units (
systemd/bsgate.service,systemd/bsgame.service) and enables them. - Installs the nftables ruleset described below, loads it, and refuses to
enable it if
nft -crejects it.
If it fails partway — a test suite failing, a hardening check failing, an invalid nftables ruleset — the script stops rather than installing something that can't prove it's safe.
Neither of these is scriptable, and the installer says so rather than pretending otherwise.
-
Claim the agent. The installer installs, enables and starts
playitd, but leaves it unclaimed. Two reasons, both hard:playit setupis an interactive browser approval that polls a terminal, andpct execgives the provisioning script no tty.- There is no non-interactive alternative.
playitdignoressecret_keywritten into/etc/playit/playit.toml— it starts, logsWaiting for frontend secret provisioning over IPC, and reportsSecret configured: falsewith the file sitting right there at thesecret_pathit prints. The secret only counts if a frontend hands it over via the daemon'sprovision_service_secretIPC call, which is whatplayit setupdoes — andplayit setuptakes no arguments, so you cannot hand it a secret you already hold.
So, on the Proxmox host after the installer finishes:
pct enter <ctid> playit setup # approve the URL it prints, let it finish playit status # expect: Secret configured: true exitNothing reaches the game server until
Secret configured: true. -
Create the tunnel. In the playit.gg dashboard, add a tunnel:
Field Value protocol UDP type proxy-protocol-v2local IP 127.0.0.1local port your --port(default41234)This must be exactly
proxy-protocol-v2, not v1. PROXY protocol v1 on UDP writes no header at all — the agent doesn't even attempt it — and the failure is completely silent on both ends: no error, no connection, nothing to grep for in the logs. It just looks like a dead server. If friends can't connect and everything else looks fine, this is the first thing to check. -
The dashboard shows the public address+port the tunnel assigns (something like
xyz.joinmc.link:12345). That's what friends' 3DS clients connect to — never your home IP. You can also read the agent's own status:pct exec <ctid> -- systemctl status playit pct exec <ctid> -- journalctl -u playit -n 50
-
Get the credentials to bake into the 3DS client build:
pct exec <ctid> -- bsgate-keys identity
Friends are admitted by public key, not by IP or password. Run these inside
the container (pct enter <ctid>, or pct exec <ctid> -- ... from the
host):
bsgate-keys identity # server pubkey + network PSK, for a client build
bsgate-keys list # who is allowed, plus any armed invite
bsgate-keys invite <label> # one-time code — the easy way, see below
bsgate-keys add <64-hex-key> <label> # allow someone by pasting their key
bsgate-keys revoke <label|64-hex-key> # remove them — disconnects them NOW, not at next loginEach friend's console generates its own keypair on first run. The
authorisation model is that console's 32-byte public key appearing in
/var/lib/bsgate/allowlist; there are two ways to get it there.
bsgate-keys invite tomprints something like
Send this to tom:
9K4B2-HMQ7X
They enter it once on their 3DS. Their console is then added to the
allowlist as 'tom' and they can join whenever they like, with no code
and nothing to accept — this is a one-time introduction, not a password.
Send it however you like. They type it into the console once, and their
console's key is written into the allowlist under tom, permanently. They
never need the code again, and you never have to accept anything. From
that moment they are an ordinary allowlist entry — indistinguishable from
one added by hand, and removed the same way, with bsgate-keys revoke tom.
This exists because the alternative asks two devices that share no clipboard to move 64 hex characters between them. A 3DS cannot copy, paste or email its key; reading it off a screen and retyping it is where this actually falls over in practice.
What the code is and is not:
- One use. The first correct entry consumes it. A second friend needs a
second
bsgate-keys invite. - Fifteen minutes, on the wall clock, so a reboot cannot silently extend
it.
bsgate-keys invite --cancelends it early;bsgate-statusshows the time remaining, because an armed invite is the one temporarily-open door on a box otherwise designed around having none. - Three wrong attempts and it is dead, and it says so in the journal.
- Stored hashed. Nothing on the box can print it a second time. Lost it? Arm another; it costs nothing.
- Not a way in on its own. A code is only reachable by a peer that has already completed the full Noise XX handshake, which needs the network PSK from your client build. It admits nobody by itself — it causes a key to be added to the allowlist, and the ordinary allowlist check is what admits them, on that connection and every one after it.
- With no invite armed, nothing changes. An unknown key is dropped and logged exactly as it always was. Enrolment is not a permanently reachable code path; it exists only in the minutes after you deliberately armed a code.
Only one invite exists at a time, and only one console may be part-way through redeeming it — a peer on probation is not a player, holds no slot in the game logic, and is discarded after ten seconds of silence.
If you can get the 64 hex characters off the console some other way, bsgate-keys add <key> <label> still works and is unchanged. add/revoke validate the
resulting allowlist against bsgate's own parser before installing it, then
reload bsgate with SIGHUP — a malformed file is refused outright rather
than partially applied, so a bad edit can't silently lock everyone out or
leave a revoked key working.
pct enter <ctid>
bsgate-statusIt takes no arguments.
This is a command, not a web page, and that was a deliberate choice, not an
oversight — a web UI was considered and dropped. The whole security design
of this box is zero inbound ports (see above), and a web UI is a listener:
a second parser, a session/auth story, and a reason for something outside
the container to talk to it. Running a command over pct enter adds none
of that — it stays inside the same "administer from the Proxmox host" model
as everything else here.
How it works. bsgate-status sends SIGUSR1 to bsgate and bsgame.
Each daemon writes a plain-text snapshot to /var/lib/bsgate/status.txt and
/var/lib/bsgame/status.txt (mode 0640, written to a temp file and
rename()d so a reader never catches a partial file), and the tool waits
for both to refresh before joining them on the session id and printing a
report. A signal was chosen over a query socket on purpose: a signal can
only be sent by root or the process's own uid and adds no attack surface,
where a socket would be another listener with another parser.
What it shows: version and service state for bsgate, bsgame, and
playit; the playit tunnel and whether the agent is claimed; connected
players with their real IP, position, and how long since they were last
heard from; stored block diffs and edit counters (accepted, plus rejections
split into out-of-range, rate-limited, and store-full); allowlist size,
sessions used/max, handshakes in flight, and total dropped packets; and
whether an invite is armed, for whom, and how long it has left.
A console part-way through redeeming an invite is reported separately, as
enrolling now, and deliberately kept out of the player table — it is not a
player, bsgame has never heard of it, and listing it alongside real
players would make a stranger look like a friend for the ten seconds it
exists.
What it does not show, and why. No ping, no latency, no lag figure — the server has no way to measure any of that, because it never solicits a reply from a client, so there is no round trip to time. The "last heard" column is silence, not latency: a player standing still is silent and perfectly healthy. The real ping number is measured and shown by the 3DS client itself.
Once installed, updates are pulled and applied from inside the container — there's no need to re-run the provisioning script.
pct enter <ctid>
updateupdatechecks GitHub for a newer release tag than the one currently installed. If there is one, it builds it and runs the full test suites first — only if both pass does it install the new binaries and restart the services. A failed build or a failed test suite is a no-op: the currently running server is left completely untouched.update --checkreports whether a newer version is available without changing anything.update --versionprints the currently installed version.- If a service fails to come back up after an update, it rolls back to the previous binaries automatically.
/var/lib/bsgate(server identity, the friend allowlist, world block diffs) and/etc/playit/playit.tomlare never touched by an update — your friends, your keys, and your world survive every update.- The update mechanism is pinned to this repository's remote and refuses to pull from anywhere else.
The installed version is tracked in this repository's VERSION file.
Layers a packet must survive, outermost first:
| # | Layer | Stops |
|---|---|---|
| 1 | nftables: zero inbound, default-deny outbound | any network path in except the tunnel the container itself dialed out to open; a compromised process getting free egress |
| 2 | PROXY protocol v2, trusted only from 127.0.0.0/8 |
a forged "real client address" header from anywhere but the loopback hop playit uses to reach bsgate |
| 3 | Stateless cookie exchange | spoofed source addresses — no state is allocated for an unproven address |
| 4 | Token-bucket rate limits (per-IP + global) | a proven-real address flooding handshakes |
| 5 | Network PSK | anyone without a client build — no handshake even starts |
| 6 | Noise XX (via libhydrogen) | passive capture and MITM; gives forward secrecy and mutual authentication |
| 7 | Public-key allowlist | anyone who has a build and the PSK but isn't a named friend — unless an invite is armed, in which case they get one guess at a ~49-bit code and nothing else |
| 8 | AEAD + sliding replay window | tampering, and re-sending a captured packet |
Design points worth knowing, all confirmed against the source:
bsgatebinds loopback only (--listen 127.0.0.1:<port>) and has no egress rule of its own in the nftables ruleset — it only ever speaks over loopback and a Unix socket, so a compromised gateway process has nowhere to phone home to.- Outbound is default-deny too. Only loopback, established/related
connections, DNS,
uid root(for apt), anduid playit(whose control-plane/relay endpoints aren't a small fixed list) get broad egress. - There is no SSH rule, on purpose. Administer the container with
pct enter <ctid>from the Proxmox host — one fewer network-facing service to keep patched. - Rate limiting happens after the cookie check, not before — charging a token before the address is proven would let a spoofed packet drain a real player's bucket.
- The pre-authentication reply is smaller than the request that triggers it (a 40-byte COOKIE reply to a 64-byte HELLO), so the gateway is a net amplification reduction, not a DDoS reflector.
- Revocation is immediate.
bsgate-keys revokedisconnects the peer mid-session overSIGHUP, it does not wait for their next login. - Enrolment never bypasses the allowlist; it writes to it. A correct invite code causes the peer's key to be appended to the allowlist file, which is validated with the daemon's own parser and reloaded before anything treats them as admitted. There is no path that admits a session without an allowlist entry existing, so a crash mid-enrolment leaves either "not enrolled" or "enrolled and allowed", never "playing but not listed".
- A probation session is not a player. It is created after the handshake
but produces no
JOIN, sobsgamenever learns the session exists unless the code checks out; it may send exactly one kind of packet, anything else ends it; there is at most one at a time; and it is swept after ten seconds. - The invite is stored hashed and its strike count is persisted on every wrong guess, so hammering it cannot be reset by restarting the daemon.
bsgametrusts nothing it receives, even though it only ever hears from an already-authenticated session. Every block edit and position update is re-validated server-side; a client sending a structurally malformed message gets kicked, not just ignored.- The systemd units run with almost nothing. Empty capability bounding
set,
NoNewPrivileges, a read-only root filesystem (ProtectSystem=strict), no devices, restricted address families (AF_INET/AF_UNIXonly), and a syscall filter that blocks privileged, mount, debug, and swap-related syscalls.
Known, accepted limits:
- The PSK is extractable from a client build. A 3DS has no secure storage. It keeps the service invisible to opportunistic scanning; it is not a secret from someone holding a CIA. The allowlist is what actually authorises a peer.
- IPv4 only. The 3DS has no IPv6 stack.
bsgate and bsgame talk over a Unix datagram socket
(/run/bsgate/game.sock, group bsgame). Messages are
[1 byte kind][4 byte session id][payload]:
| kind | direction | meaning |
|---|---|---|
1 JOIN |
gate → game | + 32-byte public key + 32-byte label |
2 DATA |
both | plaintext application payload, max 1024 bytes |
3 LEAVE |
gate → game | session ended |
4 KICK |
game → gate | disconnect this session |
bsgame is the authoritative game server: block edits and position updates
arriving over this socket are validated (game/validate.c) before being
applied and rebroadcast at 10 Hz to every other connected player. Accepted
block edits persist to an append-only, magic-prefixed, fixed-16-byte-record
file (game/diffstore.c) so the world survives a restart; on join, a player
receives the full current diff set as a batch (BS_APP_WORLD_SYNC).
proto/bs_proto.h wire format — shared verbatim by the 3DS client,
bsgate, and bsgame
gateway/bsgate.c the transport + authentication daemon
gateway/allowlist.[ch] friend public-key list (and the enrolment append)
gateway/invite.[ch] the single armed one-time enrolment code
gateway/ratelimit.[ch] token buckets
gateway/replay.[ch] sliding anti-replay window
gateway/proxyproto.[ch] PROXY protocol v2 parsing
gateway/bsgate_test.c end-to-end suite against a real bsgate process
game/bsgame.c authoritative game-logic process
game/diffstore.[ch] append-only block-diff store
game/players.[ch] connected-player table
game/validate.[ch] server-side edit/position validation
game/bsgame_test.c host test suite
systemd/bsgate.service sandboxed gateway unit
systemd/bsgame.service sandboxed game-logic unit
tools/bsgate-keys allowlist and invite management
docs/CLIENT-ENROLMENT-SPEC.md
what the 3DS build must do to send an invite code
install/ Proxmox host + in-container provisioning scripts
VERSION version the `update` command checks against
make -C gateway test # builds bsgate + bsgate_test, runs it
make -C gateway install-check # confirms PIE / RELRO+BIND_NOW / NX stack in the built binary
make -C game test # builds bsgame + bsgame_test, runs it
make -C game install-check # same hardening check for bsgamegateway/Makefile's test target fetches the pinned libhydrogen commit
into gateway/.deps/ on first run (see deps), builds bsgate and
bsgate_test, and runs the suite over real loopback UDP against a real
bsgate process — it is not a mock.
Verified, by actually running these:
gateway/bsgate_test.c: 196/196 checks pass.game/bsgame_test.c: 30/30 checks pass.- The enrolment path is covered by six end-to-end cases against a real daemon, and each was proved able to fail: five deliberate mutations (accept any code / never write the allowlist / never consume the invite / drop the one-probation cap / hand out probation with no invite armed) each turn the relevant checks red, 7, 13, 3, 4 and 22 of them respectively, while the rest of the suite stays green.
- A 3DS client transport test suite: 19/19 checks pass, run against a
real, forked
bsgateprocess. - A block-diff-store test suite: 70/70 checks pass.
make -C gateway install-checkandmake -C game install-checkboth confirm RELRO/BIND_NOW, PIE, and a non-executable stack in the built binaries.
Verified on a real Proxmox host (2026-08-19, v1.0.5): the installer ran
end to end on a live node — container created, both test suites green inside
it (PASS 89, PASS 23), bsgate and bsgame both active, both unix
sockets srwxrwx---, bsgate listening on 127.0.0.1:41234/udp with
PROXY protocol v2 expected, trusting 127.0.0.0/8, the playit agent claimed
and online, zero inbound ports. It took five fixes to get there (v1.0.3
through v1.0.5); if you are running an older tag, don't.
Not verified — read this before assuming any of it works:
- No traffic has yet crossed a real playit tunnel.
bsgate --proxy-protocolis running and expecting PROXY v2 headers, but nothing has sent it one over the wire; the PPv2 parser is covered by the test suite only. - No real 3DS console has connected to this server. All transport
testing so far is against a forked
bsgate, not a live pairing between a console and a container. bsgate↔bsgameinterop: both daemons now start and hold their sockets as two different uids on a live container, but no real handshake has been driven through the pair — only the derived wire contract between the two test suites.game/diffstore.c's full-table and torn-record recovery paths have no test coverage.bsgate-statushas been exercised against a live local daemon pair and against hand-written snapshot files, but not on the real Proxmox container and not with a real 3DS connected.- No 3DS can enter an invite code yet. The server side of enrolment is
complete and tested; the console-side entry screen belongs to the client
build and does not exist. Until it ships,
bsgate-keys addis still the only way a real console gets on the allowlist. bsgate-keys invitehas not been run as a different uid. The script drops to thebsgateaccount withrunuserso the invite file is readable by the daemon; that branch cannot be exercised on a dev machine with nobsgateaccount (runuseralso refuses to set groups inside a user namespace), so it is first exercised on the container. If it were wrong, the symptom would be an armed invite the daemon reports asnone.
If you're standing this up for the first time, the honest summary is: the
container, the build, the hardening and the two daemons are proven on real
hardware; the network path — a packet from a console, through playit, into
bsgame — is not.