mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
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:
parent
c9591a5da2
commit
ca8dd6a525
62
etc/set-switch-1password.sh
Executable file
62
etc/set-switch-1password.sh
Executable 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}"
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
35
infrastructure/inventory/group_vars/switches/vars.yml
Normal file
35
infrastructure/inventory/group_vars/switches/vars.yml
Normal 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.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"
|
||||
11
infrastructure/inventory/group_vars/switches/vault.yml
Normal file
11
infrastructure/inventory/group_vars/switches/vault.yml
Normal 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
|
||||
@ -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)
|
||||
|
||||
19
infrastructure/playbooks/harden_switch.yml
Normal file
19
infrastructure/playbooks/harden_switch.yml
Normal 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
|
||||
@ -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:
|
||||
|
||||
77
infrastructure/roles/tplink_switch_harden/README.md
Normal file
77
infrastructure/roles/tplink_switch_harden/README.md
Normal 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.
|
||||
59
infrastructure/roles/tplink_switch_harden/defaults/main.yml
Normal file
59
infrastructure/roles/tplink_switch_harden/defaults/main.yml
Normal 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" }
|
||||
60
infrastructure/roles/tplink_switch_harden/tasks/main.yml
Normal file
60
infrastructure/roles/tplink_switch_harden/tasks/main.yml
Normal 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.' }}
|
||||
@ -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===<<<"
|
||||
Loading…
Reference in New Issue
Block a user