Skip to content

Commit c2a76a6

Browse files
ccie18643claude
andcommitted
feat(ip6): operator override for the RFC 6724 policy table (R11)
Make the RFC 6724 §10.3 address-selection policy table (source/dest precedence + label) operator-overridable at runtime — the PyTCP analogue of Linux 'ip addrlabel'. A whole precedence/label table is not a scalar, and Linux exposes it via a dedicated netlink control surface rather than '/proc/sys', so this is a small dedicated control API on the policy-table module, not a scalar sysctl: - set_policy_table(entries) -> install a custom table (rejects a table with no ::/0 catch-all so 'lookup' stays total) - reset_policy_table() -> restore the RFC §10.3 default - get_policy_table() -> immutable snapshot of the active table 'lookup' now reads the active table each call, so the §5 rule-6 source-selection consumer picks up an override live without a restart. The override is a copy-on-write reference swap of the whole tuple — readers always see a complete immutable snapshot (a frozen tuple of frozen PolicyEntry records), so it is lock-free-safe under free-threading (the no-GIL COW pattern). Tests-first at test__ip6__policy_table.py::TestIp6PolicyTableOverride: override changes 'lookup' live, reset restores the default, get returns the active snapshot, and a table with no ::/0 catch-all is rejected. Pre-fix the tests error on the missing symbols; post-fix they pass. Adherence rfc6724__default_address_selection updated in lockstep (§2.1 / §10.3 flipped to met, stale 'lib/ip6_policy_table.py' paths corrected to 'protocols/ip6/ip6__policy_table.py') plus the rfc6724_source_selection.md tracker. Phase-3 note: daemon-IPC exposure of this control surface is a deferred follow-on (in-process only today). Reference: RFC 6724 §2.1 (administrator-configurable policy table). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3bf8315 commit c2a76a6

5 files changed

Lines changed: 215 additions & 34 deletions

File tree

docs/refactor/host_refinements_backlog.md

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ until the user says "push".
5656
`test__icmp6__mld1_compat.py::TestIcmp6MldForcedVersion`. Adherence:
5757
`rfc2710__mld_v1`. (R10's RFC 2710 §4 Report-suppression part remains —
5858
optimization only.)
59+
- [x] **RFC 6724 policy-table override (R11)**`set_policy_table` /
60+
`reset_policy_table` / `get_policy_table` on
61+
`protocols/ip6/ip6__policy_table.py` — the PyTCP analogue of Linux
62+
`ip addrlabel` (a dedicated control API, not a scalar sysctl, since a
63+
whole table is not a scalar). `lookup` reads the active table live
64+
(copy-on-write reference swap, lock-free under free-threading) so the
65+
§5 rule-6 selector picks up an override without a restart;
66+
`set_policy_table` rejects a table with no ::/0 catch-all so `lookup`
67+
stays total. Unit tests:
68+
`test__ip6__policy_table.py::TestIp6PolicyTableOverride`. Adherence:
69+
`rfc6724__default_address_selection` (§2.1 / §10.3 flipped to met,
70+
stale `lib/` paths corrected). Phase-3 note: daemon-IPC exposure of
71+
this control surface is a follow-on (in-process only today).
5972

6073
**The canonical SO_RCVBUF guard pattern** (mirror for any new datagram socket):
6174

@@ -227,19 +240,17 @@ stay text-only (Linux `sysctl` has no `-j` either).
227240
marginal value. Tests-first in `tests/integration/protocols/icmp6/`.
228241
- **Effort:** small. **Risk:** low. **Value:** marginal.
229242

230-
### R11 — RFC 6724 `ip.policy_table` sysctl override (small-medium)
243+
### R11 — RFC 6724 policy-table override — SHIPPED (see "Shipped" above)
231244

232-
- **Why:** the RFC 6724 §2.1 policy table (source/dest address selection
233-
precedence/label) is hard-coded; Linux exposes it as a writable table
234-
(`/proc/sys/net/ipv6/...` / `ip addrlabel`). Source:
235-
`rfc6724_source_selection.md` non-blocking arc extension.
236-
- **Scope:** make the policy table operator-overridable (a sysctl entry or a
237-
small dedicated API), re-resolved live per the qualified-module-access
238-
pattern.
239-
- **Tests-first:** unit tests asserting source-selection changes when the
240-
policy table is overridden.
241-
- **Effort:** small-medium. **Risk:** low. **Value:** low-medium (rarely
242-
tuned on a host).
245+
- Implemented as a small dedicated control API on
246+
`protocols/ip6/ip6__policy_table.py` (`set_policy_table` /
247+
`reset_policy_table` / `get_policy_table`) rather than a scalar sysctl —
248+
a whole precedence/label table is not a scalar, and Linux itself exposes
249+
it via `ip addrlabel` (netlink), not `/proc/sys`. Copy-on-write
250+
reference swap, read live by `lookup`.
251+
- **Follow-on (deferred):** expose the control API over the daemon IPC
252+
boundary for Phase-3 (in-process only today), and the corresponding
253+
`pytcp` CLI verb. Low value; do on appetite.
243254

