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

  1. curl -fsSLO https://raw.githubusercontent.com/vpavlin/shrooms/master/scripts/install.sh

    Then one of two verbs, not both. On your first machine, the one that creates the mesh and keeps its admin key:

    sudo bash install.sh init --mesh home --name laptop

    It prints a recovery key once and never again, so read The first mesh before running it. On every machine after that, which is admitted rather than creating anything:

    sudo bash install.sh prepare --name nas

    prepare installs the same daemon with no mesh at all and leaves it waiting, which is what an invite needs it to be doing.

  2. If a firewall is running, open the port — on laptops too, not only servers:

    sudo ufw allow 51820/udp                          # Ubuntu, Debian
    sudo firewall-cmd --add-port=51820/udp --permanent && sudo firewall-cmd --reload  # Fedora, RHEL
  3. The daemon is running either way. After init you are on a mesh of one and the next thing to do is admit a second device. After prepare it is waiting, and redeeming an invite brings it up without a restart.

That pipes 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, and is indistinguishable from what you would not want until you have read it. It is 953 lines with nothing clever in it: scripts/install.sh.

What you need first
Why the port matters, even on a laptop

Two peers need at least one of them reachable. A laptop with a default-deny firewall cannot be reached by a phone on the same wifi, so both ends announce perfectly good addresses, see each other as online, and fail every handshake — which looks like almost anything except a closed port.

The installer prints the right command for whichever firewall it finds running, and says nothing when none is.

What you should see

After prepare:

==> 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 <TOKEN> --name nas

After init the same three lines, and then the mesh itself — network key, admin key, and the recovery key printed once. That output is in The first mesh.

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 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, a shrooms-resolved unit that registers mesh names with the host's resolver, and /usr/local/bin/shrooms — a wrapper so shrooms status works on the host without anyone remembering the docker incantation. It runs docker exec -i, with a stdin, so the commands that prompt work through it. Under sudo on a podman host: the daemon's container is root's, and a rootless podman cannot see it.

With one exception, which is the whole reason the wrapper is more than one line. The commands that touch the mesh authority — init, invite, admin, keycard — read and write ~/.config/shrooms, and the daemon container has no such directory: its unit mounts the config, the state and the socket, and nothing of yours. So those run in a sibling container with your admin directory mounted, rather than inside the daemon's. The daemon is deliberately left without it: it verifies credentials and never signs one, and a process that cannot read a key cannot leak it (ADR-025).

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

  1. In F-Droid: Settings → Repositories → add

    https://apps.vpavlin.xyz/fdroid/repo
  2. Install Shrooms from that repository's app list.

  3. Open it and scan the QR from shrooms invite, or paste the token into the single field — it takes an invite or a network key and tells them apart itself.

What you need first
What you should see

The app on the launcher, opening on a single join screen with one field. 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

  1. In Basecamp: Package Manager → Repositories → add

    https://apps.vpavlin.xyz/basecamp/logos-repo.json
  2. Install Shrooms from the list. It pulls in shrooms_core itself — that is a declared dependency, not something to fetch by hand.

What you need first
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.

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 installing from the repository brings both. Fetching files by hand means fetching 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.

This is why the repository is the way in. An .lgx on its own is a package, not a repository: it has no update mechanism, nothing checks for a newer version, and nothing notifies you. A file installed by hand is the version you have until you remember to go and look.

Installing from apps.vpavlin.xyz/basecamp/logos-repo.json gives Basecamp something to check, and resolves shrooms_core for you. Fetching the files directly still works and is what Building the package yourself produces.

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

  1. ssh root@VPS_IP
    curl -fsSLO https://raw.githubusercontent.com/vpavlin/shrooms/master/scripts/install.sh
    sudo bash install.sh prepare --name vps --relay
    sudo ufw allow 51820/udp
  2. Redeem an invite from a machine already on the mesh.

What you need first
What you should see, and what it costs
Installed and running, waiting for a mesh.

A firewall is running here. Peers reach this node on UDP 51820, so open it:
  sudo ufw allow 51820/udp

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 — shrooms init where the binary is on the host, and bash install.sh init where it is in a container. 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:

It is also what to run on a machine already set up with install.sh prepare: that writes a config with no key, and init mints into it, keeping the name, port, mode and relay setting it already has. A config that is already on a mesh is refused instead, since minting over it would leave the device holding a key no peer has heard of.

sudo shrooms init --mesh home --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-home.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-home.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's init works the same way, and does the starting for you: 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, on either, 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.

If this machine already ran install.sh prepare, run the installer again with the other verb — there is nothing to undo first:

sudo bash install.sh init --mesh home --name laptop

init mints into the config prepare wrote, keeping the name, port, mode and relay setting already chosen there, and anything you pass on the command line wins over them. The device identity under /var/lib/shrooms is untouched either way, so the machine keeps the keys it generated. A config that is already on a mesh is refused instead, since minting over it would leave this device holding a key no peer has heard of.

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

Joining is by invite only. shrooms join <NETWORK-KEY> was removed on 2026-08-27: a raw network key no longer makes a device a member. A machine set up by someone who never sees the key still joins by shrooms invite. Credentials expire in thirty days by default, so renewal is another invite - and on a mesh minted with --no-admin the invite carries the network key instead of a credential.

A machine set up by somebody who never sees the key

Somebody else — a colleague, a script, an agent — can install and configure a node without ever holding your network key. Prepare the machine without it, and put the key in yourself afterwards.

