@@ -988,28 +988,39 @@ async def check_all_nodes():
988988# ── Speed test ────────────────────────────────────────────────────────────────
989989
990990async def _persist_node_speed (
991- node_id : int , mbps : Optional [float ], max_mbps : Optional [float ] = None
991+ node_id : int ,
992+ mbps : Optional [float ],
993+ max_mbps : Optional [float ] = None ,
994+ exit_info : Optional [dict ] = None ,
992995) -> None :
993996 """Cache a speed reading (avg + peak) on the Node row so it survives a
994997 restart and feeds NodeCircle best/min_speed + the UI's staleness colour.
995- Best-effort: a write failure must never break the speed test itself."""
996- if mbps is None :
998+
999+ `exit_info` is stored even when the download produced no number: the
1000+ tunnel came up far enough to be identified, and that is worth keeping —
1001+ a node with a flag but no speed reading is a fair description of it.
1002+ Best-effort throughout: a write failure must never break the test."""
1003+ if mbps is None and not exit_info :
9971004 return
9981005 try :
9991006 async with AsyncSession (get_async_engine ()) as session :
10001007 node = await session .get (Node , node_id )
10011008 if node :
1002- node .speed_mbps = float (mbps )
1003- node .speed_max_mbps = float (max_mbps ) if max_mbps is not None else None
1004- node .speed_tested_at = datetime .now (timezone .utc )
1009+ if mbps is not None :
1010+ node .speed_mbps = float (mbps )
1011+ node .speed_max_mbps = float (max_mbps ) if max_mbps is not None else None
1012+ node .speed_tested_at = datetime .now (timezone .utc )
1013+ if exit_info :
1014+ from app .core .speedtest import apply_exit
1015+ apply_exit (node , exit_info )
10051016 await session .commit ()
10061017 except Exception : # noqa: BLE001 — advisory cache, never fatal
10071018 pass
10081019
10091020
10101021@router .post ("/{node_id:int}/speedtest" , response_model = SpeedTestResult )
10111022async def speedtest_node (node_id : int , session : AsyncSession = Depends (get_session )):
1012- from app .core .speedtest import speedtest_node as _speedtest
1023+ from app .core .speedtest import apply_exit , speedtest_node as _speedtest
10131024
10141025 node = await session .get (Node , node_id )
10151026 if not node :
@@ -1022,7 +1033,11 @@ async def speedtest_node(node_id: int, session: AsyncSession = Depends(get_sessi
10221033 mx = result .get ("max_mbps" )
10231034 node .speed_max_mbps = float (mx ) if mx is not None else None
10241035 node .speed_tested_at = datetime .now (timezone .utc )
1036+ changed_exit = apply_exit (node , result )
1037+ if mbps is not None or changed_exit :
10251038 await session .commit ()
1039+ await session .refresh (node )
1040+ result ["node_name" ] = node .name # the flag prefix may have just changed
10261041 return SpeedTestResult (** result )
10271042
10281043
@@ -1046,14 +1061,17 @@ async def _gen():
10461061 import json
10471062 final_mbps = None
10481063 final_max = None
1064+ exit_info : dict = {}
10491065 async for event in speedtest_stream (node ):
1066+ if event .get ("phase" ) == "exit" :
1067+ exit_info = {k : event .get (k ) for k in ("exit_ip" , "exit_country" )}
10501068 if event .get ("phase" ) == "done" :
10511069 final_mbps = event .get ("mbps" )
10521070 final_max = event .get ("mbps_max" )
10531071 yield json .dumps (event ) + "\n "
10541072 # Persist the post-warmup average + peak after the stream closes. A
10551073 # fresh session — the request-scoped one is gone once streaming starts.
1056- await _persist_node_speed (node_id , final_mbps , final_max )
1074+ await _persist_node_speed (node_id , final_mbps , final_max , exit_info )
10571075
10581076 return StreamingResponse (
10591077 _gen (),
@@ -1067,13 +1085,17 @@ async def _gen():
10671085async def node_reachability (node_id : int , session : AsyncSession = Depends (get_session )):
10681086 """Does the internet actually work through this node? Fetches Google's
10691087 generate_204 over the tunnel — a real proxied round trip, distinct from
1070- the TCP-only health check. Returns {ok, latency_ms, detail}."""
1071- from app .core .speedtest import reachability_check
1088+ the TCP-only health check. Returns {ok, latency_ms, detail} plus the exit
1089+ identity it read back while the tunnel was open, which is also stored."""
1090+ from app .core .speedtest import apply_exit , reachability_check
10721091
10731092 node = await session .get (Node , node_id )
10741093 if not node :
10751094 raise HTTPException (404 , "Node not found" )
1076- return await reachability_check (node )
1095+ result = await reachability_check (node )
1096+ if apply_exit (node , result ):
1097+ await session .commit ()
1098+ return result
10771099
10781100
10791101@router .get ("/{node_id:int}/uri" )
@@ -1094,7 +1116,7 @@ async def node_uri(node_id: int, session: AsyncSession = Depends(get_session)):
10941116@router .post ("/speedtest-all" , response_model = List [SpeedTestResult ])
10951117async def speedtest_all_nodes (session : AsyncSession = Depends (get_session )):
10961118 """Run speed test on all enabled nodes sequentially (each spawns its own xray)."""
1097- from app .core .speedtest import speedtest_node as _speedtest
1119+ from app .core .speedtest import apply_exit , speedtest_node as _speedtest
10981120
10991121 nodes = list ((await session .exec (select (Node ).where (Node .enabled == True ))).all ())
11001122 results = []
@@ -1107,6 +1129,7 @@ async def speedtest_all_nodes(session: AsyncSession = Depends(get_session)):
11071129 mx = result .get ("max_mbps" )
11081130 node .speed_max_mbps = float (mx ) if mx is not None else None
11091131 node .speed_tested_at = now
1132+ apply_exit (node , result )
11101133 results .append (SpeedTestResult (** result ))
11111134 await session .commit ()
11121135 return results
@@ -1121,32 +1144,39 @@ async def apply_country_flags(session: AsyncSession = Depends(get_session)):
11211144 — keep the name they were created with. This is the one-off that brings
11221145 them in line.
11231146
1124- Unlike the write-time hook, this resolves hostnames: it runs in a worker
1125- thread rather than inside a flush, so a slow DNS server costs time here
1126- instead of stalling a database write. Idempotent — a stale flag is
1127- stripped before the current one is applied, so running it twice is the
1128- same as running it once, and a node whose country can't be determined
1129- simply keeps its bare name.
1147+ A country a speed test observed at the exit is used first and needs no
1148+ database at all. Failing that the address is looked up — and unlike the
1149+ write-time hook this resolves hostnames: it runs in a worker thread
1150+ rather than inside a flush, so a slow DNS server costs time here instead
1151+ of stalling a database write.
1152+
1153+ Idempotent — a stale flag is replaced by the current one, so running it
1154+ twice is the same as running it once. A node whose country can't be
1155+ determined is left alone rather than having its name rewritten.
11301156 """
11311157 import anyio
11321158 from app .core .geoip_lookup import _get_reader , enrich_name
11331159
1134- if _get_reader () is None :
1160+ nodes = (await session .exec (select (Node ))).all ()
1161+ if _get_reader () is None and not any (n .country for n in nodes ):
11351162 raise HTTPException (
11361163 400 ,
1137- "No GeoLite2 country database is installed, so there is nothing "
1138- "to look names up in. Add GeoLite2-Country.mmdb next to the geo "
1139- "data and restart the backend." ,
1164+ "No country is known for any node yet: no GeoLite2 database is "
1165+ "installed, and no node has been speed-tested (the speed test "
1166+ "reads the exit country back through the tunnel). Run a speed "
1167+ "test, or add GeoLite2-Country.mmdb next to the geo data and "
1168+ "restart the backend." ,
11401169 )
11411170
1142- nodes = (await session .exec (select (Node ))).all ()
1143-
1144- def _resolve_all (pairs : List [tuple ]) -> List [tuple ]:
1171+ def _resolve_all (rows : List [tuple ]) -> List [tuple ]:
11451172 # One thread for the whole batch: each name may cost a DNS lookup.
1146- return [(nid , enrich_name (name , addr )) for nid , name , addr in pairs ]
1173+ return [
1174+ (nid , enrich_name (name , addr , country = cc , keep_on_miss = True ))
1175+ for nid , name , addr , cc in rows
1176+ ]
11471177
11481178 renamed = await anyio .to_thread .run_sync (
1149- _resolve_all , [(n .id , n .name , n .address ) for n in nodes ],
1179+ _resolve_all , [(n .id , n .name , n .address , n . country ) for n in nodes ],
11501180 )
11511181
11521182 by_id = {nid : new_name for nid , new_name in renamed }
0 commit comments