244255
### Ongoing hygiene (not a discrete scheduled item)
245256

@@ -275,8 +286,8 @@ so this backlog's boundary is explicit.
275286
1. ~~**R1** (ping SO_RCVBUF)~~ — SHIPPED; the SO_RCVBUF symmetry is closed.
276287
2. Small self-contained wins, any order: ~~**R5** (CLI JSON — all four
277288
observation commands)~~ SHIPPED, ~~**R10** mld.version knob~~ SHIPPED
278-
(R10 §4 suppression remains, marginal), **R11** (RFC 6724 policy
279-
table), or dip into **R2** (setsockopt sweep).
289+
(R10 §4 suppression remains, marginal), ~~**R11** (RFC 6724 policy
290+
table)~~ SHIPPED, or dip into **R2** (setsockopt sweep).
280291
3. **R4** (IPv6 SSM) — the big-value track; do it as its own phased effort.
281292
4. Medium items as appetite allows: **R3** (SO_SNDBUF/SNDTIMEO), **R6**
282293
(HyStart++), **R7** (DF-guarded probe), **R8** (IP_RECVERR over daemon),
@@ -292,4 +303,5 @@ None block a 3.0.8 release. Everything here can equally slip to 3.0.9.
292303
this backlog plan).
293304
- **Unpushed:** `f5ecd901` (R1 ping SO_RCVBUF), `2750afb5` (R5 address
294305
JSON), `9b465047` (footer refresh), `2ecaf708` (ss/route/neighbor JSON),
295-
+ the `mld.version` knob (R10) commit. Hold until the user says "push".
306+
`3bf8315d` (R10 mld.version knob), + the R11 policy-table override
307+
commit. Hold until the user says "push".

docs/refactor/rfc6724_source_selection.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,13 @@ Ships:
212212
`lookup(address) → (precedence, label)` function. (Shipped as
213213
`packages/pytcp/pytcp/protocols/ip6/ip6__policy_table.py`, not the
214214
planned `lib/ip6_policy_table.py`.)
215-
- Optional sysctl-driven override (deferred to §12c.3.b
216-
if needed).
215+
- Operator override — **shipped** as `set_policy_table` /
216+
`reset_policy_table` / `get_policy_table` on the policy-table
217+
module (the `ip addrlabel` analogue, a dedicated control API
218+
rather than a scalar sysctl since a whole table is not a
219+
scalar; copy-on-write reference swap, lock-free under
220+
free-threading). `lookup` reads the active table live so the
221+
rule-6 selector picks up an override without a restart.
217222
- Rule 6 wired into `_select_ip6_source`.
218223

219224
## §12c.4 — IPv4 source-selection symmetry

docs/rfc/ip6/rfc6724__default_address_selection/adherence.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
| Date | September 2012 |
99
| Source text | [`rfc6724.txt`](rfc6724.txt) |
1010

11-
## Status: PARTIAL (source-selection rules 1, 2, 3, 6, 7, 8 shipped; IPv4 symmetry shipped; DAS / sysctl override deferred)
11+
## Status: PARTIAL (source-selection rules 1, 2, 3, 6, 7, 8 shipped; IPv4 symmetry shipped; policy-table override shipped; DAS deferred)
1212

1313
PyTCP runs the RFC 6724 §5 default source-address-selection
1414
algorithm on every outbound IPv6 packet whose source is
@@ -20,7 +20,8 @@ lexicographic sort encoded with rules 1, 2, 3, 6, 7, and 8,
2020
and returns the winner. The pure helpers — RFC 4007/4291
2121
scope extraction and the §2.2 CommonPrefixLen — live in
2222
`packages/pytcp/pytcp/lib/ip6_source_selection.py`; the §10.3 default policy
23-
table backing rule 6 lives in `packages/pytcp/pytcp/lib/ip6_policy_table.py`.
23+
table backing rule 6 lives in
24+
`packages/pytcp/pytcp/protocols/ip6/ip6__policy_table.py`.
2425
Rules 4 (home address), 5 (outgoing interface), and 5.5
2526
(next-hop) are no-ops on a single-interface host stack.
2627

