GitHub Action commited on
Commit
dd21a0d
·
1 Parent(s): 90eafcb

Sync from GitHub with Git LFS

Browse files
Files changed (2) hide show
  1. agents/peer_sync.py +62 -120
  2. agents/tools/storage.py +82 -100
agents/peer_sync.py CHANGED
@@ -7,58 +7,27 @@ import threading
7
  import select
8
  import netifaces
9
  import re
 
10
 
11
  from datetime import datetime, timezone as UTC
12
  from tools.storage import Storage
13
 
14
  storage = Storage()
15
 
16
- # ---------------------------
17
- # Вспомогательные функции
18
- # ---------------------------
19
- def parse_hostport(s: str):
20
- """
21
- Разбирает "IP:port" или "[IPv6]:port" и возвращает (host, port)
22
- """
23
- s = s.strip()
24
- if s.startswith("["):
25
- # IPv6 с портом: [addr]:port
26
- host, _, port = s[1:].partition("]:")
27
- try:
28
- port = int(port)
29
- except:
30
- port = None
31
- return host, port
32
- else:
33
- # IPv4 или IPv6 без []
34
- if ":" in s:
35
- host, port = s.rsplit(":", 1)
36
- try:
37
- port = int(port)
38
- except:
39
- port = None
40
- return host, port
41
- return s, None
42
-
43
- def is_ipv6(host: str):
44
- try:
45
- socket.inet_pton(socket.AF_INET6, host)
46
- return True
47
- except OSError:
48
- return False
49
-
50
  # ---------------------------
51
  # Конфигурация
52
  # ---------------------------
53
  my_id = storage.get_config_value("agent_id")
54
  agent_name = storage.get_config_value("agent_name", "unknown")
55
  local_addresses = storage.get_config_value("local_addresses", [])
 
 
56
 
57
- # Получаем уникальные локальные порты для прослушки TCP/UDP
58
  def get_local_ports():
59
  ports = set()
60
  for addr in local_addresses:
61
- _, port = parse_hostport(addr.split("://", 1)[1])
62
  if port:
63
  ports.add(port)
64
  return sorted(ports)
@@ -199,19 +168,17 @@ def udp_discovery():
199
  time.sleep(DISCOVERY_INTERVAL)
200
 
201
  # ---------------------------
202
- # TCP Peer Exchange
203
  # ---------------------------
204
  def tcp_peer_exchange():
205
- PEER_EXCHANGE_INTERVAL = 20 # для отладки сделаем меньше
206
  while True:
207
  peers = storage.get_known_peers(my_id, limit=50)
208
  print(f"[PeerExchange] Checking {len(peers)} peers (raw DB)...")
209
 
210
  for peer in peers:
211
- if isinstance(peer, dict):
212
- peer_id, addresses_json = peer["id"], peer["addresses"]
213
- else:
214
- peer_id, addresses_json = peer[0], peer[1]
215
 
216
  if peer_id == my_id:
217
  continue
@@ -222,49 +189,46 @@ def tcp_peer_exchange():
222
  print(f"[PeerExchange] JSON decode error for peer {peer_id}: {e}")
223
  addr_list = []
224
 
225
- print(f"[PeerExchange] Peer {peer_id} -> addresses={addr_list}")
226
-
227
  for addr in addr_list:
228
  norm = storage.normalize_address(addr)
229
  if not norm:
230
  continue
231
-
232
  proto, hostport = norm.split("://", 1)
233
  if proto not in ["tcp", "any"]:
234
  continue
235
-
236
- host, port = parse_hostport(hostport)
237
  if not host or not port:
238
  continue
239
 
240
  print(f"[PeerExchange] Trying {peer_id} at {host}:{port} (proto={proto})")
241
-
242
  try:
243
  # IPv6 link-local
