From 8619bcf3a282d8ccde15300bd87a802eda8e8ae4 Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 20 Jan 2026 10:28:09 +0200 Subject: [PATCH] 1028 --- files/ansible-playbooks/ptsd-migrate.yml | 126 ++++++++++++++ files/pppoe_to_dhcp.py | 203 +++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 files/pppoe_to_dhcp.py diff --git a/files/ansible-playbooks/ptsd-migrate.yml b/files/ansible-playbooks/ptsd-migrate.yml index 6081b38..dd2ffe3 100644 --- a/files/ansible-playbooks/ptsd-migrate.yml +++ b/files/ansible-playbooks/ptsd-migrate.yml @@ -606,6 +606,132 @@ - "DEV2 uptime via {{ dev2_conn_final }}:" - "{{ (dev2_uptime.stdout | default('')) | regex_replace('\r','') }}" + - name: PHASE 1 | Set local download path for DEV2 config.json + delegate_to: localhost + ansible.builtin.set_fact: + dev2_config_local_dir: "{{ _ctrl_dir | default('/tmp') }}" + dev2_config_local_path: "{{ (_ctrl_dir | default('/tmp')) }}/dev2_config_{{ inventory_hostname }}.json" + changed_when: false + + - name: PHASE 1 | DEV2 md5sum of /tmp/config.json via wrapper + delegate_to: localhost + ansible.builtin.shell: | + export DEV2_CMD="md5sum /tmp/config.json 2>/dev/null | awk '{print \$1}'" + {{ dev2_exec_cmd }} + args: { executable: /bin/bash } + register: dev2_config_md5 + changed_when: false + failed_when: false + + - name: PHASE 1 | Abort if DEV2 md5 could not be read + when: (dev2_config_md5.stdout | default('') | trim | length) == 0 + ansible.builtin.fail: + msg: "DEV2 md5sum for /tmp/config.json is empty; file missing or md5sum unavailable." + + - name: PHASE 1 | Download /tmp/config.json from DEV2 to controller (tunnel) + when: dev2_conn_final == "tunnel" + delegate_to: localhost + ansible.builtin.shell: | + set -e + PORT="{{ _local_port }}" + PASS="{{ dev2_passfile_used_tunnel }}" + sshpass -f "$PASS" scp {{ ssh_opts_common }} \ + -P "$PORT" "{{ dev2_ssh_user }}@127.0.0.1:/tmp/config.json" \ + "{{ dev2_config_local_path }}" + args: { executable: /bin/bash } + register: scp_tunnel + changed_when: true + failed_when: scp_tunnel.rc != 0 + + - name: PHASE 1 | Download /tmp/config.json from DEV2 to controller (direct LLDP IPv4) + when: dev2_conn_final == "direct_lldp" + delegate_to: localhost + ansible.builtin.shell: | + set -e + HOST="{{ lldp_dev2_ip }}" + PASS="{{ dev2_passfile_used_direct }}" + sshpass -f "$PASS" scp {{ ssh_opts_common }} \ + "{{ dev2_ssh_user }}@${HOST}:/tmp/config.json" \ + "{{ dev2_config_local_path }}" + args: { executable: /bin/bash } + register: scp_direct + changed_when: true + failed_when: scp_direct.rc != 0 + + - name: PHASE 1 | Download /tmp/config.json from DEV2 to controller (LLDP IPv4 fallback) + when: dev2_conn_final == "lldp4_fallback" + delegate_to: localhost + ansible.builtin.shell: | + set -e + HOST="{{ lldp_dev2_ip }}" + PASS="{{ dev2_passfile_used_lldp4 }}" + sshpass -f "$PASS" scp {{ ssh_opts_common }} \ + "{{ dev2_ssh_user }}@${HOST}:/tmp/config.json" \ + "{{ dev2_config_local_path }}" + args: { executable: /bin/bash } + register: scp_lldp4 + changed_when: true + failed_when: scp_lldp4.rc != 0 + + - name: PHASE 1 | Download /tmp/config.json from DEV2 to controller (LLDP IPv6 via DEV1 nested; last resort) + when: dev2_conn_final == "lldp6_via_dev1" + delegate_to: localhost + ansible.builtin.shell: | + set -e + IP6="{{ lldp_dev2_ip6 }}" + F="{{ dev2_passfile_used_lldp6 }}" + TMP_REMOTE="/tmp/config_{{ inventory_hostname }}.json" + + # 1) On DEV1: scp from DEV2 (ipv6 link-local) to DEV1 /tmp + sshpass -p "{{ dev1_pass }}" ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host | default(inventory_hostname) }}" \ + "sshpass -f '/tmp/${F}' scp \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ + -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 \ + '{{ dev2_ssh_user }}@\[${IP6}%{{ dev1_iface }}\]':/tmp/config.json '${TMP_REMOTE}'" + + # 2) From DEV1 to controller + sshpass -p "{{ dev1_pass }}" scp {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host | default(inventory_hostname) }}:${TMP_REMOTE}" \ + "{{ dev2_config_local_path }}" + + # 3) Cleanup on DEV1 (best-effort) + sshpass -p "{{ dev1_pass }}" ssh {{ ssh_opts_common }} \ + "{{ dev1_user }}@{{ ansible_host | default(inventory_hostname) }}" \ + "rm -f '${TMP_REMOTE}' 2>/dev/null || true" + args: { executable: /bin/bash } + register: scp_lldp6 + changed_when: true + failed_when: scp_lldp6.rc != 0 + + - name: PHASE 1 | Controller md5sum of downloaded config.json + delegate_to: localhost + ansible.builtin.shell: | + set -e + md5sum "{{ dev2_config_local_path }}" | awk '{print $1}' + args: { executable: /bin/bash } + register: local_config_md5 + changed_when: false + + - name: PHASE 1 | Compare DEV2 vs controller md5sum (fail on mismatch) + delegate_to: localhost + ansible.builtin.fail: + msg: >- + MD5 mismatch for /tmp/config.json: + dev2={{ dev2_config_md5.stdout | trim }}, + local={{ local_config_md5.stdout | trim }}, + file={{ dev2_config_local_path }} + when: (dev2_config_md5.stdout | trim) != (local_config_md5.stdout | trim) + + - name: PHASE 1 | Debug config.json download + md5 + when: debugging | bool + delegate_to: localhost + ansible.builtin.debug: + msg: + - "Downloaded DEV2 /tmp/config.json to {{ dev2_config_local_path }}" + - "DEV2 md5={{ dev2_config_md5.stdout | trim }}" + - "Local md5={{ local_config_md5.stdout | trim }}" + post_tasks: - name: Cleanup note delegate_to: localhost diff --git a/files/pppoe_to_dhcp.py b/files/pppoe_to_dhcp.py new file mode 100644 index 0000000..c096ac2 --- /dev/null +++ b/files/pppoe_to_dhcp.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Convert a device config from PPPoE uplink to DHCP on VLAN 4000. + +Edits ONLY these paths: + .network.zones.wan.mode -> "dhcp" + .network.zones.wan.alias -> [] (ensure present) + .network.zones.wan.stp -> false (ensure present) + .network.zones.wan.dns -> ["41.222.55.1"] + .ethernet.ports.eth0.network.vlan_access.enabled-> true + .ethernet.ports.eth0.network.vlan_access.id -> 4000 + +Everything else is preserved as-is (no key sorting, order preserved). +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +from collections import OrderedDict +from typing import Any, MutableMapping, Sequence + + +def load_json_preserve_order(path: str) -> Any: + with open(path, "r", encoding="utf-8") as f: + return json.load(f, object_pairs_hook=OrderedDict) + + +def detect_indent(raw_text: str) -> int: + """ + Best-effort indent detection. Defaults to 2 if unclear. + """ + # Look for the first line that begins with spaces then a quote (a key). + m = re.search(r"\n( +)\"", raw_text) + if not m: + return 2 + spaces = len(m.group(1)) + # Common indents are 2 or 4; accept any positive count. + return spaces if spaces > 0 else 2 + + +def get_mapping(root: Any, path: Sequence[str]) -> MutableMapping[str, Any]: + """ + Walks down dict-like objects; raises KeyError/TypeError if structure is missing. + Returns the mapping at the end of the path. + """ + cur = root + for key in path: + if not isinstance(cur, MutableMapping): + raise TypeError(f"Expected object at {'.'.join(path)}, got {type(cur).__name__}") + if key not in cur: + raise KeyError(f"Missing key '{key}' at {'.'.join(path)}") + cur = cur[key] + if not isinstance(cur, MutableMapping): + raise TypeError(f"Expected object at {'.'.join(path)}, got {type(cur).__name__}") + return cur + + +def ensure_path(root: Any, path: Sequence[str]) -> MutableMapping[str, Any]: + """ + Ensures nested dicts exist; creates missing dicts as OrderedDict. + Returns the mapping at the end of the path. + """ + cur = root + for key in path: + if not isinstance(cur, MutableMapping): + raise TypeError(f"Expected object while creating {'.'.join(path)}, got {type(cur).__name__}") + if key not in cur or cur[key] is None: + cur[key] = OrderedDict() + cur = cur[key] + if not isinstance(cur, MutableMapping): + raise TypeError(f"Expected object at {'.'.join(path)}, got {type(cur).__name__}") + return cur + + +def set_value(root: Any, path: Sequence[str], value: Any, create: bool = False) -> tuple[bool, Any, Any]: + """ + Set value at path. If create=False, path must exist. If create=True, missing + objects along the way are created. + Returns (changed, old_value, new_value). + """ + if len(path) < 1: + raise ValueError("Path must have at least one key") + parent_path = path[:-1] + leaf = path[-1] + + parent = ensure_path(root, parent_path) if create else get_mapping(root, parent_path) + + old = parent.get(leaf, None) + if old == value: + return (False, old, value) + parent[leaf] = value + return (True, old, value) + + +def validate_post(root: Any) -> None: + """ + Minimal structural and value validation for the changed fields. + Raises exceptions on mismatch. + """ + # Check mode + wan = get_mapping(root, ["network", "zones", "wan"]) + if wan.get("mode") != "dhcp": + raise ValueError("Post-check failed: .network.zones.wan.mode != 'dhcp'") + + # Check VLAN access + vlan_access = get_mapping(root, ["ethernet", "ports", "eth0", "network", "vlan_access"]) + if vlan_access.get("enabled") is not True: + raise ValueError("Post-check failed: vlan_access.enabled is not true") + if vlan_access.get("id") != 4000: + raise ValueError("Post-check failed: vlan_access.id != 4000") + + # Check schema fields + if wan.get("alias") != []: + raise ValueError("Post-check failed: .network.zones.wan.alias != []") + if wan.get("stp") is not False: + raise ValueError("Post-check failed: .network.zones.wan.stp != false") + + # DNS + if wan.get("dns") != ["41.222.55.1"]: + raise ValueError("Post-check failed: .network.zones.wan.dns != ['41.222.55.1']") + + +def main() -> int: + ap = argparse.ArgumentParser(description="Convert config JSON from PPPoE to DHCP on VLAN 4000 (minimal edits).") + ap.add_argument("input", help="Input JSON file (e.g., config.json)") + ap.add_argument("-o", "--output", help="Output file. If omitted and --in-place not set, prints to stdout.") + ap.add_argument("--in-place", action="store_true", help="Modify input file in place (creates .bak backup).") + ap.add_argument("--backup-suffix", default=".bak", help="Backup suffix for --in-place (default: .bak)") + args = ap.parse_args() + + in_path = args.input + + raw = open(in_path, "r", encoding="utf-8").read() + indent = detect_indent(raw) + + data = load_json_preserve_order(in_path) + + changes: list[str] = [] + + def apply(path: Sequence[str], value: Any, create: bool = False) -> None: + changed, old, new = set_value(data, path, value, create=create) + if changed: + changes.append(f"{'.' + '.'.join(path)}: {old!r} -> {new!r}") + + # Required edits + apply(["network", "zones", "wan", "mode"], "dhcp", create=False) + + # "Only in new" fields must exist exactly like new + apply(["network", "zones", "wan", "alias"], [], create=True) + apply(["network", "zones", "wan", "stp"], False, create=True) + + # DNS pinned as requested + apply(["network", "zones", "wan", "dns"], ["41.222.55.1"], create=True) + + # VLAN 4000 on eth0 + apply(["ethernet", "ports", "eth0", "network", "vlan_access", "enabled"], True, create=True) + apply(["ethernet", "ports", "eth0", "network", "vlan_access", "id"], 4000, create=True) + + # Validate + validate_post(data) + + # Serialize + out_text = json.dumps(data, indent=indent, ensure_ascii=False) + "\n" + + # Write + if args.in_place: + backup_path = in_path + args.backup_suffix + if not os.path.exists(backup_path): + shutil.copy2(in_path, backup_path) + else: + # Avoid overwriting an existing backup silently + raise FileExistsError(f"Backup already exists: {backup_path}") + with open(in_path, "w", encoding="utf-8") as f: + f.write(out_text) + elif args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(out_text) + else: + sys.stdout.write(out_text) + + # Report to stderr for CLI usage + sys.stderr.write("Applied changes:\n") + if changes: + for line in changes: + sys.stderr.write(f" - {line}\n") + else: + sys.stderr.write(" (no changes needed; already in desired state)\n") + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as e: + sys.stderr.write(f"ERROR: {e}\n") + raise SystemExit(2) +