Skip to content

Commit 207af9d

Browse files
committed
release: v1.4.2 — Route Explainer, host DNS controls, scanner & probe fixes
New: Route Explainer (Diagnostics). A two-layer "where does traffic to X go?" tool — a pure-Python matcher replays the same DNS + routing rule ordering the config generator builds, and for geosite/geoip categories it can't resolve offline it spins a throwaway xray probe and reads the chosen outbound from the access log for ground truth. An optional reachability stage dials the decided path through the matching outbound. New: Host resolver controls on the DNS page. A "Host resolver (this box only)" block sets additive fallback DNS for the box's OWN lookups (subscriptions, geo files, panels, health checks), applied through systemd-resolved FallbackDNS / NetworkManager / resolv.conf — idempotent and boot-applied. A shared "What is this?" popover (HostDnsHelp) now explains, on both Settings and DNS, the difference between the host's primary gateway+DNS and this fallback resolver. DNS hardening: xray DNS now uses queryStrategy UseIPv4 to close an IPv6 bypass leak (AAAA answers routing around the IPv4-only TPROXY via the client's router IPv6 default route). DoT labels corrected — xray has no native DoT, so the UI no longer implies tls:// is encrypted. DNS settings/rules now auto-reload xray on every change (settings, create, update, delete, reorder), matching the routing endpoints. The disable_ipv6 toggle is relabeled "host only". Fix: device scanner crash. A MAC appearing twice in one ARP sweep (a device on two IPs, a duplicate ARP entry) queued a second Device row with the same MAC, so the whole scan rolled back on a UNIQUE constraint every 60s and the device never persisted. Freshly-created rows are now registered in-batch so a repeat MAC updates instead of re-inserting. Fix: Route Explainer probe merge. When the live xray probe overrode the offline best-guess, only the outbound was updated — the action and matched-rule stayed from the geosite candidate, so the UI could show "action: direct / outbound: node-2". The action is now re-derived from the real outbound and the stale candidate is dropped (the access log exposes only the outbound, not the rule). Tests: 727 passing (+6). New suites for the route explainer, host fallback DNS, and the scanner dedup regression; the scanner and probe fixes each have a test proven to fail without the fix. Frontend type-checks and builds clean. No schema migration, no breaking changes; new settings default in on first boot.
1 parent 1ef8e65 commit 207af9d

24 files changed

Lines changed: 2498 additions & 22 deletions

backend/app/api/diagnostics.py

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import shlex
66
from typing import Any, Dict, List
77

8-
from fastapi import APIRouter, Query
8+
from fastapi import APIRouter, Depends, Query
9+
10+
from app.database import get_session
11+
from app.schemas import RouteExplainRequest
912