244
- if is_ipv6(host) and host.startswith("fe80:"):
245
- scope_id = None
246
- for iface in netifaces.interfaces():
247
- if iface.endswith(host):
248
- scope_id = socket.if_nametoindex(iface)
249
- break
250
- if scope_id is not None:
251
- sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
252
- sock.settimeout(3)
253
- sock.connect((host, port, 0, scope_id))
254
- else:
255
- print(f"[PeerExchange] Skipping {host}, no scope_id found")
256
  continue
 
 
 
257
  else:
258
- sock = socket.socket(socket.AF_INET6 if is_ipv6(host) else socket.AF_INET, socket.SOCK_STREAM)
 
259
  sock.settimeout(3)
260
  sock.connect((host, port))
261
 
262
- # 🔑 Отправляем данные о себе
 
 
 
 
 
 
263
  handshake = {
264
  "type": "PEER_EXCHANGE_REQUEST",
265
  "id": my_id,
266
  "name": agent_name,
267
- "addresses": my_addresses, # список вроде ["tcp4://192.168.0.10:4010"]
268
  }
269
  sock.sendall(json.dumps(handshake).encode("utf-8"))
270
 
@@ -288,7 +252,7 @@ def tcp_peer_exchange():
288
  print(f"[PeerExchange] Decode error from {host}:{port} -> {e}")
289
  continue
290
 
291
- break # успешное соединение — идём к следующему пиру
292
  except Exception as e:
293
  print(f"[PeerExchange] Connection to {host}:{port} failed: {e}")
294
  continue
@@ -296,52 +260,41 @@ def tcp_peer_exchange():
296
  time.sleep(PEER_EXCHANGE_INTERVAL)
297
 
298
  # ---------------------------
299
- # TCP Listener (обработка входящих PEER_EXCHANGE_REQUEST)
300
  # ---------------------------
301
  def tcp_listener():
302
  listen_sockets = []
303
-
304
- # Создаём TCP сокеты на всех локальных портах
305
  for port in local_ports:
306
- # IPv4
307
- try:
308
- sock4 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
309
- sock4.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
310
- sock4.bind(("", port))
311
- sock4.listen(5)
312
- listen_sockets.append(sock4)
313
- print(f"[TCP Listener] Listening IPv4 on *:{port}")
314
- except Exception as e:
315
- print(f"[TCP Listener] IPv4 bind failed on port {port}: {e}")
316
-
317
- # IPv6
318
- try:
319
- sock6 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
320
- sock6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
321
- sock6.bind(("::", port))
322
- sock6.listen(5)
323
- listen_sockets.append(sock6)
324
- print(f"[TCP Listener] Listening IPv6 on [::]:{port}")
325
- except Exception as e:
326
- print(f"[TCP Listener] IPv6 bind failed on port {port}: {e}")
327
 
328
  while True:
329
  if not listen_sockets:
330
  time.sleep(1)
331
  continue
 
332
  rlist, _, _ = select.select(listen_sockets, [], [], 1)
333
  for s in rlist:
334
  try:
335
  conn, addr = s.accept()
336
- data = conn.recv(1024)
337
-
338
  if not data:
339
  conn.close()
340
  continue
341
 
342
  try:
343
  msg = json.loads(data.decode("utf-8"))
344
- except Exception:
 
345
  conn.close()
346
  continue
347
 
@@ -350,48 +303,41 @@ def tcp_listener():
350
  peer_name = msg.get("name", "unknown")
351
  peer_addrs = msg.get("addresses", [])
352
 
353
- # Сохраняем нового пира
354
- storage.add_or_update_peer(
355
- peer_id,
356
- peer_name,
357
- peer_addrs,
358
- source="incoming",
359
- status="online"
360
- )
361
  print(f"[TCP Listener] Handshake from {peer_id} ({addr})")
362
 
363
- # Отправляем список известных пиров
 
 
364
  peers_list = []
365
  for peer in storage.get_known_peers(my_id, limit=50):
366
  peer_id = peer["id"]
367
- addresses_json = peer["addresses"]
368
  try:
369
- addresses = json.loads(addresses_json)
370
  except:
371
  addresses = []
372
 
373
- # Обработка IPv6 link-local: добавить scope_id
374
  updated_addresses = []
375
  for a in addresses:
