Skip to main content

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}