Pick the machine you are standing in front of. Each section is its prerequisites, the commands, and what you should see — then the reasoning, underneath, for when you want it.
A laptop, a NAS, a home server. One script, a container, a systemd unit.
A full participant, not a client. From a self-hosted F-Droid repository, or built here.
A view onto the daemon already running on this machine, installed as
an .lgx package.
The reachable member that anchors NAT traversal and relays for peers that cannot connect directly.
You are about to pipe somebody else's script into a root shell. It installs a container, writes a systemd unit and hands a network interface to a daemon — which is what you want it to do, and is also indistinguishable from what you would not want it to do until you have read it.
So read it, or have your AI overlords read it for you. It is 343 lines and there is nothing clever in it: scripts/install.sh. The same goes for every other install-by-curl on the internet, most of which will not say so on the page.
Prerequisites
docker or podman, and systemd/dev/net/tun presentcurl -fsSLO https://raw.githubusercontent.com/vpavlin/shrooms/master/scripts/install.sh
sudo bash install.sh prepare --name nas # install, start, wait for a mesh
What you should see
==> checking this machine
==> fetching ghcr.io/vpavlin/shrooms:latest
==> generating config (prepare)
==> installing the service
Installed and running, waiting for a mesh.
On a machine already on one:
shrooms invite
and back here:
sudo shrooms join --invite <TOKEN> --name nas
The machine is now a running daemon with no mesh. Create one if this is your first device, or redeem an invite if it is not.
Three verbs. init creates a mesh, join <NETWORK-KEY>
joins one you have the key for, and prepare installs and starts
the daemon with no mesh at all. prepare is the one to pair with
invites — a daemon without a mesh still holds its control socket and waits
to be told which network it is on, which is exactly what
shrooms join --invite needs it to be doing.
Everything after the verb is handed to shrooms unchanged —
--name, --relay, --advertise,
--port — so the script cannot drift out of date with the flags
the binary supports. The device name defaults to this machine's hostname.
Nothing is built on the box. The container carries the binary and liblogosdelivery, which needs glibc 2.38 and exists only where Logos Basecamp is installed, so shipping an image rather than a tarball is what makes a Debian 12 machine work at all. It runs with host networking on purpose: behind docker's bridge the address peers observe would be the gateway's and the source port would be rewritten, so traversal would be fighting a layer of NAT that does not exist in reality.
It touches /etc/shrooms/ (config), /var/lib/shrooms/
(device identity), /run/shrooms/ (control socket), a
shrooms systemd unit and container, and
/usr/local/bin/shrooms — a wrapper so shrooms status
works on the host without anyone remembering the docker incantation. The
wrapper runs docker exec -i, so the commands that prompt —
invite, set-key, key rotate — work
through it.
Re-running is safe: an existing config and identity are left alone unless
you pass --force. Losing the identity would mean a new overlay
address and looking like a different device to every peer, so it is never
destroyed by accident. On a SELinux host the bind mounts are relabelled
shared, without which the daemon cannot bind its control socket and systemd
restarts it forever.
Read the script before running it as root, as you should with anything fetched this way.
Worth it when you want the binary on the host rather than in a container —
and it is what scripts/deploy.sh needs on the machine you drive
a remote install from.
git clone https://github.com/vpavlin/shrooms && cd shrooms
make deps-release # prebuilt liblogosdelivery; there is no distribution of it
make shrooms
sudo make install # binary, libraries, systemd unit, bash completion
make install relinks the binary with an rpath pointing at
/usr/local/lib/shrooms, so it keeps working after you delete
the checkout. It needs Go 1.23+ with cgo and a C toolchain to build, and
glibc 2.38 or newer to run — Debian 12 has 2.36 and will not do, which is
the case the container exists for. make deps builds the library
from source and currently fails on an upstream compile error;
make deps-release downloads a repackaged copy instead, which is
the one you want.
Prerequisites
# F-Droid, Settings, Repositories, add:
http://apps.vpavlin.xyz/fdroid/repo
# then install "Shrooms" from the repository's app list
What you should see
The app on the launcher, opening on a single join screen with one field. Put
an invite token or a network key into it — it takes either and tells them
apart itself — or scan the QR that shrooms invite prints. After
joining, the roster fills with the same peers the daemon reports, and
Android's VPN key appears in the status bar.
That F-Droid repository is self-hosted. There is no Google Play listing and there is not going to be one — the repository is the author's own, published alongside the other Logos modules on the same host, and adding it is a decision you are making about who you take software from.
arm64 only. There is no x86_64 build of the delivery library, so an emulator has no node and the app is a phone-shaped thing to test on a phone.
The app is a Compose UI and a VpnService around the same Go core
as the daemon — nothing in internal/ is reimplemented. It joins
several meshes at once and roams between wifi and mobile data without you
touching it: direct on wifi, relayed on a carrier's CGNAT, and the transition
survives in both directions.
ANDROID.md
is the build in detail, and
ADR-016
is why it reuses the core rather than reimplementing it.
make apk # android/shrooms.apk, debug-signed
adb install -r android/shrooms.apk
make fdroid # build unsigned, sign on the repo host, publish
make apk needs the Android SDK and an NDK; it builds the
.aar in a container first, because gomobile wants a JDK and a
newer Go than the core deliberately does. make fdroid is the
publishing half and only useful if you run a repository of your own: the
signing key lives on that host and never leaves it, so the APK is built here
unsigned and signed there.
Prerequisites
socket_group set in /etc/shrooms/config.toml to
a group you are incurl -fsSLO https://github.com/vpavlin/shrooms/releases/download/shrooms-v0.3.0/shrooms-0.3.0.lgx
# and the core module it depends on
curl -LO https://github.com/vpavlin/shrooms/releases/download/shrooms_core-v0.2.0/shrooms_core-0.2.0.lgx
# then in Basecamp: Package Manager, Install from file, pick the .lgx
What you should see
Shrooms in Basecamp's app list, and opening it shows the same roster
shrooms status prints in a terminal: peers, whether each is
direct or relayed, throughput, and how long each took to connect. If the
socket group is not set it says so, rather than showing an empty mesh.
It also changes things, which is the part that removes the terminal: this device's name, whether it runs the rendezvous plane as edge or core, what it publishes and whether those names and its bound ports are announced, whether it relays for a mesh, whether the router is asked for a way in, which meshes run, joining one with an invite token and leaving one — and restarting the daemon, which is what applies the settings that need it. Beside that: every device's services as addresses you can click, when each credential expires, and the daemon's own log, because Basecamp cannot read the journal.
What it cannot do is admit anybody. On a mesh with admin keys, membership is a credential signed by a key the daemon has never held, so nothing reachable through this socket can make a device a member or remove one. That is the line the design draws, and it is why handing the socket to a group is a bounded grant rather than a way to admit anybody.
Two packages, versioned separately:
shrooms is the view and shrooms_core
is the part that talks to the daemon. The view declares the
dependency, so a repository installs both — but if you are
fetching .lgx files by hand, fetch both, or you
get an interface whose buttons do nothing.
The view reads the daemon through shrooms_core, a companion
module installed alongside it. That indirection is not ceremony. A
ui_qml app runs inside a sandbox where a deny-all network
manager blocks every outgoing request and XMLHttpRequest refuses
local files, so neither a status file nor a port is reachable from the view,
whatever their permissions. A Logos module runs in its own process and is not
sandboxed, which is the route Basecamp prescribes: it does one request over
the daemon's control socket and hands back the JSON.
The socket group is a real grant, like docker's.
Anyone in it can read your mesh's control plane and change this device's
behaviour. Name a group you would trust with that — on a personal machine,
your own login — and leave it unset on a shared one. The socket is
0660 and the daemon runs as root for CAP_NET_ADMIN,
which is what makes the group half of that mean anything.
It can change what this device decides for itself: its name, relay or light
node, published services, which meshes run, and leaving one. What it
cannot do is admit anybody, and that is the line the design
draws. Membership is a credential signed by an admin key the daemon has
never held — a passphrase-protected file in your home directory — so nothing
reachable through that socket can make a device a member or remove one.
Admission stays with shrooms invite and a passphrase prompt,
which is where the friction belongs.
ADR-025 is the whole
argument.
An .lgx is a package, not a repository. It
has no update mechanism of its own, so there is nothing to check for a
newer version and nothing to notify you. Upgrading means downloading the
file again and installing it over the top.
make basecamp-check # load the real view offscreen against a fixture
make basecamp-lgx # build the installable package
make basecamp-publish # cut a GitHub release carrying it
basecamp-lgx builds the portable variant through nix, because
the plain one can reference paths in the nix store of the machine that built
it — fine for a dev loop and useless as a download. CI builds it on every
push and uploads it as an artifact. basecamp-publish uploads two
assets to a release: a versioned name so an install can be pinned, and the
stable one linked above.
Prerequisites
/dev/net/tun,
and it is worth checking before you paydocker or podman, and rootssh root@VPS_IP
curl -fsSLO https://raw.githubusercontent.com/vpavlin/shrooms/master/scripts/install.sh
sudo bash install.sh prepare --name vps --relay
ufw allow 51820/udp # or your firewall's equivalent
What you should see
Installed and running, waiting for a mesh.
This node relays. Open its UDP port if a firewall is in the way:
ufw allow 51820/udp # or the equivalent
Then redeem an invite from a machine already on the mesh. Resource use is negligible — a measured node acting as a relay sits at about 20 MiB RSS and one to two per cent of one core — so what matters on a VPS is bandwidth allowance and latency, since relayed traffic takes a detour through it.
A node behind NAT learns its own public address by reflection: a peer's reply echoes the address that peer observed. That needs a peer outside your NAT. On a mesh whose members are all behind one router, no node can learn its public address at all, every candidate it announces is a LAN address, and the mesh works from the sofa and not from the street. One reachable member is the anchor that fixes this — and, being reachable, it is also what can relay for two peers that cannot reach each other directly.
You probably do not need a VPS. Any node with a reachable
address can relay: set relay = "true" in its config and restart
it. Nothing else needs configuring — the relay advertises itself in its
ordinary announce and every other node picks it up, so there are no relay
addresses to distribute and its IP can change freely. An office box with a
port forward or a home server on a static address covers every pair that
cannot connect directly.
A node will also ask the router for a way in — PCP first,
then NAT-PMP, both on the gateway's UDP 5351. That is on by default
(port_mapping), and a laptop behind a domestic NAT has been
granted a mapping, announced it, and been dialled directly by a phone on
mobile data, on a mesh with no publicly reachable member at all. Whether
your router answers is between you and your router; when it does not,
nothing is worse than before.
Two things it does not fix. Carrier-grade NAT can be neither punched nor mapped, so a phone on mobile data needs a relay somewhere in the mesh. And punching between two NATed peers is still unproven — a reachable member is what makes the awkward cases work today. ADR-012 covers who should host one.
scripts/deploy.sh is the alternative when you have the
repository checked out and would rather not type on the remote at all. It
wants docker on the far side rather than podman, and a built
./bin/shrooms here.
./scripts/deploy.sh root@VPS_IP --relay --name vps --key <NETWORK-KEY>
./scripts/deploy.sh root@VPS_IP --init --relay --name vps # or create the mesh
With --init the config is generated locally, by your
own binary, and only then copied over — which is why the passphrase prompt
works and why the admin key stays on your machine rather than on the VPS.
The config travels over ssh stdin and lands with install -m600,
so the network key is never readable at a predictable path in the remote's
/tmp. The remote generates its own device identity, so no
private key crosses the wire. It refuses to overwrite an existing config
without --force, pulls the published image on the remote by
default, and builds from your working tree when you set
BUILD_IMAGE=1.
One command, on the machine that will hold the admin key. It mints the network key, a pair of admin keys, and this device's own credential, then asks twice for a passphrase to encrypt the admin key at rest:
sudo shrooms init --name laptop
Network key: GUPDIZSVIQRCBBC6APQFYDSDKTWK6Q5SYZY6ZLMVECQDQX3GIOYQ
copy this to your other machines — it is the only secret
Device: laptop
Overlay IP: fd12:b993:4542:c6cd:1f4c:cc1a:496c:d791
Mesh prefix: fd12:b993:4542::/48
Wrote /etc/shrooms/config.toml
Minted the mesh authority.
mesh id 65V5K5ISCBHQJAEYWSH2PDPLYQ
admin key /home/you/.config/shrooms/admin.json
this device is enrolled
RECOVERY KEY — written down now or never. It is not saved anywhere,
and it is what lets you keep this mesh if the admin key above is lost:
dmaWji2cz8403ySaOkMB18Qguy4yLx783btjYhYZMvUTHxqHJFPJuJ4ms0/QVuNSyDwRbdvx7cHO3xkHkiXzPQ==
Store it away from this machine. A password manager, a Keycard, paper.
Two admin keys, always, because the set is fixed at mint: the mesh id
commits to it and the address prefix derives from the id, so adding a key
later would re-address every node. One stays here, encrypted with the
passphrase you typed a moment ago. The other is the recovery key, printed once and
never written anywhere — if you skip past it and later lose
~/.config/shrooms/admin.json, you cannot enrol another device
into this mesh again.
The admin key belongs to the person rather than to the machine, so under
sudo it is written to the invoking user's home, not root's.
Then start the daemon:
sudo systemctl enable --now shrooms
The container installer takes the same verb —
sudo bash install.sh init --name laptop — and works the same
way: the setup container is given a stdin so the passphrase prompt has
somewhere to read from, and your own ~/.config/shrooms is
mounted into it so the admin key survives the container it was minted in.
--no-admin creates a mesh with no authority at all, where
membership is the network key alone and nothing can be revoked short of
rotating it for everybody.
On a machine that is already a member, with its daemon running — the daemon is what holds the invite open, since it is the node already connected to the fleet. It asks for the admin key's passphrase, then prints a token, a QR code, and waits:
shrooms invite
Invite valid for 15m0s. On the joining device:
shrooms join --invite BEGUZ-N4WOX-PYMTR-CYKWT-QBYSX-U
[QR code]
Waiting. An invite is answered only by the node that issued it,
which is what makes it single-use — so leave this running.
Then, on the newcomer, while the other machine is still waiting:
sudo shrooms join --invite BEGUZ-N4WOX-PYMTR-CYKWT-QBYSX-U --name nas
Asking to join as "nas"...
Enrolled. Credential serial 1786439411, expires 2026-09-10T11:03:51+02:00.
The token is 128 bits, good for one device and fifteen minutes. Both sides derive from it where to meet and what to encrypt with, so a wrong token addresses a topic nobody answers rather than a guess anyone can grind against. What comes back is the network key, the admin keys and a credential issued to that device's own keys — the network key never appears on a screen.
If a daemon is already running on the joining machine and has not joined
anything, the join goes through it and it brings the mesh up itself: no
second command, no restart. That is what a machine installed with
install.sh prepare looks like.
The phone joins the same way. Scan the QR, or paste the token into the one field on the join screen — it takes an invite or a network key and tells them apart itself.
shrooms join <NETWORK-KEY> --name nas is still there for
bootstrapping and recovery, and for a mesh minted with
--no-admin that has no credentials to issue. Credentials expire
in thirty days by default, and renewal by hand is another
shrooms invite.
Handing the network key to a colleague, a script or an agent so they can install a node hands them membership of the mesh. The two-step version avoids that: prepare the machine without the key, and put the key in yourself afterwards.
# on the machine being set up, by whoever is doing the setup
sudo shrooms prepare --name nas --relay
Prepared /etc/shrooms/config.toml for "nas".
The mesh key is not set. Add it yourself:
sudo shrooms set-key
# later, by whoever holds the key
sudo shrooms set-key
Mesh key:
Key written to /etc/shrooms/config.toml.
The daemon was waiting for this and is bringing the mesh up now.
The device identity is generated during prepare, so the
machine's overlay address is settled before the key arrives and does not
change when it does. set-key reads from a prompt, or from stdin
when there is no terminal, so the key never reaches your shell history or a
command line that every other process on the box can read. It validates the
key before writing it — a mistyped one otherwise fails much later, as a mesh
where nobody ever appears.
Get the key from a machine that is already a member with
shrooms key show, or shrooms key show --qr to scan
it. If a daemon was waiting, set-key nudges it and the mesh
comes up without a restart; otherwise
sudo systemctl start shrooms.
An invite does the same job and moves no key at all, so prefer it when
somebody can stand in front of both machines within fifteen minutes.
prepare plus set-key is for when there is no
invite to redeem — a scripted rollout, or a machine built ahead of the
person who will admit it.
Expect discovery in 15 to 25 seconds from cold — most of that is the messaging node joining the fleet — and a handshake shortly after.
shrooms status
network fd48:d107:3fce::/48 peers 2 (2 up)
self laptop fd48:d107:3fce:2b84:226b:ac:f2c3:9f30
NAME OVERLAY IP ANNOUNCE TUNNEL ENDPOINT RX/TX CONNECTED IN
vps (relay) fd48:d107:3fce:a332:855c:1059:8060:59d7 online up 42s 203.0.113.4:51820 12.4K/8.1K 18s
phone fd48:d107:3fce:9d1c:7b2e:41a:cc90:1f22 online up 1m relay:…@203.0.113.4:51820 1.1K/900 24s
ping6 vps.mesh
ssh vps.mesh
A healthy roster has both columns and they mean different things.
ANNOUNCE is the gossip bus: the peer's announcement is
arriving, so discovery works. TUNNEL is WireGuard: a
handshake completed, and the age beside up should keep resetting,
since a live session rekeys about every 165 seconds. A peer that is
online with no handshake is a traversal problem,
not a discovery one — and a tunnel that says stale 12m is dead
whatever the announce column claims.
Names need nothing set up: the daemon runs a resolver for the mesh and
registers it with the system on startup, scoped to its own interface, so
.mesh is answered there and every other name keeps going
wherever it went before.
shrooms paths
reflexive addresses (as peers observe us):
203.0.113.9:41001
This is the most informative single output. One address means endpoint-independent NAT, so direct connections between NATed peers should work. Several means endpoint-dependent — symmetric — NAT, punching will not work, and traffic falls back to the relay, which is exactly why the relay exists.
| Symptom | What it means | What to do |
|---|---|---|
tun: … (need CAP_NET_ADMIN) |
the process cannot create the TUN device, or there is none to create | run it under sudo, and check /dev/net/tun exists — OpenVZ and LXC hosts often have none |
| no peers at all after 60 s | the daemon is not reaching the fleet. This is discovery, not tunnels | check outbound connectivity; make s1 confirms whether the rendezvous plane works at all |
!! rendezvous: in status |
the fleet is unreachable, or you are on a different cluster than it is | established tunnels are unaffected while you fix it. different clusterId reported: N vs M in the log means a preset mismatch; cluster_id overrides it |
peer online but no handshake |
traversal, not discovery: it is announced, you cannot reach it | shrooms paths; open the UDP port on whichever side should be reachable; make one member reachable if neither is |
peer shows stale 12m |
the tunnel is dead — the peer has not rekeyed within WireGuard's 180 s session lifetime | check the peer is actually up; a restarted peer leaves the other side holding a session that stays valid for a while |
.mesh names do not resolve |
the resolver is running but the host is not asking it — serving DNS and being asked are different things | resolvectl status shrooms0 should list the overlay address under DNS Servers and ~mesh as the domain. The daemon log says why not. sudo shrooms hosts --write is the fallback for hosts without systemd-resolved |
shrooms status needs sudo |
the daemon needs CAP_NET_ADMIN and so runs as root, which makes its control socket root:root |
set socket_group = "your-username" in the config. The unit must also carry CAP_CHOWN in both AmbientCapabilities and CapabilityBoundingSet, or the chown fails with EPERM even for uid 0 — the setting appears to work and every read still says permission denied |
error: EOF from a prompt |
the command wanted a passphrase or a key and stdin was closed | run it in a terminal rather than from a script, a unit or a pipe. set-key is the exception: it reads stdin deliberately when there is no terminal |
| the daemon exits immediately | a shared library is missing under a bare binary | deploy the container rather than a tarball, or install with sudo make install, which sets the rpath |
missing liblogosdelivery.h when building |
the library is not where the build expects it | make deps-release, or make deps-basecamp HDR=… to reuse a local Basecamp install |
| the Basecamp view shows nothing | it cannot reach the daemon's control socket, or the daemon is not running here | the view says which; set socket_group to a group you are in and restart the daemon. The module also needs shrooms_core installed beside it |
Two failures worth knowing about before they happen, because neither looks
like a failure. The rendezvous plane can die while everything looks
fine — tunnels keep carrying traffic, the roster stops changing, and
peers age out one at a time until the status page is a list of healthy
devices marked offline. Both the daemon and the app watch for it now, and
recovery is a restart in both places. And renewal has never been
watched happen on a live mesh: the sweep is built and unit-tested,
but the clock on a credential is thirty days, and
shrooms invite again is the shortest way to reset it.
Names and services, several meshes at once, relays, renewals, and binding a service so only the mesh can reach it.
The architecture, every design decision with its reasoning, and how to build and test it.
What leaks, what is deliberately deferred, and what to know before running this anywhere that matters.