@@ -36,10 +37,16 @@ Rule 6 consults the RFC 6724 §10.3 default policy table.
3637
Default labels follow the RFC figure verbatim (label 0 for
3738
::1/128, label 1 for the catch-all ::/0, label 2 for 6to4
3839
2002::/16, label 4 for IPv4-mapped, label 5 for Teredo, label
39-
13 for ULA fc00::/7, etc.). A future Phase §12c.3.b may add a
40-
sysctl-driven override of the default table; the framework
41-
is shaped so that swap-out is a one-symbol change in the
42-
selector.
40+
13 for ULA fc00::/7, etc.). An operator may replace the active
41+
table at runtime through `set_policy_table` /
42+
`reset_policy_table` / `get_policy_table` on
43+
`ip6__policy_table` — the PyTCP analogue of Linux
44+
`ip addrlabel` (a dedicated control surface, not a `/proc/sys`
45+
scalar, since a whole table is not a scalar sysctl). `lookup`
46+
reads the active table each call (a copy-on-write reference
47+
swap, lock-free under free-threading) so an override resolves
48+
live for the rule-6 selector; `set_policy_table` rejects a
49+
table with no ::/0 catch-all so `lookup` stays total.
4350

4451
The selector is invoked for both unicast and multicast
4552
destinations with an unspecified source — a DHCPv6 SOLICIT to
@@ -56,7 +63,7 @@ Per-RFC mechanism inventory:
5663

5764
| § | Mechanism | Status | Where |
5865
|------------|------------------------------------------------------------|------------------------------------|--------------------------------------------------------------------------------------------------|
59-
| §2.1 | Configurable address-selection policy table | met (default table) | `packages/pytcp/pytcp/lib/ip6_policy_table.py` exposes `DEFAULT_POLICY_TABLE`; sysctl override deferred |
66+
| §2.1 | Configurable address-selection policy table | met | `packages/pytcp/pytcp/protocols/ip6/ip6__policy_table.py` exposes `DEFAULT_POLICY_TABLE` + operator override (`set_policy_table` / `reset_policy_table` / `get_policy_table`, the `ip addrlabel` analogue) |
6067
| §2.2 | CommonPrefixLen helper | met | `common_prefix_len` (`packages/pytcp/pytcp/lib/ip6_source_selection.py`) |
6168
| §3.1 | Scope comparisons | met | `ip6_address_scope` returns RFC 4007 / 4291 codepoints |
6269
| §5 rule 1 | Prefer same address | met | `_select_ip6_source` short-circuits when the destination is owned |
@@ -70,7 +77,7 @@ Per-RFC mechanism inventory:
7077
| §5 rule 8 | Use longest matching prefix | met | sort-key tiebreak after rules 1/2/3 |
7178
| §6 (v4) | IPv4 source-selection symmetry | met (rules 1, 2, 8) | `_select_ip4_source` mirrors `_select_ip6_source` for the v4 family at TX |
7279
| §6 (DAS) | Destination address selection | not implemented | DNS-resolution selection (rules D1-D8) is out of scope at the stack layer |
73-
| §10.3 | Default policy table | met | `DEFAULT_POLICY_TABLE` mirrors RFC figure verbatim; sysctl-driven override deferred |
80+
| §10.3 | Default policy table | met | `DEFAULT_POLICY_TABLE` mirrors RFC figure verbatim; operator override via `set_policy_table` (rejects a table with no ::/0 catch-all) |
7481

7582
## Test coverage
7683

@@ -114,13 +121,17 @@ Per-RFC mechanism inventory:
114121
— the TX path runs §5 selection for a multicast
115122
destination (link-local source filled in for `ff02::1:2`)
116123
rather than dropping it
117-
- `packages/pytcp/pytcp/tests/unit/lib/test__lib__ip6_policy_table.py`
124+
- `packages/pytcp/pytcp/tests/unit/protocols/ip6/test__ip6__policy_table.py`
118125
- `TestIp6PolicyTableLookup` — RFC §10.3 (precedence, label)
119126
pairs for ::1, 6to4, Teredo, ULA, deprecated site-local,
120127
deprecated 6bone, IPv4-mapped, IPv4-compatible,
121128
catch-all GUA, and link-local (falls through to ::/0)
122129
- `TestIp6PolicyTableShape` — 9-entry default table,
123130
typed records, ::/0 catch-all present
131+
- `TestIp6PolicyTableOverride``set_policy_table` overrides
132+
`lookup` live, `reset_policy_table` restores the default,
133+
`get_policy_table` returns the active snapshot, and a table
134+
with no ::/0 catch-all is rejected
124135
- `packages/pytcp/pytcp/tests/integration/protocols/ip6/test__ip6__rfc6724_source_selection_rule_6.py`
125136
- `TestRfc6724Rule6PolicyLabel` — matching label outranks
126137
longer non-matching prefix (rule 6 > rule 8); ULA

