Reference

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 setupsetup 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) and 7777/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 into config.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 run meshhold at-rest enable and 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.