Ansible project that idempotently rolls piccolo-perf out across a fleet of Unix/Linux hosts and brings every host's Prometheus to a hardened baseline: bound to the mesh VPN interface only, TLS-terminated with a centrally-issued Let's Encrypt wildcard certificate, and firewalled as defense-in-depth.
Control node (cert_issuer_hosts)
├── certbot DNS-01 → *.mesh.example.net wildcard cert
└── cert-distribute.yml → pushes cert to fleet on renewal
Fleet hosts
├── piccolo-perf-exporter [probe_address]:9862 HTTPS/TLS
├── nginx config server [probe_address]:8443 HTTPS/TLS
└── prometheus (selected) [probe_address]:9090 HTTPS/TLS + basic auth
nftables on every host: 9862, 8443, 9090 blocked on all interfaces except the mesh VPN
All network services bind exclusively to each host's mesh-VPN IPv6 address, never to
0.0.0.0 or ::. The wildcard cert is issued once centrally and distributed to every
host that needs it, so no host carries DNS credentials.
piccolo-perf-install/
├── ansible.cfg
├── inventory/
│ ├── hosts.example.ini # copy to hosts.ini and edit
│ └── group_vars/
│ ├── all.yml # mesh topology & fleet config
│ ├── prometheus_hosts.yml # prometheus version & auth vars
│ └── config_server_hosts.yml
├── roles/
│ ├── piccolo_perf/ # install + harden piccolo-perf exporter
│ ├── config_server/ # nginx serving topology JSON over HTTPS
│ ├── prometheus_hardened/ # install or adopt-and-harden Prometheus
│ └── cert_issuer/ # DNS-01 ACME issuance + renewal timer
└── playbooks/
├── site.yml # deploys everything in one run
├── piccolo-perf.yml
├── config-server.yml
├── prometheus.yml
├── cert-issue.yml # run once to issue the wildcard cert
├── cert-distribute.yml # push renewed cert to fleet
└── verify-fleet.yml # HTTPS smoke-test across all services
Control node
- Ansible ≥ 2.15
- Python ≥ 3.9
- A DNS provider supported by a certbot DNS-01 plugin (Cloudflare, Route53, etc.)
- The project checked out somewhere the certbot deploy hook can reach:
cert_issuer_playbook_dir(default/opt/piccolo-fleet)
Fleet hosts
- Debian/Ubuntu family (uses
apt). Other families are a documented follow-up. - A mesh VPN (WireGuard or similar) with a consistent interface name across the entire fleet, providing IPv6 ULA addressing.
- SSH access from the control node via the mesh VPN.
- Python 3 (Ansible dependency).
Ports used
| Service | Port | Protocol | Bound to |
|---|---|---|---|
| piccolo-perf exporter | 9862 | TCP | mesh IPv6 only |
| config server (nginx) | 8443 | TCP | mesh IPv6 only |
| Prometheus | 9090 | TCP | mesh IPv6 only |
git clone <this-repo> /opt/piccolo-fleet
cd /opt/piccolo-fleetCopy the example inventory and edit it to match your fleet:
cp inventory/hosts.example.ini inventory/hosts.ini
$EDITOR inventory/hosts.iniThe inventory uses short hostnames as Ansible host names; ansible_host carries the
mesh-VPN FQDN used for SSH:
[piccolo_perf]
probe-a ansible_host=probe-a.mesh.example.net
probe-b ansible_host=probe-b.mesh.example.net
[prometheus_hosts]
probe-a # hosts that should run (or already run) Prometheus
[config_server_hosts]
probe-a # exactly one host serves the topology JSON
[cert_issuer_hosts]
localhost ansible_connection=local # the control node issues certsOpen inventory/group_vars/all.yml and fill in your actual values:
mesh_iface: wg0 # WireGuard interface name, same on every host
mesh_domain: mesh.example.net # internal DNS domain for the fleet
mesh_vpn_service: "wg-quick@wg0.service" # systemd unit for the mesh VPN
piccolo_perf_hosts:
- { name: probe-a, address: "fd00:dead:beef::1", site: site-a }
- { name: probe-b, address: "fd00:dead:beef::2", site: site-b }
piccolo_perf_measurements:
- { type: twamp, interval: 60s, targets: all, burst_size: 5,
burst_interval: 200ms, packet_timeout: 5s }
- { type: dns, interval: 120s,
resolvers: ["2620:fe::fe", "9.9.9.9"],
names: ["example.com"] }The piccolo_perf_hosts list is the single source of truth for fleet topology. The
config_server role serves it as JSON over HTTPS; the prometheus_hardened role uses it
to build the Prometheus scrape config. Add or remove hosts here and re-run site.yml
to propagate changes everywhere.
Two group_vars files hold secrets that must be vault-encrypted before committing.
inventory/group_vars/cert_issuer_hosts.yml is created for you with a placeholder.
Edit it to add your real DNS API token, then encrypt:
$EDITOR inventory/group_vars/cert_issuer_hosts.yml
ansible-vault encrypt inventory/group_vars/cert_issuer_hosts.ymlThe file should contain:
cert_issuer_acme_email: "admin@mesh.example.net"
# For Cloudflare (python3-certbot-dns-cloudflare):
cert_issuer_dns_credentials_content: |
dns_cloudflare_api_token = <your-token-here>The Cloudflare token needs Zone:Zone:Read and Zone:DNS:Edit permissions
for the zone that contains your mesh_domain.
DNS plugin selection: The default plugin is
cloudflare. To use a different provider, overridecert_issuer_dns_plugin_nameincert_issuer_hosts.yml:cert_issuer_dns_plugin_name: route53 # cert_issuer_certbot_dns_package and cert_issuer_certbot_authenticator # are derived automatically from cert_issuer_dns_plugin_name.
Edit inventory/group_vars/prometheus_hosts.yml to add the password variables,
then encrypt:
# Generate a bcrypt hash
htpasswd -nbBC 10 admin '<password>'
$EDITOR inventory/group_vars/prometheus_hosts.yml
# Add:
# prometheus_basic_auth_password_hash: "$2y$10$..."
# prometheus_basic_auth_password: "<your-password-here>"
ansible-vault encrypt inventory/group_vars/prometheus_hosts.ymlPass --ask-vault-pass or --vault-password-file ~/.vault_pass on every
ansible-playbook invocation that touches encrypted files.
Run these four commands in order. Each is idempotent; re-running is always safe.
This runs on the control node (cert_issuer_hosts) and issues
*.mesh.example.net via DNS-01 ACME. On success, certbot's deploy hook
automatically calls cert-distribute.yml to push the cert to every host in
inventory.
ansible-playbook -i inventory/hosts.ini playbooks/cert-issue.yml \
--ask-vault-passExpected outcome: /etc/letsencrypt/live/mesh.example.net/fullchain.pem and
privkey.pem exist on the control node, and the cert files appear at
/etc/piccolo-perf/tls/ and /etc/prometheus/tls/ on every fleet host.
ansible-playbook -i inventory/hosts.ini playbooks/site.yml \
--ask-vault-passThis is equivalent to running piccolo-perf.yml, config-server.yml, and
prometheus.yml in sequence. On first run it will:
- Download and install piccolo-perf v1.0.7 on every
piccolo_perfhost. - Deploy the
piccolo-perf-exportersystemd unit bound to the mesh IPv6 address. - Install nginx on
config_server_hostsand serve the topology JSON over HTTPS on port 8443. - Install or upgrade Prometheus to v3.13.1 on every
prometheus_hostshost, applying TLS + basic auth and rebinding from any existing0.0.0.0:9090listener to the mesh interface only. - Apply nftables rules on every host restricting the relevant ports to the mesh interface.
ansible-playbook -i inventory/hosts.ini playbooks/verify-fleet.yml \
--ask-vault-passThis hits every deployed service over HTTPS through the mesh interface:
piccolo-perf/metricson eachpiccolo_perfhost — expects HTTP 200.- Config server
/piccolo-config.json— expects HTTP 200. - Prometheus
/-/healthywith basic auth — expects HTTP 200. - Prometheus API query
up{job="piccolo_perf"}— asserts every target reports1.
inventory/hosts.ini is the single source of truth for fleet membership. Every host
in [piccolo_perf] requires two inline variables:
[piccolo_perf]
probe-a ansible_host=probe-a.mesh.example.net probe_address=fd00:dead:beef::1 site="site-a"
probe-b ansible_host=probe-b.mesh.example.net probe_address=fd00:dead:beef::2 site="site-b"| Variable | Required | Description |
|---|---|---|
ansible_host |
yes | Hostname or IP used for SSH from the control node |
probe_address |
yes | The address Prometheus scrapes and piccolo-perf uses for peer discovery. Use a native IPv6 address for non-VPN deployments or a VPN-assigned IPv6 for mesh deployments. IPv4 is accepted when IPv6 is unavailable. Deprecated alias: mesh_ipv6 (still works, emits a warning). |
site |
yes | Short label for the host's physical or logical site. Always quote numeric-looking values: site="809" |
piccolo_name |
no | Override the name piccolo-perf uses to identify this host. Defaults to the inventory hostname. Set when the OS hostname differs from the inventory name. |
The fleet topology JSON served to piccolo-perf nodes and the Prometheus scrape targets
are both generated automatically from this single inventory. There is no separate
piccolo_perf_hosts list to maintain.
Tip: verify a new host's OS hostname before adding it:
ssh user@host hostname. If it differs from the name you plan to use in inventory, setpiccolo_name=<os-hostname>on the host line.
-
Add the host to
inventory/hosts.iniwithprobe_address=andsite=:[piccolo_perf] new-probe ansible_host=new-probe.mesh.example.net probe_address=fd00:dead:beef::3 site="site-c"
Add to
[prometheus_hosts]too if it should run Prometheus. -
Run the add-host playbook:
ansible-playbook -i inventory/hosts.ini playbooks/add-host.yml \ -e new_host=new-probe --ask-vault-pass
Use
-e new_host=(not--limit) — the playbook must fetch the cert fromcert_issuer_hosts(localhost) regardless of which host is being added, and--limitwould prevent that play from running.This automatically: distributes the wildcard cert, installs piccolo-perf, regenerates the fleet config JSON (now including the new host), and updates all Prometheus scrape configs.
-
Run the decommission playbook before editing
hosts.ini:ansible-playbook -i inventory/hosts.ini playbooks/decommission.yml \ --limit old-probe --ask-vault-pass
This stops and disables piccolo-perf-exporter, removes the systemd unit, nftables rules, TLS certs, and binary from the target host.
-
Delete the host line from
inventory/hosts.ini. -
Re-run
site.ymlto regenerate the fleet config JSON and Prometheus scrape configs without the removed host:ansible-playbook -i inventory/hosts.ini playbooks/site.yml --ask-vault-pass
Measurement configuration lives in piccolo_perf_measurements in
inventory/group_vars/all.yml. After editing it:
-
Redeploy the fleet config JSON so nodes pick up the new configuration:
ansible-playbook -i inventory/hosts.ini playbooks/config-server.yml \ --ask-vault-pass
-
Restart piccolo-perf on all fleet hosts to reload immediately (otherwise each node waits up to the
config_refreshinterval, default 5 minutes):ansible -i inventory/hosts.ini piccolo_perf -b --ask-vault-pass \ -m systemd -a "name=piccolo-perf-exporter state=restarted" -
Add panels to Grafana for any new measurement types. New measurement types produce new metric names — they do not automatically appear in existing dashboard panels. piccolo-perf metrics follow the naming convention
piccolo_<type>_*. For example:- TWAMP:
piccolo_twamp_rtt_avg_ms,piccolo_twamp_loss_pct, etc. - DNS:
piccolo_dns_dns_rtt_ms,piccolo_dns_dns_success
To find what metrics a new measurement type is emitting, query Prometheus directly:
curl -sk --user admin:<password> \ 'https://[<prometheus-mesh-ipv6>]:9090/api/v1/label/__name__/values' | \ python3 -m json.tool | grep piccolo
Then add a new panel to the Grafana dashboard using the relevant metric name. Use
source,target, andsitelabel filters to match the dashboard variable selectors already in use by existing panels. - TWAMP:
Certbot's renewal timer (piccolo-cert-renew.timer) fires daily on the control node.
When a cert is actually renewed (within 30 days of expiry), the deploy hook
automatically runs cert-distribute.yml and config-server.yml, which copy the new
cert to every host and restart affected services only if the cert content changed.
To force an immediate renewal check:
ansible-playbook -i inventory/hosts.ini playbooks/cert-issue.yml \
--ask-vault-passTo distribute a cert that was renewed outside Ansible (e.g., manually via certbot):
ansible-playbook -i inventory/hosts.ini playbooks/cert-distribute.yml \
--ask-vault-passChange piccolo_perf_version in roles/piccolo_perf/defaults/main.yml or
prometheus_version in roles/prometheus_hardened/defaults/main.yml and re-run
site.yml. The install tasks check the currently installed version and only download
and replace the binary if the version doesn't match.
- Generate a new bcrypt hash:
htpasswd -nbBC 10 admin '<new-password>' - Update
prometheus_basic_auth_password_hashandprometheus_basic_auth_passwordin the vault. - Run
ansible-playbook -i inventory/hosts.ini playbooks/prometheus.yml --ask-vault-pass. Theweb.config.ymltemplate will be redeployed and Prometheus restarted.
All roles are fully idempotent. Re-running site.yml at any time is safe — hosts
already at the correct state see zero changes. Use targeted playbooks to limit scope:
# Only update piccolo-perf across the fleet
ansible-playbook -i inventory/hosts.ini playbooks/piccolo-perf.yml --ask-vault-pass
# Only update Prometheus scrape config and auth
ansible-playbook -i inventory/hosts.ini playbooks/prometheus.yml --ask-vault-pass
# Only regenerate the fleet config JSON
ansible-playbook -i inventory/hosts.ini playbooks/config-server.yml --ask-vault-passFor hosts reachable via native public IPv6 with no VPN mesh:
-
In
inventory/hosts.ini, useprobe_address=with the host's public IPv6 address:[piccolo_perf] probe-a ansible_host=probe-a.example.net probe_address=2001:db8::1 site="site-a"
-
In
inventory/group_vars/all.yml, setfirewall_modeandallowed_source_prefixes:firewall_mode: source_prefix allowed_source_prefixes: - 2001:db8::/32 # your fleet's IPv6 prefix mesh_vpn_services: [] # no VPN daemons to wait for
-
Run
site.ymlnormally — all other configuration is unchanged.
Mixed fleets (some hosts on VPN, some on native IPv6) are not supported in a single inventory. Use separate inventories with different firewall_mode values.
| Variable | Default | Description |
|---|---|---|
mesh_iface |
wt0 |
Primary mesh interface used to resolve this host's mesh IPv6 address |
mesh_domain |
mesh.example.net |
Internal DNS domain |
allowed_mesh_ifaces |
[wt0, tailscale0] |
All interfaces whose inbound traffic should reach fleet services. Accepts any interface type: wt0, tailscale0, eth0, zt0, wg0, etc. nftables allows traffic from any listed interface and drops all others on fleet ports. |
mesh_vpn_services |
[tailscaled.service, netbird.service] |
systemd units listed in After=/Wants= for fleet services. Use Wants= (not BindsTo=) so services survive a VPN restart. |
firewall_mode |
interface |
Controls nftables port restriction. interface: allow from interfaces in allowed_mesh_ifaces (VPN). source_prefix: allow from IPv6 prefixes in allowed_source_prefixes (non-VPN). none: skip nftables entirely. mtls: skip nftables (future). |
allowed_source_prefixes |
[] |
IPv6 CIDRs or addresses allowed when firewall_mode: source_prefix. Example: [2001:db8::/32, 2001:db8::1] |
piccolo_perf_config_url |
https://config.<mesh_domain>:8443/... |
URL piccolo-perf fetches its config from. Override if the default DNS name doesn't exist. |
piccolo_perf_measurements |
see file | List of measurement configs served to all piccolo-perf nodes |
Fleet host topology (names, addresses, sites) is defined entirely in inventory/hosts.ini
via probe_address= and site= host variables on each [piccolo_perf] entry — there is no
separate piccolo_perf_hosts list.
| Variable | Default | Description |
|---|---|---|
piccolo_perf_version |
1.0.7 |
Release tag to install |
piccolo_perf_binary_path |
/usr/local/bin/piccolo-perf |
Install path |
piccolo_perf_metrics_port |
9862 |
Port the exporter listens on |
piccolo_perf_twamp_port |
862 |
Port for TWAMP measurements |
piccolo_perf_probe_mode |
background |
piccolo-perf probe mode |
piccolo_perf_tls_dir |
/etc/piccolo-perf/tls |
Where the cert and key live |
piccolo_perf_cert_group |
piccolo-perf-cert |
Group with read access to TLS files |
piccolo_perf_service_name |
piccolo-perf-exporter |
systemd unit name |
piccolo_perf_config_url |
https://config.<mesh_domain>:8443/piccolo-config.json |
URL piccolo-perf fetches its config from |
| Variable | Default | Description |
|---|---|---|
prometheus_version |
3.13.1 |
Release to install |
prometheus_binary_path |
/usr/local/bin/prometheus |
Install path |
prometheus_config_dir |
/etc/prometheus |
Config directory |
prometheus_data_dir |
/var/lib/prometheus |
TSDB storage directory |
tls_cert_dir_prometheus |
/etc/prometheus/tls |
Where the cert and key live |
prometheus_web_port |
9090 |
Port Prometheus listens on |
Set in inventory/group_vars/prometheus_hosts.yml:
| Variable | Description |
|---|---|
prometheus_basic_auth_username |
Username for basic auth (default admin) |
prometheus_basic_auth_password_hash |
bcrypt hash — must be in Vault |
| Variable | Default | Description |
|---|---|---|
config_server_port |
8443 |
HTTPS port nginx listens on |
config_server_www_dir |
/etc/piccolo-fleet/www |
Document root |
config_server_tls_dir |
/etc/piccolo-fleet/tls |
TLS cert location |
config_server_remove_default_site |
false |
Remove /etc/nginx/sites-enabled/default; set true only on hosts where no other service (e.g. Grafana) uses that vhost |
Co-locating with Grafana or other nginx vhosts: the role deploys a single named vhost (piccolo-fleet-config-server.conf) on port 8443 bound exclusively to the mesh IPv6 address. It does not touch other vhosts. Set config_server_remove_default_site: false (the default) to leave any existing Grafana or default vhost in place. The systemd drop-in uses Wants= rather than BindsTo= for the mesh VPN service so that nginx stays up (and Grafana keeps serving) even if the mesh interface goes down.
First-run ordering: nginx is only started by the role when the TLS cert is already present at config_server_tls_dir. On a fresh host, run cert-issue.yml first; the deploy hook calls cert-distribute.yml, which copies the cert and reloads nginx automatically. If you run site.yml before issuing a cert, the role enables nginx but does not start it — re-run after cert distribution to bring it up.
| Variable | Default | Description |
|---|---|---|
cert_issuer_acme_email |
admin@<mesh_domain> |
Email for Let's Encrypt registration |
cert_issuer_dns_plugin_name |
cloudflare |
certbot DNS plugin name |
cert_issuer_dns_credentials_path |
/etc/letsencrypt/dns-credentials.ini |
Where credentials file is written |
cert_issuer_dns_credentials_content |
"" |
Must be in Vault — credentials file contents |
cert_issuer_dns_propagation_seconds |
60 |
Seconds to wait for DNS TXT record propagation before ACME validation |
cert_issuer_deploy_hook_path |
/usr/local/sbin/piccolo-cert-deploy-hook.sh |
Script certbot calls on renewal |
cert_issuer_playbook_dir |
/opt/piccolo-fleet |
Project root on the control node |
cert_issuer_inventory_path |
inventory/hosts.ini |
Inventory path used by the deploy hook |
Bind address as the primary control. Every service binds to a specific IPv6 address
(mesh_ipv6, resolved from ansible_facts[mesh_iface]), not to 0.0.0.0 or ::.
If the mesh interface goes down, services fail to bind rather than silently falling back
to a public address.
nftables as defense-in-depth. Each role deploys a named nftables table that drops
inbound traffic to its port(s) from any interface other than mesh_iface. This protects
against a bind-address misconfiguration surviving a restart and against other processes
opening the same ports on unintended interfaces. Loopback (lo) is always allowed for
local health checks.
Single-credential ACME issuance. The DNS provider API token lives on the control
node only, in Ansible Vault. Fleet hosts never hold credentials capable of issuing
certificates. The cert is pushed to hosts after issuance; cert-distribute.yml restarts
services only when the cert content actually changes (idempotent).
No root for piccolo-perf. The exporter unit uses DynamicUser=yes and
NoNewPrivileges=true, acquiring only the capabilities it needs
(CAP_NET_BIND_SERVICE, CAP_NET_RAW) via AmbientCapabilities.
Prometheus locked down on adopt. The prometheus_hardened role handles the
"adopt an existing exposed install" case: if Prometheus is already running bound to
0.0.0.0:9090 with no TLS, the role replaces its systemd unit and restarts it. The
Molecule scenario exercises exactly this path — prepare.yml intentionally installs a
stale, insecure Prometheus, and the verify assertions confirm it is remediated.
Check what metric names piccolo-perf is currently emitting to a Prometheus instance:
curl -sk --user admin:<password> \
'https://[<prometheus-mesh-ipv6>]:9090/api/v1/label/__name__/values' | \
python3 -m json.tool | grep piccoloQuery a specific metric across all sources (replace piccolo_twamp_rtt_avg_ms with
any metric name from the list above):
curl -sk --user admin:<password> \
'https://[<prometheus-mesh-ipv6>]:9090/api/v1/query?query=piccolo_twamp_rtt_avg_ms' | \
python3 -m json.tool | grep -E '(source|target|site|value)' | head -40Query metrics for a specific source host:
curl -sk --user admin:<password> \
'https://[<prometheus-mesh-ipv6>]:9090/api/v1/query?query=piccolo_twamp_rtt_avg_ms{source="attic-probe"}' | \
python3 -m json.tool | grep -E '(target|value)'Check a host's exporter directly (bypasses Prometheus entirely — useful to confirm piccolo-perf is running and collecting before Prometheus has scraped it):
curl -sk https://[<host-mesh-ipv6>]:9862/metrics | grep piccolo_twamp | head -20Check which targets Prometheus is currently scraping and their health:
curl -sk --user admin:<password> \
'https://[<prometheus-mesh-ipv6>]:9090/api/v1/targets' | \
python3 -m json.tool | grep -E '(scrapeUrl|health|lastError)' | paste - - -New measurement types produce new metric names (piccolo_<type>_*) that do not
automatically appear in existing Grafana panels. After adding a new measurement type
to piccolo_perf_measurements and redeploying:
- Confirm the metrics exist in Prometheus using the label query above.
- Add new panels to the Grafana dashboard manually using the new metric names.
- Use
source,target, andsitelabel filters to match existing dashboard variables.
# Check target health on a Prometheus host
curl -sk --user admin:<password> \
'https://[<prometheus-mesh-ipv6>]:9090/api/v1/targets' | \
python3 -m json.tool | grep -E '(scrapeUrl|health|lastError)' | paste - - -
# Check nftables is allowing traffic on the target host
ssh configbot@<host> sudo nft list table inet piccolo_fleet_perf
# Check piccolo-perf is running on the target host
ssh configbot@<host> sudo systemctl status piccolo-perf-exporter --no-pager | head -5ssh configbot@<host> sudo journalctl -u piccolo-perf-exporter -n 20 --no-pagerCommon causes:
Cannot load TLS cert/key— cert not distributed yet; runcert-distribute.yml --limit <host>Cannot fetch initial config— config server unreachable or returning invalid JSON; checkpiccolo_perf_config_urland verify withcurl -sk <url>JSON parse error: cannot unmarshal number into site—site=value inhosts.iniis unquoted integer; change tosite="809"
Each role ships a Molecule scenario using the Docker driver with a systemd-capable Debian 12 image. Tests include prepare → converge → idempotency check → verify.
# Run a single role's full suite
cd roles/piccolo_perf && molecule test
cd roles/config_server && molecule test
cd roles/prometheus_hardened && molecule test
# Or run all three in sequence from the project root
for r in piccolo_perf config_server prometheus_hardened; do
(cd roles/$r && molecule test) || exit 1
doneThe cert_issuer role has no Molecule scenario — DNS-01 issuance requires live DNS
infrastructure and cannot run in CI containers. It is covered by ansible-lint and
--syntax-check only. Test it manually against a real DNS zone (Let's Encrypt staging
is a good choice) before relying on it in production.
If Docker is managed by Colima on macOS, set
DOCKER_HOST before running Molecule:
export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock"
molecule testansible-lint roles playbooksZero failures are expected. Warnings on handler naming and conditional tasks are
acknowledged and suppressed in .ansible-lint.
The upstream install.sh one-liner remains the fallback for any device outside
Ansible's reach (OpenWrt routers, embedded hosts without Python):
curl -sSL https://raw.githubusercontent.com/retecolo/piccolo-perf/main/install.sh | shThis installs the binary only. TLS, interface binding, and nftables hardening for such devices require manual follow-up and are outside the scope of this project.
docs/superpowers/specs/2026-07-16-piccolo-perf-fleet-install-design.md