packages/pytcp/pytcp/protocols/ip6/ip6__policy_table.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@
3232
first hit. The default contents match the RFC §10.3 figure
3333
verbatim and Linux's /etc/gai.conf default preset.
3434
35-
A future Phase §12c.3.b may add a sysctl-driven runtime
36-
override, but the default table is sufficient for the §5
37-
rule-6 consumer to stop being a no-op.
35+
An operator may replace the active table at runtime through
36+
'set_policy_table' / 'reset_policy_table' (the PyTCP analogue
37+
of Linux 'ip addrlabel', which is a dedicated netlink control
38+
surface rather than a '/proc/sys' scalar — a whole table is
39+
not a scalar sysctl). 'lookup' reads the active table each
40+
call so an override resolves live for the §5 rule-6
41+
source-selection consumer.
3842
3943
pytcp/protocols/ip6/ip6__policy_table.py
4044
@@ -79,17 +83,61 @@ class PolicyEntry:
7983
)
8084

8185

86+
# The currently-active policy table. Defaults to the RFC
87+
# §10.3 table; an operator override swaps the whole tuple
88+
# reference (copy-on-write). Readers ('lookup' / the rule-6
89+
# consumer) read this module global each call and always see
90+
# a complete immutable snapshot — a frozen tuple of frozen
91+
# 'PolicyEntry' records — so the reference swap is safe under
92+
# free-threading without a lock (the no-GIL COW pattern).
93+
_active_policy_table: tuple[PolicyEntry, ...] = DEFAULT_POLICY_TABLE
94+
95+
8296
def lookup(address: Ip6Address, /) -> tuple[int, int]:
8397
"""
8498
Return the (precedence, label) pair from the most-specific
85-
policy-table entry that contains the given IPv6 address.
86-
The table's terminating ::/0 entry guarantees a hit for
87-
every address, so the function is total.
99+
active-policy-table entry that contains the given IPv6
100+
address. The table's terminating ::/0 entry guarantees a
101+
hit for every address, so the function is total.
88102
"""
89103

90-
for entry in DEFAULT_POLICY_TABLE:
104+
for entry in _active_policy_table:
91105
if address in entry.network:
92106
return (entry.precedence, entry.label)
93107
# Unreachable because of the ::/0 catch-all; the assert
94108
# makes the invariant explicit for static analysis.
95109
raise AssertionError("RFC 6724 §10.3 policy table missing ::/0 catch-all")
110+
111+
112+
def get_policy_table() -> tuple[PolicyEntry, ...]:
113+
"""
114+
Return the currently-active policy table as an immutable
115+
snapshot (a frozen tuple of frozen 'PolicyEntry' records).
116+
"""
117+
118+
return _active_policy_table
119+
120+
121+
def set_policy_table(entries: tuple[PolicyEntry, ...], /) -> None:
122+
"""
123+
Replace the active policy table with 'entries' — the PyTCP
124+
analogue of Linux 'ip addrlabel'. Entries are matched
125+
most-specific-first, so order them longest-prefix-first.
126+
The table MUST contain a ::/0 catch-all so 'lookup' stays
127+
total; a table without one is rejected.
128+
"""
129+
130+
if not any(entry.network == Ip6Network("::/0") for entry in entries):
131+
raise ValueError("policy table must contain a ::/0 catch-all entry so 'lookup' stays total")
132+
global _active_policy_table
133+
_active_policy_table = tuple(entries)
134+
135+
136+
def reset_policy_table() -> None:
137+
"""
138+
Restore the RFC 6724 §10.3 default policy table, discarding
139+
any operator override.
140+
"""
141+
142+
global _active_policy_table
143+
_active_policy_table = DEFAULT_POLICY_TABLE

