@push.rocks/smartnftables 2.3.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.
- package/changelog.md +7 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/managed.egress.types.d.ts +22 -0
- package/package.json +2 -2
- package/readme.md +63 -2
- package/rust/src/egress.router.rs +148 -3
- package/rust/src/egress.rs +95 -1
- package/rust/src/egress_tests.rs +414 -0
- package/rust/src/owner_egress_traffic_tests.rs +3 -3
- package/rust/src/owner_host_traffic_tests.rs +4 -4
- package/rust/src/owner_identity_tests.rs +45 -0
- package/rust/src/owner_router_traffic_tests.rs +393 -0
- package/rust/src/owner_tests.rs +3 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +23 -0
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
|
+
}
|
|
@@ -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}]})
|
|
@@ -113,7 +113,7 @@ fn hostile_capture_fixture() {
|
|
|
113
113
|
// This is a persistent *foreign fixture* table. Drop its process socket;
|
|
114
114
|
// native managed-table ownership never adopts or removes this graph.
|
|
115
115
|
}
|
|
116
|
-
fn control(source: &UdpSocket, destination: &str, target: &UdpSocket, allowed: bool) {
|
|
116
|
+
pub(super) fn control(source: &UdpSocket, destination: &str, target: &UdpSocket, allowed: bool) {
|
|
117
117
|
assert_eq!(source.send_to(b"probe", destination).unwrap(), 5);
|
|
118
118
|
if allowed {
|
|
119
119
|
assert_eq!(target.recv(&mut [0; 64]).unwrap(), 5);
|
|
@@ -305,7 +305,7 @@ const PUBLISH_SOURCE: &str = "hp_source";
|
|
|
305
305
|
const PUBLISH_HOST: &str = "hp_host";
|
|
306
306
|
const PUBLISH_PEER: &str = "hp_peer";
|
|
307
307
|
|
|
308
|
-
fn chain_names(owner: &mut Owner) -> Vec<String> {
|
|
308
|
+
pub(super) fn chain_names(owner: &mut Owner) -> Vec<String> {
|
|
309
309
|
owner
|
|
310
310
|
.graph()
|
|
311
311
|
.unwrap()
|
|
@@ -317,7 +317,7 @@ fn chain_names(owner: &mut Owner) -> Vec<String> {
|
|
|
317
317
|
}
|
|
318
318
|
/// Inbound publication through the uplink: the workload must observe the actual
|
|
319
319
|
/// client address, and the client must observe the published address back.
|
|
320
|
-
fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, client_address: &str) {
|
|
320
|
+
pub(super) fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, client_address: &str) {
|
|
321
321
|
let mut bytes = [0; 64];
|
|
322
322
|
assert_eq!(client.send_to(b"query", published).unwrap(), 5);
|
|
323
323
|
let (size, observed) = workload.recv_from(&mut bytes).expect("published delivery");
|
|
@@ -328,7 +328,7 @@ fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, clie
|
|
|
328
328
|
assert_eq!(&bytes[..size], b"reply");
|
|
329
329
|
assert_eq!(from.to_string(), published);
|
|
330
330
|
}
|
|
331
|
-
fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
|
|
331
|
+
pub(super) fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
|
|
332
332
|
assert_eq!(client.send_to(b"query", published).unwrap(), 5);
|
|
333
333
|
assert!(workload.recv(&mut [0; 64]).is_err());
|
|
334
334
|
}
|
|
@@ -204,3 +204,48 @@ fn published_host_graph_identity_preserves_destination_nat_and_admission_operand
|
|
|
204
204
|
changed.iter().filter(|(chain, _)| chain == "forward").count()
|
|
205
205
|
);
|
|
206
206
|
}
|
|
207
|
+
|
|
208
|
+
/// A published router generation must survive the same apply-time graph identity
|
|
209
|
+
/// as the rest of the table: the kernel dump is compared operand by operand, so
|
|
210
|
+
/// the workload translation and its admissions cannot be silently rewritten.
|
|
211
|
+
#[test]
|
|
212
|
+
fn published_router_graph_identity_preserves_destination_nat_and_admission_operands() {
|
|
213
|
+
let compiled = |endpoint_port: u16| {
|
|
214
|
+
let value = crate::egress::tests::published_router(serde_json::json!([
|
|
215
|
+
crate::egress::tests::router_published_port("tcp", 443, endpoint_port)
|
|
216
|
+
]));
|
|
217
|
+
let prepared = serde_json::from_value::<crate::egress::Policy>(value)
|
|
218
|
+
.unwrap()
|
|
219
|
+
.prepare()
|
|
220
|
+
.unwrap();
|
|
221
|
+
let mut chains = Vec::new();
|
|
222
|
+
let mut rules = Vec::new();
|
|
223
|
+
for (kind, attributes) in prepared.program("snft_identity").unwrap() {
|
|
224
|
+
if kind == 3 {
|
|
225
|
+
chains.push(super::chain_identity(&attributes).unwrap());
|
|
226
|
+
} else {
|
|
227
|
+
rules.push(rule_identity(&attributes).unwrap());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
(chains, rules)
|
|
231
|
+
};
|
|
232
|
+
let (chains, rules) = compiled(8443);
|
|
233
|
+
assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
|
|
234
|
+
assert_eq!(chains.len(), 16);
|
|
235
|
+
let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
|
|
236
|
+
let (_, changed) = compiled(9443);
|
|
237
|
+
let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
|
|
238
|
+
assert_ne!(translation.1, other.1);
|
|
239
|
+
assert_eq!(
|
|
240
|
+
rules.iter().filter(|(chain, _)| chain == "forward").count(),
|
|
241
|
+
changed.iter().filter(|(chain, _)| chain == "forward").count()
|
|
242
|
+
);
|
|
243
|
+
// The handoff ingress classification is part of the same identity.
|
|
244
|
+
assert_eq!(
|
|
245
|
+
rules.iter().filter(|(chain, _)| chain == "raw_return").count(),
|
|
246
|
+
changed
|
|
247
|
+
.iter()
|
|
248
|
+
.filter(|(chain, _)| chain == "raw_return")
|
|
249
|
+
.count()
|
|
250
|
+
);
|
|
251
|
+
}
|