diff --git a/etc/set-switch-1password.sh b/etc/set-switch-1password.sh new file mode 100755 index 0000000..4a1a71e --- /dev/null +++ b/etc/set-switch-1password.sh @@ -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}" diff --git a/infrastructure/inventory/group_vars/all/dns.yml b/infrastructure/inventory/group_vars/all/dns.yml index a46c763..327cca1 100644 --- a/infrastructure/inventory/group_vars/all/dns.yml +++ b/infrastructure/inventory/group_vars/all/dns.yml @@ -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 diff --git a/infrastructure/inventory/group_vars/all/vars.yml b/infrastructure/inventory/group_vars/all/vars.yml index 973af39..8db3697 100644 --- a/infrastructure/inventory/group_vars/all/vars.yml +++ b/infrastructure/inventory/group_vars/all/vars.yml @@ -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" diff --git a/infrastructure/inventory/group_vars/switches/vars.yml b/infrastructure/inventory/group_vars/switches/vars.yml new file mode 100644 index 0000000..8fd57ad --- /dev/null +++ b/infrastructure/inventory/group_vars/switches/vars.yml @@ -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.20–10.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" diff --git a/infrastructure/inventory/group_vars/switches/vault.yml b/infrastructure/inventory/group_vars/switches/vault.yml new file mode 100644 index 0000000..cecbca5 --- /dev/null +++ b/infrastructure/inventory/group_vars/switches/vault.yml @@ -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 diff --git a/infrastructure/inventory/hosts.ini b/infrastructure/inventory/hosts.ini index 69d7177..b31b94c 100644 --- a/infrastructure/inventory/hosts.ini +++ b/infrastructure/inventory/hosts.ini @@ -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) diff --git a/infrastructure/playbooks/harden_switch.yml b/infrastructure/playbooks/harden_switch.yml new file mode 100644 index 0000000..c525646 --- /dev/null +++ b/infrastructure/playbooks/harden_switch.yml @@ -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 diff --git a/infrastructure/roles/samba_reverse_dns/tasks/main.yml b/infrastructure/roles/samba_reverse_dns/tasks/main.yml index a02c022..0bd9665 100644 --- a/infrastructure/roles/samba_reverse_dns/tasks/main.yml +++ b/infrastructure/roles/samba_reverse_dns/tasks/main.yml @@ -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: diff --git a/infrastructure/roles/tplink_switch_harden/README.md b/infrastructure/roles/tplink_switch_harden/README.md new file mode 100644 index 0000000..86545c2 --- /dev/null +++ b/infrastructure/roles/tplink_switch_harden/README.md @@ -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. diff --git a/infrastructure/roles/tplink_switch_harden/defaults/main.yml b/infrastructure/roles/tplink_switch_harden/defaults/main.yml new file mode 100644 index 0000000..716a14a --- /dev/null +++ b/infrastructure/roles/tplink_switch_harden/defaults/main.yml @@ -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" } diff --git a/infrastructure/roles/tplink_switch_harden/tasks/main.yml b/infrastructure/roles/tplink_switch_harden/tasks/main.yml new file mode 100644 index 0000000..f1bf0c4 --- /dev/null +++ b/infrastructure/roles/tplink_switch_harden/tasks/main.yml @@ -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.' }} diff --git a/infrastructure/roles/tplink_switch_harden/templates/harden.exp.j2 b/infrastructure/roles/tplink_switch_harden/templates/harden.exp.j2 new file mode 100644 index 0000000..1c80608 --- /dev/null +++ b/infrastructure/roles/tplink_switch_harden/templates/harden.exp.j2 @@ -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===<<<"