Add TP-Link SG2428LP switch hardening as repeatable IaC + static IP/DNS

Secure and configure the SG2428LP managed switch (10.0.0.10) as repeatable
Ansible, driven from the control node over SSH by an expect engine.

- tplink_switch_harden role (verified JetStream CLI syntax): HTTP off/
  HTTPS-only, Telnet off, SSH v2, SNMP off, RSTP + loopback-detection,
  then copy running-config startup-config (persist).
- playbooks/harden_switch.yml + [switches] inventory group; dry by default,
  -e switch_apply=true to apply, -e switch_apply_network=true for addressing.
- Static mgmt IP 10.0.0.10/24 (off DHCP, below the .20-.199 pool), gw 10.0.0.1,
  hostname sg2428lp.
- op -> ansible-vault bridge (etc/set-switch-1password.sh); admin password in
  group_vars/switches/vault.yml.
- DNS: A/PTR sg2428lp.prole.org -> 10.0.0.10 (records added to dns.yml/ptr_records).
- Fix: tag the samba_reverse_dns zone-list set_fact so tag-limited runs define
  samba_reverse_zones.

Engine notes (hard-won): forces password-only SSH auth (the switch drops
publickey-first logins); always saves config (unsaved changes revert on reboot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chrisfu 2026-06-10 00:20:55 -07:00
parent c9591a5da2
commit ca8dd6a525
12 changed files with 428 additions and 0 deletions

62
etc/set-switch-1password.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# set-switch-1password.sh
# 1Password -> Ansible Vault bridge for the managed switch admin credential.
#
# Reads the switch admin password from 1Password (op) and writes it, encrypted
# with the repo .vault_pass, into the switches group_vars vault file. This keeps
# 1Password the human source of truth while ansible-vault is what the playbook
# consumes at run time.
#
# op://knoey/TP-Link SG2428LP/password --(op read)--> ENV --(ansible-vault)-->
# infrastructure/inventory/group_vars/switches/vault.yml
#
# Usage:
# etc/set-switch-1password.sh # default item/account below
# OP_ACCOUNT=... OP_REFERENCE=... etc/set-switch-1password.sh
#
# Requires: op (1Password CLI, unlocked), ansible-vault, repo .vault_pass.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
OP_ACCOUNT="${OP_ACCOUNT:-VM7LPYA4XFF4TE2DEI7ZHMZWEA}"
OP_REFERENCE="${OP_REFERENCE:-op://knoey/TP-Link SG2428LP/password}"
VAR_NAME="${VAR_NAME:-vault_sg2428lp_admin_password}"
VAULT_FILE="${VAULT_FILE:-infrastructure/inventory/group_vars/switches/vault.yml}"
command -v op >/dev/null 2>&1 || { echo "ERROR: 1Password CLI (op) not found. brew install 1password-cli" >&2; exit 1; }
command -v ansible-vault >/dev/null 2>&1 || { echo "ERROR: ansible-vault not found." >&2; exit 1; }
[[ -f .vault_pass ]] || { echo "ERROR: .vault_pass not found at repo root." >&2; exit 1; }
# op desktop integration sometimes needs an explicit unlock.
if ! op read --account "$OP_ACCOUNT" "$OP_REFERENCE" >/dev/null 2>&1; then
echo "Unlocking 1Password (op signin)..." >&2
op signin --account "$OP_ACCOUNT" >/dev/null || true
fi
SECRET="$(op read --account "$OP_ACCOUNT" "$OP_REFERENCE" 2>/dev/null || true)"
if [[ -z "$SECRET" ]]; then
echo "ERROR: could not read secret from $OP_REFERENCE (is 1Password unlocked?)" >&2
exit 1
fi
echo "Read secret from 1Password (${#SECRET} chars)." >&2
# Encrypt as an inline !vault var. Rely on ansible.cfg's vault_password_file so
# we don't create a duplicate 'default' vault-id (which ansible-vault rejects).
ENC="$(printf '%s' "$SECRET" | ansible-vault encrypt_string --stdin-name "$VAR_NAME")"
unset SECRET
{
echo "---"
echo "# Managed-switch admin password — DO NOT EDIT BY HAND."
echo "# Source of truth: ${OP_REFERENCE}"
echo "# Regenerate with: etc/set-switch-1password.sh"
echo "${ENC}"
} > "${VAULT_FILE}"
echo "Wrote encrypted ${VAR_NAME} -> ${VAULT_FILE}"
echo "Verify (length only):"
echo " ansible localhost -m debug -a \"msg={{ ${VAR_NAME} | length }}\" -e @${VAULT_FILE}"

View File

@ -179,6 +179,8 @@ prole_internal_a_records:
ipv4s: [10.0.0.4]
- fqdn: retropie.prole.org
ipv4s: [10.0.0.207]
- fqdn: sg2428lp.prole.org
ipv4s: [10.0.0.10]
- fqdn: synology.prole.org
ipv4s: [10.0.0.203]
- fqdn: zinfandel.prole.org

View File

@ -23,6 +23,8 @@ ptr_records:
fqdn: "raspberry.prole.org"
- last_octet: "5"
fqdn: "pi.prole.org"
- last_octet: "10"
fqdn: "sg2428lp.prole.org"
- last_octet: "207"
fqdn: "retropie.prole.org"

View File

@ -0,0 +1,35 @@
---
# Managed-switch group — non-secret facts + connection model.
#
# The switch is NOT a Linux host: we don't sudo, and we don't use the global
# ansible_user/key. The hardening role connects FROM the control node TO the
# switch over SSH via an expect engine (delegated, connection: local).
#
# Secret (vault_sg2428lp_admin_password) lives in this group's vault.yml,
# populated from 1Password by etc/set-switch-1password.sh.
ansible_connection: local
ansible_become: false
# Management endpoint + credentials (password resolved from vault).
switch_mgmt_ip: "{{ ansible_host }}"
switch_admin_user: "admin"
switch_admin_password: "{{ vault_sg2428lp_admin_password }}"
# 1Password source-of-truth reference (used by the op -> vault bridge script).
switch_op_account: "VM7LPYA4XFF4TE2DEI7ZHMZWEA"
switch_op_reference: "op://knoey/TP-Link SG2428LP/password"
# ── Desired identity + management addressing ─────────────────────────────────
# Static management IP, OFF DHCP. 10.0.0.10 sits below the DHCP pool
# (10.0.0.2010.0.0.199), so it never collides with a lease.
switch_hostname: "sg2428lp"
switch_fqdn: "{{ switch_hostname }}.{{ prole_domain }}" # sg2428lp.prole.org
switch_mgmt_ip_static: "10.0.0.10"
switch_mgmt_netmask: "255.255.255.0"
switch_mgmt_gateway: "10.0.0.1"
switch_mgmt_vlan: 1
# One-time migration source: the DHCP address the switch currently answers on.
# After cutover, set this host's ansible_host to switch_mgmt_ip_static (.10).
switch_dhcp_ip: "10.0.0.153"

View File

@ -0,0 +1,11 @@
---
# Managed-switch admin password — DO NOT EDIT BY HAND.
# Source of truth: op://knoey/TP-Link SG2428LP/password
# Regenerate with: etc/set-switch-1password.sh
vault_sg2428lp_admin_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
61356331346534643937343732303265376438343536653933393630323963613636313261313662
3134636433393836363032346161303462653631373963350a336363333436323735326230353133
38343464393263393536356435333464393436313635363062633639323362386432653436343063
3931656237356665640a626134363939643763386661313666303238653632373738626163386565
30383162633432376366326436663064666463353139623465393966323561353038

View File

@ -52,6 +52,14 @@ gandalf.prole.org
[merlin_bootstrap]
merlin ansible_host=10.0.0.36 ansible_user=ansible
# ── Managed network switches ─────────────────────────────────────────────────
# Hardened via playbooks/harden_switch.yml (role: tplink_switch_harden).
# Driven from the control node over SSH by an expect engine (connection: local,
# delegated) — NOT the standard linux ansible_user. Admin password lives in
# group_vars/switches/vault.yml (op -> vault bridge: etc/set-switch-1password.sh).
[switches]
sg2428lp ansible_host=10.0.0.10
# ── Engineer workstations — macOS / Linux ────────────────────────────────────
# workstation_kerberos.yml targets this group to deploy:
# - /etc/krb5.conf (or /Library/Preferences/edu.mit.Kerberos on macOS)

View File

@ -0,0 +1,19 @@
---
# Harden managed network switches (TP-Link JetStream).
#
# Secret pipeline: 1Password --(etc/set-switch-1password.sh)--> ansible-vault
# --> this playbook reads vault_sg2428lp_admin_password.
#
# Run:
# ansible-playbook infrastructure/playbooks/harden_switch.yml
# ansible-playbook infrastructure/playbooks/harden_switch.yml --check # dry run (no apply)
# ansible-playbook infrastructure/playbooks/harden_switch.yml -e switch_apply=true
#
# By design this does NOT apply config unless switch_apply=true, so a casual run
# only connects, validates access, and captures `show running-config`.
- name: Harden managed switches
hosts: switches
gather_facts: false
connection: local
roles:
- tplink_switch_harden

View File

@ -17,6 +17,7 @@
ansible.builtin.set_fact:
samba_reverse_zones: >-
{{ ([lan_reverse_zone] + (k3s_reverse_zones | default([]))) | unique }}
tags: [samba, samba_reverse_dns]
- name: Create reverse DNS zones if missing
ansible.builtin.command:

View File

@ -0,0 +1,77 @@
# tplink_switch_harden
Repeatable security hardening for TP-Link JetStream managed switches
(developed for the **SG2428LP** at `10.0.0.153`), driven from the control node
over SSH by a rendered `expect` engine.
## Secret pipeline
```
1Password ──op read──▶ ENV ──ansible-vault──▶ group_vars/switches/vault.yml ──▶ playbook
op://knoey/TP-Link SG2428LP/password (vault_sg2428lp_admin_password)
```
Refresh the vault from 1Password whenever the password changes:
```bash
etc/set-switch-1password.sh
```
The admin password is passed to the engine only via the `SWITCH_PW` environment
variable (`no_log`), so it never lands in inventory, the rendered script, or
process arguments.
## Usage
```bash
# Dry run — log in, capture `show system-info`, change nothing:
ansible-playbook infrastructure/playbooks/harden_switch.yml
# Apply hardening and save to startup-config:
ansible-playbook infrastructure/playbooks/harden_switch.yml -e switch_apply=true
```
`switch_apply` defaults to **false**; nothing is changed until you pass
`-e switch_apply=true`.
## What it hardens
Config-mode commands live in [`defaults/main.yml`](defaults/main.yml)
(`switch_harden_commands`): disable plaintext HTTP, ensure HTTPS, disable
Telnet, SSH v2 only, ensure SNMP off, enable RSTP, enable loopback-detection,
then `copy running-config startup-config`.
The command list was **verified against SG2428LP firmware** via on-device `?`
help. JetStream tokens vary by model/firmware — reconfirm with `?` if you point
this at a different model.
Management IP access-control and CLI idle-timeout are intentionally **opt-in**
(commented in `defaults/main.yml`) because a wrong ACL can lock you out. Apply
those last, from a host inside the permitted range.
## Static-IP cutover (DHCP → static)
`switch_network_commands` (toggled by `switch_apply_network`) sets the static
mgmt IP. In steady state — when the host's `ansible_host` already equals the
static IP — re-asserting it is a no-op. The **one-time** DHCP→static cutover is
different: the `ip address` line drops the session, so it cannot save afterward
over the same connection. Do the cutover deliberately (connect on the DHCP
address, set the static IP, reconnect on the new IP, then save), and afterward
set the host's `ansible_host` to the static IP. Same-subnet control hosts reach
the new IP directly, so gateway correctness isn't required to regain access.
## Gotchas (these cost real debugging time)
- **SSH publickey auth breaks login.** The JetStream SSH server drops the
connection when the client offers `publickey` first (the default). The engine
forces `-o PubkeyAuthentication=no -o PreferredAuthentications=password`.
Symptom without it: "connection closed after KEX, before password prompt",
intermittently (depends on which keys your agent offers).
- **Unsaved config reverts on reboot.** Web-UI changes (incl. the first-login
password) live in running-config until you explicitly save. A reboot with no
save silently reverts to factory. The engine always ends with
`copy running-config startup-config` (look for "Saving user config OK!").
- **One management session at a time.** A logged-in web-UI session occupies the
single mgmt slot and blocks SSH login. Log out of the web UI before running.
- **`no ip http server` doesn't close port 80.** It disables HTTP *management*;
port 80 stays open serving only a JS redirect to HTTPS. That's expected/secure.

View File

@ -0,0 +1,59 @@
---
# tplink_switch_harden — defaults
#
# ─────────────────────────────────────────────────────────────────────────────
# SAFETY: switch_apply defaults to FALSE.
# A normal run only logs in and captures `show system-info` (proves access,
# changes nothing). Config is applied ONLY with -e switch_apply=true.
# ─────────────────────────────────────────────────────────────────────────────
switch_apply: false
switch_expect_timeout: 30
# Network re-addressing (static IP + gateway) is DISRUPTIVE: applying it drops
# the session because the management IP changes. Kept behind its own toggle and
# OFF by default, because in steady state (already at the static IP) it is a
# no-op, and the one-time DHCP->static cutover is handled deliberately, not on
# every run. Enable with -e switch_apply_network=true during the cutover.
switch_apply_network: false
# ─────────────────────────────────────────────────────────────────────────────
# VERIFIED against SG2428LP firmware via on-device `?` help (2026-06-09).
# All entries are idempotent — most are already at these values by factory
# default, so re-applying is a harmless no-op. Each runs inside `configure`.
# ─────────────────────────────────────────────────────────────────────────────
switch_harden_commands:
- { cmd: "no ip http server", desc: "disable plaintext HTTP management (port 80)" }
- { cmd: "ip http secure-server", desc: "ensure HTTPS management is enabled" }
- { cmd: "telnet disable", desc: "disable Telnet (port 23)" }
- { cmd: "ip ssh server", desc: "ensure SSH server is enabled" }
- { cmd: "ip ssh version v2", desc: "enable SSH protocol v2" }
- { cmd: "no snmp-server", desc: "ensure SNMP agent is disabled" }
- { cmd: "spanning-tree", desc: "enable spanning tree globally" }
- { cmd: "spanning-tree mode rstp", desc: "use RSTP" }
- { cmd: "loopback-detection", desc: "enable global loopback detection" }
# Device identity — safe, non-disruptive (applied with the hardening set).
# VERIFY token: JetStream may use `hostname` or `system name`.
switch_identity_commands:
- { cmd: "hostname {{ switch_hostname }}", desc: "set system hostname" }
# Static management addressing — DISRUPTIVE (drops session on the IP change).
# Applied only when switch_apply_network=true. VERIFY exact syntax on-device:
# the mgmt interface, the dhcp-client disable, and gateway form all vary.
switch_network_commands:
- { cmd: "interface vlan {{ switch_mgmt_vlan }}", desc: "enter mgmt VLAN interface" }
- { cmd: "ip address {{ switch_mgmt_ip_static }} {{ switch_mgmt_netmask }}", desc: "set static mgmt IP (overrides DHCP alloc)" }
- { cmd: "exit", desc: "leave interface context" }
- { cmd: "ip route 0.0.0.0 0.0.0.0 {{ switch_mgmt_gateway }}", desc: "default route via gateway" }
# ─────────────────────────────────────────────────────────────────────────────
# OPT-IN, HIGHER-RISK hardening — left OUT of the default set on purpose.
# Management access-control can lock you out if the control node is not inside
# the permitted range; only enable after confirming syntax AND your admin
# subnet. Provided here as a reference to fold into switch_harden_commands once
# verified. Do the ACL LAST and from a host inside the permitted range.
# ─────────────────────────────────────────────────────────────────────────────
# switch_harden_commands_optional:
# - { cmd: "user idle-timeout 10", desc: "10-min CLI idle timeout" }
# - { cmd: "user access-control ip-based enable", desc: "enable mgmt IP allowlist" }
# - { cmd: "user access-control ip-based 10.0.0.0 255.255.255.0", desc: "permit admin subnet" }

View File

@ -0,0 +1,60 @@
---
# tplink_switch_harden — drive the JetStream CLI via a rendered expect engine.
# Runs on the control node (play uses connection: local); the engine SSHes to
# the switch. The admin password is passed only through the SWITCH_PW env var.
- name: Assert admin password is available from vault
ansible.builtin.assert:
that:
- switch_admin_password is defined
- switch_admin_password | length > 0
fail_msg: >-
vault_sg2428lp_admin_password is empty/undefined. Populate it with
etc/set-switch-1password.sh (op -> ansible-vault bridge).
- name: Create temp dir for the rendered engine
ansible.builtin.tempfile:
state: directory
suffix: switch-harden
register: _sw_tmp
changed_when: false
- name: Render hardening expect engine
ansible.builtin.template:
src: harden.exp.j2
dest: "{{ _sw_tmp.path }}/harden.exp"
mode: "0700"
changed_when: false
- name: Run switch hardening engine ({{ 'APPLY' if switch_apply | bool else 'DRY/show-only' }})
ansible.builtin.command:
cmd: "expect -f {{ _sw_tmp.path }}/harden.exp"
environment:
SWITCH_PW: "{{ switch_admin_password }}"
register: _sw_run
no_log: true # protects SWITCH_PW in the environment
changed_when: "'>>>===APPLY-END===<<<' in _sw_run.stdout"
failed_when: false # evaluate explicitly below so output prints
- name: Show switch engine output
ansible.builtin.debug:
var: _sw_run.stdout_lines
- name: Remove temp dir
ansible.builtin.file:
path: "{{ _sw_tmp.path }}"
state: absent
changed_when: false
- name: Assert hardening engine succeeded
ansible.builtin.assert:
that:
- _sw_run.rc == 0
- "'AUTH-FAILED' not in _sw_run.stdout"
- "'FORCED-PASSWORD-CHANGE' not in _sw_run.stdout"
- "'LOGIN-TIMEOUT' not in _sw_run.stdout"
fail_msg: "Switch engine failed — review the output above (rc={{ _sw_run.rc }})."
success_msg: >-
{{ 'Hardening applied and saved to startup-config.'
if switch_apply | bool else
'Access verified (dry run). Re-run with -e switch_apply=true to apply.' }}

View File

@ -0,0 +1,92 @@
#!/usr/bin/expect -f
# RENDERED by Ansible (role: tplink_switch_harden). Do not edit the rendered copy.
# Drives a TP-Link JetStream switch CLI over SSH. Password comes from $env(SWITCH_PW)
# so it never appears in this file, the inventory, or the process arguments.
set timeout {{ switch_expect_timeout | default(30) }}
set host "{{ switch_mgmt_ip }}"
set user "{{ switch_admin_user }}"
set pw $env(SWITCH_PW)
set apply {{ '1' if switch_apply | bool else '0' }}
log_user 1
# Device prompt: User EXEC '>' or Privileged '#', optionally prefixed by hostname.
set PROMPT {[\w./-]*[>#] ?$}
proc wait_prompt {} {
global PROMPT
expect {
-re {(?i)--more--|press any key|\(q to quit\)} { send -- " "; exp_continue }
-re $PROMPT {}
timeout { puts "\n>>>TIMEOUT-AT-PROMPT<<<"; exit 21 }
eof { puts "\n>>>EOF-AT-PROMPT<<<"; exit 22 }
}
}
proc do {cmd} {
send -- "$cmd\r"
wait_prompt
}
# NOTE: PubkeyAuthentication=no + PreferredAuthentications=password is REQUIRED.
# The JetStream SSH server drops the connection when the client offers publickey
# auth first (the default), which made logins appear to "close after KEX" at
# random. Forcing password-only auth makes login deterministic.
spawn ssh -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=15 -o NumberOfPasswordPrompts=1 \
-o PreferredAuthentications=password -o PubkeyAuthentication=no $user@$host
expect {
-re {(?i)password:} { log_user 0; send -- "$pw\r"; log_user 1; exp_continue }
-re {(?i)(change.*password|new password|must.*change|set.*new password)} {
puts "\n>>>FORCED-PASSWORD-CHANGE<<< switch still at first-login; aborting."; exit 9
}
-re {(?i)(permission denied|authentication fail)} { puts "\n>>>AUTH-FAILED<<<"; exit 8 }
-re $PROMPT {}
timeout { puts "\n>>>LOGIN-TIMEOUT<<<"; exit 2 }
eof { puts "\n>>>LOGIN-EOF<<<"; exit 3 }
}
# Enter privileged EXEC (default enable password is blank).
send -- "enable\r"
expect {
-re {(?i)password:} { send -- "\r"; wait_prompt }
-re $PROMPT {}
timeout {}
}
# Always capture a read-only snapshot — proves access, changes nothing.
puts "\n>>>===SHOW-START===<<<"
do "show system-info"
puts "\n>>>===SHOW-END===<<<"
if {$apply == 1} {
puts "\n>>>===APPLY-START===<<<"
do "configure"
{% for c in switch_harden_commands %}
do {{ "{" ~ c.cmd ~ "}" }} ;# {{ c.desc }}
{% endfor %}
{% for c in switch_identity_commands %}
do {{ "{" ~ c.cmd ~ "}" }} ;# {{ c.desc }}
{% endfor %}
{% if switch_apply_network | bool %}
# NETWORK re-addressing. In steady state (already at the static IP) these are
# no-ops. During the one-time DHCP->static cutover the `ip address` line drops
# the session — do that via the dedicated migration step, not this engine.
{% for c in switch_network_commands %}
do {{ "{" ~ c.cmd ~ "}" }} ;# {{ c.desc }}
{% endfor %}
{% endif %}
do "end"
# Persist running-config to startup (JetStream may ask Y/N).
send -- "copy running-config startup-config\r"
expect {
-re {(?i)(y/n|are you sure|\[y/n\]|continue)} { send -- "Y\r"; wait_prompt }
-re $PROMPT {}
timeout {}
}
puts "\n>>>===APPLY-END===<<<"
}
send -- "exit\r"
catch {expect eof}
puts "\n>>>===DONE===<<<"