The key is worth withholding even though, on a mesh with admin keys, it no longer admits anybody: it still decrypts the control plane, so whoever has it can read who is on the mesh and what is announced. Withholding it is about that, not about membership — see the note below for how the device actually becomes a member.

# 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".

This device has its keys and its name. It is not on a mesh yet.

sudo systemctl enable --now shrooms

# on a machine already in the mesh
shrooms invite
  shrooms join BEGUZ-N4WOX-PYMTR-CYKWT-QBYSX-U

# back on the new machine
sudo shrooms join BEGUZ-N4WOX-PYMTR-CYKWT-QBYSX-U
Enrolled. Credential serial 1786439411, expires 2026-09-26T09:14:02+02:00.

The device identity is generated during prepare, so the machine's overlay address is settled before it joins anything and does not change when it does. Nothing secret is in the config that lands: a name, a port, and this device's own public keys.

The network key stopped being membership, and the commands that treated it as membership are gone. shrooms join <NETWORK-KEY> and shrooms set-key were removed on 2026-08-27. They were how this worked before credentials: the key WAS the membership, so everybody holding it was a member, nobody could be removed without changing it for everybody, and it travelled by whatever means came to hand.

An invite carries the same key sealed to one device for fifteen minutes, and what makes that device a member afterwards is an admin-signed credential that can be revoked. The key still decrypts the control plane, so it is worth protecting — it just no longer admits anybody.

For a rollout with nobody present to redeem an invite, the admin issues a credential for that device's keys and hands over the one line it prints:

# wherever the admin key lives
shrooms admin issue --device <hex> --wg <hex> --name nas
Install it on that machine:

  shrooms credential set <credential>

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 on a host install: the daemon runs a resolver for the mesh and registers it with the system on startup, scoped to its own interface, so mesh names are answered there and every other name keeps going wherever it went before.

The container install cannot do that half itself, so the installer arranges it. Registering means running resolvectl, which belongs to systemd and is not in the image — the daemon serves names correctly and nothing on the host ever asks it. So install.sh writes a second unit, shrooms-resolved.service, which runs those two commands from the host on the daemon's behalf.

It watches rather than firing once, and that is not belt-and-braces. Redeeming an invite produces no systemd event at all: shrooms join hands the token to the waiting daemon, which re-executes itself into the mesh — same pid, no restart — so the moment names first become answerable is invisible to the service manager. On a machine set up with prepare, which is every device after the first, nothing would ever register them. So it reconciles every thirty seconds: registered already and resolved still holding the link, do nothing; no mesh in the config yet, do nothing and do not even wake the container; otherwise register. That also covers a later restart in place and resolved forgetting a link. Stopping it reverts.

Mounting the host's D-Bus socket into the container would also work, and is deliberately not what happens: that hands a VPN daemon the whole system bus in order to register one domain.

If the host has no resolvectl, the installer says so and names resolve through /etc/hosts instead. Note that shrooms hosts --write through the wrapper writes the container's copy, which nothing reads and the next restart destroys — so print it and write it yourself:

sudo shrooms hosts | sudo tee -a /etc/hosts

Either way shrooms status is where you find out: it says names !! not resolving here, with the two commands and this device's own address, whenever the resolver is serving and nothing has registered it.

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 — ping6 nas.mesh says Name or service not known while status shows the peer up the resolver is running but the host is not asking it — serving DNS and being asked are different things. The daemon cannot do the second half from inside a container (no resolvectl in the image), which is what shrooms-resolved.service is for; if that unit is missing, failed, or the host has no systemd-resolved, nothing registered systemctl status shrooms-resolved first — it logs which interface and address it registered, or why it gave up. shrooms status prints the two commands with this device's address filled in, to run on the host rather than through the wrapper. Then resolvectl status shrooms0 should list the overlay address under DNS Servers and the suffix as a routing domain. sudo shrooms hosts | sudo tee -a /etc/hosts is the fallback where there is no resolved at all; --write is for a host install, since through the wrapper it writes the container's own file
shrooms join times out, and the daemon log repeats connectToRelayPeers: won't attempt new connections - node is offline beside Failed to query DNS … address=one.one.one.one error="(1) Operation not permitted" the delivery library does not use the system resolver. Its dnsAddrsNameServers defaults to 1.1.1.1 and 1.0.0.1, which is how it resolves the fleet's /dns4/ entry nodes and how it probes whether it is online at all. Anything that blocks direct DNS to those addresses — a VPN with DNS-leak protection, a network that permits only its own resolver, a local service holding 1.1.1.1 — fails the probe, and the node then declares itself offline and dials nothing. The mesh never gets as far as being unreachable dig +short @1.1.1.1 one.one.one.one. If that fails while host one.one.one.one works, this is it — the two go through different paths and only the direct one matters here. Disconnecting the VPN restores it; if it does not, look for a "block when disconnected" setting, which keeps the firewall rules on while the tunnel is off. Then restart shrooms and read the log with --since: journalctl … | head shows the oldest entries, and diagnosing last week's failure as though it were this minute's is the easiest mistake here
shrooms status says the shrooms container is not running, and systemctl status shrooms says it plainly is a container install on podman, run as yourself. The daemon's container belongs to root, and rootless podman is a separate store that cannot see root's containers at all — including through the docker shim that podman-docker installs sudo shrooms .... On a real docker host the equivalent is membership of the docker group, which is what lets the check see the container without root
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
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.