Skip to content

feat(templates): bitmagnet — DHT crawler behind a WireGuard kill-switch - #142

Merged
Benehiko merged 4 commits into
mainfrom
feat/bitmagnet-template
Aug 25, 2026
Merged

feat(templates): bitmagnet — DHT crawler behind a WireGuard kill-switch#142
Benehiko merged 4 commits into
mainfrom
feat/bitmagnet-template

Conversation

@Benehiko

Copy link
Copy Markdown
Owner

Adds a bitmagnet template: a minimal Alpine VM running the bitmagnet BitTorrent DHT crawler and indexer with its own PostgreSQL database. No Docker, no Compose, fully headless — 2G / 2 CPUs.

Why a template

Crawling the DHT continuously announces the guest to the swarm, so running bitmagnet on a bare host publishes that host's IP address to tens of thousands of peers. Putting it in a VM behind an enforced tunnel is the point.

The guarantee

With WireGuard down, no software in the guest can reach the internet — not just bitmagnet, anything, as any user.

OUTPUT policy is DROP, and the exceptions are pinned rather than opened by port, because a default-deny policy is only as strong as its holes:

Exception Scope
Tunnel -o wg0 — the only unrestricted egress, and only while wg0 is up
Handshake UDP to the endpoint's resolved addresses, not the whole internet on that port
SSH replies --sport 22 and conntrack ESTABLISHED — cannot open new connections
DHCP --sport 68 --dport 67 to the broadcast address only

Both unpinned forms are usable covert channels with the tunnel down. The UDP one was verified to leak before pinning:

covert udp/51820 to 1.1.1.1: LEAK      # bare --dport 51820 ACCEPT

The endpoint is resolved before OUTPUT closes and cached to /etc/wireguard/endpoint-addrs — afterwards there is no DNS left to resolve a hostname with. As a second layer, bitmagnet's own traffic is rejected on any interface other than wg0 by group ownership, placed after the wg0 accept so tunnelled traffic is unaffected.

Verified on a live guest with wg0 down:

plain https (root):        blocked
plain dns (root):          blocked
http from bitmagnet user:  blocked
covert: sport 22 outbound: blocked
covert: udp/51820 to other:blocked
allowed: udp/51820 to ep:  permitted (expected)

SSH survived throughout.

No VPN → no crawling

Declining the VPN disables the crawler rather than warning about it — the damage is done long before anyone reads a log line. Both dht_crawler.enabled and the --keys=dht_crawler worker key, since either alone still joins the swarm. Verified on a real boot: no UDP socket bound on 3334, zero peer connections, zero torrents indexed, while PostgreSQL and the web UI ran normally with all 226 tables migrated.

Web UI

Port 3333 gets no firewall hole from any source. Reached over vee tunnel.

Index outlives the VM

--pg-data-dir <host dir> bind-mounts a host directory as PostgreSQL's data directory over virtiofs. Verified end to end: crawled 169 torrents, destroyed the VM, recreated it against the same directory — index intact, initdb correctly skipped.

Guest-environment findings