376
  proto, hostport = a.split("://")
377
- host, port = parse_hostport(hostport)
378
- if is_ipv6(host) and host.startswith("fe80:"):
379
- scope_id = None
380
- for iface in netifaces.interfaces():
381
- iface_addrs = netifaces.ifaddresses(iface).get(netifaces.AF_INET6, [])
382
- for addr_info in iface_addrs:
383
- if addr_info.get("addr") == host:
384
- scope_id = socket.if_nametoindex(iface)
385
- break
386
- if scope_id:
387
- break
388
  if scope_id:
389
  host = f"{host}%{scope_id}"
 
390
  updated_addresses.append(f"{proto}://{host}:{port}")
 
391
  peers_list.append({"id": peer_id, "addresses": updated_addresses})
392
 
393
- payload = json.dumps(peers_list).encode("utf-8")
394
- conn.sendall(payload)
395
 
396
  conn.close()
397
  except Exception as e:
@@ -401,13 +347,9 @@ def tcp_listener():
401
  # Запуск потоков
402
  # ---------------------------
403
  def start_sync(bootstrap_file="bootstrap.txt"):
404
- # Загружаем bootstrap-пиров перед запуском discovery/peer exchange
405
  load_bootstrap_peers(bootstrap_file)
406
-
407
- # Печать локальных портов для логов
408
  print(f"[PeerSync] Local ports: {local_ports}")
409
 
410
- # Запуск потоков
411
  threading.Thread(target=udp_discovery, daemon=True).start()
412
  threading.Thread(target=tcp_peer_exchange, daemon=True).start()
413
  threading.Thread(target=tcp_listener, daemon=True).start()
 
7
  import select
8
  import netifaces
9
  import re
10
+ import ipaddress
11
 
12
  from datetime import datetime, timezone as UTC
13
  from tools.storage import Storage
14
 
15
  storage = Storage()
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  # ---------------------------
18
  # Конфигурация
19
  # ---------------------------
20
  my_id = storage.get_config_value("agent_id")
21
  agent_name = storage.get_config_value("agent_name", "unknown")
22
  local_addresses = storage.get_config_value("local_addresses", [])
23
+ global_addresses = storage.get_config_value("global_addresses", [])
24
+ all_addresses = local_addresses + global_addresses # один раз
25
 
26
+ # Получаем уникальные локальные порты
27
  def get_local_ports():
28
  ports = set()
29
  for addr in local_addresses:
30
+ _, port = storage.parse_hostport(addr.split("://", 1)[1])
31
  if port:
32
  ports.add(port)
33
  return sorted(ports)
 
168
  time.sleep(DISCOVERY_INTERVAL)
169
 
170
  # ---------------------------
171
+ # TCP Peer Exchange (исходящие)
172
  # ---------------------------
173
  def tcp_peer_exchange():
174
+ PEER_EXCHANGE_INTERVAL = 20 # для отладки
175
  while True:
176
  peers = storage.get_known_peers(my_id, limit=50)
177
  print(f"[PeerExchange] Checking {len(peers)} peers (raw DB)...")
178
 
179
  for peer in peers:
180
+ peer_id = peer["id"] if isinstance(peer, dict) else peer[0]
181
+ addresses_json = peer["addresses"] if isinstance(peer, dict) else peer[1]
 
 
182
 
183
  if peer_id == my_id:
184
  continue
 
189
  print(f"[PeerExchange] JSON decode error for peer {peer_id}: {e}")
190
  addr_list = []
191
 
 
 
192
  for addr in addr_list:
193
  norm = storage.normalize_address(addr)
194
  if not norm:
195
  continue
 
196
  proto, hostport = norm.split("://", 1)
197
  if proto not in ["tcp", "any"]:
198
  continue
199
+ host, port = storage.parse_hostport(hostport)
 
200
  if not host or not port:
201
  continue
202
 
203
  print(f"[PeerExchange] Trying {peer_id} at {host}:{port} (proto={proto})")
 
204
  try:
205
  # IPv6 link-local
