This commit is contained in:
2026-01-20 10:28:09 +02:00
parent ff4bde3c97
commit 8619bcf3a2
2 changed files with 329 additions and 0 deletions

View File

@@ -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

203
files/pppoe_to_dhcp.py Normal file
View File

@@ -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)