#!/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 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 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...") 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}bash /tmp/btdeploy/device-install.sh" ) 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')}...") net_json = json.dumps(build_final_network_json(self.cfg), indent=2) b64 = base64.b64encode(net_json.encode("utf-8")).decode("ascii") write_net = ( f"echo {b64} | base64 -d > /tmp/network.json && " f"{sp}cp /tmp/network.json /opt/buttontask/config/network.json && " f"{sp}chown buttontask:buttontask /opt/buttontask/config/network.json || true && rm -f /tmp/network.json" ) rc, out = run(self.build_ssh_cmd(ip, write_net, hostkey=hostkey), timeout=60) if rc != 0: write_log(mac, out) self.update_status(mac, state="ОШИБКА", detail=f"network.json: {out[-200:]}") 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): 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 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']}") 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()