netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
//! TUN/TAP endpoint: action, event and metadata declarations.
//!
//! The wire work is in [`super::packet`]; the pipeline is in [`super`]. This file is the
//! contract with the model: which packets it can be told about, what it may say back, and what
//! the operator may configure.
//!
//! Two rules shape everything here, and both are recorded in `src/server/tuntap/CLAUDE.md`:
//!
//! 1. **Nothing raw crosses this boundary.** Events carry decoded header fields; `send_packet`
//!    takes fields and NetGet builds the bytes. The one opaque thing — a payload — travels as a
//!    short preview with an explicit `payload_encoding`, and the executor really decodes what
//!    that field names.
//! 2. **Silence is the failure.** There is no action that means "answer with an error", because
//!    a fabricated packet on a real interface is indistinguishable from a spoof.

use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
};
use crate::protocol::log_template::LogTemplate;
use crate::protocol::EventType;
use crate::state::app_state::AppState;
use anyhow::{Context, Result};
use serde_json::json;
use std::sync::LazyLock;
use tracing::debug;

use super::packet;

/// A packet arrived on the interface and passed the deterministic filter.
pub static TUNTAP_PACKET_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "tuntap_packet_received",
        "A packet was routed to the TUN/TAP interface and passed the server's packet_filter. \
         All header fields are decoded; the payload appears only as a short preview whose \
         encoding is named by payload_encoding. Most packets never reach this event - the \
         filter and the escalation budget drop them deterministically, which is what keeps an \
         interface endpoint usable at all.",
        json!({
            "type": "send_packet",
            "source": "10.7.0.2",
            "destination": "10.7.0.1",
            "protocol": "icmp",
            "icmp_type": "echo_reply",
            "icmp_id": 4660,
            "icmp_sequence": 1,
            "payload": "netget",
            "payload_encoding": "utf8"
        }),
    )
    .with_actions(vec![
        send_packet_action(),
        drop_packet_action(),
        no_response_action(),
    ])
    .with_parameters(vec![
        Parameter {
            name: "direction".to_string(),
            type_hint: "string".to_string(),
            description: "Always \"inbound\": the host routed this packet into the interface, so it is arriving at NetGet.".to_string(),
            required: true,
        },
        Parameter {
            name: "ip_version".to_string(),
            type_hint: "number".to_string(),
            description: "4 or 6.".to_string(),
            required: true,
        },
        Parameter {
            name: "source".to_string(),
            type_hint: "string".to_string(),
            description: "Source IP address.".to_string(),
            required: true,
        },
        Parameter {
            name: "destination".to_string(),
            type_hint: "string".to_string(),
            description: "Destination IP address.".to_string(),
            required: true,
        },
        Parameter {
            name: "protocol".to_string(),
            type_hint: "string".to_string(),
            description: "Protocol name, e.g. \"tcp\", \"udp\", \"icmp\", \"icmpv6\". \"unknown\" when the number has no name here.".to_string(),
            required: true,
        },
        Parameter {
            name: "protocol_number".to_string(),
            type_hint: "number".to_string(),
            description: "IANA IP protocol number, so nothing is lost when the name is \"unknown\".".to_string(),
            required: true,
        },
        Parameter {
            name: "ttl".to_string(),
            type_hint: "number".to_string(),
            description: "IPv4 time-to-live. IPv6 packets carry \"hop_limit\" instead.".to_string(),
            required: false,
        },
        Parameter {
            name: "hop_limit".to_string(),
            type_hint: "number".to_string(),
            description: "IPv6 hop limit. IPv4 packets carry \"ttl\" instead.".to_string(),
            required: false,
        },
        Parameter {
            name: "total_length".to_string(),
            type_hint: "number".to_string(),
            description: "Total IP packet length in bytes, as the header declares it.".to_string(),
            required: true,
        },
        Parameter {
            name: "source_port".to_string(),
            type_hint: "number".to_string(),
            description: "TCP/UDP source port. Absent for other protocols.".to_string(),
            required: false,
        },
        Parameter {
            name: "destination_port".to_string(),
            type_hint: "number".to_string(),
            description: "TCP/UDP destination port. Absent for other protocols.".to_string(),
            required: false,
        },
        Parameter {
            name: "tcp_flags".to_string(),
            type_hint: "array".to_string(),
            description: "TCP flags set, as names: fin, syn, rst, psh, ack, urg, ece, cwr. A [\"syn\"] with no \"ack\" is a new connection attempt.".to_string(),
            required: false,
        },
        Parameter {
            name: "seq".to_string(),
            type_hint: "number".to_string(),
            description: "TCP sequence number.".to_string(),
            required: false,
        },
        Parameter {
            name: "ack".to_string(),
            type_hint: "number".to_string(),
            description: "TCP acknowledgement number.".to_string(),
            required: false,
        },
        Parameter {
            name: "window".to_string(),
            type_hint: "number".to_string(),
            description: "TCP advertised window.".to_string(),
            required: false,
        },
        Parameter {
            name: "icmp_type".to_string(),
            type_hint: "string".to_string(),
            description: "ICMP type name, e.g. \"echo_request\", \"time_exceeded\".".to_string(),
            required: false,
        },
        Parameter {
            name: "icmp_type_number".to_string(),
            type_hint: "number".to_string(),
            description: "ICMP type number, for types with no name here.".to_string(),
            required: false,
        },
        Parameter {
            name: "icmp_code".to_string(),
            type_hint: "number".to_string(),
            description: "ICMP code.".to_string(),
            required: false,
        },
        Parameter {
            name: "icmp_id".to_string(),
            type_hint: "number".to_string(),
            description: "Echo identifier. Copy it into the reply or the sender will ignore it.".to_string(),
            required: false,
        },
        Parameter {
            name: "icmp_sequence".to_string(),
            type_hint: "number".to_string(),
            description: "Echo sequence number. Copy it into the reply.".to_string(),
            required: false,
        },
        Parameter {
            name: "ethernet".to_string(),
            type_hint: "object".to_string(),
            description: "TAP mode only: {source_mac, destination_mac, ethertype}. Absent in TUN mode, which has no link layer.".to_string(),
            required: false,
        },
        Parameter {
            name: "payload_length".to_string(),
            type_hint: "number".to_string(),
            description: "True length of the transport payload in bytes, whatever the preview shows.".to_string(),
            required: true,
        },
        Parameter {
            name: "payload_preview".to_string(),
            type_hint: "string".to_string(),
            description: "At most 64 bytes of the payload, encoded as payload_encoding says. A preview, not the payload - use payload_length for the real size.".to_string(),
            required: true,
        },
        Parameter {
            name: "payload_encoding".to_string(),
            type_hint: "string".to_string(),
            description: "\"utf8\" when the previewed bytes were all printable ASCII, otherwise \"hex\". NetGet decides once and states the answer here; never guess.".to_string(),
            required: true,
        },
        Parameter {
            name: "summary".to_string(),
            type_hint: "string".to_string(),
            description: "One-line human-readable rendering of the packet.".to_string(),
            required: true,
        },
    ])
    .with_alternative_example(json!({
        "type": "drop_packet",
        "reason": "not addressed to a service this endpoint answers for"
    }))
    .with_log_template(
        LogTemplate::new()
            .with_info("TUN/TAP packet {summary}")
            .with_debug("TUN/TAP {protocol} {source} > {destination} len={total_length}")
            .with_trace("TUN/TAP {summary} payload({payload_encoding})={payload_preview}"),
    )
});

/// The interface was created and is carrying traffic.
pub static TUNTAP_INTERFACE_UP_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "tuntap_interface_up",
        "The TUN/TAP interface is up and NetGet is now the far end of it. Raised once per \
         server. The configured packet_filter and escalation budget are reported so the answer \
         can be written knowing how much traffic will actually be seen.",
        json!({"type": "no_response"}),
    )
    .with_actions(vec![send_packet_action(), no_response_action()])
    .with_parameters(vec![
        Parameter {
            name: "interface".to_string(),
            type_hint: "string".to_string(),
            description: "Interface name the operating system assigned, e.g. \"utun7\" or \"tun0\".".to_string(),
            required: true,
        },
        Parameter {
            name: "mode".to_string(),
            type_hint: "string".to_string(),
            description: "\"tun\" (layer 3, bare IP) or \"tap\" (layer 2, Ethernet).".to_string(),
            required: true,
        },
        Parameter {
            name: "address".to_string(),
            type_hint: "string".to_string(),
            description: "Local address assigned to the interface.".to_string(),
            required: true,
        },
        Parameter {
            name: "netmask".to_string(),
            type_hint: "string".to_string(),
            description: "Netmask of the interface.".to_string(),
            required: true,
        },
        Parameter {
            name: "mtu".to_string(),
            type_hint: "number".to_string(),
            description: "Interface MTU.".to_string(),
            required: true,
        },
        Parameter {
            name: "packet_filter".to_string(),
            type_hint: "string".to_string(),
            description: "The filter expression deciding which packets become events at all.".to_string(),
            required: true,
        },
        Parameter {
            name: "llm_escalation".to_string(),
            type_hint: "string".to_string(),
            description: "\"never\" or \"unhandled\" - whether a packet no handler answered may reach the model.".to_string(),
            required: true,
        },
        Parameter {
            name: "llm_max_per_minute".to_string(),
            type_hint: "number".to_string(),
            description: "Ceiling on model consultations per minute. Packets over it are dropped, not queued.".to_string(),
            required: true,
        },
    ])
    .with_log_template(
        LogTemplate::new().with_info("TUN/TAP interface {interface} up ({mode}, {address}/{netmask}, mtu {mtu})"),
    )
});

