Skip to content

Commit afc1a8c

Browse files
committed
release: v1.4.5
Fixed: switching the active node now actually applies. POST /api/system/active-node only wrote the active_node_id DB row — it never regenerated the xray config or reloaded xray, so activating a node (a WireGuard chain especially) left traffic exiting the PREVIOUS node while the UI showed the new one. It now regenerates, re-applies nftables, and hot-reloads. Also fixed the runtime balancer override: xray api bo passed the balancer tag positionally instead of via -b <tag>, so every override was a silent no-op. Added: seamless NodeCircle rotation. An enabled circle routes proxy traffic at a per-circle xray balancer over all preloaded members; rotation hot-swaps the selected node via the gRPC balancerOverride API — no xray restart, so live connections finish on their current node and only new ones move. Manual active-node switches into a circle pin the balancer the same way. Added: node import UX — drag & drop into the upload box, WireGuard .conf (and .ini) in the file picker, and a name-from-filename toggle for single-config imports. Notes: WireGuard remains exit-only in a chain — it can't carry transit as a relay (re-verified live: a mid-chain WG forwards 0 bytes). No schema migration; Alembic head unchanged. Backend suite 766 passing.
1 parent 1a76f19 commit afc1a8c

14 files changed

Lines changed: 369 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@ All notable user-facing changes to PiTun. Full per-release detail lives in the
44
[GitHub Releases](https://github.com/DaveBugg/PiTun/releases); this file is the
55
committed summary.
66

7+
## v1.4.5 — 2026-06-18
8+
9+
Node-circle rotation that no longer drops connections, a fix so switching the
10+
active node (WireGuard chains especially) actually takes effect, and node-import
11+
quality-of-life (drag & drop, `.conf` files, name-from-filename).
12+
13+
### Fixed
14+
15+
- **Switching the active node now applies immediately.** `POST /api/system/active-node`
16+
only wrote the DB row — it never regenerated the xray config or reloaded xray,
17+
so activating a node (a WireGuard chain especially) left traffic exiting the
18+
*previous* node while the UI showed the new one. It now regenerates + hot-reloads.
19+
- **Balancer override silently failed.** `xray api bo` was called with the balancer
20+
tag positionally instead of via `-b <tag>` ("balancer tag not specified"), so
21+
every runtime balancer override was a no-op.
22+
23+
### Added
24+
25+
- **Seamless NodeCircle rotation.** An enabled circle now routes proxy traffic at a
26+
per-circle xray balancer over all preloaded members; rotation hot-swaps the
27+
selected node via the gRPC `balancerOverride` API — no xray restart, so live
28+
connections finish on their current node and only new ones move. Manual
29+
active-node switches into a circle pin the balancer the same way.
30+
- **Node import UX.** The upload box now accepts **drag & drop**, the file picker
31+
allows WireGuard `.conf` (and `.ini`), and a **"name from filename"** toggle names
32+
a single-config import after the dropped file.
33+
34+
### Notes
35+
36+
- WireGuard can still only be a chain's **exit** hop — it can't carry transit as a
37+
relay (verified again live: a mid-chain WG forwards 0 bytes). The circle balancer
38+
preloads each member together with its stream relay, so WG-circle rotation works.
39+
740
## v1.4.4 — 2026-06-16
841

942
Multi-hop node chaining that actually wires every hop, a Route Explainer that

backend/app/api/nodes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ async def import_nodes(
419419
from app.core.uri_parser import parse_uri_list
420420

421421
parsed = parse_uri_list(body.uris)
422+
# "Name from filename" (single-config file upload): override the parsed
423+
# node's name only when exactly one node came through, so a multi-URI
424+
# paste/subscription file never collapses every node to the same name.
425+
if body.name_override and len(parsed) == 1:
426+
parsed[0]["name"] = body.name_override
422427
imported = 0
423428
skipped = 0
424429
errors: List[str] = []

backend/app/api/system.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,46 @@ async def set_mode(body: ModeUpdate, session: AsyncSession = Depends(get_session
538538
await session.commit()
539539

540540

541+
async def _pin_circle_balancer(session: AsyncSession, node_id: int) -> None:
542+
"""If `node_id` belongs to a running NodeCircle, pin that circle's xray
543+
balancer to it via gRPC balancerOverride. The config routes circle proxy
544+
traffic at a balancer over all members (cold-start strategy = random), so
545+
after a (re)load we must override to the actually-selected node.
546+
Best-effort — silently skipped if the API isn't up."""
547+
try:
548+
from app.core import xray_api
549+
from app.core.config_gen import resolve_active_circle
550+
from app.models import NodeCircle
551+
circles = list((await session.exec(select(NodeCircle))).all())
552+
cid, member_ids = resolve_active_circle(circles, node_id)
553+
if cid and node_id in (member_ids or []) and await xray_api.is_api_available():
554+
await xray_api.override_balancer(f"circle-{cid}", [f"node-{node_id}"])
555+
except Exception as exc:
556+
logging.getLogger(__name__).debug("pin circle balancer skipped: %s", exc)
557+
558+
541559
@router.post("/active-node", status_code=204)
542560
async def set_active_node(body: ActiveNodeUpdate, session: AsyncSession = Depends(get_session)):
561+
from app.core.xray import xray_manager
562+
543563
node = await session.get(Node, body.node_id)
544564
if not node:
545565
raise HTTPException(404, "Node not found")
546566
await _set_setting(session, "active_node_id", str(body.node_id))
547567
await session.commit()
548568

569+
# Switching the active node must actually APPLY it: regenerate the xray
570+
# config (picks the new active outbound + wires its chain) and hot-reload.
571+
# Without this the DB flips but xray keeps serving the PREVIOUS node — so
572+
# activating a WireGuard chain left traffic exiting the old node and the
573+
# real exit IP never changed, even though the UI showed the new node.
574+
await _regenerate_and_write(session)
575+
settings_map = await _load_settings_map(session)
576+
await _apply_nftables(session, settings_map)
577+
if xray_manager.is_running:
578+
await xray_manager.reload()
579+
await _pin_circle_balancer(session, body.node_id)
580+
549581

550582
# ── Settings ──────────────────────────────────────────────────────────────────
551583

@@ -983,9 +1015,16 @@ async def _regenerate_and_write(session: AsyncSession, *, _self_heal_attempts: i
9831015
dns_rules = list((await session.exec(select(DNSRule).where(DNSRule.enabled == True))).all())
9841016
balancer_groups = list((await session.exec(select(BalancerGroup))).all())
9851017
routing_sets, device_set_macs = await collect_routing_set_context(session)
1018+
from app.models import NodeCircle
1019+
from app.core.config_gen import resolve_active_circle
1020+
circles = list((await session.exec(select(NodeCircle))).all())
1021+
active_circle_id, active_circle_node_ids = resolve_active_circle(
1022+
circles, active_node.id if active_node else None
1023+
)
9861024
config = generate_config(
9871025
active_node, all_nodes, rules, mode, settings_map, dns_rules, balancer_groups,
9881026
routing_sets=routing_sets, device_set_macs=device_set_macs,
1027+
active_circle_id=active_circle_id, active_circle_node_ids=active_circle_node_ids,
9891028
)
9901029

9911030
# On retry passes we skip pre-flight to avoid recursive cost — the

backend/app/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# OpenAPI metadata, `/health` response, and `/system/status` so the
66
# frontend can display it next to the xray version. Bump this on each
77
# release — frontend keeps its own version in `frontend/package.json`.
8-
APP_VERSION = "1.4.4"
8+
APP_VERSION = "1.4.5"
99

1010

1111
class Settings(BaseSettings):

backend/app/core/circle_scheduler.py

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -399,57 +399,62 @@ async def _safe_event(**kwargs):
399399
async def _seamless_rotate(
400400
self, prev_node_id: int, next_node_id: int
401401
) -> None:
402+
new_tag = f"node-{next_node_id}"
402403
try:
403404
from app.core.xray import xray_manager
404405
if not xray_manager.is_running:
405406
return
406-
407407
from app.core import xray_api
408-
from app.core.config_gen import _build_outbound, _stream_settings
409-
410-
if not await xray_api.is_api_available():
411-
logger.warning("xray API not available, falling back to full restart")
412-
await self._full_reload()
413-
return
414-
408+
from app.core.config_gen import resolve_active_circle
409+
410+
# Resolve the circle the rotated node belongs to. When the live
411+
# config routes that circle's proxy traffic at a balancer over all
412+
# preloaded members, we can hot-swap the selected member with a
413+
# single gRPC balancerOverride — xray picks the outbound per NEW
414+
# connection, so existing connections keep their current member and
415+
# finish naturally. Zero restart, zero outbound churn.
415416
async with AsyncSession(get_async_engine()) as session:
416-
next_node = await session.get(Node, next_node_id)
417-
if not next_node:
418-
logger.error("Node %d not found for seamless rotation", next_node_id)
419-
await self._full_reload()
420-
return
421-
try:
422-
new_outbound = _build_outbound(next_node)
423-
except Exception as exc:
424-
logger.error("Failed to build outbound for node %d: %s", next_node_id, exc)
425-
return
417+
from app.models import NodeCircle
418+
circles = list((await session.exec(select(NodeCircle))).all())
419+
cid, member_ids = resolve_active_circle(circles, next_node_id)
426420

427-
new_tag = f"node-{next_node_id}"
428-
old_tag = f"node-{prev_node_id}"
429-
430-
added = await xray_api.add_outbound(new_outbound)
431-
if not added:
432-
# `add_outbound` already retries on "existing tag found" via
433-
# its idempotency path. Any other failure means the live xray
434-
# doesn't know about the new outbound — writing config file
435-
# alone would leave live state desynced. Fall back to a full
436-
# reload so live xray and config file agree.
437-
logger.warning(
438-
"Seamless rotation: add_outbound(%s) failed — falling back to full reload",
439-
new_tag,
421+
in_balancer = bool(
422+
cid and member_ids and next_node_id in member_ids
423+
)
424+
if in_balancer and await xray_api.is_api_available():
425+
bal_tag = f"circle-{cid}"
426+
if await xray_api.override_balancer(bal_tag, [new_tag]):
427+
await self._update_config_file() # keep file's selector/active in sync
428+
logger.info(
429+
"Seamless rotation: balancerOverride %s → %s "
430+
"(live connections finish on their current node)",
431+
bal_tag, new_tag,
432+
)
433+
return
434+
# Override missed — the balancer/members aren't loaded in the
435+
# live xray yet (config predates this circle being active).
436+
# Reload to materialize them, then pin the rotated node.
437+
logger.info(
438+
"balancerOverride(%s) miss — reloading to materialize circle balancer",
439+
bal_tag,
440440
)
441441
await self._full_reload()
442+
try:
443+
await xray_api.override_balancer(bal_tag, [new_tag])
444+
except Exception:
445+
pass
442446
return
443447

444-
await self._update_config_file()
445-
446-
logger.info(
447-
"Seamless rotation: %s → %s (old connections finish naturally)",
448-
old_tag, new_tag,
448+
# No circle balancer (single-node active) or API down → the only
449+
# way to apply is a full reload (restart, drops live connections).
450+
logger.warning(
451+
"Rotation to %s: no circle balancer / xray API unavailable — full reload",
452+
new_tag,
449453
)
454+
await self._full_reload()
450455

451456
except Exception as exc:
452-
logger.error("Seamless rotation failed, falling back to full restart: %s", exc)
457+
logger.error("Seamless rotation failed, falling back to full reload: %s", exc)
453458
await self._full_reload()
454459

455460
async def _update_config_file(self) -> None:
@@ -471,12 +476,16 @@ async def _update_config_file(self) -> None:
471476
dns_rules = list((await session.exec(select(DNSRule).where(DNSRule.enabled == True))).all())
472477
balancer_groups = list((await session.exec(select(BalancerGroup))).all())
473478
mode = settings_map.get("mode", "rules")
474-
from app.core.config_gen import collect_routing_set_context
479+
from app.core.config_gen import collect_routing_set_context, resolve_active_circle
480+
from app.models import NodeCircle
475481
routing_sets, device_set_macs = await collect_routing_set_context(session)
482+
circles = list((await session.exec(select(NodeCircle))).all())
483+
acid, acids = resolve_active_circle(circles, active_node.id if active_node else None)
476484
config = generate_config(
477485
active_node, all_nodes, rules, mode, settings_map,
478486
dns_rules, balancer_groups,
479487
routing_sets=routing_sets, device_set_macs=device_set_macs,
488+
active_circle_id=acid, active_circle_node_ids=acids,
480489
)
481490
await write_config(config)
482491
except Exception as exc:

backend/app/core/config_gen.py

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,14 +475,27 @@ def _build_outbound(node: Node) -> Dict[str, Any]:
475475
]
476476

477477

478-
def _routing_rule_to_xray(rule: RoutingRule, active_node_id: Optional[int]) -> Optional[Dict[str, Any]]:
479-
"""Convert a RoutingRule DB row to an xray routing rule dict."""
478+
def _routing_rule_to_xray(
479+
rule: RoutingRule,
480+
active_node_id: Optional[int],
481+
active_balancer_tag: Optional[str] = None,
482+
) -> Optional[Dict[str, Any]]:
483+
"""Convert a RoutingRule DB row to an xray routing rule dict.
484+
485+
When `active_balancer_tag` is set (the active node belongs to a running
486+
NodeCircle), `action == "proxy"` targets that balancer instead of a
487+
single `node-<id>` outbound — so circle rotation can hot-swap the
488+
selected node via the gRPC balancerOverride API without a restart.
489+
"""
480490
values = [v.strip() for v in rule.match_value.split(",") if v.strip()]
481491

482492
xray_rule: Dict[str, Any] = {"type": "field"}
483493

484494
if rule.action == "proxy":
485-
xray_rule["outboundTag"] = f"node-{active_node_id}" if active_node_id else "direct"
495+
if active_balancer_tag:
496+
xray_rule["balancerTag"] = active_balancer_tag
497+
else:
498+
xray_rule["outboundTag"] = f"node-{active_node_id}" if active_node_id else "direct"
486499
elif rule.action == "direct":
487500
xray_rule["outboundTag"] = "direct"
488501
elif rule.action == "block":
@@ -760,6 +773,29 @@ def _build_tun_inbound(settings_map: Dict[str, str]) -> Dict[str, Any]:
760773
return inbound
761774

762775

776+
def resolve_active_circle(circles, active_node_id):
777+
"""Return (circle_id, member_node_ids) of the enabled NodeCircle that
778+
contains `active_node_id`, else (None, None).
779+
780+
Callers pass the active circle into `generate_config` so the proxy
781+
traffic routes via a per-circle balancer over all members — that's what
782+
lets rotation hot-swap the selected node through the gRPC balancerOverride
783+
API without restarting xray (live connections survive).
784+
"""
785+
if not active_node_id:
786+
return None, None
787+
for c in circles or []:
788+
if not getattr(c, "enabled", False):
789+
continue
790+
try:
791+
ids = json.loads(c.node_ids) if isinstance(c.node_ids, str) else (c.node_ids or [])
792+
except Exception:
793+
ids = []
794+
if active_node_id in ids:
795+
return c.id, list(ids)
796+
return None, None
797+
798+
763799
def generate_config(
764800
active_node: Optional[Node],
765801
all_nodes: List[Node],
@@ -770,6 +806,8 @@ def generate_config(
770806
balancer_groups: Optional[List[BalancerGroup]] = None,
771807
routing_sets: Optional[List[RoutingSet]] = None,
772808
device_set_macs: Optional[Dict[int, List[str]]] = None,
809+
active_circle_id: Optional[int] = None,
810+
active_circle_node_ids: Optional[List[int]] = None,
773811
) -> Dict[str, Any]:
774812
"""Build full xray JSON configuration.
775813
@@ -984,6 +1022,32 @@ def generate_config(
9841022
except Exception as exc:
9851023
logger.error("Failed to build outbound for node %d: %s", active_node.id, exc)
9861024

1025+
# NodeCircle members: when the active node belongs to a running circle,
1026+
# preload EVERY member's outbound (+ its chain) and route proxy traffic at
1027+
# a balancer over them (built below). Rotation then hot-swaps the selected
1028+
# member via the gRPC balancerOverride API — no xray restart, so live
1029+
# connections survive (xray picks the outbound per-connection at dispatch).
1030+
circle_member_tags: List[str] = []
1031+
circle_active = bool(active_circle_id and active_circle_node_ids and active_node)
1032+
if circle_active:
1033+
for nid in active_circle_node_ids:
1034+
tag = f"node-{nid}"
1035+
if not any(o.get("tag") == tag for o in outbounds):
1036+
node = next((n for n in all_nodes if n.id == nid), None)
1037+
if node and node.enabled:
1038+
try:
1039+
ob = _build_outbound(node)
1040+
_apply_chain(node, ob, outbounds, used_ids, all_nodes)
1041+
outbounds.append(ob)
1042+
used_ids.add(nid)
1043+
except Exception as exc:
1044+
logger.warning("Circle member node %d skip: %s", nid, exc)
1045+
if any(o.get("tag") == tag for o in outbounds):
1046+
circle_member_tags.append(tag)
1047+
active_balancer_tag = (
1048+
f"circle-{active_circle_id}" if (circle_active and circle_member_tags) else None
1049+
)
1050+
9871051
# Additional nodes for "node:<id>" routing rules
9881052
for node in all_nodes:
9891053
if node.id not in used_ids and node.enabled:
@@ -1056,6 +1120,18 @@ def generate_config(
10561120
"strategy": {"type": bg.strategy},
10571121
})
10581122

1123+
# Active-NodeCircle balancer. Selector lists every preloaded member; the
1124+
# actually-selected member is pinned at runtime via balancerOverride (the
1125+
# apply path overrides to active_node on (re)load, the scheduler overrides
1126+
# to the next member on rotation). `random` is just a valid cold-start
1127+
# default before the first override lands.
1128+
if active_balancer_tag and circle_member_tags:
1129+
xray_balancers.append({
1130+
"tag": active_balancer_tag,
1131+
"selector": circle_member_tags,
1132+
"strategy": {"type": "random"},
1133+
})
1134+
10591135
# Routing
10601136
routing_rules: List[Dict[str, Any]] = [
10611137
# Stats API: route api inbound to api outbound (internal)
@@ -1099,10 +1175,14 @@ def generate_config(
10991175
elif mode == "global":
11001176
if bypass_private:
11011177
routing_rules.append({"type": "field", "ip": _PRIVATE_CIDRS, "outboundTag": "direct"})
1178+
_global_target = (
1179+
{"balancerTag": active_balancer_tag} if active_balancer_tag
1180+
else {"outboundTag": f"node-{active_node.id}" if active_node else "direct"}
1181+
)
11021182
routing_rules.append({
11031183
"type": "field",
11041184
"ip": ["0.0.0.0/0", "::/0"],
1105-
"outboundTag": f"node-{active_node.id}" if active_node else "direct",
1185+
**_global_target,
11061186
})
11071187
else:
11081188
# rules mode
@@ -1137,7 +1217,7 @@ def generate_config(
11371217
for rule in sorted_rules:
11381218
if rule.routing_set_id != sid:
11391219
continue
1140-
xray_rule = _routing_rule_to_xray(rule, active_node_id)
1220+
xray_rule = _routing_rule_to_xray(rule, active_node_id, active_balancer_tag)
11411221
if xray_rule:
11421222
xray_rule["inboundTag"] = [tag]
11431223
routing_rules.append(xray_rule)
@@ -1148,7 +1228,7 @@ def generate_config(
11481228
# (matched by inboundTag), then falls through to globals.
11491229
for rule in sorted_rules:
11501230
if rule.routing_set_id is None:
1151-
xray_rule = _routing_rule_to_xray(rule, active_node_id)
1231+
xray_rule = _routing_rule_to_xray(rule, active_node_id, active_balancer_tag)
11521232
if xray_rule:
11531233
routing_rules.append(xray_rule)
11541234

0 commit comments

Comments
 (0)