mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-30 19:52:03 +01:00
feat: reversible route matching
Routes now default to bidirectional matching — packets traversing the configured path in reverse (C->B->A instead of A->B->C) also count towards health. A 'reversible' toggle lets users constrain a route to one direction when needed. - Route model: new 'reversible' column (default true, server_default 1) - Migration: a1b2c3d4e5f6 (additive ALTER TABLE) - Matching engine: _match_hops + _fetch_candidate_paths_maybe_bidirectional helpers shared across evaluate_route, recent_matches, and preview_route - API schemas: reversible on RouteCreate/Update/Read/Detail/PreviewRequest - SPA: toggle in modal; card path chips show <-> for reversible, -> otherwise - Seed loader + example YAML: reads 'reversible' key - 4 new collector tests (reverse match, non-reversible, both-directions, API)
This commit is contained in:
1 parent
14fbc45387
commit
0e202b3999
12 files changed
+211
-11
No files matched your search
@@ -0,0 +1,37 @@
|
||||
"""add reversible column to routes
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 8f2a3c4d5e6f
|
||||
Create Date: 2026-07-13 01:00:00.000000+00:00
|
||||
|
||||
Adds a ``reversible`` boolean to the ``routes`` table (default true).
|
||||
When true, the matching engine also checks the reverse-ordered path,
|
||||
so A->B->C matches packets observed as C->B->A.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "8f2a3c4d5e6f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"routes",
|
||||
sa.Column(
|
||||
"reversible",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("routes", "reversible")
|
||||
@@ -18,6 +18,7 @@ Ipswich ↔ Norwich:
|
||||
# degraded_threshold: 10 # optional; omit/null = 2x threshold
|
||||
# max_hop_span: 8 # optional; omit/null = unlimited
|
||||
enabled: true
|
||||
reversible: true # match both directions (A->B and B->A)
|
||||
path:
|
||||
- a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
- 9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b7c6d5e4f9a8b
|
||||
|
||||
@@ -89,6 +89,7 @@ def _route_to_read(route: Route) -> RouteRead:
|
||||
degraded_threshold=route.degraded_threshold,
|
||||
max_hop_span=route.max_hop_span,
|
||||
enabled=route.enabled,
|
||||
reversible=route.reversible,
|
||||
route_nodes=[_route_node_to_read(rn) for rn in route.route_nodes],
|
||||
route_observers=[_route_observer_to_read(ro) for ro in route.route_observers],
|
||||
route_result=_result_to_summary(route.route_result),
|
||||
@@ -191,6 +192,7 @@ def create_route(
|
||||
degraded_threshold=body.degraded_threshold,
|
||||
max_hop_span=body.max_hop_span,
|
||||
enabled=body.enabled,
|
||||
reversible=body.reversible,
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
@@ -297,6 +299,8 @@ def update_route(
|
||||
route.max_hop_span = body.max_hop_span
|
||||
if body.enabled is not None:
|
||||
route.enabled = body.enabled
|
||||
if body.reversible is not None:
|
||||
route.reversible = body.reversible
|
||||
|
||||
if body.node_public_keys is not None:
|
||||
nodes = _resolve_nodes_by_pubkey(session, body.node_public_keys)
|
||||
@@ -364,6 +368,7 @@ def preview(
|
||||
"max_hop_span": body.max_hop_span,
|
||||
"packet_count_threshold": body.packet_count_threshold,
|
||||
"degraded_threshold": body.degraded_threshold,
|
||||
"reversible": body.reversible,
|
||||
}
|
||||
result = preview_route(session, config, since)
|
||||
return RoutePreviewResponse(**result)
|
||||
@@ -789,6 +789,7 @@ def _import_routes(
|
||||
route.degraded_threshold = value.get("degraded_threshold")
|
||||
route.max_hop_span = value.get("max_hop_span")
|
||||
route.enabled = value.get("enabled", True)
|
||||
route.reversible = value.get("reversible", True)
|
||||
# Replace path nodes wholesale
|
||||
for rn in list(route.route_nodes):
|
||||
session.delete(rn)
|
||||
@@ -807,6 +808,7 @@ def _import_routes(
|
||||
degraded_threshold=value.get("degraded_threshold"),
|
||||
max_hop_span=value.get("max_hop_span"),
|
||||
enabled=value.get("enabled", True),
|
||||
reversible=value.get("reversible", True),
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
|
||||
@@ -110,6 +110,38 @@ def is_subsequence(
|
||||
return True
|
||||
|
||||
|
||||
def _match_hops(
|
||||
hops: list[dict[str, Any]],
|
||||
expected: list[str],
|
||||
max_hop_span: Optional[int] = None,
|
||||
reversible: bool = True,
|
||||
) -> bool:
|
||||
"""Check whether *hops* match *expected* forward (and optionally reverse)."""
|
||||
if is_subsequence(hops, expected, max_hop_span):
|
||||
return True
|
||||
if reversible and len(expected) > 1:
|
||||
return is_subsequence(hops, list(reversed(expected)), max_hop_span)
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_candidate_paths_maybe_bidirectional(
|
||||
session: Session,
|
||||
expected: list[str],
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
reversible: bool = True,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch candidate paths from one or both endpoints of *expected*."""
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
|
||||
for rp_id, hops in fetch_candidate_paths(
|
||||
session, expected[-1], since, observer_ids
|
||||
).items():
|
||||
if rp_id not in paths:
|
||||
paths[rp_id] = hops
|
||||
return paths
|
||||
|
||||
|
||||
def prefix_collision_counts(session: Session, match_width: int) -> dict[str, int]:
|
||||
"""Count how many nodes share each public-key prefix at *match_width*.
|
||||
|
||||
@@ -279,13 +311,16 @@ def evaluate_route(
|
||||
[ro.node_id for ro in route.route_observers] if route.route_observers else None
|
||||
)
|
||||
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
reversible = getattr(route, "reversible", True)
|
||||
paths = _fetch_candidate_paths_maybe_bidirectional(
|
||||
session, expected, since, observer_ids, reversible
|
||||
)
|
||||
|
||||
eff_degraded = effective_degraded_threshold(route)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, route.max_hop_span):
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
@@ -388,11 +423,14 @@ def recent_matches(
|
||||
)
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=route.window_hours)
|
||||
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
reversible = getattr(route, "reversible", True)
|
||||
paths = _fetch_candidate_paths_maybe_bidirectional(
|
||||
session, expected, since, observer_ids, reversible
|
||||
)
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, route.max_hop_span):
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
first = hops[0] if hops else {}
|
||||
matches.append(
|
||||
{
|
||||
@@ -418,7 +456,8 @@ def preview_route(
|
||||
"""Preview matching for an unsaved route config.
|
||||
|
||||
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
|
||||
``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``.
|
||||
``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``,
|
||||
``reversible``.
|
||||
"""
|
||||
node_ids: list[str] = config.get("node_ids") or []
|
||||
match_width: int = config.get("match_width") or 1
|
||||
@@ -426,6 +465,7 @@ def preview_route(
|
||||
max_hop_span: Optional[int] = config.get("max_hop_span")
|
||||
threshold: int = config.get("packet_count_threshold") or 3
|
||||
degraded: Optional[int] = config.get("degraded_threshold")
|
||||
reversible: bool = config.get("reversible", True)
|
||||
|
||||
if len(node_ids) < 2:
|
||||
return {
|
||||
@@ -460,6 +500,10 @@ def preview_route(
|
||||
candidate_count = _count_candidate_receptions(
|
||||
session, first_prefix, since, observer_ids
|
||||
)
|
||||
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
|
||||
candidate_count += _count_candidate_receptions(
|
||||
session, expected[-1], since, observer_ids
|
||||
)
|
||||
if candidate_count > PREVIEW_CANDIDATE_CAP:
|
||||
return {
|
||||
"matched_count": None,
|
||||
@@ -468,7 +512,9 @@ def preview_route(
|
||||
"candidate_count": candidate_count,
|
||||
}
|
||||
|
||||
paths = fetch_candidate_paths(session, first_prefix, since, observer_ids)
|
||||
paths = _fetch_candidate_paths_maybe_bidirectional(
|
||||
session, expected, since, observer_ids, reversible
|
||||
)
|
||||
|
||||
eff_degraded = degraded or (threshold * DEGRADED_DEFAULT_MULTIPLIER)
|
||||
|
||||
@@ -476,7 +522,7 @@ def preview_route(
|
||||
contributing: dict[str, int] = {}
|
||||
|
||||
for hops in paths.values():
|
||||
if is_subsequence(hops, expected, max_hop_span):
|
||||
if _match_hops(hops, expected, max_hop_span, reversible):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
|
||||
@@ -85,6 +85,12 @@ class Route(Base, UUIDMixin, TimestampMixin):
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
reversible: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
route_nodes: Mapped[list["RouteNode"]] = relationship(
|
||||
"RouteNode",
|
||||
|
||||
@@ -65,6 +65,9 @@ class RouteCreate(BaseModel):
|
||||
default=None, description="Max hop distance between first and last node"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this route is evaluated")
|
||||
reversible: bool = Field(
|
||||
default=True, description="Whether to match the path in both directions"
|
||||
)
|
||||
node_public_keys: list[str] = Field(
|
||||
..., description="Ordered path node public keys (64-char hex, >= 2, distinct)"
|
||||
)
|
||||
@@ -99,6 +102,7 @@ class RouteUpdate(BaseModel):
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
reversible: Optional[bool] = None
|
||||
node_public_keys: Optional[list[str]] = None
|
||||
observer_public_keys: Optional[list[str]] = None
|
||||
|
||||
@@ -133,6 +137,7 @@ class RouteRead(BaseModel):
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: bool
|
||||
reversible: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
route_observers: list[RouteObserverRead] = []
|
||||
route_result: Optional[RouteResultSummary] = None
|
||||
@@ -179,6 +184,7 @@ class RouteDetail(BaseModel):
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
enabled: bool
|
||||
reversible: bool
|
||||
route_nodes: list[RouteNodeRead] = []
|
||||
route_observers: list[RouteObserverRead] = []
|
||||
route_result: Optional[RouteResultSummary] = None
|
||||
@@ -202,6 +208,7 @@ class RoutePreviewRequest(BaseModel):
|
||||
degraded_threshold: Optional[int] = None
|
||||
max_hop_span: Optional[int] = None
|
||||
observer_public_keys: Optional[list[str]] = None
|
||||
reversible: bool = Field(default=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_preview(self) -> "RoutePreviewRequest":
|
||||
|
||||
@@ -61,9 +61,10 @@ function renderSummaryStrip(routes) {
|
||||
|
||||
function renderPathChips(route) {
|
||||
const nodes = route.route_nodes || [];
|
||||
const arrow = route.reversible !== false ? '\u2194' : '\u2192';
|
||||
return html`<div class="flex flex-wrap items-center gap-1 text-sm">
|
||||
${nodes.map((rn, i) => html`
|
||||
${i > 0 ? html`<span class="opacity-50">\u2192</span>` : nothing}
|
||||
${i > 0 ? html`<span class="opacity-50">${arrow}</span>` : nothing}
|
||||
<span class="badge badge-ghost badge-sm">${rn.name || rn.public_key?.slice(0, 8) || rn.node_id.slice(0, 8)}</span>
|
||||
`)}
|
||||
</div>`;
|
||||
@@ -147,9 +148,23 @@ function renderDetailContent(route, detail) {
|
||||
${matches.length > 0 ? html`<div>
|
||||
<strong class="opacity-70">${t('routes.recent_matches')}:</strong>
|
||||
<div class="mt-1 space-y-1">
|
||||
${matches.map(m => html`<div class="font-mono text-xs opacity-60">
|
||||
${(m.hops || []).map((h, i) => html`${i > 0 ? ' \u2192 ' : nothing}${h.node_hash}`).slice(0, 10)}
|
||||
</div>`)}
|
||||
${matches.map(m => {
|
||||
const pathLookup = new Map(
|
||||
(route.route_nodes || []).map(rn =>
|
||||
[rn.expected_hash?.toLowerCase(), rn])
|
||||
);
|
||||
return html`<div class="flex flex-wrap items-center gap-0.5 text-xs">
|
||||
${(m.hops || []).slice(0, 10).map((h, i) => {
|
||||
const rn = pathLookup.get((h.node_hash || '').toLowerCase());
|
||||
return html`
|
||||
${i > 0 ? html`<span class="opacity-30 mx-0.5">\u2192</span>` : nothing}
|
||||
${rn
|
||||
? html`<span class="badge badge-primary badge-sm">${rn.name || h.node_hash}</span>`
|
||||
: html`<span class="badge badge-ghost badge-sm opacity-50">${h.node_hash}</span>`}
|
||||
`;
|
||||
})}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
<div class="opacity-50 text-xs">
|
||||
@@ -324,11 +339,18 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
|
||||
placeholder="\u221E" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" id="route-modal-enabled" class="checkbox checkbox-sm"
|
||||
.checked=${route.enabled !== false} />
|
||||
<span class="text-sm">${t('routes.enabled_label')}</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" id="route-modal-reversible" class="checkbox checkbox-sm"
|
||||
.checked=${route.reversible !== false} />
|
||||
<span class="text-sm">${t('routes.reversible_label')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
|
||||
@@ -652,6 +674,7 @@ export async function render(container, params, router) {
|
||||
const degradedEl = document.getElementById('route-modal-degraded');
|
||||
const spanEl = document.getElementById('route-modal-span');
|
||||
const enabledEl = document.getElementById('route-modal-enabled');
|
||||
const reversibleEl = document.getElementById('route-modal-reversible');
|
||||
|
||||
const isEdit = modalState.isEdit;
|
||||
const nodePublicKeys = modalState.pathNodes.map(n => n.public_key);
|
||||
@@ -671,6 +694,7 @@ export async function render(container, params, router) {
|
||||
packet_count_threshold: parseInt(thresholdEl.value, 10) || 3,
|
||||
max_hop_span: spanEl.value ? parseInt(spanEl.value, 10) : null,
|
||||
enabled: enabledEl.checked,
|
||||
reversible: reversibleEl.checked,
|
||||
node_public_keys: nodePublicKeys,
|
||||
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
|
||||
};
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"degraded_label": "Degraded",
|
||||
"span_label": "Max Span",
|
||||
"enabled_label": "Enabled",
|
||||
"reversible_label": "Reversible (match both directions)",
|
||||
"disabled": "Disabled",
|
||||
"not_evaluated": "Not yet evaluated",
|
||||
"diagnosis": "Diagnosis",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"quality_no_coverage": "Geen dekking",
|
||||
"quality_unknown": "Onbekend",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"reversible_label": "Omkeerbaar (beide richtingen)",
|
||||
"search_nodes_placeholder": "Zoek op naam of public key\u2026",
|
||||
"path_label": "Padknooppunten",
|
||||
"path_help": "Zoek op knooppuntnaam of public key en selecteer uit de resultaten. Minimaal 2 unieke knooppunten vereist.",
|
||||
|
||||
@@ -67,6 +67,23 @@ class TestCreateRoute:
|
||||
assert data["name"] == "Route1"
|
||||
assert len(data["route_nodes"]) == 2
|
||||
assert data["route_nodes"][0]["expected_hash"] is not None
|
||||
assert data["reversible"] is True
|
||||
|
||||
def test_create_non_reversible(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
api_db_session.commit()
|
||||
|
||||
resp = client_no_auth.post(
|
||||
"/api/v1/routes",
|
||||
json={
|
||||
"name": "OneWay",
|
||||
"node_public_keys": [n.public_key for n in nodes],
|
||||
"reversible": False,
|
||||
},
|
||||
headers={"X-User-Roles": "admin"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["reversible"] is False
|
||||
|
||||
def test_duplicate_name_rejected(self, client_no_auth, api_db_session):
|
||||
nodes = _sample_nodes(api_db_session)
|
||||
|
||||
@@ -84,6 +84,7 @@ def _make_route(
|
||||
observers: list[Node] | None = None,
|
||||
enabled: bool = True,
|
||||
window_hours: int = 24,
|
||||
reversible: bool = True,
|
||||
) -> Route:
|
||||
route = Route(
|
||||
name=name,
|
||||
@@ -93,6 +94,7 @@ def _make_route(
|
||||
max_hop_span=max_hop_span,
|
||||
enabled=enabled,
|
||||
window_hours=window_hours,
|
||||
reversible=reversible,
|
||||
)
|
||||
db_session.add(route)
|
||||
db_session.flush()
|
||||
@@ -341,6 +343,56 @@ class TestEvaluateRoute:
|
||||
assert quality == RouteQuality.CLEAR.value
|
||||
assert count == 4 # short-circuited at effective_degraded = 4
|
||||
|
||||
def test_reversible_matches_reverse_direction(self, db_session):
|
||||
"""Reversible route matches packets travelling the reverse path."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
|
||||
# Packets going B->A (reverse of configured A->B)
|
||||
for i in range(5):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["BB", "AA"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.HEALTHY.value
|
||||
assert count == 5
|
||||
|
||||
def test_non_reversible_ignores_reverse_direction(self, db_session):
|
||||
"""Non-reversible route does NOT match reverse-direction packets."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(
|
||||
db_session, "R1", [node_a, node_b], threshold=3, reversible=False
|
||||
)
|
||||
|
||||
# Only reverse-direction packets exist (B->A)
|
||||
for i in range(5):
|
||||
_make_reception(db_session, None, f"pkt{i}", ["BB", "AA"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert count == 0
|
||||
|
||||
def test_reversible_matches_both_directions(self, db_session):
|
||||
"""Reversible route matches packets in both directions."""
|
||||
node_a = _make_node(db_session, "aa" + "0" * 62)
|
||||
node_b = _make_node(db_session, "bb" + "0" * 62)
|
||||
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
|
||||
|
||||
_make_reception(db_session, None, "fwd1", ["AA", "BB"])
|
||||
_make_reception(db_session, None, "rev1", ["BB", "AA"])
|
||||
_make_reception(db_session, None, "fwd2", ["AA", "BB"])
|
||||
_make_reception(db_session, None, "rev2", ["BB", "AA"])
|
||||
db_session.commit()
|
||||
|
||||
since = _NOW - timedelta(hours=24)
|
||||
state, quality, count = evaluate_route(db_session, route, since)
|
||||
assert state == RouteState.HEALTHY.value
|
||||
assert count == 4
|
||||
|
||||
|
||||
class TestEvaluateAllRoutes:
|
||||
def test_only_enabled_routes(self, db_session):
|
||||
@@ -437,6 +489,7 @@ class TestPreviewRoute:
|
||||
"node_ids": [node_a.id, node_b.id],
|
||||
"match_width": 1,
|
||||
"packet_count_threshold": 3,
|
||||
"reversible": False,
|
||||
},
|
||||
since,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user