|
5 | 5 | import shlex |
6 | 6 | from typing import Any, Dict, List |
7 | 7 |
|
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 |
9 | 12 |
|
10 | 13 | router = APIRouter(prefix="/diagnostics", tags=["diagnostics"]) |
11 | 14 | logger = logging.getLogger(__name__) |
@@ -522,3 +525,276 @@ async def docker_logs( |
522 | 525 | """Get backend logs.""" |
523 | 526 | log_lines = _get_container_logs(lines=lines, filter_level=level) |
524 | 527 | 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 |
0 commit comments