Three non-obvious things, all found by booting rather than by tests:

  • virtiofs blocks the guest chown. virtiofsd runs unprivileged, so chown postgres on the share fails with EPERM and initdb then refuses the directory, leaving a dead cluster. The guest's postgres account is renumbered to the host owner instead — and because that UID is usually 1000, already taken by Alpine's login user, the occupant is moved aside first.
  • /etc/conf.d values are not exported. They are shell variables sourced by the init script, so bitmagnet never saw the credentials, fell back to its defaults, connected as postgres with no password and panicked on every start — while OpenRC reported started. bitmagnet also locates config.yml relative to its working directory with no flag to override it, so the service now sets a working directory holding a 0600 config, and is supervised so a startup race with PostgreSQL respawns.
  • A runcmd starting with [ invalidates all of cloud-init. It parses as a YAML flow sequence, breaking the whole user-data document; cloud-init discards every module and the guest boots with no packages, no SSH keys and no services, leaving one warning line in the serial log. TestBitmagnetUserDataIsValidYAML renders the real cloud-init and parses it — it caught a second instance (cut -d:) during development.

Testing

15 unit tests covering kill-switch narrowness and ordering, crawler gating, the initdb reuse guard, UID adoption, loopback-only Postgres, and cloud-init YAML validity. Each security-relevant test was confirmed to fail when its protection is removed. Lint clean, full suite passes, Windows cross-build OK.

Not covered

The --wg-conf first boot — where wg-quick up wg0 runs behind OUTPUT DROP with the handshake pinned to a resolved address — has not been watched end to end; no WireGuard config exists on the dev host. If endpoint resolution ever returns nothing the tunnel cannot come up and the VM stays reachable by SSH but silent, which is the safe direction, but it is worth watching on a first real create.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WcuEg4e4xzsUkGA39sh4VC

Benehiko and others added 4 commits August 23, 2026 18:12
…ch (#142)

Adds a `bitmagnet` template: a minimal Alpine VM running the bitmagnet
BitTorrent DHT crawler and indexer with its own PostgreSQL database, no
Docker or Compose involved.

The VPN is the reason this is a template rather than a compose file on an
existing host. Crawling the DHT continuously announces the guest to the
swarm, so running bitmagnet on a bare host publishes that host's IP address
to tens of thousands of peers.

The guarantee is that with WireGuard down, no software in the guest can
reach the internet — not just bitmagnet, anything, as any user. OUTPUT
policy is DROP, and the exceptions are pinned rather than opened by port,
because a default-deny policy is only as strong as its holes:

  - the handshake is allowed to the endpoint's resolved addresses, not to
    the whole internet on UDP/51820
  - SSH replies require conntrack ESTABLISHED, so the rule cannot carry
    newly-opened outbound connections
  - DHCP is restricted to the broadcast address and source port 68

Both unpinned forms are usable covert channels with the tunnel down; the
UDP one was verified to leak before pinning. bitmagnet's own traffic is
additionally rejected on any interface other than wg0 by group ownership.

With no VPN configured the crawler is disabled outright rather than merely
warned about — both dht_crawler.enabled and the dht_crawler worker key, since
either alone still joins the swarm. The database and web UI still run, so the
VM is a working indexer with an empty index and adding a tunnel is the only
step needed to start crawling.

The web UI (port 3333) is never exposed: it gets no firewall hole from any
source and is reached over `vee tunnel`.

`--pg-data-dir` bind-mounts a host directory as PostgreSQL's data directory
over virtiofs, so the crawled index outlives the VM. Verified by crawling,
destroying the VM, and recreating it against the same directory with the
index intact and initdb correctly skipped.

Three things about the guest environment that are not obvious:

  - virtiofs passes host ownership through and vee runs virtiofsd
    unprivileged, so the guest cannot chown the share. `chown postgres` fails
    with EPERM and initdb then refuses the directory, leaving a dead cluster.
    The guest's postgres account is renumbered to the host owner instead —
    and because that UID is usually 1000, already taken by the Alpine image's
    login user, the occupant is moved aside first.
  - bitmagnet locates config.yml relative to its working directory and offers
    no flag or environment variable to override that. OpenRC /etc/conf.d
    values are shell variables sourced by the init script, not exported, so
    credentials placed there were never seen: bitmagnet fell back to its
    defaults, connected as "postgres" with no password, and panicked on every
    start while OpenRC reported "started". The service now sets a working
    directory holding a 0600 config, and is supervised so a startup race with
    PostgreSQL respawns rather than leaving a permanently dead crawler.
  - cloud-init renders each single-line runcmd as a bare YAML scalar, so a
    command starting with "[" parses as a flow sequence and invalidates the
    entire user-data document. cloud-init then discards every module and the
    guest boots with no packages, no SSH keys and no services, leaving one
    warning line in the serial log as the only evidence. TestBitmagnetUserData
    IsValidYAML renders the real cloud-init and parses it to catch this class;
    it caught a second instance (`cut -d:`) during development.

Wired into `vee create` (--pg-data-dir, --wg-conf), the MCP server, CLI help,
README and the docs site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcuEg4e4xzsUkGA39sh4VC
Adds --nordvpn-token (and optional --nordvpn-country) to the bitmagnet
template, so NordVPN users no longer have to export a NordLynx config from
the dashboard by hand. NordLynx is WireGuard, so a Nord account already has
everything a wg0.conf needs.

The two halves come from different places and are assembled in
vpn.NordLynxConfig: the account's NordLynx private key from an authenticated
endpoint (HTTP Basic with the literal username "token"), and the server's
public key, address and port from the public, load-aware recommendations
endpoint. The endpoint is recorded as the server's IP rather than its
hostname, because the guest's kill-switch pins its handshake hole to resolved
addresses and there is no DNS left once OUTPUT is denied.

An unknown --nordvpn-country is an error rather than a silent fallback:
connecting through a different jurisdiction than the one requested is exactly
the surprise this template exists to avoid.

Also fixes vpn.ValidateToken, which never validated anything. It fetched the
public /servers/countries endpoint and discarded the response, so it reported
success for any non-empty string and the first sign of a bad token was a
guest that could not connect. It now performs an authenticated request and
reports rejection with the URL to generate a new token. This affects the
torrent template's prompt as well, where the check has been silently passing
bad tokens.

Threading a context through templateExtras and the VPN prompts removed the
need for the contextcheck suppression at the MCP call site.

The credentials endpoint is the one NordVPN's own clients use, not a
documented, versioned public API — it can change without notice, which is why
the failure messages are specific and --wg-conf remains supported.

Verified against the live API: server selection, country filtering, and token
rejection. The authenticated success path is covered by tests against a stub
server in the shapes observed live; it has not been run with a real token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcuEg4e4xzsUkGA39sh4VC
Pointing --pg-data-dir at an NFS mount does not fail — it hangs, and it hangs
in the worst possible place.

PostgreSQL's initdb fsyncs thousands of small files during bootstrap. Over
virtiofs onto NFS each one is a full network round trip: measured at 58 kB/s
and ~70ms per 4k synchronous write on a real NAS, which turns a ten-second
initdb into hours. NFS mounted "hard" (the default) never returns an error
either, so the guest blocks rather than failing.

The consequence is worse than a slow create. cloud-init stalls mid-initdb, so
every runcmd after it never runs — including the firewall rules and the
WireGuard tunnel. The VM boots, answers SSH, and looks created, while having
no kill-switch at all. Observed on a real create: OUTPUT policy still ACCEPT,
wg0 absent, cloud-init wedged in fuse_create_open three minutes in with the
guest's initdb blocked on virtiofsd.

The docs already said to use local storage. That was not enough, because the
failure gives no signal at the moment the mistake is made — so the check now
lives in the code, at create time, before the VM or any credentials are
touched. It rejects NFS, SMB/CIFS, 9p, Ceph, GlusterFS and FUSE by statfs
magic, on both the CLI and MCP paths, in 8ms.

An undetectable filesystem is treated as local: failing a create on a statfs
error would be a worse outcome than proceeding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcuEg4e4xzsUkGA39sh4VC
… guest

Two separate bugs, both of which made `vee tunnel` unusable against Alpine
templates.

First, runSSHTunnel read cfg.CloudInit.User directly instead of resolving the
account the way vee ssh does. That misses the explicit ssh_user override, and
it misses every template that creates no user of its own and injects the SSH
keys into the image's default account instead (docker, dns-sink, bitmagnet —
the Alpine image ships no bash for useradd). For those the field is empty, so
ssh(1) substituted the host username and the tunnel failed with "Permission
denied (publickey)" against an account that never existed in the guest — while
vee ssh to the same VM worked, which made it look like a key problem rather
than a username one. Resolution now goes through cfg.SSHUsername(), extracted
into tunnelSSHUser so it can be tested against vee ssh for agreement.

Second, the Alpine cloud image ships "AllowTcpForwarding no", so sshd refused
every forward and connections reset the moment they were used. The bitmagnet
template depends on forwarding: the web UI is deliberately given no firewall
hole, so `vee tunnel` over SSH is the only way to reach it, and without this
it was unreachable by any means. A drop-in sets AllowTcpForwarding yes,
validated with sshd -t before reloading — a bad config would leave sshd dead
and a kill-switched guest unrecoverable.

Verified against a live guest: the tunnel now returns bitmagnet's UI (301 to
/webui) where it previously reset the connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcuEg4e4xzsUkGA39sh4VC
@Benehiko
Benehiko merged commit 32b2915 into main Aug 25, 2026
4 checks passed
@Benehiko
Benehiko deleted the feat/bitmagnet-template branch August 25, 2026 05:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant