@push.rocks/smartnftables 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- use super::{Policy, Scope};
1
+ use super::{Policy, Prepared, Scope};
2
2
  use serde_json::{json, Value};
3
3
 
4
4
  fn link(index: u32, name: &str, kind: &str, address: &str) -> Value {
@@ -483,3 +483,610 @@ fn allocation_pool_guard_maximum_fits_the_atomic_graph_without_link_bindings() {
483
483
  let excessive: Vec<_> = (0..65).map(|part| format!("10.{part}.0.0/16")).collect();
484
484
  assert!(normalized(changed(pool_guard(), "/scope/prefixes", json!(excessive))).is_err());
485
485
  }
486
+
487
+ pub(crate) fn published_port(protocol: &str, host_port: u16, target_port: u16) -> Value {
488
+ json!({"protocol":protocol,"hostPort":host_port,"targetPort":target_port,
489
+ "targetAddress":"10.240.0.2","hostIp":"192.0.2.2"})
490
+ }
491
+ pub(crate) fn published_host(ports: Value) -> Value {
492
+ let mut value = host();
493
+ value["scope"]["publishedPorts"] = ports;
494
+ value
495
+ }
496
+ fn prepared(value: Value) -> Prepared {
497
+ normalized(value).unwrap().prepare().unwrap()
498
+ }
499
+ fn program_bytes(prepared: &Prepared, table: &str) -> Vec<u8> {
500
+ let mut bytes = Vec::new();
501
+ for (kind, attributes) in prepared.program(table).unwrap() {
502
+ let encoded = crate::wire::encode_attrs(&attributes);
503
+ bytes.extend(kind.to_be_bytes());
504
+ bytes.extend((encoded.len() as u32).to_be_bytes());
505
+ bytes.extend(encoded);
506
+ }
507
+ bytes
508
+ }
509
+ /// Chain names in creation order, then every rule as (chain, expression names).
510
+ fn graph(prepared: &Prepared, table: &str) -> (Vec<String>, Vec<(String, Vec<String>)>) {
511
+ let mut chains = Vec::new();
512
+ let mut rules = Vec::new();
513
+ for (kind, attributes) in prepared.program(table).unwrap() {
514
+ assert_eq!(crate::wire::text(&attributes, 1).unwrap(), table);
515
+ if kind == 3 {
516
+ chains.push(crate::wire::text(&attributes, 3).unwrap());
517
+ continue;
518
+ }
519
+ let expressions = crate::wire::attrs(&crate::wire::one(&attributes, 4).unwrap().value).unwrap();
520
+ rules.push((
521
+ crate::wire::text(&attributes, 2).unwrap(),
522
+ expressions
523
+ .iter()
524
+ .map(|expression| {
525
+ let fields = crate::wire::attrs(&expression.value).unwrap();
526
+ crate::wire::text(&fields, 1).unwrap()
527
+ })
528
+ .collect(),
529
+ ));
530
+ }
531
+ (chains, rules)
532
+ }
533
+
534
+ #[test]
535
+ fn host_v2_without_published_ports_keeps_its_exact_digest_and_compiled_bytes() {
536
+ use sha2::{Digest, Sha256};
537
+ let baseline = prepared(host());
538
+ // Frozen before the optional publication field existed.
539
+ assert_eq!(
540
+ baseline.digest,
541
+ "sha256:b24cd2bc4e812014cabd71a77f57a2fe085c4b83d4e7b7939ccfca28949c6848"
542
+ );
543
+ let bytes = program_bytes(&baseline, "snft_v2_host_golden");
544
+ assert_eq!(bytes.len(), 24932);
545
+ assert_eq!(
546
+ format!("{:x}", Sha256::digest(&bytes)),
547
+ "91735de50a831184648bf79cd7a840f7af5264f3c6ec461f5412b3fc1d1ef6ec"
548
+ );
549
+ let serialized = serde_json::to_string(&baseline.policy).unwrap();
550
+ assert!(!serialized.contains("publishedPorts"));
551
+ // An explicit empty publication is the same canonical policy, digest and graph.
552
+ assert_eq!(prepared(published_host(json!([]))), baseline);
553
+ let (chains, rules) = graph(&baseline, "snft_v2_host_golden");
554
+ assert!(!chains.contains(&"pre".to_string()));
555
+ assert!(rules.iter().all(|(chain, _)| chain != "pre"));
556
+ }
557
+
558
+ #[test]
559
+ fn host_v2_published_ports_compile_uplink_dnat_and_exact_forward_admission() {
560
+ use sha2::{Digest, Sha256};
561
+ let baseline = graph(&prepared(host()), "snft_v2_host_golden");
562
+ let value = published_host(json!([
563
+ published_port("tcp", 443, 8443),
564
+ published_port("udp", 8125, 8125)
565
+ ]));
566
+ let published = prepared(value.clone());
567
+ assert_ne!(published.digest, prepared(host()).digest);
568
+ // Publication order is canonical, so a reordered list is the same policy.
569
+ let mut reordered = value;
570
+ reordered["scope"]["publishedPorts"]
571
+ .as_array_mut()
572
+ .unwrap()
573
+ .reverse();
574
+ assert_eq!(prepared(reordered), published);
575
+ let (chains, rules) = graph(&published, "snft_v2_host_golden");
576
+ assert_eq!(chains.len(), baseline.0.len() + 1);
577
+ assert_eq!(chains.iter().filter(|name| *name == "pre").count(), 1);
578
+ assert_eq!(rules.len(), baseline.1.len() + 8);
579
+ let translations: Vec<_> = rules.iter().filter(|(chain, _)| chain == "pre").collect();
580
+ assert_eq!(translations.len(), 2);
581
+ for (_, expressions) in &translations {
582
+ assert_eq!(expressions.last().unwrap(), "nat");
583
+ assert_eq!(expressions.iter().filter(|name| *name == "immediate").count(), 2);
584
+ }
585
+ // The three admissions precede the capture barrier jumps of their handoff.
586
+ let forward: Vec<_> = rules
587
+ .iter()
588
+ .filter(|(chain, _)| chain == "forward")
589
+ .map(|(_, expressions)| expressions.len())
590
+ .collect();
591
+ assert_eq!(forward.len(), baseline.1.iter().filter(|(chain, _)| chain == "forward").count() + 6);
592
+ let (_, first) = rules.iter().find(|(chain, _)| chain == "forward").unwrap();
593
+ assert!(first.contains(&"ct".to_string()) && first.len() > 4);
594
+ let bytes = program_bytes(&published, "snft_v2_host_golden");
595
+ assert_eq!(bytes.len(), 36322);
596
+ assert_eq!(
597
+ format!("{:x}", Sha256::digest(&bytes)),
598
+ "1d3485bb739ac3601642610eba465b80c9b5ac34df22a5374cfbc8773623710b"
599
+ );
600
+ }
601
+
602
+ #[test]
603
+ fn host_v2_published_ports_are_owned_by_their_generation_and_leave_with_it() {
604
+ let published = prepared(published_host(json!([published_port("tcp", 443, 8443)])));
605
+ let rollback = prepared(host());
606
+ let (chains, rules) = graph(&published, "snft_v2_host_rollback");
607
+ assert!(chains.contains(&"pre".to_string()));
608
+ assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
609
+ // Every operation names the owned table, and the rollback target compiles the
610
+ // same graph as before publication; reconcile deletes the complete previous
611
+ // graph and re-adds the target in one batch, so the publication cannot survive.
612
+ let (rollback_chains, rollback_rules) = graph(&rollback, "snft_v2_host_rollback");
613
+ assert!(!rollback_chains.contains(&"pre".to_string()));
614
+ assert!(rollback_rules.iter().all(|(chain, _)| chain != "pre"));
615
+ assert_eq!(rollback_chains.len() + 1, chains.len());
616
+ assert_eq!(rollback_rules.len() + 4, rules.len());
617
+ assert_ne!(published.digest, rollback.digest);
618
+ }
619
+
620
+ #[test]
621
+ fn host_v2_published_ports_reject_unbound_targets_conflicts_and_out_of_range_ports() {
622
+ assert!(normalized(published_host(json!([published_port("tcp", 443, 8443)]))).is_ok());
623
+ let mut unknown = published_port("tcp", 443, 8443);
624
+ unknown["hostInterface"] = json!("ens18");
625
+ let mut second_address = published_host(json!([
626
+ changed(published_port("tcp", 443, 8443), "/hostIp", json!("192.0.2.5"))
627
+ ]));
628
+ second_address["scope"]["uplink"]["requiredIpv4Addresses"] = json!(["192.0.2.2", "192.0.2.5"]);
629
+ let mut duplicate_across_addresses = second_address.clone();
630
+ duplicate_across_addresses["scope"]["publishedPorts"]
631
+ .as_array_mut()
632
+ .unwrap()
633
+ .push(published_port("tcp", 443, 9443));
634
+ assert!(normalized(second_address).is_ok());
635
+ for invalid in [
636
+ published_host(json!([unknown])),
637
+ published_host(json!([published_port("sctp", 443, 8443)])),
638
+ published_host(json!([published_port("tcp", 0, 8443)])),
639
+ published_host(json!([published_port("tcp", 443, 0)])),
640
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/hostPort", json!(65536))])),
641
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/targetPort", json!(65536))])),
642
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/hostPort", json!(-1))])),
643
+ // The published address must be an exact current uplink address.
644
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/hostIp", json!("192.0.2.5"))])),
645
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/hostIp", json!("10.240.0.1"))])),
646
+ // The target must be a current leased transit source address.
647
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("10.240.0.3"))])),
648
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("10.240.0.1"))])),
649
+ published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("192.0.2.2"))])),
650
+ // A published port cannot collide with the leased outbound SNAT ranges.
651
+ published_host(json!([published_port("tcp", 10500, 8443)])),
652
+ published_host(json!([published_port("udp", 10000, 8443)])),
653
+ // One protocol/port is published exactly once, including across addresses.
654
+ published_host(json!([published_port("tcp", 443, 8443), published_port("tcp", 443, 9443)])),
655
+ duplicate_across_addresses,
656
+ published_host(json!((0..65).map(|index| published_port("tcp", 20000 + index, 8443)).collect::<Vec<_>>())),
657
+ ] {
658
+ assert!(normalized(invalid).is_err());
659
+ }
660
+ // Bounded input still meets the atomic reserve first: every publication costs
661
+ // one translation and three admissions, so prepare rejects before any kernel work.
662
+ let ports = |count: u16| -> Value {
663
+ json!((0..count)
664
+ .map(|index| published_port("tcp", 20000 + index, 8443))
665
+ .collect::<Vec<_>>())
666
+ };
667
+ let prepared = prepared(published_host(ports(12)));
668
+ let program = prepared
669
+ .program(&format!("snft_{}", "x".repeat(59)))
670
+ .unwrap();
671
+ let bytes: usize = program
672
+ .iter()
673
+ .map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
674
+ .sum();
675
+ assert!(program.len() <= 768 && bytes <= 100_000);
676
+ assert!(normalized(published_host(ports(13))).unwrap().prepare().is_err());
677
+ println!("PUBLISHED_MAX ports=12 operations={} bytes={bytes}", program.len());
678
+ }
679
+
680
+ pub(crate) fn router_published_port(protocol: &str, transit_port: u16, endpoint_port: u16) -> Value {
681
+ json!({"protocol":protocol,"transitPort":transit_port,"endpointPort":endpoint_port,
682
+ "endpointAddress":"10.241.0.2","transitSourceAddress":"10.240.0.2"})
683
+ }
684
+ pub(crate) fn published_router(ports: Value) -> Value {
685
+ let mut value = router();
686
+ value["scope"]["publishedPorts"] = ports;
687
+ value
688
+ }
689
+
690
+ #[test]
691
+ fn router_v2_without_published_ports_keeps_its_exact_digest_and_compiled_bytes() {
692
+ use sha2::{Digest, Sha256};
693
+ let baseline = prepared(router());
694
+ // Frozen before the optional publication field existed.
695
+ assert_eq!(
696
+ baseline.digest,
697
+ "sha256:762a556e0bf512b653acfcf86699ac7681f26e6cc9ea1d26671443e3d2ca0b61"
698
+ );
699
+ let bytes = program_bytes(&baseline, "snft_v2_router_golden");
700
+ assert_eq!(bytes.len(), 53272);
701
+ assert_eq!(
702
+ format!("{:x}", Sha256::digest(&bytes)),
703
+ "66850722fbd38a45a986cebeddcc75a541ebb1e2f003ea4375621decb2e08f99"
704
+ );
705
+ let serialized = serde_json::to_string(&baseline.policy).unwrap();
706
+ assert!(!serialized.contains("publishedPorts"));
707
+ // An explicit empty publication is the same canonical policy, digest and graph.
708
+ assert_eq!(prepared(published_router(json!([]))), baseline);
709
+ let (chains, rules) = graph(&baseline, "snft_v2_router_golden");
710
+ assert!(!chains.contains(&"pre".to_string()));
711
+ assert!(rules.iter().all(|(chain, _)| chain != "pre"));
712
+ }
713
+
714
+ #[test]
715
+ fn router_v2_published_ports_compile_handoff_dnat_classification_and_admission() {
716
+ use sha2::{Digest, Sha256};
717
+ let baseline = graph(&prepared(router()), "snft_v2_router_golden");
718
+ let value = published_router(json!([
719
+ router_published_port("tcp", 443, 8443),
720
+ router_published_port("udp", 8125, 8125)
721
+ ]));
722
+ let published = prepared(value.clone());
723
+ assert_ne!(published.digest, prepared(router()).digest);
724
+ // Publication order is canonical, so a reordered list is the same policy.
725
+ let mut reordered = value;
726
+ reordered["scope"]["publishedPorts"]
727
+ .as_array_mut()
728
+ .unwrap()
729
+ .reverse();
730
+ assert_eq!(prepared(reordered), published);
731
+ let (chains, rules) = graph(&published, "snft_v2_router_golden");
732
+ assert_eq!(chains.len(), baseline.0.len() + 1);
733
+ assert_eq!(chains.iter().filter(|name| *name == "pre").count(), 1);
734
+ // Six rules per publication: one translation, one handoff ingress and one
735
+ // endpoint ingress classification, and the three forward admissions.
736
+ assert_eq!(rules.len(), baseline.1.len() + 12);
737
+ let translations: Vec<_> = rules.iter().filter(|(chain, _)| chain == "pre").collect();
738
+ assert_eq!(translations.len(), 2);
739
+ for (_, expressions) in &translations {
740
+ assert_eq!(expressions.last().unwrap(), "nat");
741
+ assert_eq!(
742
+ expressions.iter().filter(|name| *name == "immediate").count(),
743
+ 2
744
+ );
745
+ }
746
+ let counted = |graph: &Vec<(String, Vec<String>)>, chain: &str| {
747
+ graph.iter().filter(|(name, _)| name == chain).count()
748
+ };
749
+ assert_eq!(
750
+ counted(&rules, "forward"),
751
+ counted(&baseline.1, "forward") + 6
752
+ );
753
+ // The classification leads the handoff ingress barrier, ahead of every denial
754
+ // in it, and never touches conntrack because raw runs before it.
755
+ let returns: Vec<_> = rules
756
+ .iter()
757
+ .filter(|(chain, _)| chain == "raw_return")
758
+ .map(|(_, expressions)| expressions)
759
+ .collect();
760
+ assert_eq!(returns.len(), counted(&baseline.1, "raw_return") + 2);
761
+ for expressions in &returns[..2] {
762
+ assert!(!expressions.contains(&"ct".to_string()));
763
+ }
764
+ assert!(returns[2..]
765
+ .iter()
766
+ .any(|expressions| expressions.contains(&"ct".to_string())));
767
+ assert_eq!(returns.last().unwrap().len(), 1);
768
+ // The endpoint side is classified in the workload ingress chain, behind the
769
+ // handoff barrier that opens it and ahead of every leased zone assignment.
770
+ let incoming: Vec<_> = rules
771
+ .iter()
772
+ .filter(|(chain, _)| chain == "raw_in")
773
+ .map(|(_, expressions)| expressions)
774
+ .collect();
775
+ assert_eq!(incoming.len(), counted(&baseline.1, "raw_in") + 2);
776
+ for expressions in &incoming[3..5] {
777
+ assert!(!expressions.contains(&"ct".to_string()));
778
+ }
779
+ assert!(incoming[5..]
780
+ .iter()
781
+ .any(|expressions| expressions.contains(&"ct".to_string())));
782
+ let bytes = program_bytes(&published, "snft_v2_router_golden");
783
+ assert_eq!(bytes.len(), 67818);
784
+ assert_eq!(
785
+ format!("{:x}", Sha256::digest(&bytes)),
786
+ "62bf0f5aace2a19fb8a92571d1c4d45d774aa003884c239e473c67a7e103bb12"
787
+ );
788
+ }
789
+
790
+ #[test]
791
+ fn router_v2_published_ports_are_owned_by_their_generation_and_leave_with_it() {
792
+ let published = prepared(published_router(json!([router_published_port(
793
+ "tcp", 443, 8443
794
+ )])));
795
+ let rollback = prepared(router());
796
+ let (chains, rules) = graph(&published, "snft_v2_router_rollback");
797
+ assert!(chains.contains(&"pre".to_string()));
798
+ assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
799
+ // Every operation names the owned table, and the rollback target compiles the
800
+ // same graph as before publication; reconcile deletes the complete previous
801
+ // graph and re-adds the target in one batch, so the publication cannot survive.
802
+ let (rollback_chains, rollback_rules) = graph(&rollback, "snft_v2_router_rollback");
803
+ assert!(!rollback_chains.contains(&"pre".to_string()));
804
+ assert!(rollback_rules.iter().all(|(chain, _)| chain != "pre"));
805
+ assert_eq!(rollback_chains.len() + 1, chains.len());
806
+ assert_eq!(rollback_rules.len() + 6, rules.len());
807
+ assert_ne!(published.digest, rollback.digest);
808
+ // A released generation takes its publication with it: the scope without
809
+ // generations rejects the publication it could no longer bind.
810
+ let mut revoked = published_router(json!([router_published_port("tcp", 443, 8443)]));
811
+ revoked["scope"]["generations"] = json!([]);
812
+ assert!(normalized(revoked).is_err());
813
+ }
814
+
815
+ #[test]
816
+ fn router_v2_published_ports_reject_unbound_endpoints_conflicts_and_out_of_range_ports() {
817
+ assert!(normalized(published_router(json!([router_published_port("tcp", 443, 8443)]))).is_ok());
818
+ let mut unknown = router_published_port("tcp", 443, 8443);
819
+ unknown["endpointId"] = json!("work");
820
+ for invalid in [
821
+ published_router(json!([unknown])),
822
+ published_router(json!([router_published_port("sctp", 443, 8443)])),
823
+ published_router(json!([router_published_port("tcp", 0, 8443)])),
824
+ published_router(json!([router_published_port("tcp", 443, 0)])),
825
+ published_router(json!([changed(
826
+ router_published_port("tcp", 443, 8443),
827
+ "/transitPort",
828
+ json!(65536)
829
+ )])),
830
+ published_router(json!([changed(
831
+ router_published_port("tcp", 443, 8443),
832
+ "/endpointPort",
833
+ json!(65536)
834
+ )])),
835
+ published_router(json!([changed(
836
+ router_published_port("tcp", 443, 8443),
837
+ "/transitPort",
838
+ json!(-1)
839
+ )])),
840
+ // The destination must be a current workload endpoint address of this scope.
841
+ published_router(json!([changed(
842
+ router_published_port("tcp", 443, 8443),
843
+ "/endpointAddress",
844
+ json!("10.241.0.3")
845
+ )])),
846
+ // Not the endpoint's router-local gateway, and not the transit address.
847
+ published_router(json!([changed(
848
+ router_published_port("tcp", 443, 8443),
849
+ "/endpointAddress",
850
+ json!("10.241.0.1")
851
+ )])),
852
+ published_router(json!([changed(
853
+ router_published_port("tcp", 443, 8443),
854
+ "/endpointAddress",
855
+ json!("10.240.0.2")
856
+ )])),
857
+ // Not loopback, not a broadcast/multicast address, and IPv4 only.
858
+ published_router(json!([changed(
859
+ router_published_port("tcp", 443, 8443),
860
+ "/endpointAddress",
861
+ json!("127.0.0.1")
862
+ )])),
863
+ published_router(json!([changed(
864
+ router_published_port("tcp", 443, 8443),
865
+ "/endpointAddress",
866
+ json!("fd00::2")
867
+ )])),
868
+ // A TUN endpoint keeps private routing only; publication needs a veth.
869
+ published_router(json!([changed(
870
+ router_published_port("tcp", 443, 8443),
871
+ "/endpointAddress",
872
+ json!("10.242.0.2")
873
+ )])),
874
+ // The published address must be a current leased transit source address
875
+ // that is present on the handoff.
876
+ published_router(json!([changed(
877
+ router_published_port("tcp", 443, 8443),
878
+ "/transitSourceAddress",
879
+ json!("10.240.0.3")
880
+ )])),
881
+ published_router(json!([changed(
882
+ router_published_port("tcp", 443, 8443),
883
+ "/transitSourceAddress",
884
+ json!("10.241.0.1")
885
+ )])),
886
+ // A published port cannot collide with the leased outbound SNAT ranges
887
+ // translated to that same transit address.
888
+ published_router(json!([router_published_port("tcp", 10500, 8443)])),
889
+ published_router(json!([router_published_port("udp", 10000, 8443)])),
890
+ // One protocol/port is published exactly once across the scope.
891
+ published_router(json!([
892
+ router_published_port("tcp", 443, 8443),
893
+ router_published_port("tcp", 443, 9443)
894
+ ])),
895
+ published_router(json!((0..65)
896
+ .map(|index| router_published_port("tcp", 20000 + index, 8443))
897
+ .collect::<Vec<_>>())),
898
+ ] {
899
+ assert!(normalized(invalid).is_err());
900
+ }
901
+ // Bounded input still meets the atomic reserve first: every publication costs
902
+ // six operations, so prepare rejects before any kernel work.
903
+ let ports = |count: u16| -> Value {
904
+ json!((0..count)
905
+ .map(|index| router_published_port("tcp", 20000 + index, 8443))
906
+ .collect::<Vec<_>>())
907
+ };
908
+ let prepared = prepared(published_router(ports(5)));
909
+ let program = prepared
910
+ .program(&format!("snft_{}", "x".repeat(59)))
911
+ .unwrap();
912
+ let bytes: usize = program
913
+ .iter()
914
+ .map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
915
+ .sum();
916
+ assert!(program.len() <= 768 && bytes <= 100_000);
917
+ assert!(normalized(published_router(ports(6)))
918
+ .unwrap()
919
+ .prepare()
920
+ .is_err());
921
+ println!("ROUTER_PUBLISHED_MAX ports=5 operations={} bytes={bytes}", program.len());
922
+ }
923
+
924
+ /// One arriving IPv4 tuple for the ordered raw classifiers of a compiled chain.
925
+ #[derive(Clone, Copy)]
926
+ struct Arrival {
927
+ interface_index: u32,
928
+ interface_name: &'static str,
929
+ protocol: u8,
930
+ source: [u8; 4],
931
+ destination: [u8; 4],
932
+ source_port: u16,
933
+ destination_port: u16,
934
+ }
935
+ /// Walks one compiled chain in program order and reports the first rule that
936
+ /// matches an arriving tuple as (position, verdict, conntrack zone it sets). Raw
937
+ /// prerouting classification is the only hook this covers, so an unexpected
938
+ /// expression panics instead of being silently ignored.
939
+ fn classify(prepared: &Prepared, chain: &str, arrival: &Arrival) -> Option<(usize, i32, u16)> {
940
+ let table = "snft_v2_router_order";
941
+ let mut position = 0;
942
+ for (kind, attributes) in prepared.program(table).unwrap() {
943
+ if kind == 3 || crate::wire::text(&attributes, 2).unwrap() != chain {
944
+ continue;
945
+ }
946
+ let index = position;
947
+ position += 1;
948
+ let mut pending: Option<Vec<u8>> = None;
949
+ let mut immediate: Option<Vec<u8>> = None;
950
+ let mut verdict = 0;
951
+ let mut zone = 0;
952
+ let mut matched = true;
953
+ for expression in
954
+ crate::wire::attrs(&crate::wire::one(&attributes, 4).unwrap().value).unwrap()
955
+ {
956
+ let fields = crate::wire::attrs(&expression.value).unwrap();
957
+ let data = crate::wire::attrs(&crate::wire::one(&fields, 2).unwrap().value).unwrap();
958
+ match crate::wire::text(&fields, 1).unwrap().as_str() {
959
+ "meta" => {
960
+ pending = Some(match crate::wire::number(&data, 2).unwrap() {
961
+ 4 => arrival.interface_index.to_ne_bytes().to_vec(),
962
+ 6 => [arrival.interface_name.as_bytes(), &[0]].concat(),
963
+ 15 => vec![libc::NFPROTO_IPV4 as u8],
964
+ 16 => vec![arrival.protocol],
965
+ key => panic!("meta {key}"),
966
+ })
967
+ }
968
+ "payload" => {
969
+ pending = Some(
970
+ match (
971
+ crate::wire::number(&data, 2).unwrap(),
972
+ crate::wire::number(&data, 3).unwrap(),
973
+ ) {
974
+ (1, 12) => arrival.source.to_vec(),
975
+ (1, 16) => arrival.destination.to_vec(),
976
+ (2, 0) => arrival.source_port.to_be_bytes().to_vec(),
977
+ (2, 2) => arrival.destination_port.to_be_bytes().to_vec(),
978
+ load => panic!("payload {load:?}"),
979
+ },
980
+ )
981
+ }
982
+ "bitwise" => {
983
+ let mask = crate::wire::attrs(&crate::wire::one(&data, 4).unwrap().value)
984
+ .unwrap()[0]
985
+ .value
986
+ .clone();
987
+ let loaded = pending.take().unwrap();
988
+ pending = Some(
989
+ loaded
990
+ .iter()
991
+ .zip(&mask)
992
+ .map(|(byte, mask)| byte & mask)
993
+ .collect(),
994
+ );
995
+ }
996
+ "cmp" => {
997
+ assert_eq!(crate::wire::number(&data, 2).unwrap(), 0);
998
+ let expected = crate::wire::attrs(&crate::wire::one(&data, 3).unwrap().value)
999
+ .unwrap()[0]
1000
+ .value
1001
+ .clone();
1002
+ matched = pending.take().unwrap() == expected;
1003
+ }
1004
+ "ct" => {
1005
+ // Only the zone store reaches raw classification; a loaded
1006
+ // conntrack key would need a conntrack lookup that has not run.
1007
+ assert_eq!(crate::wire::number(&data, 2).unwrap(), 17);
1008
+ assert_eq!(crate::wire::number(&data, 4).unwrap(), 1);
1009
+ zone = u16::from_ne_bytes(immediate.take().unwrap().try_into().unwrap());
1010
+ }
1011
+ "immediate" => {
1012
+ let inner =
1013
+ crate::wire::attrs(&crate::wire::one(&data, 2).unwrap().value).unwrap();
1014
+ if crate::wire::number(&data, 1).unwrap() == 0 {
1015
+ let code = crate::wire::attrs(&crate::wire::one(&inner, 2).unwrap().value)
1016
+ .unwrap();
1017
+ verdict = crate::wire::number(&code, 1).unwrap() as i32;
1018
+ } else {
1019
+ immediate = Some(crate::wire::one(&inner, 1).unwrap().value.clone());
1020
+ }
1021
+ }
1022
+ other => panic!("{other}"),
1023
+ }
1024
+ if !matched {
1025
+ break;
1026
+ }
1027
+ }
1028
+ if matched {
1029
+ return Some((index, verdict, zone));
1030
+ }
1031
+ }
1032
+ None
1033
+ }
1034
+
1035
+ #[test]
1036
+ fn router_v2_published_endpoint_traffic_is_classified_before_the_leased_grants() {
1037
+ // A published reply to a client whose source port equals the leased grant's
1038
+ // destination port on its public prefix. Raw runs before conntrack, so only
1039
+ // the payload tuple separates it from ordinary leased egress.
1040
+ let reply = Arrival {
1041
+ interface_index: 2,
1042
+ interface_name: "work",
1043
+ protocol: 6,
1044
+ source: [10, 241, 0, 2],
1045
+ destination: [198, 51, 100, 7],
1046
+ source_port: 9443,
1047
+ destination_port: 443,
1048
+ };
1049
+ // Without the publication that exact tuple is leased into the generation zone,
1050
+ // so the ordering below is what keeps the publication usable at all.
1051
+ let leased = classify(&prepared(router()), "raw_in", &reply).unwrap();
1052
+ assert_eq!((leased.1, leased.2), (1, 1));
1053
+ let published = prepared(published_router(json!([router_published_port(
1054
+ "tcp", 443, 9443
1055
+ )])));
1056
+ let (index, verdict, zone) = classify(&published, "raw_in", &reply).unwrap();
1057
+ // Accepted with no zone set: the reply stays in the default zone, where the
1058
+ // translated publication and its admission live.
1059
+ assert_eq!((verdict, zone), (1, 0));
1060
+ // Every other workload flow keeps its leased classification, and the leased
1061
+ // classifier that claims the tuple above is reached only after this one.
1062
+ let (leased_index, leased_verdict, leased_zone) = classify(
1063
+ &published,
1064
+ "raw_in",
1065
+ &Arrival {
1066
+ source_port: 50000,
1067
+ ..reply
1068
+ },
1069
+ )
1070
+ .unwrap();
1071
+ assert_eq!((leased_verdict, leased_zone), (1, 1));
1072
+ assert!(index < leased_index, "{index} {leased_index}");
1073
+ // The classification is exact: another link, another workload address and
1074
+ // another protocol are not published and are not classified here.
1075
+ for foreign in [
1076
+ Arrival {
1077
+ interface_index: 3,
1078
+ interface_name: "vpn",
1079
+ ..reply
1080
+ },
1081
+ Arrival {
1082
+ source: [10, 241, 0, 3],
1083
+ ..reply
1084
+ },
1085
+ Arrival {
1086
+ protocol: 17,
1087
+ ..reply
1088
+ },
1089
+ ] {
1090
+ assert!(classify(&published, "raw_in", &foreign).is_none());
1091
+ }
1092
+ }
@@ -25,7 +25,7 @@ pub(super) fn connect(a: &str, aname: &str, aip: &str, b: &str, bname: &str, bip
25
25
  ns_ip(ns, &["link", "set", name, "up"]);
26
26
  }