1013
router = APIRouter(prefix="/diagnostics", tags=["diagnostics"])
1114
logger = logging.getLogger(__name__)
@@ -522,3 +525,276 @@ async def docker_logs(
522525
"""Get backend logs."""
523526
log_lines = _get_container_logs(lines=lines, filter_level=level)
524527
return {"lines": log_lines}
528+
529+
530+
# ── Route Explainer ───────────────────────────────────────────────────────────
531+
532+
@router.post("/explain")
533+
async def explain_route(body: RouteExplainRequest, session=Depends(get_session)):
534+
"""Explain where traffic to a target would go: which DNS rule + server
535+
resolves it, which routing rule matches + the resulting outbound, and
536+
(optionally) whether it actually connects.
537+
538+
Layer A (python matcher) is always run. When it can't decide because a
539+
geosite/geoip rule blocks certainty AND `verify_routing` is set, layer
540+
B (live xray probe) supplies the ground-truth outbound.
541+
"""
542+
import ipaddress
543+
import time as _time
544+
from sqlmodel import select
545+
from app.models import (
546+
RoutingRule, DNSRule, Node, Device, RoutingSet,
547+
Settings as DBSettings,
548+
)
549+
from app.core import route_explain as rex
550+
from app.schemas import (
551+
RouteExplainResult, RouteExplainDns, RouteExplainRouting,
552+
RouteExplainReachability,
553+
)
554+
555+
target = body.target.strip().rstrip(".")
556+
port = body.port
557+
protocol = (body.protocol or "tcp").lower()
558+
559+
# is the target a literal IP?
560+
is_ip = False
561+
try:
562+
ipaddress.ip_address(target)
563+
is_ip = True
564+
except ValueError:
565+
is_ip = False
566+
567+
# Load DB state
568+
settings_map = {r.key: r.value for r in (await session.exec(select(DBSettings))).all()}
569+
routing_rules = list((await session.exec(select(RoutingRule))).all())
570+
dns_rules = list((await session.exec(select(DNSRule))).all())
571+
nodes = list((await session.exec(select(Node))).all())
572+
node_labels = {n.id: n.name for n in nodes}
573+
mode = settings_map.get("mode", "rules")
574+
bypass_private = settings_map.get("bypass_private", "true").lower() == "true"
575+
active_node_id = None
576+
aid = settings_map.get("active_node_id", "")
577+
if aid:
578+
try:
579+
active_node_id = int(aid)
580+
except ValueError:
581+
active_node_id = None
582+
583+
# Optional per-device (routing set) context
584+
set_context = None
585+
if body.from_mac:
586+
mac = body.from_mac.strip().lower()
587+
dev = (await session.exec(
588+
select(Device).where(Device.mac == mac)
589+
)).first()
590+
if dev and dev.routing_set_id:
591+
rs = await session.get(RoutingSet, dev.routing_set_id)
592+
if rs:
593+
member_ids = [
594+
r.id for r in routing_rules
595+
if getattr(r, "routing_set_id", None) == rs.id
596+
]
597+
set_context = (rs, member_ids)
598+
599+
# ── DNS stage ──
600+
dns_x = rex.explain_dns(
601+
target, is_ip=is_ip, dns_rules=dns_rules, settings_map=settings_map,
602+
)
603+
resolved_ips: list[str] = []
604+
resolve_error = None
605+
if not is_ip:
606+
try:
607+
from app.api.dns import _resolve_plain, _resolve_doh
608+
srv = dns_x.server or settings_map.get("dns_upstream", "8.8.8.8")
609+
if (dns_x.server_type or "").lower() == "doh" or str(srv).startswith("http"):
610+
doh = srv if str(srv).startswith("http") else f"https://{srv}/dns-query"
611+
ips, _lat = await _resolve_doh(target, doh)
612+
else:
613+
# dot maps to plaintext tcp/53 in PiTun — _resolve_plain
614+
# uses UDP/53 which returns the same records for an
615+
# explain (we only need the address, not the transport).
616+
ips, _lat = await _resolve_plain(target, str(srv) if srv else None)
617+
resolved_ips = ips
618+
except Exception as exc: # noqa: BLE001
619+
resolve_error = str(exc)
620+
primary_ip = (target if is_ip else (resolved_ips[0] if resolved_ips else None))
621+
622+
# ── Routing stage (A: python) ──
623+
route_x = rex.explain_routing(
624+
target=target, is_ip=is_ip, resolved_ip=primary_ip,
625+
port=port, protocol=protocol, mode=mode,
626+
bypass_private=bypass_private, rules=routing_rules,
627+
active_node_id=active_node_id, node_labels=node_labels,
628+
set_context=set_context,
629+
)
630+
method = "python_matcher"
631+
probe_detail = None
632+
633+
# ── Routing stage (B: xray probe) when uncertain + requested ──
634+
if body.verify_routing and not route_x.certain and not is_ip:
635+
try:
636+
from app.core.config_gen import generate_config, collect_routing_set_context
637+
from app.core.route_explain_probe import xray_probe_routing
638+
from app.models import BalancerGroup
639+
active_node = await session.get(Node, active_node_id) if active_node_id else None
640+
all_nodes = [n for n in nodes if n.enabled]
641+
balancers = list((await session.exec(select(BalancerGroup))).all())
642+
rsets, dmap = await collect_routing_set_context(session)
643+
cfg = generate_config(
644+
active_node, all_nodes,
645+
[r for r in routing_rules if r.enabled],
646+
mode, settings_map, dns_rules, balancers,
647+
routing_sets=rsets, device_set_macs=dmap,
648+
)
649+
probe = await xray_probe_routing(
650+
base_config=cfg, target=target, port=port, protocol=protocol,
651+
)
652+
probe_detail = probe.get("detail")
653+
if probe.get("ok") and probe.get("outbound"):
654+
ob = probe["outbound"]
655+
route_x.outbound = ob
656+
route_x.outbound_label = rex._label_for(ob, node_labels)
657+
route_x.certain = True
658+
method = "xray_probe"
659+
# xray's access log exposes only the chosen OUTBOUND, never
660+
# which rule matched. Layer A's matched_rule was merely the
661+
# geosite/geoip CANDIDATE that triggered this probe — it did
662+
# NOT necessarily match. Re-derive the action from the real
663+
# outbound and drop the stale candidate so the UI can't show
664+
# the contradiction "action: direct / outbound: node-2".
665+
blocker = route_x.blocking_rule
666+
route_x.action = rex.action_from_outbound(ob)
667+
route_x.matched_rule_id = None
668+
route_x.matched_rule_name = None
669+
route_x.matched_rule_type = None
670+
route_x.matched_value = None
671+
if blocker:
672+
route_x.notes.append(
673+
f"Layer A couldn't decide because rule {blocker} needs "
674+
"the geosite/geoip .dat files. xray evaluated the full "
675+
f"ruleset live and routed the target to {ob} "
676+
"(its access log exposes only the chosen outbound, not "
677+
"which rule matched)."
678+
)
679+
route_x.notes.append("Ground-truth decision from live xray probe.")
680+
except Exception as exc: # noqa: BLE001
681+
probe_detail = f"probe failed: {exc}"
682+
683+
# ── Reachability stage ──
684+
reach = RouteExplainReachability(tested=False)
685+
if body.test_reachability and primary_ip:
686+
reach.tested = True
687+
ob = route_x.outbound or "direct"
688+
t0 = _time.monotonic()
689+
if ob == "block":
690+
reach.ok = False
691+
reach.via = "block"
692+
reach.detail = "Routing action is BLOCK — connection would be dropped."
693+
elif ob == "direct":
694+
code = await _http_probe_direct(primary_ip, target, port=port, timeout=6.0)
695+
reach.ok = code is not None
696+
reach.http_code = code
697+
reach.via = "direct"
698+
reach.latency_ms = int((_time.monotonic() - t0) * 1000)
699+
reach.detail = (f"HTTP {code} direct (SO_MARK bypass)"
700+
if code is not None else "no response (direct)")
701+
else:
702+
# proxy / node-N → go through the live SOCKS inbound so it
703+
# follows the SAME routing rules the box runs.
704+
code, detail = await _probe_via_socks(settings_map, target, port)
705+
reach.ok = code is not None
706+
reach.http_code = code
707+
reach.via = ob
708+
reach.latency_ms = int((_time.monotonic() - t0) * 1000)
709+
reach.detail = detail
710+
711+
return RouteExplainResult(
712+
target=target, port=port, protocol=protocol, is_ip=is_ip,
713+
dns=RouteExplainDns(
714+
is_ip=dns_x.is_ip,
715+
matched_rule_id=dns_x.matched_rule_id,
716+
matched_rule_name=dns_x.matched_rule_name,
717+
matched_pattern=dns_x.matched_pattern,
718+
server=dns_x.server,
719+
server_type=dns_x.server_type,
720+
uses_global_upstream=dns_x.uses_global_upstream,
721+
query_strategy=dns_x.query_strategy,
722+
geosite_uncertain=dns_x.geosite_uncertain,
723+
resolved_ips=resolved_ips,
724+
resolve_error=resolve_error,
725+
note=dns_x.note,
726+
),
727+
routing=RouteExplainRouting(
728+
matched_rule_id=route_x.matched_rule_id,
729+
matched_rule_name=route_x.matched_rule_name,
730+
matched_rule_type=route_x.matched_rule_type,
731+
matched_value=route_x.matched_value,
732+
action=route_x.action,
733+
outbound=route_x.outbound,
734+
outbound_label=route_x.outbound_label,
735+
certain=route_x.certain,
736+
blocking_rule=route_x.blocking_rule,
737+
rules_evaluated=route_x.rules_evaluated,
738+
set_id=route_x.set_id,
739+
set_name=route_x.set_name,
740+
method=method,
741+
probe_detail=probe_detail,
742+
notes=route_x.notes,
743+
),
744+
reachability=reach,
745+
)
746+
747+
748+
async def _probe_via_socks(settings_map: dict, target: str, port: int) -> tuple:
749+
"""Connect to target:port through the live xray SOCKS inbound (1080)
750+
so reachability follows the same routing the box runs. Returns
751+
(http_code|None, detail). Uses LAN proxy creds if auth is on."""
752+
socks_port = int(settings_map.get("socks_port", "1080"))
753+
user = settings_map.get("lan_proxy_auth_user", "")
754+
pw = settings_map.get("lan_proxy_auth_pass", "")
755+
auth_on = settings_map.get("lan_proxy_auth_enabled", "false").lower() == "true"
756+
try:
757+
reader, writer = await asyncio.wait_for(
758+
asyncio.open_connection("127.0.0.1", socks_port), timeout=4
759+
)
760+
except Exception as exc: # noqa: BLE001
761+
return None, f"cannot reach local SOCKS inbound: {exc}"
762+
try:
763+
if auth_on and user:
764+
writer.write(b"\x05\x01\x02") # offer user/pass
765+
await writer.drain()
766+
ver = await asyncio.wait_for(reader.readexactly(2), timeout=4)
767+
if ver[1] == 0x02:
768+
u = user.encode()[:255]; p = pw.encode()[:255]
769+
writer.write(b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p)
770+
await writer.drain()
771+
st = await asyncio.wait_for(reader.readexactly(2), timeout=4)
772+
if st[1] != 0x00:
773+
return None, "SOCKS auth rejected"
774+
else:
775+
writer.write(b"\x05\x01\x00")
776+
await writer.drain()
777+
await asyncio.wait_for(reader.readexactly(2), timeout=4)
778+
host = target.encode()[:255]
779+
writer.write(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + port.to_bytes(2, "big"))
780+
await writer.drain()
781+
rep = await asyncio.wait_for(reader.readexactly(10), timeout=6)
782+
if rep[1] != 0x00:
783+
return None, f"SOCKS connect failed (code {rep[1]})"
784+
# connected through the proxy — send a tiny HTTP probe for a status
785+
writer.write(f"GET / HTTP/1.0\r\nHost: {target}\r\nConnection: close\r\n\r\n".encode())
786+
await writer.drain()
787+
data = await asyncio.wait_for(reader.read(256), timeout=6)
788+
first = data.split(b"\r\n", 1)[0]
789+
parts = first.split(b" ", 2)
790+
if len(parts) >= 2 and parts[1].isdigit():
791+
return int(parts[1]), f"HTTP {parts[1].decode()} via proxy"
792+
return 0, "connected via proxy (no HTTP status parsed)"
793+
except Exception as exc: # noqa: BLE001
794+
return None, f"proxy probe error: {exc}"
795+
finally:
796+
try:
797+
writer.close()
798+
await asyncio.wait_for(writer.wait_closed(), timeout=2)
799+
except Exception:
800+
pass

backend/app/api/dns.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"bypass_cn_dns",
4343
"bypass_ru_dns",
4444
"dns_disable_fallback",
45+
"dns_query_strategy",
46+
"host_fallback_dns",
4547
}
4648

