@push.rocks/smartnftables 2.3.0 → 2.5.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.
Files changed (36) hide show
  1. package/changelog.md +19 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.js +3 -3
  8. package/dist_ts/classes.managednftables.d.ts +5 -0
  9. package/dist_ts/classes.managednftables.js +13 -6
  10. package/dist_ts/managed.egress.types.d.ts +53 -1
  11. package/package.json +7 -7
  12. package/readme.md +153 -4
  13. package/rust/src/egress.compile.rs +49 -1
  14. package/rust/src/egress.host.rs +22 -0
  15. package/rust/src/egress.hostgrant.rs +187 -0
  16. package/rust/src/egress.poolguard.rs +7 -0
  17. package/rust/src/egress.router.rs +189 -3
  18. package/rust/src/egress.rs +244 -1
  19. package/rust/src/egress_hostgrant_tests.rs +948 -0
  20. package/rust/src/egress_tests.rs +414 -0
  21. package/rust/src/main.rs +6 -2
  22. package/rust/src/owner.rs +116 -4
  23. package/rust/src/owner_egress_tests.rs +1 -1
  24. package/rust/src/owner_egress_traffic_tests.rs +3 -3
  25. package/rust/src/owner_host_traffic_tests.rs +4 -4
  26. package/rust/src/owner_hostgrant_traffic_tests.rs +280 -0
  27. package/rust/src/owner_identity_tests.rs +125 -1
  28. package/rust/src/owner_poolguard_tests.rs +69 -0
  29. package/rust/src/owner_router_traffic_tests.rs +393 -0
  30. package/rust/src/owner_tests.rs +6 -0
  31. package/rust/src/policy.rs +1 -1
  32. package/rust/src/wire.rs +15 -1
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.manageddockerforwarding.ts +2 -2
  35. package/ts/classes.managednftables.ts +13 -5
  36. package/ts/managed.egress.types.ts +55 -1
@@ -676,3 +676,417 @@ fn host_v2_published_ports_reject_unbound_targets_conflicts_and_out_of_range_por
676
676
  assert!(normalized(published_host(ports(13))).unwrap().prepare().is_err());
677
677
  println!("PUBLISHED_MAX ports=12 operations={} bytes={bytes}", program.len());
678
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
+ }
package/rust/src/main.rs CHANGED
@@ -11,6 +11,10 @@ mod wire;
11
11
  use serde::Deserialize;
12
12
  use std::io::{BufRead, Read, Write};
13
13
 
