| name | multi-node-ssh |
| description | SSH ProxyCommand patterns for managing Proxmox nodes behind the OpenWrt router. Use when adding LAN hosts, debugging satellite connectivity, writing Molecule scenarios for LAN nodes, or working with tasks/bootstrap_lan_host.yml. |
Multi-Node SSH Patterns
LAN hosts sit behind the OpenWrt router on 10.10.10.0/24 and are NOT directly
reachable from the controller. All SSH goes through the primary Proxmox host
via ProxyCommand. LAN hosts are ONLY reachable when the OpenWrt router VM is
running on the primary host.
Rules
- NEVER assume LAN hosts are reachable without the OpenWrt baseline running.
Plays targeting
lan_hosts MUST come after the OpenWrt configure play in
site.yml. Molecule scenarios for LAN hosts MUST verify the baseline first.
- NEVER remove SSH authorized_keys or API tokens in cleanup. These are
operator prerequisites, not playbook artifacts.
- NEVER manage SSH keys or passwords in playbooks. The operator sets up key
auth manually via the Proxmox GUI shell or
ssh-copy-id through the tunnel.
- ALWAYS add
ServerAliveInterval=15 and ServerAliveCountMax=4 to SSH args
for ProxyCommand connections. Without keepalives, the connection drops during
long sequences of local Ansible tasks that don't send traffic.
- ALWAYS export env vars before running Molecule:
set -a; source test.env; set +a; molecule test -s mesh1-infra
Architecture
Controller (laptop, 192.168.86.0/24)
└──SSH──► Primary host (home, 192.168.86.201)
├──SSH──► OpenWrt VM (10.10.10.1, manages LAN)
└──ProxyCommand──► LAN host (mesh1, 10.10.10.210)
Dependency chain: controller → home → OpenWrt VM → LAN exists → mesh1 reachable.
Patterns
Adding a new LAN node (full checklist)
- Physical setup: connect the node to the LAN switch behind OpenWrt
- Discover the node from the primary host:
ssh root@10.10.10.1 'cat /tmp/dhcp.leases'
for i in $(seq 200 220); do ping -c1 -W1 10.10.10.$i &>/dev/null && echo 10.10.10.$i; done
- Set up SSH key auth (one-time, requires console or Proxmox GUI):
ssh -L 8007:<node-ip>:8006 root@192.168.86.201
ssh-copy-id -o "ProxyCommand=ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -W %h:%p root@192.168.86.201" root@<node-ip>
Both the controller's AND the primary host's keys must be authorized.
- Add env var:
NODENAME_API_TOKEN= to .env/test.env
- Add to inventory:
inventory/hosts.yml under lan_hosts
- Create host_vars:
inventory/host_vars/nodename.yml with ansible_host
- Run bootstrap: the
tasks/bootstrap_lan_host.yml task handles DHCP
lease and API token creation automatically during converge
- Verify:
ssh -o "ProxyCommand=ssh -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -W %h:%p root@$PRIMARY_HOST" root@<node-ip> hostname
SSH tunnel for browser access
ssh -L 8007:<lan-host-ip>:8006 root@192.168.86.201
BAD: Cleanup removes credentials
- name: Remove SSH keys
file: path=/root/.ssh/authorized_keys state=absent
- name: Remove API token
command: pveum user token remove root@pam ansible
GOOD: Cleanup only removes playbook artifacts
- name: Remove ansible-managed files
file: path={{ item }} state=absent
loop:
- /etc/network/interfaces.d/ansible-bridges.conf
- /etc/ansible/facts.d/vm_builds.fact
Environment variable convention
PRIMARY_HOST=192.168.86.201
HOME_API_TOKEN=cab59c9a-...
MESH1_API_TOKEN=39d2976f-...
Convention: <INVENTORY_HOSTNAME>_API_TOKEN (uppercased, hyphens → underscores).
group_vars/proxmox.yml resolves dynamically — no per-host override needed:
proxmox_api_token_secret: >-
{{ lookup('env', (inventory_hostname | upper | replace('-', '_')) + '_API_TOKEN') }}
Inventory layout
proxmox:
children:
lan_hosts:
hosts:
mesh1: {}
ansible_ssh_common_args: >-
-o ProxyCommand="ssh
-o ServerAliveInterval=15
-o ServerAliveCountMax=4
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-W %h:%p root@{{ lookup('env', 'PRIMARY_HOST') }}"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=30
-o ServerAliveInterval=15
-o ServerAliveCountMax=4
Connection hardening
Three layers prevent mid-play SSH drops through the ProxyCommand tunnel:
- ansible.cfg:
ControlPersist=300s + ServerAliveInterval=15 on ALL connections.
- lan_hosts.yml: ProxyCommand (not ProxyJump) with keepalives on the jump connection.
- site.yml:
meta: reset_connection + wait_for_connection pre_tasks on the first
Phase 2 play targeting lan_hosts.
Previous bug: mesh1 became UNREACHABLE mid-play during proxmox_igpu (54 tasks ok,
then "Data could not be sent"). Root cause: the ControlMaster carrying the ProxyCommand
tunnel had no keepalives and could silently die.
Bootstrap flow (tasks/bootstrap_lan_host.yml)
Called from the primary host (router_nodes):
- Verifies SSH key auth — fails with setup instructions if not working
- Creates DHCP static lease on OpenWrt (if missing)
- Creates API token on the LAN host (if missing)
- Saves token to
test.env on the controller
Default test includes LAN nodes
The default molecule test scenario exercises BOTH home (primary) and
mesh1 (LAN satellite). site.yml is phased so LAN hosts are only
contacted after OpenWrt creates the LAN:
- Phase 1: backup + infra + OpenWrt on
proxmox:!lan_hosts (home only)
- Phase 2: bootstrap + backup + infra on
lan_hosts (mesh1)
- Phase 3: services on flavor groups spanning both (e.g.,
vpn_nodes)
The separate mesh1-infra scenario is still available for quick iteration
on mesh1 hardware issues without running the full ~10 minute default suite.
Baseline workflow for LAN nodes
LAN nodes (mesh1) are ONLY reachable when the OpenWrt baseline is running.
Prefer keeping the baseline up between test runs:
molecule converge
molecule verify
molecule converge -s mesh1-infra
molecule verify -s mesh1-infra
molecule test
molecule converge
Molecule scenario for LAN nodes
platforms:
- name: home
groups: [proxmox, router_nodes]
- name: mesh1
groups: [proxmox, lan_hosts]
provisioner:
env:
HOME_API_TOKEN: ${HOME_API_TOKEN}
PRIMARY_HOST: ${PRIMARY_HOST}
MESH1_API_TOKEN: ${MESH1_API_TOKEN}
Test sequence: dependency → syntax → converge → verify → cleanup
(no initial cleanup — baseline must exist).
Troubleshooting
LAN host unreachable
- Verify OpenWrt is running:
qm status 100 on the primary host
- Verify LAN bridge IP:
ip -4 addr show | grep 10.10.10 on the primary host
- Ping from primary:
ping -c1 10.10.10.210
- Check DHCP lease:
ssh root@10.10.10.1 'cat /tmp/dhcp.leases'
SSH "Permission denied"
- Verify key auth from primary:
ssh -o BatchMode=yes root@10.10.10.210 hostname
- Re-push keys via Proxmox GUI shell or SSH tunnel (see "Adding a new LAN node")
DHCP lease drift
If the node gets a different IP after router reboot, the DHCP static lease
may not have been committed. Check on OpenWrt:
uci show dhcp | grep mesh1 — if empty, re-run the bootstrap.
API token issues
- List tokens:
pveum user token list root@pam on the LAN host
- Create:
pveum user token add root@pam ansible --privsep=0
- Save to
test.env: MESH1_API_TOKEN=<value>