Files
2026-06-25 15:28:46 +03:00

173 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Ethernet network configuration via NetworkManager (nmcli).
Reading current state is done directly (read-only nmcli). Applying a new
configuration is delegated to the privileged helper script `bt-netconfig`
(invoked through sudo) so the web service can keep running unprivileged.
"""
import ipaddress
import json
import re
import subprocess
from flask import Blueprint, abort, jsonify, request
from auth import login_required
from config_store import config_path
from paths import scripts_dir
network_bp = Blueprint("network", __name__)
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
def network_config_path():
return config_path().parent / "network.json"
def save_network_config(payload):
path = network_config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
f.write("\n")
tmp.replace(path)
def _run(args, timeout=10):
try:
out = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return out.returncode, out.stdout, out.stderr
except FileNotFoundError:
return 127, "", f"{args[0]} not found"
except subprocess.TimeoutExpired:
return 124, "", "timeout"
def list_ethernet_devices():
"""Return [{device, state, connection}] for ethernet interfaces."""
rc, out, _ = _run([
"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status",
])
devices = []
if rc != 0:
return devices
for line in out.splitlines():
parts = line.split(":")
if len(parts) < 4:
continue
device, dtype, state, connection = parts[0], parts[1], parts[2], parts[3]
if dtype != "ethernet":
continue
devices.append({"device": device, "state": state, "connection": connection})
return devices
def device_details(device):
"""Return current IP4 config for a device."""
rc, out, _ = _run([
"nmcli", "-t", "-f",
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION",
"device", "show", device,
])
info = {"device": device, "addresses": [], "gateway": "", "dns": [],
"connection": ""}
if rc != 0:
return info
for line in out.splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
val = val.strip()
if not val:
continue
if key.startswith("IP4.ADDRESS"):
info["addresses"].append(val)
elif key == "IP4.GATEWAY":
info["gateway"] = val
elif key.startswith("IP4.DNS"):
info["dns"].append(val)
elif key == "GENERAL.CONNECTION":
info["connection"] = val
return info
def current_state():
devices = list_ethernet_devices()
for d in devices:
d.update({k: v for k, v in device_details(d["device"]).items()
if k != "device"})
return {"devices": devices}
def _valid_ip(value):
try:
ipaddress.ip_address(value)
return True
except ValueError:
return False
def _validate_payload(data):
iface = (data.get("iface") or "").strip()
if not _IFACE_RE.match(iface):
return None, "invalid iface"
mode = (data.get("mode") or "dhcp").strip().lower()
if mode not in ("dhcp", "static"):
return None, "mode must be dhcp or static"
payload = {"iface": iface, "mode": mode}
if mode == "static":
address = (data.get("address") or "").strip()
if not _valid_ip(address):
return None, "invalid address"
try:
prefix = int(data.get("prefix", 24))
except (TypeError, ValueError):
return None, "invalid prefix"
if not (1 <= prefix <= 32):
return None, "prefix must be 1..32"
gateway = (data.get("gateway") or "").strip()
if gateway and not _valid_ip(gateway):
return None, "invalid gateway"
dns = [d.strip() for d in (data.get("dns") or "").replace(";", ",").split(",")
if d.strip()]
for d in dns:
if not _valid_ip(d):
return None, f"invalid dns: {d}"
payload.update({
"address": address,
"prefix": prefix,
"gateway": gateway,
"dns": ",".join(dns),
})
return payload, None
@network_bp.route("/api/network", methods=["GET"])
@login_required
def api_get_network():
return jsonify(current_state())
@network_bp.route("/api/network", methods=["PUT"])
@login_required
def api_set_network():
data = request.get_json(force=True, silent=True) or {}
payload, err = _validate_payload(data)
if err:
abort(400, err)
script = str(scripts_dir() / "bt-netconfig")
args = ["sudo", "-n", script, payload["iface"], payload["mode"]]
if payload["mode"] == "static":
args += [payload["address"], str(payload["prefix"]),
payload.get("gateway", ""), payload.get("dns", "")]
rc, out, err_out = _run(args, timeout=30)
if rc != 0:
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
or f"exit {rc}"}), 500
save_network_config(payload)
return jsonify({"ok": True, "output": out.strip()})