206
+ if storage.is_ipv6(host) and host.startswith("fe80:"):
207
+ scope_id = storage.get_ipv6_scope(host)
208
+ if scope_id is None:
209
+ print(f"[PeerExchange] Skipping {host}, no scope_id")
 
 
 
 
 
 
 
 
210
  continue
211
+ sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
212
+ sock.settimeout(3)
213
+ sock.connect((host, port, 0, scope_id))
214
  else:
215
+ sock = socket.socket(socket.AF_INET6 if storage.is_ipv6(host) else socket.AF_INET,
216
+ socket.SOCK_STREAM)
217
  sock.settimeout(3)
218
  sock.connect((host, port))
219
 
220
+ # LAN или Интернет
221
+ if storage.is_private(host):
222
+ send_addresses = all_addresses
223
+ else:
224
+ send_addresses = [a for a in all_addresses
225
+ if is_public(stprage.parse_hostport(a.split("://", 1)[1])[0])]
226
+
227
  handshake = {
228
  "type": "PEER_EXCHANGE_REQUEST",
229
  "id": my_id,
230
  "name": agent_name,
231
+ "addresses": send_addresses,
232
  }
233
  sock.sendall(json.dumps(handshake).encode("utf-8"))
234
 
 
252
  print(f"[PeerExchange] Decode error from {host}:{port} -> {e}")
253
  continue
254
 
255
+ break
256
  except Exception as e:
257
  print(f"[PeerExchange] Connection to {host}:{port} failed: {e}")
258
  continue
 
260
  time.sleep(PEER_EXCHANGE_INTERVAL)
261
 
262
  # ---------------------------
263
+ # TCP Listener (входящие)
264
  # ---------------------------
265
  def tcp_listener():
266
  listen_sockets = []
 
 
267
  for port in local_ports:
268
+ for family, addr_str in [(socket.AF_INET, ""), (socket.AF_INET6, "::")]:
269
+ try:
270
+ sock = socket.socket(family, socket.SOCK_STREAM)
271
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
272
+ sock.bind((addr_str, port))
273
+ sock.listen(5)
274
+ listen_sockets.append(sock)
275
+ proto_str = "IPv6" if family == socket.AF_INET6 else "IPv4"
276
+ print(f"[TCP Listener] Listening {proto_str} on {addr_str}:{port}")
277
+ except Exception as e:
278
+ print(f"[TCP Listener] {proto_str} bind failed on port {port}: {e}")
 
 
 
 
 
 
 
 
 
 
279
 
280
  while True:
281
  if not listen_sockets:
282
  time.sleep(1)
283
  continue
284
+
285
  rlist, _, _ = select.select(listen_sockets, [], [], 1)
286
  for s in rlist:
287
  try:
288
  conn, addr = s.accept()
289
+ data = conn.recv(64 * 1024)
 
290
  if not data:
291
  conn.close()
292
  continue
293
 
294
  try:
295
  msg = json.loads(data.decode("utf-8"))
296
+ except Exception as e:
297
+ print(f"[TCP Listener] JSON decode error from {addr}: {e}")
298
  conn.close()
299
  continue
300
 
 
303
  peer_name = msg.get("name", "unknown")
304
  peer_addrs = msg.get("addresses", [])
305
 
306
+ storage.add_or_update_peer(peer_id, peer_name, peer_addrs,
307
+ source="incoming", status="online")
 
 
 
 
 
 
308
  print(f"[TCP Listener] Handshake from {peer_id} ({addr})")
309
 
310
+ # LAN или Интернет
311
+ is_lan = storage.is_private(addr[0])
312
+
313
  peers_list = []
314
  for peer in storage.get_known_peers(my_id, limit=50):
315
  peer_id = peer["id"]
 
316
  try:
317
+ addresses = json.loads(peer["addresses"])
318
  except:
319
  addresses = []
320
 
 
321
  updated_addresses = []
322
  for a in addresses:
323
  proto, hostport = a.split("://")