27
27
  }
28
- fn binding(name: &str, address: &str, kind: &str) -> Value {
28
+ pub(super) fn binding(name: &str, address: &str, kind: &str) -> Value {
29
29
  let output = std::process::Command::new("/bin/ip")
30
30
  .args(["-j", "-d", "link", "show", "dev", name])
31
31
  .output()
@@ -37,12 +37,12 @@ fn binding(name: &str, address: &str, kind: &str) -> Value {
37
37
  "interfaceLinkIndex":if kind=="tun" {observed["ifindex"].clone()} else {observed["link_index"].clone()},
38
38
  "requiredIpv4Addresses":[address]})
39
39
  }
40
- fn protection() -> Value {
40
+ pub(super) fn protection() -> Value {
41
41
  json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),"prefixes":["10.0.0.0/8","192.0.2.0/24"],
42
42
  "platformEndpoints":[{"id":"dns","address":"192.0.2.3","protocol":"udp","port":5300},
43
43
  {"id":"mail","address":"192.0.2.3","protocol":"tcp","port":3000}]})
44
44
  }
45
- fn allocation() -> Value {
45
+ pub(super) fn allocation() -> Value {
46
46
  json!({"lease":{"id":"lease-a","generation":1,"digest":format!("sha256:{}","a".repeat(64))},
47
47
  "transitSourceAddress":"10.240.0.2","sourcePortRanges":[{"protocol":"udp","first":10000,"last":10015},
48
48
  {"protocol":"tcp","first":10016,"last":10031}]})