14
+ /// One request line from the facade; the facade's `managedIpcBytes` bounds the
15
+ /// same lines. A transition carries two complete policies.
16
+ pub(crate) const IPC_BYTES: usize = 1_048_576;
17
+
14
18
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
19
  pub enum Error {
16
20
  Invalid,
@@ -141,12 +145,12 @@ fn main() {
141
145
  loop {
142
146
  let mut bytes = Vec::new();
143
147
  let read = std::io::Read::by_ref(&mut input)
144
- .take(262_145)
148
+ .take(IPC_BYTES as u64 + 1)
145
149
  .read_until(b'\n', &mut bytes);
146
150
  if matches!(read, Ok(0)) {
147
151
  break;
148
152
  }
149
- if read.is_err() || bytes.len() > 262_144 || bytes.last() != Some(&b'\n') {
153
+ if read.is_err() || bytes.len() > IPC_BYTES || bytes.last() != Some(&b'\n') {
150
154
  std::process::exit(2);
151
155
  }
152
156
  let request: Request = match serde_json::from_slice(&bytes) {
package/rust/src/owner.rs CHANGED
@@ -66,6 +66,8 @@ struct Graph {
66
66
  table: Vec<Attr>,
67
67
  chains: Vec<Vec<Attr>>,
68
68
  rules: Vec<Vec<Attr>>,
69
+ /// Each named set with the key of every element it holds.
70
+ sets: Vec<(Vec<Attr>, Vec<Vec<u8>>)>,
69
71
  }
70
72
  pub struct Owner {
71
73
  identity: Identity,
@@ -220,8 +222,31 @@ impl Owner {
220
222
  }
221
223
  }
222
224
  let rules = socket.query(7, vec![Attr::string(1, &table_name)], true)?;
225
+ // Set dumps select the table. Every element is read back, so a set
226
+ // whose content differs from the compiled target never verifies.
227
+ let mut sets = Vec::new();
228
+ for set in socket.query(10, vec![Attr::string(1, &table_name)], true)? {
229
+ if wire::text(&set, 1)? != table_name {
230
+ return Err(Error::Conflict);
231
+ }
232
+ let name = wire::text(&set, 2)?;
233
+ let mut keys = Vec::new();
234
+ for message in socket.query(
235
+ 13,
236
+ vec![Attr::string(1, &table_name), Attr::string(2, &name)],
237
+ true,
238
+ )? {
239
+ if wire::text(&message, 1)? != table_name || wire::text(&message, 2)? != name {
240
+ return Err(Error::Conflict);
241
+ }
242
+ if let Ok(list) = wire::one(&message, 3) {
243
+ keys.extend(element_keys(&wire::attrs(&list.value)?)?);
244
+ }
245
+ }
246
+ sets.push((set, keys));
247
+ }
223
248
  // The managed compiler never creates any of these. Foreign objects reject.
224
- for operation in [10, 19, 23] {
249
+ for operation in [19, 23] {
225
250
  if !socket
226
251
  .query(operation, vec![Attr::string(1, &table_name)], true)?
227
252
  .is_empty()
@@ -235,6 +260,7 @@ impl Owner {
235
260
  table,
236
261
  chains,
237
262
  rules,
263
+ sets,
238
264
  }));
239
265
  }
240
266
  }
@@ -310,7 +336,37 @@ impl Owner {
310
336
  }
311
337
  result
312
338
  };
313
- Ok(expected_chains == actual_chains && regroup(expected_rules) == regroup(actual_rules))
339
+ // Element order is the set backend's; element keys are the identity.
340
+ let mut expected_sets = std::collections::BTreeMap::<String, (Vec<u8>, Vec<Vec<u8>>)>::new();
341
+ for (kind, attrs) in &program {
342
+ match kind {
343
+ 9 => {
344
+ expected_sets.insert(wire::text(attrs, 2)?, (set_identity(attrs)?, Vec::new()));
345
+ }
346
+ 12 => {
347
+ let entry = expected_sets
348
+ .get_mut(&wire::text(attrs, 2)?)
349
+ .ok_or(Error::Invalid)?;
350
+ entry.1.extend(element_keys(&wire::attrs(&wire::one(attrs, 3)?.value)?)?);
351
+ }
352
+ _ => {}
353
+ }
354
+ }
355
+ let mut actual_sets = std::collections::BTreeMap::new();
356
+ for (attrs, keys) in &graph.sets {
357
+ if actual_sets
358
+ .insert(wire::text(attrs, 2)?, (set_identity(attrs)?, keys.clone()))
359
+ .is_some()
360
+ {
361
+ return Err(Error::Conflict);
362
+ }
363
+ }
364
+ for entry in expected_sets.values_mut().chain(actual_sets.values_mut()) {
365
+ entry.1.sort();
366
+ }
367
+ Ok(expected_chains == actual_chains
368
+ && regroup(expected_rules) == regroup(actual_rules)
369
+ && expected_sets == actual_sets)
314
370
  }
315
371
  fn adopt(&mut self, graph: Graph, transition: &Transition) -> Result<Graph> {
316
372
  self.binding(&graph, transition.previous.as_ref())?;
@@ -437,6 +493,17 @@ impl Owner {
437
493
  ],
438
494
  ));
439
495
  }
496
+ // After every rule that binds it; deleting a set deletes its elements.
497
+ for (set, _) in &graph.sets {
498
+ operations.push((
499
+ 11,
500
+ 0,
501
+ vec![
502
+ Attr::string(1, &table_name),
503
+ Attr::u64(16, wire::handle(set, 16)?),
504
+ ],
505
+ ));
506
+ }
440
507
  graph.generation
