Files
Button/sshDeploy/mass_deploy.py
T
2026-08-24 16:40:48 +03:00

803 lines
30 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mass_deploy.py — fake-DHCP + SSH массовый деплой ButtonTask.
CLI-режим сохранён, но логика вынесена в DeployBackend, чтобы её можно было
использовать и из GUI.
"""
import base64
import copy
import ctypes
import ipaddress
import json
import os
import shlex
import socket
import struct
import subprocess
import sys
import threading
import time
from shutil import which
DHCP_SERVER_PORT = 67
DHCP_CLIENT_PORT = 68
MAGIC_COOKIE = b"\x63\x82\x53\x63"
def get_runtime_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
SCRIPT_DIR = get_runtime_dir()
def ensure_admin():
if os.name != "nt":
return
try:
is_admin = ctypes.windll.shell32.IsUserAnAdmin()
except Exception:
is_admin = False
if not is_admin:
params = " ".join(f'"{a}"' for a in sys.argv)
ctypes.windll.shell32.ShellExecuteW(
None, "runas", sys.executable, params, None, 1
)
sys.exit(0)
class Status:
def __init__(self):
self.lock = threading.Lock()
self.rows = {}
def update(self, mac, **kw):
with self.lock:
row = self.rows.setdefault(mac, {"ip": "", "state": "", "detail": ""})
row.update(kw)
row["ts"] = time.strftime("%H:%M:%S")
def snapshot(self):
with self.lock:
return {k: dict(v) for k, v in self.rows.items()}
def clear(self):
with self.lock:
self.rows.clear()
class Leases:
def __init__(self, path, pool_start, pool_end, reserved_ips):
self.path = path
self.pool_start = int(ipaddress.IPv4Address(pool_start))
self.pool_end = int(ipaddress.IPv4Address(pool_end))
self.reserved = set(reserved_ips)
self.lock = threading.Lock()
self.map = {}
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
self.map = json.load(f)
except Exception:
self.map = {}
def _save(self):
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self.map, f, indent=2)
def get_or_assign(self, mac):
with self.lock:
if mac in self.map:
return self.map[mac]
used = set(self.map.values()) | self.reserved
for ip_int in range(self.pool_start, self.pool_end + 1):
ip = str(ipaddress.IPv4Address(ip_int))
if ip not in used:
self.map[mac] = ip
self._save()
return ip
raise RuntimeError("пул IP-адресов исчерпан — расширьте pool_start/pool_end в config.json")
class HostKeys:
def __init__(self, path):
self.path = path
self.lock = threading.Lock()
self.map = {}
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
self.map = json.load(f)
except Exception:
self.map = {}
def _save(self):
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self.map, f, indent=2)
def get(self, mac):
with self.lock:
return self.map.get(mac)
def set(self, mac, host_key):
with self.lock:
self.map[mac] = host_key
self._save()
def mac_bytes_to_str(b):
return ":".join(f"{x:02x}" for x in b)
def parse_dhcp_options(data):
opts = {}
i = 0
n = len(data)
while i < n:
code = data[i]
if code == 255:
break
if code == 0:
i += 1
continue
if i + 1 >= n:
break
length = data[i + 1]
val = data[i + 2 : i + 2 + length]
opts[code] = val
i += 2 + length
return opts
def build_dhcp_reply(msg_type, xid, flags, chaddr, yiaddr, server_ip, subnet_mask, lease_seconds):
header = struct.pack(
"!BBBBIHH4s4s4s4s16s64s128s",
2, 1, 6, 0, xid, 0, flags,
b"\x00\x00\x00\x00",
socket.inet_aton(yiaddr),
socket.inet_aton(server_ip),
b"\x00\x00\x00\x00",
chaddr + b"\x00" * (16 - len(chaddr)),
b"\x00" * 64,
b"\x00" * 128,
)
options = MAGIC_COOKIE
options += bytes([53, 1, msg_type])
options += bytes([54, 4]) + socket.inet_aton(server_ip)
options += bytes([51, 4]) + struct.pack("!I", lease_seconds)
options += bytes([1, 4]) + socket.inet_aton(subnet_mask)
options += bytes([3, 4]) + socket.inet_aton(server_ip)
options += bytes([6, 4]) + socket.inet_aton(server_ip)
options += bytes([255])
return header + options
def resolve_tool(path_or_name):
if not path_or_name:
return None
if os.path.isabs(path_or_name) and os.path.isfile(path_or_name):
return path_or_name
cand = os.path.join(SCRIPT_DIR, path_or_name)
if os.path.isfile(cand):
return cand
return which(path_or_name)
def resolve_bundle(cfg, config_dir):
raw = cfg["bundle_path"]
candidates = []
if os.path.isabs(raw):
candidates.append(raw)
else:
candidates.append(os.path.normpath(os.path.join(config_dir, raw)))
candidates.append(os.path.normpath(os.path.join(SCRIPT_DIR, raw)))
candidates.append(os.path.normpath(os.path.join(SCRIPT_DIR, "..", "winDeployScripts", os.path.basename(raw))))
for c in candidates:
if os.path.isfile(c):
return c
return None
def find_nearby_tgz(config_dir):
found = []
for base in (config_dir, SCRIPT_DIR, os.path.join(SCRIPT_DIR, "..", "winDeployScripts")):
base = os.path.normpath(base)
if not os.path.isdir(base):
continue
for name in os.listdir(base):
if name.endswith(".tgz"):
found.append(os.path.join(base, name))
return found
def shell_quote(s):
return shlex.quote(s)
def sudo_prefix(cfg):
if not cfg.get("use_sudo"):
return ""
return f"echo {shell_quote(cfg.get('device_password') or '')} | sudo -S -p '' "
def write_log(mac, text):
logs_dir = os.path.join(SCRIPT_DIR, "logs")
os.makedirs(logs_dir, exist_ok=True)
path = os.path.join(logs_dir, f"{mac.replace(':', '-')}.log")
with open(path, "w", encoding="utf-8", errors="replace") as f:
f.write(text or "")
return path
def run(cmd, timeout=None, input_text=None):
proc = subprocess.run(
cmd,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
text=True,
)
return proc.returncode, proc.stdout or ""
def wait_tcp_open(ip, port, timeout_s):
deadline = time.time() + timeout_s
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(2)
if s.connect_ex((ip, port)) == 0:
return True
time.sleep(2)
return False
def build_final_network_json(cfg):
final_ip = cfg.get("final_ip", "192.168.1.60")
net = ipaddress.IPv4Network(f"{final_ip}/{cfg.get('subnet_mask', '255.255.255.0')}", strict=False)
gateway = str(ipaddress.IPv4Address(int(net.network_address) + 1))
return {
"iface": "eth0",
"mode": "static",
"address": final_ip,
"prefix": net.prefixlen,
"gateway": gateway,
"dns": gateway,
}
def network_json_matches(cfg, text):
"""Best-effort check that remote network.json has the expected static profile."""
try:
data = json.loads(text)
except json.JSONDecodeError:
return False
expected = build_final_network_json(cfg)
for key in ("iface", "mode", "address", "prefix", "gateway", "dns"):
if str(data.get(key, "")).strip() != str(expected.get(key, "")).strip():
return False
return True
def load_config(config_path):
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def validate_and_prepare(cfg, config_path):
config_dir = os.path.dirname(os.path.abspath(config_path))
errors = []
auth = cfg.get("auth")
if auth not in ("password", "key"):
errors.append(f'auth должен быть "password" или "key" (сейчас: {auth!r})')
for key in ("server_ip", "subnet_mask", "pool_start", "pool_end", "bundle_path", "device_user"):
if not cfg.get(key):
errors.append(f"не задано поле {key}")
try:
ipaddress.IPv4Address(cfg["server_ip"])
ipaddress.IPv4Address(cfg["pool_start"])
ipaddress.IPv4Address(cfg["pool_end"])
if cfg.get("bind_ip"):
ipaddress.IPv4Address(cfg["bind_ip"])
if cfg.get("final_ip"):
ipaddress.IPv4Address(cfg["final_ip"])
if int(ipaddress.IPv4Address(cfg["pool_start"])) > int(ipaddress.IPv4Address(cfg["pool_end"])):
errors.append("pool_start больше pool_end")
except Exception as e:
errors.append(f"некорректный IP в конфиге: {e}")
bundle = resolve_bundle(cfg, config_dir)
if not bundle:
nearby = find_nearby_tgz(config_dir)
msg = f"не найден бандл: {cfg.get('bundle_path')}"
if nearby:
msg += "\n рядом есть:\n " + "\n ".join(nearby)
errors.append(msg)
else:
cfg["_bundle_path"] = bundle
if auth == "password":
cfg["_plink"] = resolve_tool(cfg.get("plink_path", "plink.exe"))
cfg["_pscp"] = resolve_tool(cfg.get("pscp_path", "pscp.exe"))
if not cfg["_plink"]:
errors.append("plink.exe не найден (положите рядом со скриптом или в PATH; https://www.putty.org/)")
if not cfg["_pscp"]:
errors.append("pscp.exe не найден (положите рядом со скриптом или в PATH; https://www.putty.org/)")
if not cfg.get("device_password"):
errors.append("device_password пуст при auth=password")
if cfg.get("host_key"):
cfg["_hostkey"] = cfg["host_key"].strip()
else:
key = cfg.get("device_key_path") or ""
if not key or not os.path.isfile(key):
errors.append(f"device_key_path не найден: {key!r}")
after = (cfg.get("after_install") or "halt").lower()
if after not in ("halt", "reboot"):
errors.append('after_install должен быть "halt" или "reboot"')
cfg.setdefault("bind_ip", cfg["server_ip"])
cfg.setdefault("final_ip", "192.168.1.60")
cfg.setdefault("after_install", "halt")
cfg.setdefault("use_sudo", True)
return config_dir, errors
def list_windows_interfaces():
if os.name != "nt":
return []
cmd = [
"powershell", "-NoProfile", "-Command",
"Get-NetIPAddress -AddressFamily IPv4 | "
"Where-Object {$_.IPAddress -notlike '169.254.*' -and $_.InterfaceAlias -notlike 'Loopback*'} | "
"Sort-Object InterfaceAlias | "
"Select-Object InterfaceAlias,IPAddress,PrefixLength | ConvertTo-Json"
]
try:
rc, out = run(cmd, timeout=20)
if rc != 0 or not out.strip():
return []
data = json.loads(out)
if isinstance(data, dict):
data = [data]
return [
{
"name": x.get("InterfaceAlias", ""),
"ip": x.get("IPAddress", ""),
"prefix": x.get("PrefixLength", 24),
"label": f"{x.get('InterfaceAlias', '')} - {x.get('IPAddress', '')}",
}
for x in data if x.get("IPAddress")
]
except Exception:
return []
class DeployBackend:
def __init__(self, config_path, cfg=None):
self.config_path = os.path.abspath(config_path)
self.cfg = copy.deepcopy(cfg) if cfg is not None else load_config(self.config_path)
self.config_dir, errors = validate_and_prepare(self.cfg, self.config_path)
if errors:
raise ValueError("\n".join(errors))
self.status = Status()
self.stats_lock = threading.Lock()
self.dhcp_stats = {"rx": 0, "offer": 0, "ack": 0, "last_err": ""}
self.stop_evt = threading.Event()
self.deployed = set()
self.deployed_lock = threading.Lock()
self.observers = []
self.running = False
self.error = ""
reserved = {self.cfg["server_ip"], self.cfg.get("bind_ip") or self.cfg["server_ip"], self.cfg["final_ip"]}
self.leases = Leases(os.path.join(self.config_dir, "leases.json"), self.cfg["pool_start"], self.cfg["pool_end"], reserved)
self.hostkeys = HostKeys(os.path.join(self.config_dir, "host_keys.json"))
self.dhcp_thread = None
def add_observer(self, callback):
self.observers.append(callback)
def notify(self):
snapshot = self.get_snapshot()
for cb in list(self.observers):
try:
cb(snapshot)
except Exception:
pass
def update_status(self, key, **kw):
self.status.update(key, **kw)
self.notify()
def update_dhcp_stats(self, **kw):
with self.stats_lock:
self.dhcp_stats.update(kw)
self.notify()
def get_snapshot(self):
with self.stats_lock:
stats = dict(self.dhcp_stats)
rows = self.status.snapshot()
if "dhcp" in rows:
rows.pop("dhcp", None)
return {
"running": self.running,
"error": self.error,
"config": {
"server_ip": self.cfg.get("server_ip"),
"bind_ip": self.cfg.get("bind_ip"),
"bundle_path": self.cfg.get("_bundle_path", self.cfg.get("bundle_path")),
"final_ip": self.cfg.get("final_ip"),
"after_install": self.cfg.get("after_install"),
},
"dhcp": stats,
"rows": rows,
}
def _windows_if_index(self, ip):
if os.name != "nt":
return None
try:
out = subprocess.check_output(
["powershell", "-NoProfile", "-Command",
f"(Get-NetIPAddress -IPAddress {ip} -AddressFamily IPv4 | Select-Object -First 1).InterfaceIndex"],
text=True, stderr=subprocess.DEVNULL, timeout=15,
).strip()
return int(out) if out.isdigit() else None
except Exception:
return None
def _open_dhcp_send_sock(self, server_ip):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
if_index = self._windows_if_index(server_ip)
if if_index is not None:
try:
s.setsockopt(socket.IPPROTO_IP, 31, struct.pack("!I", if_index))
except OSError:
pass
try:
s.bind((server_ip, DHCP_SERVER_PORT))
except OSError:
s.bind((server_ip, 0))
return s, if_index
def accept_host_key(self, mac, ip):
"""Возвращает fingerprint для ЭТОГО mac; не пишет в общий cfg (параллельный деплой)."""
if self.cfg["auth"] != "password":
return None
cached = self.hostkeys.get(mac)
if cached:
return cached.strip()
if self.cfg.get("host_key"):
key = self.cfg["host_key"].strip()
self.hostkeys.set(mac, key)
return key
cmd = [
self.cfg["_plink"], "-batch", "-pw", self.cfg["device_password"],
f"{self.cfg['device_user']}@{ip}", "exit",
]
try:
_, out = run(cmd, timeout=30)
except Exception:
return None
for line in out.splitlines():
line = line.strip()
if line.startswith("ssh-") and "SHA256:" in line:
self.hostkeys.set(mac, line)
return line
return None
def build_copy_cmd(self, ip, local_path, remote_path, hostkey=None):
if self.cfg["auth"] == "password":
cmd = [self.cfg["_pscp"], "-batch", "-pw", self.cfg["device_password"]]
if hostkey:
cmd += ["-hostkey", hostkey]
cmd += [local_path, f"{self.cfg['device_user']}@{ip}:{remote_path}"]
return cmd
return [
"scp", "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=NUL",
"-i", self.cfg["device_key_path"], local_path, f"{self.cfg['device_user']}@{ip}:{remote_path}",
]
def build_ssh_cmd(self, ip, remote_cmd, hostkey=None):
if self.cfg["auth"] == "password":
cmd = [self.cfg["_plink"], "-batch", "-pw", self.cfg["device_password"]]
if hostkey:
cmd += ["-hostkey", hostkey]
cmd += [f"{self.cfg['device_user']}@{ip}", remote_cmd]
return cmd
return [
"ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=NUL",
"-i", self.cfg["device_key_path"], f"{self.cfg['device_user']}@{ip}", remote_cmd,
]
def ssh_run(self, ip, remote_cmd, hostkey=None, timeout=60):
return run(self.build_ssh_cmd(ip, remote_cmd, hostkey=hostkey), timeout=timeout)
def write_network_json(self, mac, ip, hostkey):
"""Write final static network.json and verify on disk (retries on SSH flake)."""
net_json = json.dumps(build_final_network_json(self.cfg), indent=2)
b64 = base64.b64encode(net_json.encode("utf-8")).decode("ascii")
final_ip = self.cfg.get("final_ip", "192.168.1.60")
sp = sudo_prefix(self.cfg)
write_cmd = (
f"echo {b64} | base64 -d > /tmp/network.json && "
f"{sp}install -m0644 -o buttontask -g buttontask /tmp/network.json "
f"/opt/buttontask/config/network.json && rm -f /tmp/network.json"
)
verify_cmd = "cat /opt/buttontask/config/network.json"
attempts = max(1, int(self.cfg.get("network_write_retries", 3)))
timeout = int(self.cfg.get("network_write_timeout_seconds", 90))
pause = float(self.cfg.get("network_write_retry_pause_seconds", 4))
last_out = ""
for attempt in range(1, attempts + 1):
detail = f"пишу static {final_ip}..."
if attempt > 1:
detail = f"повтор {attempt}/{attempts}: пишу static {final_ip}..."
self.update_status(mac, state="сеть", detail=detail)
try:
rc, out = self.ssh_run(ip, write_cmd, hostkey=hostkey, timeout=timeout)
except subprocess.TimeoutExpired as e:
rc, out = 124, e.stdout or "timeout"
last_out = out or ""
if rc != 0:
if attempt < attempts:
time.sleep(pause)
continue
return False, last_out
try:
vrc, vout = self.ssh_run(ip, verify_cmd, hostkey=hostkey, timeout=30)
except subprocess.TimeoutExpired as e:
vrc, vout = 124, e.stdout or "timeout"
if vrc == 0 and network_json_matches(self.cfg, vout):
return True, vout.strip()
last_out = vout or last_out
if attempt < attempts:
time.sleep(pause)
return False, last_out or "network.json verify failed"
def deploy_device(self, mac, ip):
sp = sudo_prefix(self.cfg)
self.update_status(mac, ip=ip, state="ждём ssh", detail="ожидаю открытия порта 22...")
if not wait_tcp_open(ip, 22, self.cfg.get("ssh_timeout_seconds", 180)):
self.update_status(mac, state="ОШИБКА", detail="SSH не поднялся за отведённое время")
return
self.update_status(mac, state="hostkey", detail="получаю SSH fingerprint...")
hostkey = self.accept_host_key(mac, ip)
if self.cfg["auth"] == "password" and not hostkey:
self.update_status(mac, state="ОШИБКА", detail="не удалось получить SSH host key")
return
bundle = self.cfg["_bundle_path"]
remote_tgz = "/tmp/btdeploy.tgz"
self.update_status(mac, state="заливаю", detail=f"копирую {os.path.basename(bundle)}...")
rc, out = run(self.build_copy_cmd(ip, bundle, remote_tgz, hostkey=hostkey), timeout=300)
if rc != 0:
write_log(mac, out)
self.update_status(mac, state="ОШИБКА", detail=f"копирование не удалось: {out[-200:]}")
return
self.update_status(mac, state="устанавливаю", detail="выполняю device-install.sh...")
if self.cfg.get("reset_config"):
install_cmd = f"{sp}env BUTTONTASK_RESET_CONFIG=1 bash /tmp/btdeploy/device-install.sh"
else:
install_cmd = f"{sp}bash /tmp/btdeploy/device-install.sh"
remote_cmd = (
f"rm -rf /tmp/btdeploy && mkdir -p /tmp/btdeploy && "
f"tar xzf {remote_tgz} -C /tmp/btdeploy && "
f"if [ -f /tmp/btdeploy/config/network.json ]; then "
f"mv /tmp/btdeploy/config/network.json /tmp/btdeploy/config/network.json.sshdeploy; fi && "
f"{sp}rm -f /opt/buttontask/config/network.json && "
f"{install_cmd}"
)
rc, out = run(
self.build_ssh_cmd(ip, remote_cmd, hostkey=hostkey),
timeout=self.cfg.get("install_timeout_seconds", 600),
)
if "INSTALL-OK" not in out:
log_path = write_log(mac, out)
tail = (out.strip().splitlines() or ["нет вывода"])[-1][:160]
self.update_status(mac, state="ОШИБКА", detail=f"{tail} (лог: {log_path})")
return
self.update_status(mac, state="сеть", detail=f"пишу static {self.cfg.get('final_ip', '192.168.1.60')}...")
ok, out = self.write_network_json(mac, ip, hostkey)
if not ok:
log_path = write_log(mac, out)
self.update_status(
mac, state="ОШИБКА",
detail=f"network.json: {(out or 'verify failed')[-180:]} (лог: {log_path})",
)
return
try:
run(self.build_ssh_cmd(ip, f"{sp}rm -rf /tmp/btdeploy /tmp/btdeploy.tgz", hostkey=hostkey), timeout=30)
except Exception:
pass
after = (self.cfg.get("after_install") or "halt").lower()
if after == "reboot":
self.update_status(mac, state="ребут", detail="перезагружаю устройство...")
power_cmd = f"{sp}reboot"
else:
self.update_status(mac, state="halt", detail="выключаю устройство (halt)...")
power_cmd = f"{sp}halt -p"
try:
run(self.build_ssh_cmd(ip, power_cmd, hostkey=hostkey), timeout=30)
except Exception:
pass
if after == "halt":
self.update_status(mac, state="ГОТОВО", detail=f"установлено, static {self.cfg.get('final_ip')}, выключено")
else:
self.update_status(mac, state="ГОТОВО", detail="установлено, reboot отправлен")
def on_lease(self, mac, ip):
with self.deployed_lock:
if mac in self.deployed:
return
self.deployed.add(mac)
threading.Thread(target=self.deploy_device, args=(mac, ip), daemon=True).start()
def dhcp_server_loop(self):
raw_bind = (self.cfg.get("bind_ip") or "0.0.0.0").strip()
listen_ip = "0.0.0.0" if raw_bind in ("", "0.0.0.0", "*") else raw_bind
server_ip = self.cfg["server_ip"]
subnet_mask = self.cfg["subnet_mask"]
lease_seconds = self.cfg.get("lease_seconds", 86400)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
try:
sock.bind((listen_ip, DHCP_SERVER_PORT))
except OSError as e:
self.error = f"не удалось слушать {listen_ip}:{DHCP_SERVER_PORT}: {e}"
self.update_dhcp_stats(last_err=f"bind {listen_ip}:67 — {e}")
self.running = False
self.notify()
return
try:
send_sock, if_index = self._open_dhcp_send_sock(server_ip)
except OSError as e:
sock.close()
self.error = f"не удалось открыть сокет отправки DHCP: {e}"
self.update_dhcp_stats(last_err=f"send bind {server_ip}{e}")
self.running = False
self.notify()
return
self.update_status("dhcp", ip=server_ip, state="listen", detail=f"recv={listen_ip}:67 send={server_ip}" + (f" if={if_index}" if if_index else ""))
self.update_dhcp_stats(last_err="")
sock.settimeout(1.0)
while not self.stop_evt.is_set():
try:
data, _ = sock.recvfrom(4096)
except socket.timeout:
continue
except OSError:
break
with self.stats_lock:
self.dhcp_stats["rx"] += 1
if len(data) < 240:
self.notify()
continue
try:
xid = struct.unpack("!I", data[4:8])[0]
flags = struct.unpack("!H", data[10:12])[0]
hlen = data[2]
chaddr = data[28 : 28 + hlen]
mac = mac_bytes_to_str(chaddr)
msg_type = parse_dhcp_options(data[240:]).get(53, b"\x00")[0]
ip = self.leases.get_or_assign(mac)
except Exception as e:
self.update_dhcp_stats(last_err=str(e))
continue
reply_type = 2 if msg_type == 1 else 5 if msg_type == 3 else None
if reply_type is None:
self.notify()
continue
reply = build_dhcp_reply(reply_type, xid, flags, chaddr, ip, server_ip, subnet_mask, lease_seconds)
try:
send_sock.sendto(reply, ("255.255.255.255", DHCP_CLIENT_PORT))
except OSError as e:
self.update_status(mac, ip=ip, state="ОШИБКА", detail=f"DHCP send: {e}")
self.update_dhcp_stats(last_err=str(e))
continue
with self.stats_lock:
if reply_type == 2:
self.dhcp_stats["offer"] += 1
else:
self.dhcp_stats["ack"] += 1
if reply_type == 2:
self.update_status(mac, ip=ip, state="discover", detail="DHCPDISCOVER получен, отправлен OFFER")
else:
self.update_status(mac, ip=ip, state="acked", detail="DHCPACK отправлен, IP выдан")
self.on_lease(mac, ip)
self.notify()
send_sock.close()
sock.close()
def start(self):
if self.running:
return
self.stop_evt.clear()
self.running = True
self.error = ""
self.status.clear()
with self.stats_lock:
self.dhcp_stats = {"rx": 0, "offer": 0, "ack": 0, "last_err": ""}
self.deployed = set()
self.dhcp_thread = threading.Thread(target=self.dhcp_server_loop, daemon=True)
self.dhcp_thread.start()
self.notify()
def stop(self):
self.stop_evt.set()
self.running = False
self.notify()
def build_runtime_config(base_cfg, interface_ip=None, bundle_path=None, reset_config=None):
cfg = copy.deepcopy(base_cfg)
if interface_ip:
cfg["server_ip"] = interface_ip
cfg["bind_ip"] = "0.0.0.0"
if bundle_path:
cfg["bundle_path"] = bundle_path
if reset_config is not None:
cfg["reset_config"] = bool(reset_config)
return cfg
def printer_loop(backend):
while backend.running and not backend.stop_evt.is_set():
snap = backend.get_snapshot()
rows = snap["rows"]
stats = snap["dhcp"]
os.system("cls" if os.name == "nt" else "clear")
print("=== ButtonTask mass-deploy (fake-DHCP + SSH) ===")
print(f"DHCP: rx={stats['rx']} offer={stats['offer']} ack={stats['ack']}" + (f" err={stats['last_err']}" if stats.get("last_err") else ""))
print(f"{'MAC':<18} {'IP':<15} {'Статус':<12} {'Время':<9} Детали")
print("-" * 90)
for mac, r in sorted(rows.items()):
print(f"{mac:<18} {r['ip']:<15} {r['state']:<12} {r['ts']:<9} {r['detail'][:60]}")
if not rows:
print("(ждём DHCPDISCOVER на свитче...)")
print("\nCtrl+C — выход")
time.sleep(1)
def main():
ensure_admin()
if len(sys.argv) < 2:
print("Использование: python mass_deploy.py config.json")
sys.exit(1)
config_path = os.path.abspath(sys.argv[1])
backend = DeployBackend(config_path)
print(f"[deploy] бандл: {backend.cfg['_bundle_path']}")
print(f"[deploy] DHCP listen: {backend.cfg.get('bind_ip')}:{DHCP_SERVER_PORT} (server_ip={backend.cfg['server_ip']})")
print(f"[deploy] final_ip: {backend.cfg['final_ip']}, after_install: {backend.cfg['after_install']}")
if backend.cfg.get("reset_config"):
print("[deploy] reset_config: config.json и .master будут перезаписаны из бандла")
time.sleep(1.5)
backend.start()
printer = threading.Thread(target=printer_loop, args=(backend,), daemon=True)
printer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
backend.stop()
time.sleep(1.2)
print("\nОстановлено.")
if __name__ == "__main__":
main()