4749

@@ -66,6 +68,8 @@ def _settings_map_to_dns(m: dict) -> DNSSettingsRead:
6668
bypass_cn_dns=m.get("bypass_cn_dns", "false").lower() == "true",
6769
bypass_ru_dns=m.get("bypass_ru_dns", "false").lower() == "true",
6870
dns_disable_fallback=m.get("dns_disable_fallback", "true").lower() == "true",
71+
dns_query_strategy=m.get("dns_query_strategy", "UseIPv4"),
72+
host_fallback_dns=m.get("host_fallback_dns", ""),
6973
)
7074

7175

@@ -78,6 +82,28 @@ async def _upsert_setting(session: AsyncSession, key: str, value: str) -> None:
7882
session.add(DBSettings(key=key, value=value))
7983

8084

85+
async def _auto_reload_xray(session: AsyncSession) -> None:
86+
"""Regenerate the xray config and reload if running.
87+
88+
Called after ANY DNS settings/rule change so the operator sees it
89+
take effect immediately — same contract the routing-rules endpoints
90+
have always had. Before this, DNS changes only landed on the next
91+
xray reload (Start/Restart, NodeCircle rotation, backend restart),
92+
which made DNS Rules look like they "didn't work" until something
93+
else happened to reload xray. No-op when xray isn't running — the
94+
next /system/start builds the fresh config from the saved settings.
95+
"""
96+
try:
97+
from app.core.xray import xray_manager
98+
if not xray_manager.is_running:
99+
return
100+
from app.api.system import _regenerate_and_write
101+
await _regenerate_and_write(session)
102+
await xray_manager.reload()
103+
except Exception as exc:
104+
logger.warning("Auto-reload after DNS change failed: %s", exc)
105+
106+
81107
# ── DNS Settings ──────────────────────────────────────────────────────────────
82108