324
+ host, port = storage.parse_hostport(hostport)
325
+
326
+ # Фильтруем по LAN/Internet
327
+ if not is_lan and not is_public(host):
328
+ continue
329
+
330
+ # IPv6 link-local
331
+ if storage.is_ipv6(host) and host.startswith("fe80:"):
332
+ scope_id = storage.get_ipv6_scope(host)
 
 
333
  if scope_id:
334
  host = f"{host}%{scope_id}"
335
+
336
  updated_addresses.append(f"{proto}://{host}:{port}")
337
+
338
  peers_list.append({"id": peer_id, "addresses": updated_addresses})
339
 
340
+ conn.sendall(json.dumps(peers_list).encode("utf-8"))
 
341
 
342
  conn.close()
343
  except Exception as e:
 
347
  # Запуск потоков
348
  # ---------------------------
349
  def start_sync(bootstrap_file="bootstrap.txt"):
 
350
  load_bootstrap_peers(bootstrap_file)
 
 
351
  print(f"[PeerSync] Local ports: {local_ports}")
352
 
 
353
  threading.Thread(target=udp_discovery, daemon=True).start()
354
  threading.Thread(target=tcp_peer_exchange, daemon=True).start()
355
  threading.Thread(target=tcp_listener, daemon=True).start()
agents/tools/storage.py CHANGED
@@ -6,8 +6,11 @@ import os
6
  import json
7
  import uuid
8
  import time
 
 
 
9
 
10
- from datetime import datetime, timedelta, UTC, timezone
11
  from werkzeug.security import generate_password_hash, check_password_hash
12
  from tools.identity import generate_did
13
  from tools.crypto import generate_keypair
@@ -17,6 +20,7 @@ UTC = timezone.utc
17
  SCRIPTS_BASE_PATH = "scripts"
18
 
19
  class Storage:
 
20
  def __init__(self, config=None):
21
  self.config = config or {}
22
  db_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "agent_data.db"))
@@ -880,6 +884,76 @@ class Storage:
880
  }
881
  return None
882
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  # Работа с пирам (agent_peers)
884
  def add_or_update_peer(
885
  self, peer_id, name, addresses,
@@ -889,18 +963,17 @@ class Storage:
889
  c = self.conn.cursor()
890
 
891
  # нормализация адресов
892
- addresses = list({a.strip() for a in (addresses or []) if a and a.strip()})
893
 
894
- existing_id = None
895
  existing_addresses = []
896
  existing_pubkey = None
897
  existing_capabilities = {}
898
 
899
  if peer_id:
900
- c.execute("SELECT id, addresses, pubkey, capabilities FROM agent_peers WHERE id=?", (peer_id,))
901
  row = c.fetchone()
902
  if row:
903
- existing_id, db_addresses_json, existing_pubkey, db_caps_json = row
904
  try:
905
  existing_addresses = json.loads(db_addresses_json) or []
906
  except:
@@ -910,7 +983,9 @@ class Storage:
910
  except:
911
  existing_capabilities = {}
912
 
913
- combined_addresses = list({*existing_addresses, *addresses})
 
 
914
  final_pubkey = pubkey or existing_pubkey
915
  final_capabilities = capabilities or existing_capabilities
916
 
@@ -937,6 +1012,7 @@ class Storage:
937
  ))
938
  self.conn.commit()
939
 
 
940
  def get_online_peers(self, limit=50):
941
  c = self.conn.cursor()
942
  c.execute("SELECT id, addresses FROM agent_peers WHERE status='online' LIMIT ?", (limit,))
@@ -947,100 +1023,6 @@ class Storage:
947
  c.execute("SELECT id, addresses FROM agent_peers WHERE id != ? LIMIT ?", (my_id, limit))
948
  return c.fetchall()
949
 
