163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Drive the labsim VyOS router over its serial console.
|
||
|
|
|
||
|
|
Three phases:
|
||
|
|
--phase live wait for the live system and log in
|
||
|
|
--phase install run `install image` unattended
|
||
|
|
--phase configure apply bond0 (LACP) + per-VLAN gateway addresses
|
||
|
|
|
||
|
|
The installer prompt list is the same one the bastion's install driver answers
|
||
|
|
(src/bastion/src/templates/vyos-install.py.ts). Two of them are easy to miss and
|
||
|
|
both hang forever rather than failing: the reinstall-only "copy data to the new
|
||
|
|
image?", and "choose two disks for RAID-1 mirroring?" — every RAID prompt
|
||
|
|
defaults to YES.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
import pexpect
|
||
|
|
|
||
|
|
PASSWORD = "vyos"
|
||
|
|
PROMPT = r"[\$#] $"
|
||
|
|
|
||
|
|
|
||
|
|
def console(vm: str, timeout: int = 60) -> pexpect.spawn:
|
||
|
|
c = pexpect.spawn(f"sudo virsh console {vm} --force", encoding="utf-8", timeout=timeout)
|
||
|
|
c.expect("Connected to domain", timeout=30)
|
||
|
|
return c
|
||
|
|
|
||
|
|
|
||
|
|
def login(c: pexpect.spawn, timeout: int = 300) -> None:
|
||
|
|
"""Get to a shell prompt, whether we land at a login or an open session."""
|
||
|
|
deadline = time.time() + timeout
|
||
|
|
while time.time() < deadline:
|
||
|
|
c.sendline("")
|
||
|
|
i = c.expect(["login:", PROMPT, pexpect.TIMEOUT], timeout=20)
|
||
|
|
if i == 0:
|
||
|
|
c.sendline("vyos")
|
||
|
|
c.expect("assword:", timeout=20)
|
||
|
|
c.sendline(PASSWORD)
|
||
|
|
j = c.expect([PROMPT, "incorrect", pexpect.TIMEOUT], timeout=30)
|
||
|
|
if j == 0:
|
||
|
|
return
|
||
|
|
elif i == 1:
|
||
|
|
return
|
||
|
|
raise SystemExit("timed out waiting for a VyOS shell")
|
||
|
|
|
||
|
|
|
||
|
|
def run(c: pexpect.spawn, cmd: str, timeout: int = 60) -> str:
|
||
|
|
c.sendline(cmd)
|
||
|
|
c.expect(PROMPT, timeout=timeout)
|
||
|
|
return c.before or ""
|
||
|
|
|
||
|
|
|
||
|
|
def phase_install(c: pexpect.spawn) -> None:
|
||
|
|
"""Answer `install image` end to end."""
|
||
|
|
rules: list[tuple[str, str]] = [
|
||
|
|
(r"Would you like to continue\?", "yes"),
|
||
|
|
(r"What would you like to name this image\?", ""),
|
||
|
|
(r"Please confirm password for the .vyos. user:", PASSWORD),
|
||
|
|
(r"Please enter a password for the .vyos. user:", PASSWORD),
|
||
|
|
(r"What console should be used by default", "K"),
|
||
|
|
# every RAID variant defaults to YES — decline them all
|
||
|
|
(r"Would you like to [^?]*RAID-1 mirroring", "no"),
|
||
|
|
(r"Installation will delete all data on (?:the drive|both drives)\. Continue\?", "yes"),
|
||
|
|
(r"Which one should be used for installation\?", "/dev/vda"),
|
||
|
|
(r"Would you like to use all the free space on the drive\?", "yes"),
|
||
|
|
(r"Which file would you like as boot config\?", "1"),
|
||
|
|
# reinstall-only; unanswered it blocks on stdin until the world ends
|
||
|
|
(r"Would you like to copy data to the new image\?", "yes"),
|
||
|
|
(r"From which image would you like to save config information\?", "1"),
|
||
|
|
]
|
||
|
|
patterns = [r for r, _ in rules] + [r"The image installed successfully",
|
||
|
|
r"Unable to install VyOS", pexpect.TIMEOUT]
|
||
|
|
|
||
|
|
c.sendline("install image")
|
||
|
|
for _ in range(60):
|
||
|
|
i = c.expect(patterns, timeout=180)
|
||
|
|
if i < len(rules):
|
||
|
|
c.sendline(rules[i][1])
|
||
|
|
continue
|
||
|
|
if i == len(rules):
|
||
|
|
print(" installer: success")
|
||
|
|
return
|
||
|
|
if i == len(rules) + 1:
|
||
|
|
raise SystemExit("installer reported failure")
|
||
|
|
raise SystemExit("installer went quiet (unanswered prompt?)")
|
||
|
|
raise SystemExit("installer exceeded expected prompt count")
|
||
|
|
|
||
|
|
|
||
|
|
def phase_configure(c: pexpect.spawn, vlans: list[tuple[str, str, str]]) -> None:
|
||
|
|
"""bond0 over eth0+eth1 with LACP, then a gateway address per VLAN."""
|
||
|
|
# Production shape: VLAN 1 (management) is the NATIVE/untagged VLAN on the
|
||
|
|
# bond, everything else is a tagged vif. This matters beyond fidelity —
|
||
|
|
# LACPDUs are untagged, so a trunk with no native VLAN has nowhere to put
|
||
|
|
# them and the bond never negotiates.
|
||
|
|
native = [v for v in vlans if v[0] == "1"]
|
||
|
|
tagged = [v for v in vlans if v[0] != "1"]
|
||
|
|
|
||
|
|
cmds = [
|
||
|
|
"configure",
|
||
|
|
"set interfaces bonding bond0 mode '802.3ad'",
|
||
|
|
"set interfaces bonding bond0 hash-policy 'layer2+3'",
|
||
|
|
"set interfaces bonding bond0 lacp-rate 'fast'",
|
||
|
|
"set interfaces bonding bond0 member interface 'eth0'",
|
||
|
|
"set interfaces bonding bond0 member interface 'eth1'",
|
||
|
|
"set service ssh port '22'",
|
||
|
|
"set system login user vyos authentication plaintext-password 'vyos'",
|
||
|
|
]
|
||
|
|
for vid, name, prefix in native:
|
||
|
|
cmds.append(f"set interfaces bonding bond0 address '{prefix}.1/24'")
|
||
|
|
cmds.append(f"set interfaces bonding bond0 description '{name} (native)'")
|
||
|
|
for vid, name, prefix in tagged:
|
||
|
|
cmds.append(f"set interfaces bonding bond0 vif {vid} address '{prefix}.1/24'")
|
||
|
|
cmds.append(f"set interfaces bonding bond0 vif {vid} description '{name}'")
|
||
|
|
cmds += ["commit", "save", "exit"]
|
||
|
|
|
||
|
|
for cmd in cmds:
|
||
|
|
out = run(c, cmd, timeout=180)
|
||
|
|
low = out.lower()
|
||
|
|
if "invalid" in low or "syntax error" in low or "commit failed" in low:
|
||
|
|
print(f" !! {cmd}\n{out.strip()[-300:]}")
|
||
|
|
raise SystemExit(f"config command rejected: {cmd}")
|
||
|
|
print(" config committed and saved")
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
ap = argparse.ArgumentParser()
|
||
|
|
ap.add_argument("--vm", required=True)
|
||
|
|
ap.add_argument("--phase", required=True, choices=["live", "install", "configure"])
|
||
|
|
ap.add_argument("--vlans", default="", help="space separated vid:name:prefix:real entries")
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
c = console(args.vm)
|
||
|
|
try:
|
||
|
|
login(c)
|
||
|
|
if args.phase == "live":
|
||
|
|
print(" live system reachable")
|
||
|
|
elif args.phase == "install":
|
||
|
|
phase_install(c)
|
||
|
|
else:
|
||
|
|
vlans = []
|
||
|
|
for entry in args.vlans.split():
|
||
|
|
parts = entry.split(":")
|
||
|
|
if len(parts) >= 3:
|
||
|
|
vlans.append((parts[0], parts[1], parts[2]))
|
||
|
|
if not vlans:
|
||
|
|
raise SystemExit("no VLANs passed to configure")
|
||
|
|
phase_configure(c, vlans)
|
||
|
|
return 0
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
c.sendline("")
|
||
|
|
c.close(force=True)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|