441
508
  } else {
442
509
  if transition.previous.is_some() || self.applied.is_some() {
@@ -454,9 +521,10 @@ impl Owner {
454
521
  self.socket()?.generation()?
455
522
  };
456
523
  for (operation, attributes) in transition.target.program(&table_name)? {
524
+ // Chains, sets and elements are created exclusively; rules append.
457
525
  operations.push((
458
526
  operation,
459
- if operation == 3 { 0x600 } else { 0xc00 },
527
+ if operation == 6 { 0xc00 } else { 0x600 },
460
528
  attributes,
461
529
  ));
462
530
  }
@@ -612,6 +680,41 @@ fn chain_identity(attributes: &[Attr]) -> Result<Vec<u8>> {
612
680
  }
613
681
  serde_json::to_vec(&wire::canonical(&identity, false)?).map_err(|_| Error::Protocol)
614
682
  }
683
+ /// A set is its table, name, flags, key type and key length. The handle, the
684
+ /// informational backend type and count, the transaction ID and an empty
685
+ /// description are not identity; anything else rejects.
686
+ fn set_identity(attributes: &[Attr]) -> Result<Vec<u8>> {
687
+ let mut identity = Vec::new();
688
+ for attribute in attributes {
689
+ match attribute.id() {
690
+ 10 | 16 | 19 | 20 => {}
691
+ 9 if wire::attrs(&attribute.value)?.is_empty() => {}
692
+ 1..=5 => identity.push(attribute.clone()),
693
+ _ => return Err(Error::Conflict),
694
+ }
695
+ }
696
+ for id in [1, 2, 3, 4, 5] {
697
+ wire::one(&identity, id)?;
698
+ }
699
+ serde_json::to_vec(&wire::canonical(&identity, false)?).map_err(|_| Error::Protocol)
700
+ }
701
+ /// Only plain keys: an element with data, flags, timeouts, expressions or any
702
+ /// other extension is not one the compiler created.
703
+ fn element_keys(list: &[Attr]) -> Result<Vec<Vec<u8>>> {
704
+ let mut keys = Vec::new();
705
+ for element in list {
706
+ let fields = wire::attrs(&element.value)?;
707
+ if element.id() != 1 || fields.len() != 1 {
708
+ return Err(Error::Conflict);
709
+ }
710
+ let key = wire::attrs(&wire::one(&fields, 1)?.value)?;
711
+ if key.len() != 1 {
712
+ return Err(Error::Conflict);
713
+ }
714
+ keys.push(wire::one(&key, 1)?.value.clone());
715
+ }
716
+ Ok(keys)
717
+ }
615
718
  fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
616
719
  let chain = wire::text(attributes, 2)?;
617
720
  if attributes
@@ -638,7 +741,7 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
638
741
  "cmp" => &[3],
639
742
  "bitwise" => &[4, 5],
640
743
  "range" => &[3, 4],
641
- "meta" | "payload" | "ct" | "nat" => &[],
744
+ "meta" | "payload" | "ct" | "nat" | "lookup" => &[],
642
745
  _ => return Err(Error::Conflict),
643
746
  };
644
747
  for attribute in &mut data {
@@ -669,6 +772,7 @@ fn rule_identity(attributes: &[Attr]) -> Result<(String, Vec<u8>)> {
669
772
  /// neither unknown fields nor default repair may hide a changed kernel rule.
670
773
  fn validate_egress_expression(name: &str, data: &[Attr]) -> Result<()> {
671
774
  let allowed: &[u16] = match name {
775
+ "lookup" => &[1, 2, 5],
672
776
  "ct" | "range" => &[1, 2, 3, 4],
673
777
  "nat" => &[1, 2, 3, 4, 5, 6, 7],
674
778
  _ => return Ok(()),
@@ -680,6 +784,14 @@ fn validate_egress_expression(name: &str, data: &[Attr]) -> Result<()> {
680
784
  wire::one(data, attribute.id())?; // Duplicate fields are never canonical identity.
681
785
  }
682
786
  match name {
787
+ "lookup" => {
788
+ // A plain membership test of a named set: no map, no inversion.
789
+ wire::text(data, 1)?;
790
+ wire::number(data, 2)?;
791
+ if data.len() != 3 || wire::number(data, 5)? != 0 {
792
+ return Err(Error::Conflict);
793
+ }
794
+ }
683
795
  "ct" => {
684
796
  let read = data.iter().any(|item| item.id() == 1);
685
797
  let write = data.iter().any(|item| item.id() == 4);
@@ -85,7 +85,7 @@ pub(super) fn recover(name: &str, policy: egress::Policy) {
85
85
  chain_identity(actual),
86
86
  "chain {name}"
87
87
  );
88
- } else {
88
+ } else if kind == 6 {
89
89
  let stamp = &wire::one(&expected, 7).unwrap().value;
90
90
  let actual = graph
91
91
  .rules
@@ -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}]})