packages/pytcp/pytcp/tests/unit/protocols/ip6/test__ip6__policy_table.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@
3131
ver 3.0.8
3232
"""
3333

34+
from typing import override
3435
from unittest import TestCase
3536

36-
from net_addr import Ip6Address
37+
from net_addr import Ip6Address, Ip6Network
3738
from pytcp.protocols.ip6.ip6__policy_table import (
3839
DEFAULT_POLICY_TABLE,
3940
PolicyEntry,
41+
get_policy_table,
4042
lookup,
43+
reset_policy_table,
44+
set_policy_table,
4145
)
4246
from pytcp.tests.lib.parameterized import parameterized_class
4347

@@ -197,3 +201,104 @@ def test__lib__ip6_policy_table__catch_all_entry_present(self) -> None:
197201
(40, 1),
198202
msg="The ::/0 catch-all must have precedence 40 and label 1.",
199203
)
204+
205+
206+
class TestIp6PolicyTableOverride(TestCase):
207+
"""
208+
The RFC 6724 §10.3 operator policy-table override tests.
209+
"""
210+
211+
@override
212+
def setUp(self) -> None:
213+
"""
214+
Restore the default policy table after every test so an
215+
override cannot leak into a sibling test.
216+
"""
217+
218+
self.addCleanup(reset_policy_table)
219+
220+
def test__set_policy_table_overrides_lookup(self) -> None:
221+
"""
222+
Ensure installing a custom policy table makes 'lookup' resolve a
223+
destination's (precedence, label) from the custom table — an
224+
operator override the source-selection rule-6 consumer reads live.
225+
226+
Reference: RFC 6724 §2.1 (Policy table is administrator-configurable).
227+
"""
228+
229+
set_policy_table(
230+
(
231+
PolicyEntry(network=Ip6Network("2001:db8::/32"), precedence=99, label=7),
232+
PolicyEntry(network=Ip6Network("::/0"), precedence=40, label=1),
233+
)
234+
)
235+
236+
self.assertEqual(
237+
lookup(Ip6Address("2001:db8::1")),
238+
(99, 7),
239+
msg="After an override, 'lookup' must resolve from the custom policy table.",
240+
)
241+
242+
def test__reset_policy_table_restores_default(self) -> None:
243+
"""
244+
Ensure 'reset_policy_table' restores the RFC §10.3 default table
245+
after an operator override.
246+
247+
Reference: RFC 6724 §10.3 (Default policy table).
248+
"""
249+
250+
set_policy_table(
251+
(
252+
PolicyEntry(network=Ip6Network("2001:db8::/32"), precedence=99, label=7),
253+
PolicyEntry(network=Ip6Network("::/0"), precedence=40, label=1),
254+
)
255+
)
256+
reset_policy_table()
257+
258+
self.assertEqual(
259+
lookup(Ip6Address("2001:db8::1")),
260+
(40, 1),
261+
msg="After reset, 'lookup' must resolve from the RFC default table again.",
262+
)
263+
264+
def test__set_policy_table_rejects_missing_catch_all(self) -> None:
265+
"""
266+
Ensure 'set_policy_table' rejects a table with no ::/0 catch-all
267+
entry, since 'lookup' must stay total (every address must yield a
268+
(precedence, label) pair without raising).
269+
270+
Reference: RFC 6724 §10.3 (::/0 default catch-all keeps lookup total).
271+
"""
272+
273+
with self.assertRaises(ValueError) as ctx:
274+
set_policy_table((PolicyEntry(network=Ip6Network("2001:db8::/32"), precedence=99, label=7),))
275+
276+
self.assertIn(
277+
"::/0",
278+
str(ctx.exception),
279+
msg="The rejection message must name the missing ::/0 catch-all requirement.",
280+
)
281+
282+
def test__get_policy_table_returns_active_table(self) -> None:
283+
"""
284+
Ensure 'get_policy_table' returns the currently-active table — the
285+
default before any override, and the custom table after one.
286+
287+
Reference: PyTCP test infrastructure (no RFC clause).
288+
"""
289+
290+
self.assertEqual(
291+
get_policy_table(),
292+
DEFAULT_POLICY_TABLE,
293+
msg="Before any override 'get_policy_table' must return the default table.",
294+
)
295+
custom = (
296+
PolicyEntry(network=Ip6Network("2001:db8::/32"), precedence=99, label=7),
297+
PolicyEntry(network=Ip6Network("::/0"), precedence=40, label=1),
298+
)
299+
set_policy_table(custom)
300+
self.assertEqual(
301+
get_policy_table(),
302+
custom,
303+
msg="After an override 'get_policy_table' must return the custom table.",
304+
)

0 commit comments

Comments
 (0)