83109
@router.get("/settings", response_model=DNSSettingsRead)
@@ -96,6 +122,26 @@ async def update_dns_settings(
96122
if field in _DNS_SETTING_KEYS:
97123
await _upsert_setting(session, field, str(val).lower() if isinstance(val, bool) else str(val))
98124
await session.commit()
125+
126+
# Host fallback DNS is the only DNS-page setting that touches the
127+
# HOST network stack (not the xray config). Apply it additively to
128+
# the host resolver — non-destructive, keeps DHCP/router DNS first.
129+
if "host_fallback_dns" in updates:
130+
try:
131+
from app.core.network_apply import apply_host_fallback_dns
132+
raw = str(updates["host_fallback_dns"] or "")
133+
servers = [s.strip() for s in raw.split(",") if s.strip()]
134+
apply_host_fallback_dns(servers)
135+
except Exception as exc:
136+
logger.warning("host_fallback_dns apply failed: %s", exc)
137+
138+
# Every other DNS setting (mode, upstreams, queryStrategy, fakedns,
139+
# bypass toggles, sniffing) lives in the xray config — reload so the
140+
# change applies now instead of on the next restart.
141+
xray_keys = set(updates) - {"host_fallback_dns"}
142+
if xray_keys:
143+
await _auto_reload_xray(session)
144+
99145
m = await _get_settings_map(session)
100146
return _settings_map_to_dns(m)
101147

@@ -117,6 +163,7 @@ async def create_dns_rule(
117163
session.add(rule)
118164
await session.commit()
119165
await session.refresh(rule)
166+
await _auto_reload_xray(session)
120167
return rule
121168

122169

@@ -135,6 +182,7 @@ async def update_dns_rule(
135182
session.add(rule)
136183
await session.commit()
137184
await session.refresh(rule)
185+
await _auto_reload_xray(session)
138186
return rule
139187

140188

@@ -148,6 +196,7 @@ async def delete_dns_rule(
148196
raise HTTPException(status_code=404, detail="DNS rule not found")
149197
await session.delete(rule)
150198
await session.commit()
199+
await _auto_reload_xray(session)
151200

152201

153202
@router.post("/rules/reorder", status_code=204)
@@ -163,6 +212,7 @@ async def reorder_dns_rules(
163212
rule.order = idx * 10
164213
session.add(rule)
165214
await session.commit()
215+
await _auto_reload_xray(session)
166216

167217

168218
# ── DNS Query Log ─────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)