950
- # Нормализация адресов
951
- def normalize_address(self, addr: str) -> str:
952
- addr = addr.strip()
953
- if not addr:
954
- return None
955
- if "://" not in addr:
956
- return f"any://{addr}"
957
- return addr
958
-
959
- # Bootstrap
960
- def load_bootstrap(self, bootstrap_file="bootstrap.txt"):
961
- """
962
- Загружает узлы из bootstrap.txt.
963
- Поддерживаются адреса:
964
- tcp://host:port
965
- udp://host:port
966
- any://host:port
967
- TCP-узлы проверяются запросом /identity.
968
- UDP/any регистрируются без проверки (any учитывается как TCP+UDP).
969
- """
970
- import requests
971
-
972
- base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
973
- bootstrap_path = os.path.join(base_path, bootstrap_file)
974
-
975
- if not os.path.exists(bootstrap_path):
976
- print(f"[Bootstrap] Файл {bootstrap_file} не найден по пути {bootstrap_path}")
977
- return
978
-
979
- for line in open(bootstrap_path, encoding="utf-8"):
980
- line = line.strip()
981
- if not line or line.startswith("#"):
982
- continue
983
-
984
- try:
985
- addr = self.normalize_address(line)
986
- if addr is None:
987
- continue
988
-
989
- # Разворачиваем any:// на tcp и udp
990
- addrs_to_register = []
991
- proto, hostport = addr.split("://")
992
- if proto == "any":
993
- addrs_to_register.append(f"tcp://{hostport}")
994
- addrs_to_register.append(f"udp://{hostport}")
995
- else:
996
- addrs_to_register.append(addr)
997
-
998
- for a in addrs_to_register:
999
- proto2, hostport2 = a.split("://")
1000
-
1001
- # TCP → проверяем /identity
1002
- if proto2 == "tcp":
1003
- try:
1004
- url = f"http://{hostport2}/identity"
1005
- r = requests.get(url, timeout=3)
1006
- if r.status_code == 200:
1007
- info = r.json()
1008
- peer_id = info.get("id")
1009
- name = info.get("name", "unknown")
1010
- pubkey = info.get("pubkey")
1011
- capabilities = info.get("capabilities", {})
1012
-
1013
- self.add_or_update_peer(
1014
- peer_id=peer_id,
1015
- name=name,
1016
- addresses=[a],
1017
- source="bootstrap",
1018
- status="online",
1019
- pubkey=pubkey,
1020
- capabilities=capabilities,
1021
- )
1022
- print(f"[Bootstrap] Добавлен узел {peer_id} ({a})")
1023
- else:
1024
- print(f"[Bootstrap] {a} недоступен (HTTP {r.status_code})")
1025
- except Exception as e:
1026
- print(f"[Bootstrap] Ошибка при подключении к {a}: {e}")
1027
-
1028
- # UDP → просто регистрируем
1029
- elif proto2 == "udp":
1030
- peer_id = str(uuid.uuid4())
1031
- self.add_or_update_peer(
1032
- peer_id=peer_id,
1033
- name="unknown",
1034
- addresses=[a],
1035
- source="bootstrap",
1036
- status="unknown"
1037
- )
1038
- print(f"[Bootstrap] Добавлен адрес (без проверки): {a}")
1039
-
1040
- except Exception as e:
1041
- print(f"[Bootstrap] Ошибка парсинга {line}: {e}")
1042
-
1043
-
1044
  # Утилиты
1045
  def close(self):
1046
  self.conn.close()
 
6
  import json
7
  import uuid
8
  import time
9
+ import socket
10
+ import ipaddress
11
+ import netifaces
12
 
13
+ from datetime import datetime, timedelta, UTC, timezone, timezone as UTC
14
  from werkzeug.security import generate_password_hash, check_password_hash
15
  from tools.identity import generate_did
16
  from tools.crypto import generate_keypair
 
20
  SCRIPTS_BASE_PATH = "scripts"
21
 
22
  class Storage:
23
+ _scope_cache = {}
24
  def __init__(self, config=None):
25
  self.config = config or {}
26
  db_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "agent_data.db"))
 
884
  }
885
  return None
886
 
