@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.
- package/changelog.md +19 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.manageddockerforwarding.js +3 -3
- package/dist_ts/classes.managednftables.d.ts +5 -0
- package/dist_ts/classes.managednftables.js +13 -6
- package/dist_ts/managed.egress.types.d.ts +53 -1
- package/package.json +7 -7
- package/readme.md +153 -4
- package/rust/src/egress.compile.rs +49 -1
- package/rust/src/egress.host.rs +22 -0
- package/rust/src/egress.hostgrant.rs +187 -0
- package/rust/src/egress.poolguard.rs +7 -0
- package/rust/src/egress.router.rs +189 -3
- package/rust/src/egress.rs +244 -1
- package/rust/src/egress_hostgrant_tests.rs +948 -0
- package/rust/src/egress_tests.rs +414 -0
- package/rust/src/main.rs +6 -2
- package/rust/src/owner.rs +116 -4
- package/rust/src/owner_egress_tests.rs +1 -1
- package/rust/src/owner_egress_traffic_tests.rs +3 -3
- package/rust/src/owner_host_traffic_tests.rs +4 -4
- package/rust/src/owner_hostgrant_traffic_tests.rs +280 -0
- package/rust/src/owner_identity_tests.rs +125 -1
- package/rust/src/owner_poolguard_tests.rs +69 -0
- package/rust/src/owner_router_traffic_tests.rs +393 -0
- package/rust/src/owner_tests.rs +6 -0
- package/rust/src/policy.rs +1 -1
- package/rust/src/wire.rs +15 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.manageddockerforwarding.ts +2 -2
- package/ts/classes.managednftables.ts +13 -5
- package/ts/managed.egress.types.ts +55 -1
package/rust/src/egress_tests.rs
CHANGED
|
@@ -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(
|
|
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() >
|
|
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 [
|
|
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
|
-
|
|
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 ==
|
|
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);
|
|
@@ -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}]})
|