Initial commit of AthonetTools

This commit is contained in:
2025-08-21 12:59:43 +00:00
commit cd932b8fcb
2483 changed files with 433999 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
from flask import Blueprint
bp = Blueprint("cc_v1", __name__)

25
services/api/cc_v1/ncm.py Normal file
View File

@@ -0,0 +1,25 @@
from flask import Blueprint, request, jsonify, current_app
from services.combocore.ncm import get_routes, derive_eth0_cidr_gw
bp = Blueprint("cc_ncm_v1", __name__)
@bp.post("/routes")
def api_routes():
body = request.get_json(silent=True) or {}
host = body.get("host", "").strip()
if not host:
return jsonify({"ok": False, "error": "host required"}), 400
cfg = current_app.config
routes = get_routes(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
return jsonify({"ok": True, "routes": routes})
@bp.post("/oam")
def api_oam_eth0():
body = request.get_json(silent=True) or {}
host = body.get("host", "").strip()
if not host:
return jsonify({"ok": False, "error": "host required"}), 400
cfg = current_app.config
routes = get_routes(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
cidr, gw = derive_eth0_cidr_gw(routes)
return jsonify({"ok": True, "cidr": cidr, "gw": gw})

24
services/api/cc_v1/pls.py Normal file
View File

@@ -0,0 +1,24 @@
from flask import Blueprint, request, jsonify, current_app
from services.combocore.pls import login as pls_login, get_me
bp = Blueprint("cc_pls_v1", __name__)
@bp.post("/login")
def api_login():
body = request.get_json(silent=True) or {}
host = body.get("host", "").strip()
if not host:
return jsonify({"ok": False, "error": "host required"}), 400
cfg = current_app.config
token = pls_login(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
return jsonify({"ok": True, "access_token": token})
@bp.post("/me")
def api_me():
body = request.get_json(silent=True) or {}
host = body.get("host", "").strip()
if not host:
return jsonify({"ok": False, "error": "host required"}), 400
cfg = current_app.config
me = get_me(host, cfg["CORE_API_USER"], cfg["CORE_API_PASS"], cfg["VERIFY_SSL"], cfg["REQUEST_TIMEOUT"])
return jsonify({"ok": True, "me": me})

63
services/combocore.py Normal file
View File

@@ -0,0 +1,63 @@
# services/combocore.py
import requests
from requests.exceptions import HTTPError
requests.packages.urllib3.disable_warnings()
def _fmt_host(host: str) -> str:
host = host.strip().strip("[]")
# if IPv6, wrap with []
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def login(host: str, username="admin", password="Super4dmin!") -> str:
h = _fmt_host(host)
url = f"https://{h}/core/pls/api/1/auth/login"
r = requests.post(url, json={"username": username, "password": password}, verify=False, timeout=10)
r.raise_for_status()
j = r.json()
# API returns "access_token"
return j["access_token"]
def get_routes(host: str, token: str) -> list:
h = _fmt_host(host)
url = f"https://{h}/core/ncm/api/1/status/routes"
r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, verify=False, timeout=10)
r.raise_for_status()
return r.json()
def extract_eth0_cidr_gw(routes: list) -> tuple[str, str]:
"""
From the routes list:
- CIDR = <prefsrc>/<mask-from-eth0-connected-route>
- GW = default route gateway on eth0
"""
prefsrc = None
mask = None
gw = None
# default gw via eth0
for r in routes:
if r.get("family") == "inet" and r.get("type") == "unicast" and r.get("dst") == "default" and r.get("dev") == "eth0":
gw = r.get("gateway")
break
# find eth0 connected network to derive mask, and prefsrc for IP
for r in routes:
if r.get("family") == "inet" and r.get("dev") == "eth0":
if not prefsrc and r.get("prefsrc"):
prefsrc = r.get("prefsrc")
dst = r.get("dst") or ""
if "/" in dst:
try:
mask = dst.split("/", 1)[1]
except Exception:
pass
if prefsrc and mask and gw:
break
if not (prefsrc and mask and gw):
raise ValueError("Unable to derive eth0 CIDR/GW from routes payload")
return f"{prefsrc}/{mask}", gw

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
DEFAULT_TIMEOUT = 10.0
class ComboCoreClient:
def __init__(self, host: str, username: str, password: str, verify_ssl: bool = False, timeout: float = DEFAULT_TIMEOUT):
self.base = f"https://{host}"
self.username = username
self.password = password
self.verify = verify_ssl
self.timeout = timeout
self._token = None
self._s = requests.Session()
retry = Retry(total=3, backoff_factor=0.3, status_forcelist=[429,500,502,503,504])
self._s.mount("https://", HTTPAdapter(max_retries=retry))
self._s.mount("http://", HTTPAdapter(max_retries=retry))
# ----- PLS auth -----
def login(self) -> str:
r = self._s.post(
f"{self.base}/core/pls/api/1/auth/login",
json={"username": self.username, "password": self.password},
timeout=self.timeout, verify=self.verify
)
r.raise_for_status()
j = r.json()
self._token = j.get("access_token") or j.get("token")
if not self._token:
raise RuntimeError("No access token in login response.")
return self._token
def _auth_headers(self):
if not self._token:
self.login()
return {"Authorization": f"Bearer {self._token}"}
def get(self, path: str):
r = self._s.get(f"{self.base}{path}", headers=self._auth_headers(), timeout=self.timeout, verify=self.verify)
if r.status_code == 401:
self._token = None
r = self._s.get(f"{self.base}{path}", headers=self._auth_headers(), timeout=self.timeout, verify=self.verify)
r.raise_for_status()
return r.json()
def post(self, path: str, json=None):
r = self._s.post(f"{self.base}{path}", headers=self._auth_headers(), json=json or {}, timeout=self.timeout, verify=self.verify)
if r.status_code == 401:
self._token = None
r = self._s.post(f"{self.base}{path}", headers=self._auth_headers(), json=json or {}, timeout=self.timeout, verify=self.verify)
r.raise_for_status()
return r.json()

29
services/combocore/ncm.py Normal file
View File

@@ -0,0 +1,29 @@
from typing import Tuple, Optional, List, Dict
from .client import ComboCoreClient
def get_routes(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> List[Dict]:
cli = ComboCoreClient(host, user, pwd, verify_ssl, timeout)
return cli.get("/core/ncm/api/1/status/routes")
def derive_eth0_cidr_gw(routes: List[Dict]) -> Tuple[str, Optional[str]]:
gw = None
ip = None
masklen = None
for rt in routes:
if rt.get("family") == "inet" and rt.get("dst") == "default" and rt.get("dev") == "eth0":
gw = rt.get("gateway")
for rt in routes:
if rt.get("family") == "inet" and rt.get("dev") == "eth0":
if not ip and rt.get("prefsrc"):
ip = rt["prefsrc"]
dst = rt.get("dst", "")
if "/" in dst:
try:
masklen = int(dst.split("/", 1)[1])
except Exception:
pass
if ip and masklen is not None:
break
if not ip or masklen is None:
raise RuntimeError("Could not derive eth0 IP/prefix from routes.")
return f"{ip}/{masklen}", gw

View File

@@ -0,0 +1,9 @@
from .client import ComboCoreClient
# Minimal PLS surface to start
def login(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> str:
return ComboCoreClient(host, user, pwd, verify_ssl, timeout).login()
def get_me(host: str, user: str, pwd: str, verify_ssl=False, timeout=10.0) -> dict:
cli = ComboCoreClient(host, user, pwd, verify_ssl, timeout)
return cli.get("/core/pls/api/1/auth/me") # adjust if different

42
services/local_net.py Normal file
View File

@@ -0,0 +1,42 @@
# services/local_net.py
import subprocess
import re
def _sh(cmd):
out = subprocess.check_output(cmd, text=True)
return out
def get_eth0_dhcp_snapshot():
"""
Returns {'iface':'eth0','cidr':'X.X.X.X/YY','ip':'X.X.X.X','prefixlen':YY,'gw':'A.B.C.D'}
Raises on failure.
"""
iface = "eth0"
# ip -o -4 addr show dev eth0 | awk '{print $4}'
addrs = _sh(["/sbin/ip", "-o", "-4", "addr", "show", "dev", iface]).strip()
if not addrs:
addrs = _sh(["ip", "-o", "-4", "addr", "show", "dev", iface]).strip()
m = re.search(r"\s(\d+\.\d+\.\d+\.\d+/\d+)\s", addrs)
if not m:
raise RuntimeError(f"No IPv4 address on {iface}")
cidr = m.group(1)
ip = cidr.split("/")[0]
prefixlen = int(cidr.split("/")[1])
# ip route show default dev eth0 | awk '/default/ {print $3}'
routes = _sh(["/sbin/ip", "route", "show", "default", "dev", iface])
if not routes:
routes = _sh(["ip", "route", "show", "default", "dev", iface])
m2 = re.search(r"default via (\d+\.\d+\.\d+\.\d+)", routes)
gw = m2.group(1) if m2 else ""
return {
"iface": iface,
"cidr": cidr,
"ip": ip,
"prefixlen": prefixlen,
"gw": gw
}

43
services/net_info.py Normal file
View File

@@ -0,0 +1,43 @@
# services/net_info.py
import subprocess
import json
def _run(cmd: list[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()
def _first_ipv4_cidr_from_ip_json(ip_json: dict) -> str | None:
# Find first IPv4 on the interface
for addr in ip_json.get("addr_info", []):
if addr.get("family") == "inet" and addr.get("local") and addr.get("prefixlen") is not None:
return f'{addr["local"]}/{addr["prefixlen"]}'
return None
def get_iface_cidr(iface: str) -> str | None:
# ip -j addr show dev eth0
raw = _run(["ip", "-j", "addr", "show", "dev", iface])
data = json.loads(raw)
if not data:
return None
return _first_ipv4_cidr_from_ip_json(data[0])
def get_default_gw_for_iface(iface: str) -> str | None:
lines = _run(["ip", "route", "show", "default", "dev", iface]).splitlines()
for line in lines:
parts = line.split()
if parts and parts[0] == "default":
try:
idx = parts.index("via")
return parts[idx + 1]
except ValueError:
continue
except IndexError:
continue
return None
def get_eth0_dhcp_snapshot() -> dict:
iface = "eth0"
cidr = get_iface_cidr(iface) or ""
gw = get_default_gw_for_iface(iface) or ""
if not cidr or not gw:
raise RuntimeError("Could not determine eth0 CIDR and/or default gateway")
return {"iface": iface, "cidr": cidr, "gw": gw}

48
services/remote_admin.py Normal file
View File

@@ -0,0 +1,48 @@
# services/remote_admin.py
import requests, ipaddress
REQUEST_KW = dict(timeout=10, verify=False)
def validate_ipv4(ip: str) -> str:
try:
ipaddress.IPv4Address(ip)
return ip
except Exception:
raise ValueError(f"Invalid IPv4 address: {ip}")
def authenticate(ip: str, username: str, password: str) -> str:
url = f"https://{ip}/core/pls/api/1/auth/login"
r = requests.post(url, json={"username": username, "password": password}, **REQUEST_KW)
r.raise_for_status()
data = r.json()
token = data.get("access_token") or data.get("token") or data.get("data", {}).get("access_token")
if not token:
raise RuntimeError("No access token in auth response")
return token
ALLOWED_SERVICES = {"ssh", "webconsole"}
ALLOWED_ACTIONS = {"enable", "enable-autostart", "start"}
def service_action(ip: str, service: str, action: str, username: str, password: str) -> dict:
if service not in ALLOWED_SERVICES:
raise ValueError(f"Unsupported service: {service}")
if action not in ALLOWED_ACTIONS:
raise ValueError(f"Unsupported action: {action}")
ip = validate_ipv4(ip)
token = authenticate(ip, username, password)
url = f"https://{ip}/core/pls/api/1/services/{service}/{action}"
r = requests.post(url, headers={"Authorization": f"Bearer {token}"}, **REQUEST_KW)
r.raise_for_status()
return r.json() if r.content else {"ok": True}
def perform_service_sequence(ip: str, service: str, username: str, password: str) -> None:
for action in ("enable", "enable-autostart", "start"):
service_action(ip, service, action, username, password)
# Backwards-compatible wrappers (optional)
def ssh_action(ip, action, username, password):
return service_action(ip, "ssh", action, username, password)
def perform_ssh_sequence(ip, username, password):
return perform_service_sequence(ip, "ssh", username, password)

31
services/state.py Normal file
View File

@@ -0,0 +1,31 @@
# services/state.py
from pathlib import Path
import json
STATE_FILE = Path("system_info.json")
def load_state():
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text() or "{}")
return {}
def save_state(d: dict):
STATE_FILE.write_text(json.dumps(d, indent=2))
def set_target_ip(ip: str):
st = load_state()
st["target_host_ip"] = ip
save_state(st)
def get_target_ip() -> str | None:
return load_state().get("target_host_ip")
def set_mgmt_info(cidr: str, gw: str):
"""Persist the current DHCP values we discovered for eth0 so we can render static OAM later."""
st = load_state()
st["mgmt"] = {"cidr": cidr, "gw": gw}
save_state(st)
def get_mgmt_info() -> dict:
"""Return {'cidr': 'x.x.x.x/yy', 'gw': 'x.x.x.x'} if previously captured, else {}."""
return load_state().get("mgmt") or {}

52
services/yaml_writer.py Normal file
View File

@@ -0,0 +1,52 @@
# services/yaml_writer.py
from pathlib import Path
from typing import Dict, Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
import shutil
ANSIBLE_ROOT = Path("ansible_workspace")
STAGING = ANSIBLE_ROOT / "staging"
TEMPLATE_DIR = Path("templates/ansible_templates")
STATIC_SEEDS = TEMPLATE_DIR / "_seeds" # optional: for csv/license seed files
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(enabled_extensions=()),
trim_blocks=True,
lstrip_blocks=True,
)
def clean_dir(p: Path):
if p.exists():
for item in sorted(p.rglob("*"), reverse=True):
if item.is_file():
item.unlink()
elif item.is_dir():
item.rmdir()
def ensure_tree(scenario: str, hostname: str, esxi_host: str) -> Path:
"""
Returns the base path to .../staging/<scenario>/ and ensures the tree exists.
"""
base = STAGING / scenario
(base / "host_vars" / hostname).mkdir(parents=True, exist_ok=True)
(base / "host_vars" / esxi_host).mkdir(parents=True, exist_ok=True)
return base
def render_to_file(template_name: str, context: Dict[str, Any], out_path: Path):
print(f"[DEBUG] Rendering {out_path} with context: {context}")
tmpl = env.get_template(template_name)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(tmpl.render(**context))
def copy_seed(name: str, dest: Path):
"""
Copies a static seed file (e.g., impus.csv, supis.csv, license_vars.yaml)
from templates/ansible_templates/_seeds/ if present; otherwise creates empty.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
seed = STATIC_SEEDS / name
if seed.exists():
shutil.copyfile(seed, dest)
else:
dest.write_text("") # empty default