Install it once,
then admit
the next device.

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.

What are you meshing?

A Linux machine

A laptop, a NAS, a home server. One script, a container, a systemd unit.

An Android phone

A full participant, not a client. From a self-hosted F-Droid repository, or built here.

A desktop, in Basecamp

A view onto the daemon already running on this machine, installed as an .lgx package.

A cloud server

The reachable member that anchors NAT traversal and relays for peers that cannot connect directly.

A Linux machine

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

curl -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.

What the installer does

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.

Building from source instead

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.

An Android phone

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.

Where it comes from, and why not the Play Store

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.

Building the APK yourself

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.

A desktop, in Logos Basecamp

Prerequisites

curl -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.

Why it needs a companion module

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.

Building the package yourself

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.

A cloud server

Prerequisites

ssh 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.

Why a reachable member matters

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.

Driving the install from your own machine

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.

The first mesh

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.

The second device

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.

A machine set up by somebody who never sees the key

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.

Check it worked

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.

When it does not work

SymptomWhat it meansWhat 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.

Where to go next

Guides →

Names and services, several meshes at once, relays, renewals, and binding a service so only the mesh can reach it.

Dev notes →

The architecture, every design decision with its reasoning, and how to build and test it.

SECURITY.md →

What leaks, what is deliberately deferred, and what to know before running this anywhere that matters.