/// The interface stopped carrying traffic.
pub static TUNTAP_INTERFACE_DOWN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "tuntap_interface_down",
        "The TUN/TAP interface stopped carrying traffic - the server was stopped or the device \
         closed. Informational: the interface is already gone, so no packet can be sent in \
         reply. The packet counters for the whole run are included.",
        json!({"type": "no_response"}),
    )
    .with_actions(vec![no_response_action()])
    .with_parameters(vec![
        Parameter {
            name: "interface".to_string(),
            type_hint: "string".to_string(),
            description: "Interface name that went down.".to_string(),
            required: true,
        },
        Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why the interface stopped: \"stopped\", \"closed\" or an error description.".to_string(),
            required: true,
        },
        Parameter {
            name: "packets_received".to_string(),
            type_hint: "number".to_string(),
            description: "Packets read from the interface over the run.".to_string(),
            required: true,
        },
        Parameter {
            name: "packets_escalated".to_string(),
            type_hint: "number".to_string(),
            description: "Of those, how many became an event. The gap between this and packets_received is what the deterministic filter absorbed.".to_string(),
            required: true,
        },
        Parameter {
            name: "packets_sent".to_string(),
            type_hint: "number".to_string(),
            description: "Packets NetGet wrote back to the interface.".to_string(),
            required: true,
        },
    ])
    .with_log_template(
        LogTemplate::new()
            .with_info("TUN/TAP interface {interface} down: {reason}")
            .with_debug("TUN/TAP {interface} down: {reason}, received={packets_received} escalated={packets_escalated} sent={packets_sent}"),
    )
});

// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------

