803 lines
30 KiB
Python
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()
|