feat: introduce Vue Bootstrap interface and scoped router network status
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""Read-only, scoped network checks for registered equipment. Never logs credentials."""
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
||||
def safe_ip(value):
|
||||
ip = ipaddress.ip_address(value)
|
||||
return not (ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified or ip.is_reserved)
|
||||
|
||||
|
||||
def probe(host):
|
||||
"""Connect only to resolved, validated addresses, preventing DNS rebinding."""
|
||||
try:
|
||||
addresses = list(dict.fromkeys(x[4][0] for x in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)))[:3]
|
||||
if not addresses or not all(safe_ip(ip) for ip in addresses):
|
||||
return dict(host=host, reachable=False, ports=[], error='地址不能用于设备网络探测')
|
||||
except (OSError, ValueError):
|
||||
return dict(host=host, reachable=False, ports=[], error='地址解析失败')
|
||||
def connect(port):
|
||||
for ip in addresses:
|
||||
try:
|
||||
with socket.create_connection((ip, port), timeout=.55): return port
|
||||
except OSError: pass
|
||||
return None
|
||||
with ThreadPoolExecutor(max_workers=5) as pool:
|
||||
ports = [port for port in pool.map(connect, (80, 443, 22, 554, 8790)) if port]
|
||||
return dict(host=host, reachable=bool(ports), ports=ports,
|
||||
error='' if ports else '常用服务端口未响应,设备可能离线或未开放这些服务')
|
||||
|
||||
|
||||
def tail_peer(host, cidr):
|
||||
if not host: return None
|
||||
try:
|
||||
result = subprocess.run(['tailscale', 'status', '--json'], capture_output=True, text=True, timeout=2, check=True)
|
||||
status = json.loads(result.stdout)
|
||||
peer = next((p for p in (status.get('Peer') or {}).values()
|
||||
if host in p.get('TailscaleIPs', []) or host.lower().rstrip('.') == p.get('DNSName', '').lower().rstrip('.')), None)
|
||||
if not peer: return dict(available=False, note='本机 Tailscale 未找到此节点')
|
||||
routes = list(dict.fromkeys((peer.get('PrimaryRoutes') or [])+(peer.get('AllowedIPs') or [])))
|
||||
# Online is control-plane presence; it does not prove the subnet is reachable.
|
||||
return dict(available=True, online=bool(peer.get('Online')), active=bool(peer.get('Active')),
|
||||
subnet=cidr, subnetAvailable=(cidr in routes) if cidr else None)
|
||||
except (OSError, subprocess.SubprocessError, ValueError, TypeError):
|
||||
return dict(available=False, note='本机暂时无法读取 Tailscale 状态')
|
||||
|
||||
|
||||
def management_links(results):
|
||||
links=[]
|
||||
for item in results:
|
||||
host=item['host']; authority='['+host+']' if ':' in host else host
|
||||
try:
|
||||
if not safe_ip(host):continue
|
||||
except ValueError:
|
||||
if not re.fullmatch(r'[A-Za-z0-9.-]{1,253}',host):continue
|
||||
ports=item.get('ports',[])
|
||||
port=next((p for p in (443,80,8790) if p in ports),80)
|
||||
scheme='https' if port==443 else 'http'
|
||||
links.append(dict(kind=item['kind'],url=scheme+'://'+authority+(':'+str(port) if port==8790 else '')+'/',port=port,verified=port in ports))
|
||||
return links
|
||||
|
||||
|
||||
class NetworkStatus:
|
||||
def __init__(self, app):
|
||||
self.a=app; self.cache={}; self.lock=threading.Lock(); self.gates={}
|
||||
|
||||
def read(self, user, query):
|
||||
a=self.a
|
||||
if not a.HOUSEHOLDS.manager(user): raise a.Problem('此操作需要家庭管理员权限',403)
|
||||
table={'device':'devices','camera':'assets','recorder':'recorders'}.get(query.get('source'))
|
||||
if not table: raise a.Problem('设备类型不正确')
|
||||
item=next((d for d in a.HOUSEHOLDS.objects(user,table) if d['id']==query.get('id')),None)
|
||||
if not item: raise a.Problem('设备不存在或未获授权',404)
|
||||
hosts=[]
|
||||
direct=item.get('host') if table=='recorders' else item.get('managementHost')
|
||||
if table=='assets':
|
||||
direct=next((c.get('host') for c in a.HOUSEHOLDS.objects(user,'cameras') if c.get('assetId')==item['id'] and c.get('sourceKind')!='recorder' and c.get('host')),direct)
|
||||
for kind,host in [('local',direct),('tailscale',item.get('tailscaleHost'))]:
|
||||
if host and host not in [h[1] for h in hosts]: hosts.append((kind,host))
|
||||
key=(a.HOUSEHOLDS.family_of(item),table,item['id'],tuple(hosts),item.get('networkCidr',''))
|
||||
with self.lock:
|
||||
if key in self.cache and time.monotonic()-self.cache[key][0]<30:return {**self.cache[key][1],'cached':True}
|
||||
# Cap concurrency and collapse repeated requests to the same device.
|
||||
if key in self.gates:raise a.Problem('正在检查该设备,请稍后再试',429)
|
||||
if len(self.gates)>=4:raise a.Problem('网络检查繁忙,请稍后再试',429)
|
||||
self.gates[key]=True
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures=[(kind,pool.submit(probe,host)) for kind,host in hosts]
|
||||
tail=pool.submit(tail_peer,item.get('tailscaleHost',''),item.get('networkCidr',''))
|
||||
targets=[dict(f.result(),kind=kind) for kind,f in futures]
|
||||
result=dict(id=item['id'],source=query['source'],checkedAt=time.time(),targets=targets,tailscale=tail.result(),links=management_links(targets),cached=False)
|
||||
with self.lock:
|
||||
self.cache={k:v for k,v in self.cache.items() if time.monotonic()-v[0]<30}
|
||||
if len(self.cache)>=256:self.cache.pop(next(iter(self.cache)))
|
||||
self.cache[key]=(time.monotonic(),result)
|
||||
return result
|
||||
finally:
|
||||
with self.lock:self.gates.pop(key,None)
|
||||
Reference in New Issue
Block a user