/// `send_packet` — the only action that puts anything on the interface.
///
/// Structured by construction: the model names the fields and NetGet lays out the headers and
/// computes every checksum. There is no byte-blob parameter, and there is no way to ask for a
/// packet NetGet cannot account for field by field.
pub fn send_packet_action() -> ActionDefinition {
    ActionDefinition {
        name: "send_packet".to_string(),
        description:
            "Write one packet to the interface, described by its header fields. NetGet builds \
             the IP header, the transport header and every checksum. IPv4 and IPv6 are chosen \
             from the addresses, which must be the same family. For TAP (layer 2) interfaces \
             supply source_mac and destination_mac as well; for TUN supply neither. Anything \
             the model does not name takes a documented default - it is never guessed from the \
             packet being answered, so an echo reply must copy icmp_id and icmp_sequence \
             itself or the sender will ignore it."
                .to_string(),
        parameters: vec![
            Parameter {
                name: "source".to_string(),
                type_hint: "string".to_string(),
                description: "Source IP address. Same family as destination.".to_string(),
                required: true,
            },
            Parameter {
                name: "destination".to_string(),
                type_hint: "string".to_string(),
                description: "Destination IP address. Same family as source.".to_string(),
                required: true,
            },
            Parameter {
                name: "protocol".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Protocol name (\"icmp\", \"icmpv6\", \"tcp\", \"udp\", \"gre\", \"esp\", \
                     \"ospf\", \"sctp\", \"vrrp\", \"igmp\", \"ah\") or an IANA number 0-255. \
                     \"icmp\" is IPv4-only and \"icmpv6\" IPv6-only; mixing them is refused."
                        .to_string(),
                required: true,
            },
            Parameter {
                name: "ip_version".to_string(),
                type_hint: "number".to_string(),
                description:
                    "4 or 6. Optional - it is derived from the addresses. Supplying one that \
                     contradicts them is refused rather than silently ignored."
                        .to_string(),
                required: false,
            },
            Parameter {
                name: "ttl".to_string(),
                type_hint: "number".to_string(),
                description: "IPv4 time-to-live, 0-255. Default 64.".to_string(),
                required: false,
            },
            Parameter {
                name: "hop_limit".to_string(),
                type_hint: "number".to_string(),
                description: "IPv6 hop limit, 0-255. Default 64. Synonym of ttl.".to_string(),
                required: false,
            },
            Parameter {
                name: "identification".to_string(),
                type_hint: "number".to_string(),
                description: "IPv4 identification field, 0-65535. Default 0.".to_string(),
                required: false,
            },
            Parameter {
                name: "source_port".to_string(),
                type_hint: "number".to_string(),
                description: "TCP/UDP source port. Required for tcp and udp.".to_string(),
                required: false,
            },
            Parameter {
                name: "destination_port".to_string(),
                type_hint: "number".to_string(),
                description: "TCP/UDP destination port. Required for tcp and udp.".to_string(),
                required: false,
            },
            Parameter {
                name: "flags".to_string(),
                type_hint: "array".to_string(),
                description: "TCP flags as names: fin, syn, rst, psh, ack, urg, ece, cwr. Default \
                     [\"ack\"]. An unknown name is refused rather than dropped."
                    .to_string(),
                required: false,
            },
            Parameter {
                name: "seq".to_string(),
                type_hint: "number".to_string(),
                description: "TCP sequence number. Default 0.".to_string(),
                required: false,
            },
            Parameter {
                name: "ack".to_string(),
                type_hint: "number".to_string(),
                description: "TCP acknowledgement number. Default 0.".to_string(),
                required: false,
            },
            Parameter {
                name: "window".to_string(),
                type_hint: "number".to_string(),
                description: "TCP window. Default 65535.".to_string(),
                required: false,
            },
            Parameter {
                name: "icmp_type".to_string(),
                type_hint: "string".to_string(),
                description: "ICMP type name (\"echo_request\", \"echo_reply\", \
                     \"destination_unreachable\", \"time_exceeded\", \"packet_too_big\", \
                     \"neighbor_advertisement\", ...) or a number. Required for icmp/icmpv6."
                    .to_string(),
                required: false,
            },
            Parameter {
                name: "icmp_code".to_string(),
                type_hint: "number".to_string(),
                description: "ICMP code, 0-255. Default 0.".to_string(),
                required: false,
            },
            Parameter {
                name: "icmp_id".to_string(),
                type_hint: "number".to_string(),
                description:
                    "Echo identifier for echo_request/echo_reply. Copy the one from the packet \
                     being answered; the sender matches on it."
                        .to_string(),
                required: false,
            },
            Parameter {
                name: "icmp_sequence".to_string(),
                type_hint: "number".to_string(),
                description:
                    "Echo sequence for echo_request/echo_reply. Copy the one from the packet \
                     being answered."
                        .to_string(),
                required: false,
            },
            Parameter {
                name: "source_mac".to_string(),
                type_hint: "string".to_string(),
                description:
                    "TAP only: source MAC, aa:bb:cc:dd:ee:ff. Must be supplied together with \
                     destination_mac, and only on a layer-2 interface."
                        .to_string(),
                required: false,
            },
            Parameter {
                name: "destination_mac".to_string(),
                type_hint: "string".to_string(),
                description: "TAP only: destination MAC. See source_mac.".to_string(),
                required: false,
            },
            Parameter {
                name: "payload".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Transport payload as text, or as hex digits when payload_encoding is \
                     \"hex\". Omit for a header-only packet."
                        .to_string(),
                required: false,
            },
            Parameter {
                name: "payload_encoding".to_string(),
                type_hint: "string".to_string(),
                description: "How to read \"payload\": \"utf8\" (default) or \"hex\". It is never \
                     guessed - \"48656c6c6f\" is both valid text and valid hex, and only the \
                     sender knows which was meant. Invalid hex is refused, not sent as text."
                    .to_string(),
                required: false,
            }
            .with_choices(["utf8", "hex"]),
        ],
        example: json!({
            "type": "send_packet",
            "source": "10.7.0.2",
            "destination": "10.7.0.1",
            "protocol": "icmp",
            "icmp_type": "echo_reply",
            "icmp_code": 0,
            "icmp_id": 4660,
            "icmp_sequence": 1,
            "ttl": 64,
            "payload": "netget",
            "payload_encoding": "utf8"
        }),
        log_template: Some(
            LogTemplate::new()
                .with_info("TUN/TAP send {protocol} {source} > {destination}")
                .with_debug("TUN/TAP send {protocol} {source} > {destination} ttl={ttl}"),
        ),
    }
}

/// `drop_packet` — the explicit, and correct, default.
pub fn drop_packet_action() -> ActionDefinition {
    ActionDefinition {
        name: "drop_packet".to_string(),
        description:
            "Discard this packet and send nothing. This is the right answer far more often \
             than not: the interface is real, and a packet NetGet cannot justify field by \
             field is a packet the host will treat as genuine. Dropping is also what happens \
             on any failure, so saying it explicitly is a way to record the decision in the \
             log rather than to change the outcome."
                .to_string(),
        parameters: vec![Parameter {
            name: "reason".to_string(),
            type_hint: "string".to_string(),
            description: "Why, for the log. Optional but worth writing.".to_string(),
            required: false,
        }],
        example: json!({
            "type": "drop_packet",
            "reason": "no service listens on this port"
        }),
        log_template: Some(LogTemplate::new().with_info("TUN/TAP drop: {reason}")),
    }
}

/// `no_response` — acknowledge without acting.
pub fn no_response_action() -> ActionDefinition {
    ActionDefinition {
        name: "no_response".to_string(),
        description: "Acknowledge the event without writing anything to the interface. Use it for \
             lifecycle events, where there is nothing to answer. For a packet, drop_packet \
             says the same thing more precisely."
            .to_string(),
        parameters: vec![],
        example: json!({"type": "no_response"}),
        log_template: None,
    }
}

/// TUN/TAP protocol declarations.
pub struct TunTapProtocol;

impl TunTapProtocol {
    /// Create the (stateless) protocol description the registry holds.
    pub fn new() -> Self {
        Self
    }
}

impl Default for TunTapProtocol {
    fn default() -> Self {
        Self::new()
    }
}

impl Protocol for TunTapProtocol {
    /// Deliberately empty.
    ///
    /// Every verb this protocol has is an answer to a packet or a lifecycle event, and the
    /// sync list carries all of them. An async `send_packet` would need the live interface
    /// handle, which the stateless registry struct does not have - and advertising a verb the
    /// executor cannot fulfil is the "advertised but unexecutable" bug the whole-tree ratchets
    /// exist to catch.
    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
        vec![]
    }

    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        vec![
            send_packet_action(),
            drop_packet_action(),
            no_response_action(),
        ]
    }

    fn protocol_name(&self) -> &'static str {
        "TUN/TAP"
    }

    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            TUNTAP_PACKET_RECEIVED_EVENT.clone(),
            TUNTAP_INTERFACE_UP_EVENT.clone(),
            TUNTAP_INTERFACE_DOWN_EVENT.clone(),
        ]
    }

    fn stack_name(&self) -> &'static str {
        "TUN/TAP>IP"
    }

    fn keywords(&self) -> Vec<&'static str> {
        vec![
            "tun",
            "tap",
            "tuntap",
            "tun/tap",
            "utun",
            "virtual interface",
            "network interface",
            "layer 3 interface",
        ]
    }

    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::{
            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
        };

        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            // Creating a TUN or TAP device needs root on every platform this builds for:
            // /dev/net/tun on Linux, the utun control socket on macOS. Not CAP_NET_RAW, not
            // BPF access - the device does not exist until a privileged process asks for it.
            .privilege_requirement(PrivilegeRequirement::Root)
            .connectionless()
            // Deliberately silent: a packet written to a real TUN/TAP interface is indistinguishable
            // from a spoof, and NetGet does not know what the host expected, so a failure drops the
            // packet. There is no error frame at the IP layer that would not assert something.
            .deliberately_silent()
            .implementation("tun 0.8 (Linux /dev/net/tun, macOS utun) with a hand-written decoder and builder in packet.rs. Every packet routed to the interface is decoded to structured header fields - IPv4/IPv6, TCP, UDP, ICMP, ICMPv6, Ethernet for TAP - and tested against a deterministic packet_filter before anything else happens. send_packet builds IPv4/IPv6, TCP, UDP and ICMP/ICMPv6 headers from named fields and computes the header, ICMP and pseudo-header checksums. The transport and the pipeline are separated: the pipeline runs over a pair of in-process channels, so it is exercised in full without root.")
            .llm_control("Deliberately bounded, because a per-packet LLM call is unusable - one ping is a call per second and a TCP handshake three in milliseconds. Three gates in order: packet_filter (native code, every packet, default \"icmp\"); the server's own event_handlers, which are the primary answer path and cost no model call; and only then llm_escalation/llm_max_per_minute, which lets an unhandled packet reach the model at most llm_max_per_minute times a minute (default 6). The model answers with send_packet, drop_packet or no_response, described by header fields - never raw bytes.")
            .e2e_testing("The packet layer is tested against literal packet bytes: real IPv4/IPv6 headers, TCP with flags, UDP, ICMP echo, an Ethernet-framed TAP packet, and the macOS 4-byte AF prefix and Linux flags+EtherType prefix both explicitly. Built packets are re-decoded and their checksums verified to zero. The event -> handler -> action pipeline runs over an in-process channel transport against a mock model, including the escalation bound: 40 packets are injected and exactly the intended few reach the model. The real interface is never created - that needs root, which the suite does not have.")
            .notes("PROVEN: the decoder and builder, against literal bytes; the filter grammar; the escalation bound, measured as LLM call counts against a recording mock; that a failure sends nothing. NOT PROVEN: interface creation and the real transport, which have never been executed - no test in this suite has root, so tun::create() has never run and no packet has ever crossed a real utun or /dev/net/tun. Treat the transport as unexercised code. TAP is refused on macOS: utun is layer 3 only and CoreBluetooth-style third-party kexts are out of scope, so spawn() returns a clear Err naming the reason rather than pretending. PATH TO BETA: run it under sudo on this machine with a real utun - `ping 10.7.0.2` for ICMP and `nc` for TCP - and confirm the host accepts the replies NetGet builds; that has not been done and must not be claimed until it is. On any failure - LLM error, a refused action, an over-budget packet - the packet is dropped and nothing is written. That is not a fallback, it is the design: a fabricated packet on a real interface is indistinguishable from a spoof, and NetGet does not know what the host expected. The log distinguishes decision=handler_send / handler_drop / handler_silent / model_send / model_drop / model_silent / fail_closed_llm_error / fail_closed_build_error / fail_closed_rate_limited / llm_disabled so silence is never ambiguous - in particular \"the model said drop\" and \"the model said nothing\" are different tokens.")
            .build()
    }

    fn description(&self) -> &'static str {
        "TUN/TAP interface endpoint - NetGet becomes a network interface and every routed packet becomes an event"
    }

    fn example_prompt(&self) -> &'static str {
        "Become a TUN interface on 10.7.0.1/24 and answer every ping to 10.7.0.2 with an echo reply"
    }

    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        // Every one of these nine is read by TunTapConfig::from_params in mod.rs. The three
        // that bound LLM involvement - packet_filter, llm_escalation, llm_max_per_minute - are
        // the reason this protocol is usable at all; see src/server/tuntap/CLAUDE.md.
        vec![
            ParameterDefinition {
                name: "interface_name".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Name to request for the interface. Linux: anything, e.g. \"tun0\". macOS: \
                     must be \"utunN\" for a number N, and the kernel may hand back a \
                     different one. Omit to let the platform choose."
                        .to_string(),
                required: false,
                example: json!("utun7"),
                default: None,
            },
            ParameterDefinition {
                name: "mode".to_string(),
                type_hint: "string".to_string(),
                description:
                    "\"tun\" (layer 3, bare IP packets; the default) or \"tap\" (layer 2, \
                     Ethernet frames). TAP is Linux-only: macOS utun has no layer-2 mode and \
                     no TAP device without a third-party kext, so \"tap\" is refused there \
                     with a clear error rather than silently downgraded."
                        .to_string(),
                required: false,
                example: json!("tun"),
                default: None,
            },
            ParameterDefinition {
                name: "address".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Local address assigned to the interface. Default 10.7.0.1. The host routes \
                     the rest of the subnet to NetGet, so this is the address the host sees as \
                     the near end."
                        .to_string(),
                required: false,
                example: json!("10.7.0.1"),
                default: None,
            },
            ParameterDefinition {
                name: "netmask".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Netmask for the interface address. Default 255.255.255.0, which routes \
                     the whole /24 into NetGet."
                        .to_string(),
                required: false,
                example: json!("255.255.255.0"),
                default: None,
            },
            ParameterDefinition {
                name: "mtu".to_string(),
                type_hint: "integer".to_string(),
                description: "Interface MTU, 576-65535. Default 1500.".to_string(),
                required: false,
                example: json!(1500),
                default: None,
            },
            ParameterDefinition {
                name: "packet_filter".to_string(),
                type_hint: "string".to_string(),
                description:
                    "WHICH PACKETS BECOME EVENTS AT ALL. Everything else is decoded, counted \
                     and dropped in native code with no handler and no model call - which is \
                     what makes an interface endpoint usable, since anything real is thousands \
                     of packets. Default \"icmp\": only pings are surfaced, because a ping is \
                     one packet a second and is the one thing you can watch by hand. Grammar: \
                     \"all\", \"none\", or comma-separated alternatives (OR) of \
                     plus-separated terms (AND). Terms: v4, v6, a protocol name (icmp, tcp, \
                     udp, ...), ip-proto-<n>, tcp:<port>, udp:<port>, port:<n>, from:<addr>, \
                     to:<addr>, host:<addr>, and tcp-syn - which matches only a SYN without an \
                     ACK, so a whole TCP connection costs one event instead of one per packet. \
                     Example: \"tcp-syn+to:10.7.0.2, icmp\"."
                        .to_string(),
                required: false,
                example: json!("icmp"),
                default: None,
            },
            ParameterDefinition {
                name: "llm_escalation".to_string(),
                type_hint: "string".to_string(),
                description:
                    "Whether a packet that no event handler answered may reach the model. \
                     \"unhandled\" (default) lets it, subject to llm_max_per_minute. \"never\" \
                     forbids it outright, so the server is purely deterministic: handlers \
                     answer, everything else is dropped, and no LLM budget is ever spent. \
                     Script and static handlers are the intended answer path either way - the \
                     model is the last resort, not the default."
                        .to_string(),
                required: false,
                example: json!("unhandled"),
                default: None,
            },
            ParameterDefinition {
                name: "llm_max_per_minute".to_string(),
                type_hint: "integer".to_string(),
                description:
                    "Hard ceiling on model consultations per rolling minute, 0-3600. Default \
                     6. Packets over the ceiling are DROPPED, not queued - queueing a packet \
                     is meaningless once the sender has moved on. 0 has the same effect as \
                     llm_escalation \"never\". At the default, a ping arriving once a second \
                     produces six model calls in the first minute and then silence, which is \
                     intentional: the filter is how you decide what is interesting, not this."
                        .to_string(),
                required: false,
                example: json!(6),
                default: None,
            },
            ParameterDefinition {
                name: "packet_information".to_string(),
                type_hint: "string".to_string(),
                description:
                    "How to read the platform's 4-byte per-packet prefix - the single most \
                     common source of \"why is every packet off by four bytes\". \"auto\" \
                     (default) trusts the tun crate, which strips and re-adds it itself. \
                     \"macos_utun\" is four bytes of big-endian address family (AF_INET=2, \
                     AF_INET6=30), which macOS utun always prepends. \"linux_pi\" is two bytes \
                     of flags plus a big-endian EtherType, which Linux prepends unless the \
                     device was opened with IFF_NO_PI. \"none\" is a bare packet. The prefix is \
                     validated, not skipped: a mismatch is an error rather than four bytes of \
                     silent misalignment."
                        .to_string(),
                required: false,
                example: json!("auto"),
                default: None,
            },
        ]
    }

    fn group_name(&self) -> &'static str {
        "VPN & Routing"
    }

    fn get_startup_examples(&self) -> StartupExamples {
        StartupExamples::new(
            // LLM mode. Note the filter: even here the model only ever sees pings, and at most
            // six a minute. Anything wider needs the operator to say so.
            json!({
                "type": "open_server",
                "port": 0,
                "base_stack": "tuntap",
                "instruction": "You are the far end of a TUN interface at 10.7.0.1/24. When a ping arrives for any address in that subnet, answer it with an ICMP echo reply that copies the request's icmp_id and icmp_sequence and swaps source and destination. Drop anything else.",
                "startup_params": {
                    "address": "10.7.0.1",
                    "netmask": "255.255.255.0",
                    "packet_filter": "icmp",
                    "llm_escalation": "unhandled",
                    "llm_max_per_minute": 6
                }
            }),
            // Script mode. This is the shape to copy for anything with real traffic: the
            // script answers every ping in-process with no model call at all, so the filter
            // can be widened without cost.
            json!({
                "type": "open_server",
                "port": 0,
                "base_stack": "tuntap",
                "startup_params": {
                    "address": "10.7.0.1",
                    "packet_filter": "icmp, tcp-syn",
                    "llm_escalation": "never"
                },
                "event_handlers": [{
                    "event_pattern": "tuntap_packet_received",
                    "handler": {
                        "type": "script",
                        "language": "python",
                        "code": "if event['protocol'] == 'icmp' and event['icmp_type'] == 'echo_request':\n    return [{'type': 'send_packet', 'source': event['destination'], 'destination': event['source'], 'protocol': 'icmp', 'icmp_type': 'echo_reply', 'icmp_id': event['icmp_id'], 'icmp_sequence': event['icmp_sequence']}]\nreturn [{'type': 'drop_packet', 'reason': 'only pings are answered'}]"
                    }
                }]
            }),
            // Static mode. A fixed answer plus interpolation of the fields that must be echoed
            // back - no model call, and the correlation the sender matches on is preserved.
            json!({
                "type": "open_server",
                "port": 0,
                "base_stack": "tuntap",
                "startup_params": {
                    "address": "10.7.0.1",
                    "packet_filter": "icmp",
                    "llm_escalation": "never"
                },
                "event_handlers": [{
                    "event_pattern": "tuntap_packet_received",
                    "handler": {
                        "type": "static",
                        "actions": [{
                            "type": "send_packet",
                            "source": "{{event.destination}}",
                            "destination": "{{event.source}}",
                            "protocol": "icmp",
                            "icmp_type": "echo_reply",
                            "icmp_id": "{{event.icmp_id}}",
                            "icmp_sequence": "{{event.icmp_sequence}}"
                        }]
                    }
                }]
            }),
        )
    }
}

impl Server for TunTapProtocol {
    fn spawn(
        &self,
        ctx: crate::protocol::SpawnContext,
    ) -> std::pin::Pin<
        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
    > {
        Box::pin(async move { super::TunTapServer::spawn_with_llm_actions(ctx).await })
    }

    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
        let action_type = action
            .get("type")
            .and_then(|v| v.as_str())
            .context("Missing action type")?;

        match action_type {
            "send_packet" => {
                let bytes = packet::build_packet(&action)
                    .map_err(|e| anyhow::anyhow!("send_packet refused: {e}"))?;
                debug!("TUN/TAP built a {}-byte packet", bytes.len());
                Ok(ActionResult::Output(bytes))
            }
            "drop_packet" => {
                let reason = action
                    .get("reason")
                    .and_then(|v| v.as_str())
                    .unwrap_or("no reason given");
                debug!("TUN/TAP drop_packet: {}", reason);
                Ok(ActionResult::NoAction)
            }
            "no_response" => Ok(ActionResult::NoAction),
            other => Err(anyhow::anyhow!("Unknown TUN/TAP action type: {}", other)),
        }
    }
}