netget/protocol/metadata.rs
1//! Protocol metadata definitions
2//!
3//! Defines metadata about protocol implementations including state and notes.
4
5pub use crate::privilege::DeviceClass;
6
7/// How severe a [`PrivilegeRequirement`] is, for display purposes.
8///
9/// Exists so renderers do not have to match the requirement enum exhaustively —
10/// adding a variant should not break every place that colours it.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PrivilegeSeverity {
13 /// Anyone can start this
14 None,
15 /// Needs something the user may already have, or can be granted without root
16 Elevated,
17 /// Needs full root/administrator
18 Root,
19}
20
21/// Privilege requirements for a protocol
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum PrivilegeRequirement {
24 /// No special privileges required
25 None,
26 /// Requires ability to bind to privileged ports (< 1024)
27 PrivilegedPort(u16),
28 /// Requires **raw IP sockets** (`SOCK_RAW`) — ICMP, IGMP, OSPF.
29 ///
30 /// Not the same as [`Self::PacketCapture`]: on Linux both come from
31 /// `CAP_NET_RAW`, but on macOS a user in the ChmodBPF group has capture without
32 /// raw sockets. Declaring the wrong one either refuses a user who can run the
33 /// protocol or admits one who cannot.
34 RawSockets,
35 /// Requires **layer-2 capture/injection** via BPF or `AF_PACKET` — ARP,
36 /// DataLink, IS-IS.
37 PacketCapture,
38 /// Requires access to a local hardware device class — a Bluetooth adapter, a
39 /// USB device, an NFC reader.
40 ///
41 /// None of these is a port or a socket, and `Root` would be both false and
42 /// needlessly exclusive: a desktop user typically has adapter access already.
43 DeviceAccess(DeviceClass),
44 /// Requires full root/administrator access
45 Root,
46}
47
48impl PrivilegeRequirement {
49 /// Get a human-readable description of the requirement
50 pub fn description(&self) -> String {
51 match self {
52 Self::None => "None".to_string(),
53 Self::PrivilegedPort(port) => {
54 format!("Privileged port {} (requires root or capabilities)", port)
55 }
56 Self::RawSockets => "Raw IP socket access (requires root or CAP_NET_RAW)".to_string(),
57 Self::PacketCapture => {
58 "Layer-2 packet capture (requires root, CAP_NET_RAW, or BPF device access)"
59 .to_string()
60 }
61 Self::DeviceAccess(class) => {
62 format!("Access to a {} on this host", class.as_str())
63 }
64 Self::Root => "Root/Administrator access required".to_string(),
65 }
66 }
67
68 /// Rough severity, for display. See [`PrivilegeSeverity`].
69 pub fn severity(&self) -> PrivilegeSeverity {
70 match self {
71 Self::None => PrivilegeSeverity::None,
72 Self::PrivilegedPort(_)
73 | Self::RawSockets
74 | Self::PacketCapture
75 | Self::DeviceAccess(_) => PrivilegeSeverity::Elevated,
76 Self::Root => PrivilegeSeverity::Root,
77 }
78 }
79
80 /// Check if this requirement is met by the given system capabilities
81 pub fn is_met_by(&self, caps: &crate::privilege::SystemCapabilities) -> bool {
82 match self {
83 Self::None => true,
84 Self::PrivilegedPort(_) => caps.can_bind_privileged_ports,
85 Self::RawSockets => caps.has_raw_socket_access,
86 Self::PacketCapture => caps.has_packet_capture_access,
87 Self::DeviceAccess(class) => caps.has_device_access(*class),
88 Self::Root => caps.is_root,
89 }
90 }
91}
92
93/// Protocol maturity and readiness state
94///
95/// The derived `PartialOrd`/`Ord` follow the **declaration order below**, which
96/// is deliberately least-mature to most-mature: `Incomplete < Experimental <
97/// Beta < Stable`. Anything that compares maturities (the `--min-stability`
98/// gate, the `/stability` listing) relies on this — do not reorder the variants.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
100pub enum DevelopmentState {
101 /// Incomplete implementation, not functional (e.g., OpenVPN)
102 /// Will not show in LLM prompts
103 Incomplete,
104
105 /// Experimental - LLM-created, not human reviewed
106 /// May have limitations or bugs
107 Experimental,
108
109 /// Beta - Human reviewed, works with real clients
110 /// Mostly stable but may have minor issues
111 Beta,
112
113 /// Stable - Follows real protocol specs, well-designed LLM prompting,
114 /// supports scripting for automation, LLM has sufficient control
115 Stable,
116}
117
118impl DevelopmentState {
119 /// Get the string representation for display
120 pub fn as_str(&self) -> &'static str {
121 match self {
122 Self::Incomplete => "Incomplete",
123 Self::Experimental => "Experimental",
124 Self::Beta => "Beta",
125 Self::Stable => "Stable",
126 }
127 }
128
129 /// All variants, least-mature first.
130 ///
131 /// Handy for building help text and grouped listings without hardcoding the
132 /// order at each call site.
133 pub const ALL: [DevelopmentState; 4] = [
134 Self::Incomplete,
135 Self::Experimental,
136 Self::Beta,
137 Self::Stable,
138 ];
139
140 /// Parse a development state from a string, case-insensitively.
141 ///
142 /// Accepts the four variant names in any casing (`"beta"`, `"BETA"`,
143 /// `"Beta"`). Used by the `--min-stability` CLI flag. Returns `None` for an
144 /// unrecognised value so callers can produce their own error message.
145 pub fn parse_ci(s: &str) -> Option<Self> {
146 match s.trim().to_ascii_lowercase().as_str() {
147 "incomplete" => Some(Self::Incomplete),
148 "experimental" => Some(Self::Experimental),
149 "beta" => Some(Self::Beta),
150 "stable" => Some(Self::Stable),
151 _ => None,
152 }
153 }
154}
155
156impl std::str::FromStr for DevelopmentState {
157 type Err = String;
158
159 fn from_str(s: &str) -> Result<Self, Self::Err> {
160 Self::parse_ci(s).ok_or_else(|| {
161 format!(
162 "invalid development state '{}' (expected one of: incomplete, experimental, beta, stable)",
163 s
164 )
165 })
166 }
167}
168
169/// Protocol metadata including state and notes (legacy)
170#[derive(Debug, Clone)]
171pub struct ProtocolMetadata {
172 /// Current implementation state
173 pub state: DevelopmentState,
174 /// Optional notes explaining the state or limitations
175 pub notes: Option<&'static str>,
176 /// Privilege requirements for this protocol
177 pub privilege_requirement: PrivilegeRequirement,
178}
179
180impl ProtocolMetadata {
181 /// Create new metadata with just a state (no privileges required)
182 pub const fn new(state: DevelopmentState) -> Self {
183 Self {
184 state,
185 notes: None,
186 privilege_requirement: PrivilegeRequirement::None,
187 }
188 }
189
190 /// Create new metadata with state and notes (no privileges required)
191 pub const fn with_notes(state: DevelopmentState, notes: &'static str) -> Self {
192 Self {
193 state,
194 notes: Some(notes),
195 privilege_requirement: PrivilegeRequirement::None,
196 }
197 }
198
199 /// Create new metadata with state and privilege requirement
200 pub const fn with_privilege(
201 state: DevelopmentState,
202 privilege_requirement: PrivilegeRequirement,
203 ) -> Self {
204 Self {
205 state,
206 notes: None,
207 privilege_requirement,
208 }
209 }
210
211 /// Create new metadata with state, notes, and privilege requirement
212 pub const fn with_notes_and_privilege(
213 state: DevelopmentState,
214 notes: &'static str,
215 privilege_requirement: PrivilegeRequirement,
216 ) -> Self {
217 Self {
218 state,
219 notes: Some(notes),
220 privilege_requirement,
221 }
222 }
223
224 /// Check if this protocol should be shown to the LLM
225 pub fn is_available_to_llm(&self) -> bool {
226 self.state != DevelopmentState::Incomplete
227 }
228}
229
230/// What a server puts on the wire when the model cannot or will not answer.
231///
232/// **This replaces a prose list, and the prose list was wrong in both directions.** The root
233/// `CLAUDE.md` names ~20 "deliberately silent" protocols; auditing them found NDP logging its
234/// own *transmit* failure as `decision=model_silent` (so `grep decision=fail_closed_` found
235/// nothing for a real outage), and several BLE profiles on the list by family membership rather
236/// than by anyone deciding. A paragraph cannot be checked; a declaration can.
237///
238/// The distinction is not a style choice. It follows from one question: **is every reply this
239/// protocol can send a positive assertion?** If so, inventing one on failure is worse than
240/// saying nothing — `openvpn`'s only pre-TLS server message is
241/// `P_CONTROL_HARD_RESET_SERVER_V2`, and sending it *is* admitting the peer, so "fixing the
242/// silence" there would turn a backend outage into an authentication bypass. ARP would write a
243/// fabricated MAC into a stranger's neighbour cache. If instead the protocol has an error
244/// vocabulary — an HTTP status, a RESP `-LOADING`, a Modbus exception — silence just makes the
245/// peer wait out its own timeout, and answering is strictly better.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum FailureMode {
248 /// The protocol answers, in its own error vocabulary, with a
249 /// [`crate::utils::wire_failure`] *category* — never the error text.
250 ///
251 /// Keep the two categories distinct where the protocol can express them (503 vs 500, RESP
252 /// `LOADING` vs `ERR`, MySQL 1205 vs 1105): a client that can tell "overloaded" from
253 /// "broken" backs off instead of recording a permanent fault.
254 Answers,
255
256 /// The protocol writes **nothing**, because every frame it could send would assert
257 /// something it does not know to be true.
258 ///
259 /// A protocol declaring this still owes the log the distinction the wire cannot carry:
260 /// `decision=model_reject` / `model_silent` / `fail_closed_llm_error`, as `src/server/radius/`
261 /// does. Silence on the wire is not silence in the log.
262 DeliberatelySilent,
263}
264
265/// The IP port transport a protocol uses, including protocols with no well-known port.
266///
267/// Not the same thing as [`ProtocolMetadataV2::connectionless`]: that says whether the runtime
268/// may reap idle peers, and TFTP is UDP yet must not be reaped. This says which socket type to
269/// probe when asking "is the well-known port already taken?" — a UDP listener on 53 says nothing
270/// about a TCP bind on 53.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum PortTransport {
273 Tcp,
274 Udp,
275 /// SCTP (M3UA). No in-use probe is attempted for it: the standard library has no SCTP
276 /// socket, and the real bind reports a taken port on its own.
277 Sctp,
278}
279
280impl PortTransport {
281 pub fn as_str(&self) -> &'static str {
282 match self {
283 Self::Tcp => "tcp",
284 Self::Udp => "udp",
285 Self::Sctp => "sctp",
286 }
287 }
288}
289
290/// Enhanced protocol metadata with detailed implementation information
291#[derive(Debug, Clone)]
292pub struct ProtocolMetadataV2 {
293 /// Current maturity/readiness state
294 pub state: DevelopmentState,
295
296 /// Privilege requirements for this protocol
297 pub privilege_requirement: PrivilegeRequirement,
298
299 /// Freeform description of implementation approach
300 /// Examples:
301 /// - "hyper v1.0 web server library"
302 /// - "russh v0.40 with SFTP support"
303 /// - "Manual NTP packet parser with 48-byte construction"
304 /// - "defguard_wireguard_rs v0.7 - creates real TUN interfaces"
305 /// - "Custom Tor OR protocol with ntor handshake - 2,182 LOC"
306 pub implementation: &'static str,
307
308 /// Freeform description of what the LLM controls
309 /// Examples:
310 /// - "Full byte stream control"
311 /// - "Response content (status, headers, body)"
312 /// - "Authentication decisions + shell responses + SFTP operations"
313 /// - "Time responses (stratum, timestamps)"
314 /// - "Query responses (result sets, OK, errors)"
315 /// - "No LLM control - direct Ollama delegation"
316 /// - "Observation only - no LLM interaction"
317 pub llm_control: &'static str,
318
319 /// Freeform description of E2E testing approach
320 /// Examples:
321 /// - "reqwest HTTP client"
322 /// - "ssh2 crate (libssh2 bindings)"
323 /// - "OpenSSH ssh command"
324 /// - "Manual NTP packet construction"
325 /// - "tokio-postgres client"
326 /// - "Not yet implemented"
327 /// - "N/A (honeypot only)"
328 pub e2e_testing: &'static str,
329
330 /// Optional notes about limitations or special features
331 pub notes: Option<&'static str>,
332
333 /// The protocol has no connection lifecycle of its own: its "connections"
334 /// are per-remote-address bookkeeping entries (UDP, raw IP, link-level)
335 /// that nothing ever closes, so the runtime reaps them after
336 /// `last_activity` goes stale.
337 ///
338 /// Leave `false` for anything connection-oriented. The reaper used to run
339 /// over every server, and an idle TCP-style connection — a telnet peer
340 /// waiting ten seconds for a human's manual answer — was evicted and shown
341 /// as closed while its socket was perfectly alive.
342 pub connectionless: bool,
343
344 /// What this server puts on the wire when the model cannot or will not answer.
345 ///
346 /// Defaults to [`FailureMode::Answers`], which is the right default: a protocol that has an
347 /// error vocabulary and stays silent leaves its peer to wait out a timeout, and that is the
348 /// failure the August 2026 sweep found in 64 of 135 servers. Declaring
349 /// [`FailureMode::DeliberatelySilent`] is an assertion that every frame this protocol could
350 /// send would be a claim it cannot support — say why in `notes`.
351 pub failure_mode: FailureMode,
352
353 /// The largest single inbound message this server will buffer from a peer, in bytes.
354 ///
355 /// `None` means the protocol reads nothing whose length a peer chooses: a fixed-size frame
356 /// (ARP, BOOTP), a single `recv_from` into a fixed buffer, or a profile that delegates its
357 /// whole read loop to another protocol. `None` is a claim, not an absence — a protocol that
358 /// *does* accumulate and declares `None` is the unbounded-read defect, and
359 /// `tests/max_inbound_bytes_declaration_test.rs` is the ratchet that says so.
360 ///
361 /// **The number must be the one the code enforces**, at the point the length is decided —
362 /// not an aspiration. The value exists to be greppable and testable: every declaration
363 /// should have a test that sends this many bytes plus one and asserts the refusal happens
364 /// *before* any model call. A bound nobody tested is a comment.
365 ///
366 /// Bound the size the peer **declared**, before doing arithmetic on it. NATS's `HPUB` limit
367 /// was applied to `total − header`, which leaves `header` unbounded and lets `header ==
368 /// total` pass every check with a zero-length body — thirty bytes on the wire buffering
369 /// toward 4 GB.
370 pub max_inbound_bytes: Option<usize>,
371
372 /// The port this protocol is registered for — IANA's registry, or where IANA has none, the
373 /// default its own specification or reference implementation documents (Elasticsearch
374 /// 9200, `hg serve` 8000). Never a "developer" port like 8080 standing in for HTTP's 80.
375 ///
376 /// `None` is a claim, not an absence: the protocol has no port of its own — raw sockets,
377 /// link layer, a device, a pipe, or an application protocol carried on plain HTTP whose
378 /// port is HTTP's. `tests/well_known_port_declaration_test.rs` requires every server to
379 /// declare one or be listed there with the reason.
380 ///
381 /// This is what a server starts on when the caller names no port, subject to
382 /// [`crate::protocol::default_port::resolve_default_port`]: below 1024 it needs privilege,
383 /// and a port already taken falls back to an OS-assigned one. An explicit port — `0`
384 /// included — is never replaced.
385 pub well_known_port: Option<u16>,
386
387 /// The transport used by this protocol's port-based listener. Also applies when
388 /// [`Self::well_known_port`] is `None`, such as raw UDP on an operator-chosen port.
389 /// The builder's default is TCP; socketless protocols do not use this field.
390 pub well_known_transport: PortTransport,
391
392 /// Why this server can only ever *answer* a peer, when that is a property of the protocol
393 /// itself: every message it may send is the reply to one the peer sent first (HTTP/1.1 and
394 /// the protocols carried on plain HTTP request/response). `None` claims nothing.
395 ///
396 /// It is what the dashboard says on a peer's disabled `[ send message ]`, and what the MCP
397 /// `send_to_peer` error gives as the reason, instead of "not implemented here yet" — for
398 /// these protocols there is no "yet". A protocol that registers a peer handle
399 /// (`server/peer_support.rs`) can message a peer unprompted, so it must not declare this;
400 /// `tests/request_only_declaration_test.rs` holds that from source.
401 ///
402 /// Write the reason as the sentence a person reads: "HTTP only answers requests; a server
403 /// cannot send a peer anything unprompted".
404 pub request_only: Option<&'static str>,
405}
406
407impl ProtocolMetadataV2 {
408 /// Create a new builder for protocol metadata
409 pub const fn builder() -> ProtocolMetadataV2Builder {
410 ProtocolMetadataV2Builder::new()
411 }
412
413 /// Check if this protocol should be shown to the LLM
414 pub fn is_available_to_llm(&self) -> bool {
415 self.state != DevelopmentState::Incomplete
416 }
417
418 /// Get a human-readable summary
419 pub fn summary(&self) -> String {
420 format!(
421 "{} - {} - LLM: {}",
422 self.state.as_str(),
423 self.implementation,
424 self.llm_control
425 )
426 }
427}
428
429/// Builder for constructing ProtocolMetadataV2
430pub struct ProtocolMetadataV2Builder {
431 state: DevelopmentState,
432 privilege_requirement: PrivilegeRequirement,
433 implementation: &'static str,
434 llm_control: &'static str,
435 e2e_testing: &'static str,
436 notes: Option<&'static str>,
437 connectionless: bool,
438 failure_mode: FailureMode,
439 max_inbound_bytes: Option<usize>,
440 well_known_port: Option<u16>,
441 well_known_transport: PortTransport,
442 request_only: Option<&'static str>,
443}
444
445impl Default for ProtocolMetadataV2Builder {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451impl ProtocolMetadataV2Builder {
452 pub const fn new() -> Self {
453 Self {
454 state: DevelopmentState::Experimental,
455 privilege_requirement: PrivilegeRequirement::None,
456 implementation: "",
457 llm_control: "",
458 e2e_testing: "",
459 notes: None,
460 connectionless: false,
461 failure_mode: FailureMode::Answers,
462 max_inbound_bytes: None,
463 well_known_port: None,
464 well_known_transport: PortTransport::Tcp,
465 request_only: None,
466 }
467 }
468
469 pub const fn state(mut self, state: DevelopmentState) -> Self {
470 self.state = state;
471 self
472 }
473
474 pub const fn privilege_requirement(mut self, req: PrivilegeRequirement) -> Self {
475 self.privilege_requirement = req;
476 self
477 }
478
479 pub const fn implementation(mut self, desc: &'static str) -> Self {
480 self.implementation = desc;
481 self
482 }
483
484 pub const fn llm_control(mut self, desc: &'static str) -> Self {
485 self.llm_control = desc;
486 self
487 }
488
489 pub const fn e2e_testing(mut self, desc: &'static str) -> Self {
490 self.e2e_testing = desc;
491 self
492 }
493
494 pub const fn notes(mut self, notes: &'static str) -> Self {
495 self.notes = Some(notes);
496 self
497 }
498
499 /// Declare that this protocol writes nothing when the model cannot answer — see
500 /// [`FailureMode::DeliberatelySilent`]. Put the reason in `notes`.
501 pub const fn deliberately_silent(mut self) -> Self {
502 self.failure_mode = FailureMode::DeliberatelySilent;
503 self
504 }
505
506 /// Declare that this protocol answers the peer when the model cannot — see
507 /// [`FailureMode::Answers`].
508 ///
509 /// `Answers` is already the default, so this changes no value. It records that someone
510 /// looked at the failure path and decided, which is what
511 /// `tests/failure_mode_declaration_test.rs` requires of every connectionless server: on a
512 /// datagram protocol there is no connection to close, so silence is what a failure produces
513 /// *unless* someone wrote the answer, and a default cannot tell those two apart. Put what
514 /// the peer receives in a comment beside the call.
515 pub const fn answers_on_failure(mut self) -> Self {
516 self.failure_mode = FailureMode::Answers;
517 self
518 }
519
520 /// Mark the protocol connectionless — see [`ProtocolMetadataV2::connectionless`].
521 pub const fn connectionless(mut self) -> Self {
522 self.connectionless = true;
523 self
524 }
525
526 /// Declare the largest single inbound message this server buffers from a peer — see
527 /// [`ProtocolMetadataV2::max_inbound_bytes`].
528 ///
529 /// Pass the constant the code actually enforces, so the declaration moves when the bound
530 /// does: `.max_inbound_bytes(MAX_REQUEST_BYTES)`, never a literal repeated from it.
531 pub const fn max_inbound_bytes(mut self, bytes: usize) -> Self {
532 self.max_inbound_bytes = Some(bytes);
533 self
534 }
535
536 /// Declare the TCP port this protocol is registered for — see
537 /// [`ProtocolMetadataV2::well_known_port`].
538 pub const fn well_known_port(mut self, port: u16) -> Self {
539 self.well_known_port = Some(port);
540 self.well_known_transport = PortTransport::Tcp;
541 self
542 }
543
544 /// Declare the UDP port this protocol is registered for — see
545 /// [`ProtocolMetadataV2::well_known_port`].
546 pub const fn well_known_udp_port(mut self, port: u16) -> Self {
547 self.well_known_port = Some(port);
548 self.well_known_transport = PortTransport::Udp;
549 self
550 }
551
552 /// Declare the SCTP port this protocol is registered for — see
553 /// [`ProtocolMetadataV2::well_known_port`].
554 pub const fn well_known_sctp_port(mut self, port: u16) -> Self {
555 self.well_known_port = Some(port);
556 self.well_known_transport = PortTransport::Sctp;
557 self
558 }
559
560 /// Declare a port transport without assigning a well-known port. Raw UDP has
561 /// no registered port of its own, but its listener still occupies UDP's namespace.
562 pub const fn port_transport(mut self, transport: PortTransport) -> Self {
563 self.well_known_transport = transport;
564 self
565 }
566
567 /// Declare that the protocol only answers requests, with the reason a person reads — see
568 /// [`ProtocolMetadataV2::request_only`].
569 pub const fn request_only(mut self, reason: &'static str) -> Self {
570 self.request_only = Some(reason);
571 self
572 }
573
574 pub const fn build(self) -> ProtocolMetadataV2 {
575 ProtocolMetadataV2 {
576 state: self.state,
577 privilege_requirement: self.privilege_requirement,
578 implementation: self.implementation,
579 llm_control: self.llm_control,
580 e2e_testing: self.e2e_testing,
581 notes: self.notes,
582 connectionless: self.connectionless,
583 failure_mode: self.failure_mode,
584 max_inbound_bytes: self.max_inbound_bytes,
585 well_known_port: self.well_known_port,
586 well_known_transport: self.well_known_transport,
587 request_only: self.request_only,
588 }
589 }
590}