Security & hardening
Login brute-force lockout, TOTP 2FA, TLS, DoS limits, peer ACL, and a hardening checklist.
MeshHold defaults to a publicly-bound Web UI (api.listen_addr =
0.0.0.0:8080) because the homelab and multi-host deployments it targets
need to reach it across a network. That makes the login and the listener
the front door, so the daemon ships several layers of protection. This
page is the map: what's on by default, what you can tune, and how to lock
a node down further.
TL;DR — what's on by default
- Brute-force lockout on the Web UI login (15 failed attempts per IP → 15-minute lockout).
- TLS on by default (a self-signed cert is generated on first start; bring your own or use ACME).
- Slowloris-safe limits: idle-connection timeout, a header-size cap, and a ceiling on concurrent connections.
- libp2p runs in private-network mode — peers without your swarm key can't complete a handshake — with a connection manager and resource manager bounding peer load.
- Security headers on every API response.
- Audit log of security-relevant events, surfaced in the Web UI.
Optional, off by default: TOTP two-factor login and a fail2ban jail (see below).
Login brute-force protection
Failed logins are counted per client IP in a sliding window. Cross the
threshold and that IP is locked out — further attempts get 429 Too Many
Requests with a Retry-After header, before the password is even
hashed, so a locked-out client can't keep burning CPU. The Web UI shows
the remaining attempts and a live countdown while locked.
The same per-IP budget is shared across the password login, the tray bootstrap-exchange, and the vault-unlock endpoint, so an attacker can't dodge the cap by spreading guesses across them.
Defaults and tuning (config.yaml):
api:
auth:
login_max_attempts: 15 # 0 disables the lockout entirely
login_window: "15m" # rolling window the failures are counted in
login_lockout: "15m" # how long the lockout holds once tripped
The admin password itself is stored only as an Argon2id hash (in the
encrypted metadata store, never in config.yaml). Set or change it with
meshhold set-password.
Upgrading a node from a build older than the Argon2id switch resets the
login. The hash used to be bcrypt, and bcrypt is no longer in the binary,
so an existing hash cannot be verified even once — not even to re-hash it
in place. Such a node answers 409 on login, naming the problem, rather
than "invalid password": the password may well be right, and sending you
round the lockout for it would be the worst of both. The attempt is not
counted against the lockout either. Run meshhold set-password on the box
to set a new one; nothing else about the node is touched.
Two-factor authentication (TOTP)
You can require a time-based one-time code (Google Authenticator, Aegis, FreeOTP, …) on top of the password. It's managed from the console only — there is deliberately no Web-UI toggle, so an attacker who steals a live session can't disarm it.
# Enable — prints a QR code, the secret, and one-time recovery codes:
meshhold 2fa setup
# Check state / how many recovery codes remain:
meshhold 2fa status
# Turn it off (reverts to password-only):
meshhold 2fa disable
These work whether the daemon is running (they go through its local
admin-token endpoint) or stopped (they open the metadata store directly,
same as set-password). After enabling, the Web UI login asks for a
6-digit code as a second step. Lost your device? Enter one of the
recovery codes printed at setup (each works once), or run meshhold
2fa disable from a shell on the box.
Recovery codes are Argon2id-hashed too, under one salt for the set so that
checking a code costs one derivation rather than one per code. The upgrade
described above invalidates them for the same reason it invalidates the
password — they were bcrypt-hashed and can no longer be checked. This does
not lock you out: the TOTP secret is untouched, so your authenticator
keeps working. Mint a fresh set when convenient with meshhold 2fa disable
followed by meshhold 2fa setup — setup refuses to run while a second
factor is already enrolled, which is why it takes both.
Transport security (TLS)
TLS is enabled by default. On first start the daemon generates a self-signed certificate under its metadata dir (your browser will warn once; accept it and the SPA caches its bearer). For a real certificate:
api:
tls:
cert_file: /etc/meshhold/tls/fullchain.pem
key_file: /etc/meshhold/tls/privkey.pem
# …or let the daemon fetch one via ACME / Let's Encrypt:
acme_domain: node.example.com
The minimum protocol version is TLS 1.2. The only time TLS is skipped is a pure-loopback bind with no cert configured.
Reverse proxies & client IP
By default the daemon trusts no proxy: it uses the real TCP peer as
the client IP, so a spoofed X-Forwarded-For can't forge the address in
the audit log or get an innocent IP banned by fail2ban. If you run
MeshHold behind a reverse proxy you control, list it so X-Forwarded-For
is honoured from it:
api:
trusted_proxies: ["127.0.0.1", "10.0.0.0/8"]
Denial-of-service limits
The HTTP listener sets an idle-connection timeout and a header-size cap,
and bounds the number of simultaneous connections (api.max_connections,
default 512; 0 = unlimited). We deliberately do not set blanket
read/write timeouts, because the same listener serves long media streams,
Server-Sent Events, and large uploads.
The libp2p host runs a connection manager that trims the peer set back to a low watermark when it grows past a high one, plus a resource manager whose limits auto-scale to the machine's memory and file-descriptor budget:
node:
resources:
conn_low: 128
conn_high: 512
conn_grace_period: "30s"
max_block_streams: 64
max_block_streams caps how many block fetches the node has open at once
— one block is one stream, and a mounted vault reading in parallel across
several peers can otherwise ask for more than the resource manager will
grant, at which point the extra streams fail rather than queue. Leave the
key out to take the default; set a negative value to remove the cap.
On a phone the resource manager does not auto-scale: node.low_memory
sizes it to a fixed budget instead, because a device with plenty of
installed RAM still kills the app long before libp2p's own accounting
would object.
Peer access control
The swarm key is a private-network pre-shared key: a peer that doesn't hold it cannot complete the Noise handshake, so it never opens a stream. To expel a specific peer, add its ID to the blocklist:
node:
peer_blocklist: ["12D3KooW…"]
To rotate the swarm key for a whole network, use the Change swarm key
button on the network card in the Web UI (or meshhold networks
set-swarm-key), then re-pair your nodes.
Encryption at rest
A node created from scratch is encrypted at rest by default: the metadata store (BadgerDB), the libp2p identity and the saved networks — including your swarm key — are written encrypted from the first boot.
A node that already existed is not converted automatically. Migrating takes it offline while it converts, and afterwards it depends on its key store continuing to work — that is a decision, not something to do to somebody because they upgraded. Such a node warns on every start, shows the offer in Settings → Security, and converts when you say so:
meshhold at-rest enable
then restart. meshhold at-rest status reports where any node stands.
The master key comes from the OS keystore: Windows DPAPI (user
scope), macOS Keychain, Linux Secret Service, Android Keystore. On a
headless Linux server, where there is no session D-Bus for a keyring, the
daemon falls back to a key file bound to /etc/machine-id — enough that a
copy of the data directory alone is not enough to read it, but not a
defence against local root or a full disk image. Set
at_rest_encryption.passphrase_file to manage the key yourself via
systemd-creds or Vault, and use full-disk encryption (LUKS, or your
provider's volume encryption) for the rest.
Check where a node stands with meshhold at-rest status. Note that once
the state is encrypted, a daemon that cannot obtain the master key refuses
to start rather than overwrite it — so back up whatever the key derives
from (the keyring entry, the passphrase file, /etc/machine-id) alongside
the data. meshhold at-rest disable --yes is the supported way back, and
it needs that key to work.
Shared machines (Windows service installs)
A machine-wide install keeps its state in C:\ProgramData\MeshHold,
restricted to SYSTEM and Administrators. Ordinary users on the same
machine cannot read the node's identity, its swarm key, or the loopback
admin token — so launching the tray gives them a password prompt, not a
session. They need the web UI password like any other client.
The default ACL on C:\ProgramData grants every local user read access and
passes it down by inheritance, and file modes do not override that on
Windows. Nodes installed before this restriction shipped were in that state;
they are fixed by upgrading and restarting the service, which re-applies it
on every start.
A local administrator can still read everything — they can take ownership or run as SYSTEM, and no file permission prevents that. The boundary this draws is between administrators and ordinary users, which is the one a shared office or family machine needs.
Per-user installs are unaffected: their state lives in the user's own profile, which already carries the right permissions.
Secrets in config.yaml
config.yaml is a bad place for a secret and often an unavoidable one: it
gets templated by deployment tools on every run, committed to repositories,
and collected into support bundles. So every secret it can carry — the swarm
key, vault keys, webhook tokens, enrichment API keys — can come from an
environment variable or a separate file instead, and those win over the
inline value.
Find the ones still in the file:
meshhold config lint
Each finding names the environment variable and the *_file key that would
take it out. See
keeping secrets out of this file.
Audit log
Security-relevant events — login successes and failures, lockouts tripped, 2FA enable/disable, vault unlocks, swarm-key rotation, management-key verification, tunnel and port-forward open/close, peer appear/leave — are recorded in a local audit log. Open it from Profile → View logs in the Web UI. Warning- and critical-severity events also fire a push notification to your registered devices. The log is local-only; it is not gossiped to other nodes.
Network-level banning (fail2ban)
The daemon's per-IP lockout refuses the login; fail2ban can drop the offender's packets entirely at the firewall. The Linux package ships a ready-made filter and jail (disabled by default). See Fail2Ban integration for the walkthrough.
A hardening checklist
- Set a strong Web UI password; consider enabling
meshhold 2fa setup. - Keep TLS on; install a real certificate for anything internet-facing.
- Put the node behind a firewall and only expose
8080/tcp(Web UI) and7777/tcp(libp2p) to the networks that need them. - If behind a reverse proxy, set
api.trusted_proxies. - Run
meshhold config lint— it finds typo'd keys that silently do nothing, and secrets still written intoconfig.yaml. - Check
meshhold at-rest status. New nodes encrypt themselves; a node that predates that keeps its swarm key and vault keys in the clear until you runmeshhold at-rest enableand restart. - On a shared Windows machine, prefer the service install: its state directory is restricted to administrators, so other users need the password to get in.
- Enable the fail2ban jail for internet-facing nodes.
- Review Profile → View logs periodically, and act on lockout pushes.