Files
docker-migrate/discover/network.py
T
Stitch505 97b3623915 bugfix: comprehensive stability audit and fixes
- discover/docker.py: handle comma-separated compose_file in labels
- discover/network.py: replace os.getlogin() with robust user detection
- target.py: add lsb_release fallback via hostnamectl, guard None compose_file,
  use container name (not cached CID) for docker logs
- main.py: call reset_state(mode='target') before target mode,
  improve EOF handling info message
- source.py: remove redundant set_stage('DONE') inside transfer_offer
- transfer.py: fix stage naming for resume after transfer
- add dry_run.py for local logic validation
2026-05-22 20:58:37 +04:00

258 lines
9.8 KiB
Python

# -*- coding: utf-8 -*-
"""
network.py — Discovery сети хоста, loopback-proxy, sidecar, routes, iptables, sysctl
"""
import os
import re
import json
import socket
import ipaddress
from core.runner import run, exists
from core.color import info, warn, success, log_cmd
def get_container_host_connections(cid):
"""
Смотрит внутри контейнера (через nsenter -t <pid> -n) установленные/слушающие соединения.
Возвращает список: {proto, local, remote, estado}
"""
from discover.docker import get_container_pid
pid = get_container_pid(cid)
if not pid:
return []
# Проверим ss внутри netns контейнера
out = run(f"nsenter -t {pid} -n ss -tlnp", check=False)
# и established тоже
out2 = run(f"nsenter -t {pid} -n ss -tnp state established", check=False)
results = []
for src in (out, out2):
for ln in src.stdout.splitlines():
parts = ln.split()
if len(parts) < 4:
continue
# Формат: State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
try:
proto = "tcp"
local = parts[3]
remote = parts[4] if len(parts) > 4 else None
state = parts[0]
results.append({"proto": proto, "local": local, "remote": remote, "state": state})
except Exception:
continue
return results
def find_listeners_on_host(port):
"""На хосте ищет, кто слушает указанный TCP порт"""
listeners = []
out = run(f"ss -tlnp 'sport = :{port}'", check=False)
for ln in out.stdout.splitlines():
# На некоторых системах столбец users содержит pid/process
# Пример: LISTEN 0 128 127.0.0.1:40000 0.0.0.0:* users:(("warp-svc",pid=1234,fd=5))
m = re.search(r'users:\(\("([^"]+)"', ln)
if m:
proc = m.group(1)
listeners.append({"process": proc, "line": ln.strip()})
else:
# fallback: ищем pid через lsof
lof = run(f"lsof -i TCP:{port} -sTCP:LISTEN -t", check=False)
if lof.stdout.strip():
for pid in lof.stdout.strip().split():
try:
cmdline = open(f"/proc/{pid}/comm", "r").read().strip()
listeners.append({"pid": pid, "process": cmdline, "line": ln.strip()})
except Exception:
pass
return listeners
def find_sidecar_processes(cid, container_ports):
"""
Ищет sidecar-процессы на хосте, к которым контейнер стучится через loopback.
"""
info("Ищем loopback-соединения контейнера (sidecar / proxy / WARP) ...")
conns = get_container_host_connections(cid)
sidecars = []
for c in conns:
local = c.get("local", "")
if "127.0.0.1" in local or "localhost" in local or "::1" in local:
# Извлекаем порт
m = re.search(r':(\d+)$', local)
if m:
port = int(m.group(1))
listeners = find_listeners_on_host(port)
if listeners:
for l in listeners:
info(f" Контейнер подключается к {local} → процесс на хосте: {l.get('process', '?')} (pid={l.get('pid', '?')})")
sidecars.append({
"type": "loopback_listener",
"container_port_target": port,
"host_process": l.get("process"),
"host_pid": l.get("pid"),
"method": "ss_lsof",
})
if sidecars:
success(f"Найдено sidecar/loopback зависимостей: {len(sidecars)}")
return sidecars
def get_process_details(pid):
"""Собирает детали о процессе: exe, cmdline, cwd, open files, systemd unit"""
try:
base = f"/proc/{pid}"
exe = os.readlink(f"{base}/exe") if os.path.islink(f"{base}/exe") else None
cmdline = open(f"{base}/cmdline", "rb").read().replace(b'\x00', b' ').decode("utf-8", "ignore").strip()
cwd = os.readlink(f"{base}/cwd") if os.path.islink(f"{base}/cwd") else None
# open files
fds = os.listdir(f"{base}/fd")
files = []
for fd in fds:
try:
p = os.readlink(f"{base}/fd/{fd}")
if p.startswith("/") and not p.startswith("/dev/") and not p.startswith("/proc/"):
files.append(p)
except Exception:
pass
# systemd unit
unit = None
try:
cgroup = open(f"{base}/cgroup", "r").read()
for ln in cgroup.splitlines():
if ".service" in ln:
parts = ln.split(":")
if len(parts) >= 3:
# 0::/system.slice/nginx.service
m = re.search(r'/([^/]+\.service)$', parts[-1])
if m:
unit = m.group(1)
except Exception:
pass
return {"exe": exe, "cmdline": cmdline, "cwd": cwd, "files": list(set(files)), "unit": unit}
except Exception as e:
return {"error": str(e)}
def gather_host_network_info():
"""
Собирает текущее состояние сети хоста для manifest.
"""
info("Собираем сетевую конфигурацию хоста ...")
data = {}
# routes
if exists("ip"):
r = run("ip route show", check=False)
data["ip_routes"] = r.stdout.strip().splitlines()
r2 = run("ip rule show", check=False)
data["ip_rules"] = r2.stdout.strip().splitlines()
# interfaces
if exists("ip"):
r = run("ip addr show", check=False)
data["ip_addr"] = r.stdout.strip().splitlines()
# iptables
data["iptables"] = {}
for table in ("filter", "nat", "mangle", "raw"):
r = run(f"iptables -t {table} -S", check=False)
data["iptables"][table] = r.stdout.strip().splitlines()
# nftables
if exists("nft"):
r = run("nft list ruleset", check=False)
data["nftables"] = r.stdout.strip().splitlines()
# sysctl отличия от дефолта (берём всё, потом diff-анализ можно делать вручную)
r = run("sysctl -a", check=False)
data["sysctl"] = r.stdout.strip().splitlines()
return data
def find_systemd_units_related(procs):
"""
Ищет systemd unit-файлы для процессов (sidecar и других).
"""
units = []
seen = set()
for p in procs:
# p может быть dict с pid
pid = p.get("pid")
if not pid:
continue
# systemd unit через systemctl status
try:
out = run(f"systemctl status {pid}", check=False)
# строка вида: ... Loaded: loaded (/lib/systemd/system/xxx.service; ...)
for ln in out.stdout.splitlines():
if "Loaded:" in ln and ".service" in ln:
m = re.search(r'loaded\s+\(([^)]+)\)', ln)
if m:
path = m.group(1).split(";")[0].strip()
name = os.path.basename(path)
if name not in seen:
seen.add(name)
units.append({"name": name, "path": path, "related_to": p.get("process", "?")})
except Exception:
pass
return units
def gather_cron_jobs(user_hint=None):
"""
Ищет cron-задания, связанные с сервисом (по имени или пути).
Если user_hint — список подсказок (название сервиса, путь), ищем по ним.
"""
jobs = []
# crontab -l для текущего пользователя и root
current_user = os.environ.get('USER') or os.environ.get('LOGNAME')
if not current_user:
try:
import pwd
current_user = pwd.getpwuid(os.getuid()).pw_name
except Exception:
current_user = None
users = []
if current_user:
users.append(current_user)
users.append("root")
for user in users:
try:
out = run(f"crontab -u {user} -l", check=False)
for ln in out.stdout.splitlines():
if ln.strip().startswith("#"):
continue
if user_hint:
for hint in user_hint:
if hint.lower() in ln.lower():
jobs.append({"user": user, "line": ln.strip()})
break
else:
jobs.append({"user": user, "line": ln.strip()})
except Exception:
pass
# /etc/cron.d, /etc/cron.hourly, etc.
cron_dirs = ["/etc/cron.d", "/etc/cron.hourly", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"]
for d in cron_dirs:
if not os.path.isdir(d):
continue
for f in os.listdir(d):
fp = os.path.join(d, f)
try:
content = open(fp, "r", encoding="utf-8", errors="ignore").read()
if user_hint:
for hint in user_hint:
if hint.lower() in content.lower():
jobs.append({"file": fp, "content": content[:500]})
break
else:
jobs.append({"file": fp, "content": content[:500]})
except Exception:
pass
return jobs