887
+ # Работа с пирам (agent_peers)
888
+ @staticmethod
889
+ def parse_hostport(s: str):
890
+ """
891
+ Разбирает "IP:port" или "[IPv6]:port" и возвращает (host, port)
892
+ """
893
+ s = s.strip()
894
+ if s.startswith("["):
895
+ host, _, port = s[1:].partition("]:")
896
+ try:
897
+ port = int(port)
898
+ except:
899
+ port = None
900
+ return host, port
901
+ else:
902
+ if ":" in s:
903
+ host, port = s.rsplit(":", 1)
904
+ try:
905
+ port = int(port)
906
+ except:
907
+ port = None
908
+ return host, port
909
+ return s, None
910
+
911
+ @staticmethod
912
+ def is_ipv6(host: str):
913
+ try:
914
+ socket.inet_pton(socket.AF_INET6, host)
915
+ return True
916
+ except OSError:
917
+ return False
918
+
919
+ @staticmethod
920
+ def is_private(ip: str) -> bool:
921
+ try:
922
+ return ipaddress.ip_address(ip).is_private
923
+ except ValueError:
924
+ return False
925
+
926
+ @classmethod
927
+ def get_ipv6_scope(cls, host):
928
+ if host in cls._scope_cache:
929
+ return cls._scope_cache[host]
930
+ for iface in netifaces.interfaces():
931
+ iface_addrs = netifaces.ifaddresses(iface).get(netifaces.AF_INET6, [])
932
+ for addr_info in iface_addrs:
933
+ if addr_info.get("addr") == host:
934
+ scope_id = socket.if_nametoindex(iface)
935
+ cls._scope_cache[host] = scope_id
936
+ return scope_id
937
+ return None
938
+
939
+ # Нормализация адресов
940
+ @classmethod
941
+ def normalize_address(cls, addr: str) -> str:
942
+ addr = addr.strip()
943
+ if not addr:
944
+ return None
945
+ if "://" not in addr:
946
+ addr = f"any://{addr}"
947
+
948
+ proto, hostport = addr.split("://", 1)
949
+ host, port = cls.parse_hostport(hostport)
950
+
951
+ # IPv6 без квадратных скобок
952
+ if cls.is_ipv6(host) and not host.startswith("["):
953
+ host = f"[{host}]"
954
+
955
+ return f"{proto}://{host}:{port}" if port else f"{proto}://{host}"
956
+
957
  # Работа с пирам (agent_peers)
958
  def add_or_update_peer(
959
  self, peer_id, name, addresses,
 
963
  c = self.conn.cursor()
964
 
965
  # нормализация адресов
966
+ addresses = list({self.normalize_address(a) for a in (addresses or []) if a and a.strip()})
967
 
 
968
  existing_addresses = []
969
  existing_pubkey = None
970
  existing_capabilities = {}
971
 
972
  if peer_id:
973
+ c.execute("SELECT addresses, pubkey, capabilities FROM agent_peers WHERE id=?", (peer_id,))
974
  row = c.fetchone()
975
  if row:
976
+ db_addresses_json, existing_pubkey, db_caps_json = row
977
  try:
978
  existing_addresses = json.loads(db_addresses_json) or []
979
  except:
 
983
  except:
984
  existing_capabilities = {}
985
 
986
+ # объединяем и нормализуем адреса, чтобы IPv6 всегда были с []
987
+ combined_addresses = list({self.normalize_address(a) for a in (*existing_addresses, *addresses)})
988
+
989
  final_pubkey = pubkey or existing_pubkey
990
  final_capabilities = capabilities or existing_capabilities
991
 
 
1012
  ))
1013
  self.conn.commit()
1014
 
1015
+ # Получение известных/онлайн пиров
1016
  def get_online_peers(self, limit=50):
1017
  c = self.conn.cursor()
1018
  c.execute("SELECT id, addresses FROM agent_peers WHERE status='online' LIMIT ?", (limit,))
 
1023
  c.execute("SELECT id, addresses FROM agent_peers WHERE id != ? LIMIT ?", (my_id, limit))
1024
  return c.fetchall()
1025
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1026
  # Утилиты
1027
  def close(self):
1028
  self.conn.close()