Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ce88fac
feat(cubenet): port L7 custom-port dataplane and spec tests
Aug 9, 2026
26c09b1
feat(cubelet): port L7 custom-port runtime into embedded network runtime
Aug 9, 2026
5c9f374
feat(cubeegress): port L7 custom-port lua policy and iptables mark va…
Aug 9, 2026
30ba7de
feat(cubeapi,cubemaster): validate egress rule port/scheme at API and…
Aug 9, 2026
c36913d
feat(sdk): validate L7 custom port in egress rule policy
Aug 9, 2026
f95039d
feat(sdk/node): add port/scheme validation to egress rule match
Aug 9, 2026
88794f6
feat(sdk/go): add port/scheme validation to egress rule match
Aug 10, 2026
86ceca1
feat(deploy): bind L7 skb-mark config into one-click install
Aug 10, 2026
7d68c8f
feat(examples): port L7 egress network demos incl. custom-port echo
Aug 10, 2026
5b63864
docs(cubenet): add L7 custom-port design docs and fix bpf object wild…
Aug 10, 2026
458cf1a
docs(cubenet): fix stale map-name and function-name comments
Aug 10, 2026
e1f86aa
fix(examples): probe cube-egress admin on :9091 after #1285 port move
Aug 11, 2026
a3fa691
test(sdk_compat): add L7 custom-port e2e coverage and integration fixes
Aug 14, 2026
5b15f9b
fix(cubenet): keep legacy allow_out pin until DNS migration succeeds
Aug 17, 2026
f31f29e
fix(cubeegress): make port-less deny rules match all intercepted ports
Aug 17, 2026
eff0440
fix(cubeegress): install TPROXY chain atomically via scratch+swap
Aug 17, 2026
bcdcd45
fix(cubelet): deep-copy egress rule match pointers
Aug 17, 2026
b0cf925
fix(cubenet): let plain allow_out coexist with L7 rules on a shared d…
Aug 17, 2026
2862ca9
fix(cubenet): recover from half-pinned policy map generation on startup
Aug 17, 2026
1724f76
fix(cubenet): let plain allow_out coexist with L7 rules for static IP…
Aug 17, 2026
f5abd12
fix(cubenet): keep plain L3 access for L7 hosts covered by a wildcard…
Aug 17, 2026
16b53cb
test(cubevs): run cubevs module unit tests in CI and run.sh
Aug 18, 2026
2b2571d
test(cubevs): assert FLOW_HTTP verdict in classify_egress_flow
Aug 18, 2026
fcdf0fa
test(cubeegress): cover sandbox_gateway_ip_from_cidr arithmetic
Aug 18, 2026
1baa6cc
test(cubevs): cover allow_out migration success path and dump port re…
Aug 18, 2026
e66b524
test(sdk_compat): prove HTTPS custom-port interception via TLS issuer
Aug 18, 2026
3c2904d
fix(cubenet): keep tcp_conntracks index bounds-checkable on older clang
Aug 18, 2026
22d324c
ci(unit-test): add cubevs display_name to the test matrix mapping
Aug 18, 2026
de6701d
test(sdk_compat): add retry hardening to external-endpoint e2e requests
Aug 18, 2026
5fc90dc
test(sdk_compat): unify MITM CA CN default to the authoritative value
Aug 18, 2026
d1fe16b
test(sdk_compat): use an OS-assigned port for the L7 echo server
Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CubeAPI/src/cubemaster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,8 @@ pub struct CubeEgressRuleMatch {
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<i32>,
}

#[derive(Debug, Serialize, Clone)]
Expand Down
7 changes: 7 additions & 0 deletions CubeAPI/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ pub struct EgressRule {
///
/// Multi-field semantics: AND across fields, OR within `method`.
/// Comparisons on sni/host/scheme are case-insensitive.
///
/// `port` + `scheme` together pin the (host, port) tuple CubeEgress intercepts.
/// Both nil keeps the legacy default {80/http, 443/https}. When `port` is set,
/// `scheme` MUST also be set — same-`(host, port)` rules across the policy
/// must agree on `scheme` (the server rejects the whole policy on mismatch).
#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)]
pub struct EgressRuleMatch {
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -90,6 +95,8 @@ pub struct EgressRuleMatch {
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<i32>,
}

/// Rule action.
Expand Down
126 changes: 123 additions & 3 deletions CubeAPI/src/services/sandboxes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ use crate::{
},
error::{AppError, AppResult},
models::{
EgressRule, LogLevel as ModelLogLevel, NewSandbox, Sandbox, SandboxAutoResume,
SandboxDetail, SandboxLifecycleConfig, SandboxLog, SandboxLogEntry, SandboxLogs,
SandboxLogsV2Response, SandboxNetworkConfig, SandboxOnTimeout, SandboxState,
EgressRule, EgressRuleMatch, LogLevel as ModelLogLevel, NewSandbox, Sandbox,
SandboxAutoResume, SandboxDetail, SandboxLifecycleConfig, SandboxLog, SandboxLogEntry,
SandboxLogs, SandboxLogsV2Response, SandboxNetworkConfig, SandboxOnTimeout, SandboxState,
SandboxVolumeMount,
},
};
Expand Down Expand Up @@ -1049,6 +1049,12 @@ pub(crate) fn build_cube_network_config(
allow_internet_access == Some(false),
)?;

if let Some(rs) = network.and_then(|n| n.rules.as_ref()) {
for (index, rule) in rs.iter().enumerate() {
validate_egress_rule_match(&rule.r#match, index)?;
}
}

let rules: Vec<CubeEgressRule> = network
.and_then(|n| n.rules.as_ref())
.map(|rs| rs.iter().map(map_egress_rule).collect())
Expand Down Expand Up @@ -1080,6 +1086,33 @@ pub(crate) fn build_cube_network_config(
}))
}

/// Validate the port/scheme pair on one egress rule match, mirroring the
/// SDK client-side contract and the CubeEgress Lua validation: a set port
/// must be in [1, 65535] and must be paired with a scheme, and a set scheme
/// must be http or https (case-insensitive — downstream normalizes).
fn validate_egress_rule_match(rule_match: &EgressRuleMatch, index: usize) -> AppResult<()> {
if let Some(port) = rule_match.port {
if !(1..=65535).contains(&port) {
return Err(AppError::BadRequest(format!(
"network.rules[{index}].match.port must be in [1, 65535], got {port}"
)));
}
if rule_match.scheme.is_none() {
return Err(AppError::BadRequest(format!(
"network.rules[{index}].match.port requires match.scheme to be set"
)));
}
}
if let Some(scheme) = rule_match.scheme.as_deref() {
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return Err(AppError::BadRequest(format!(
"network.rules[{index}].match.scheme must be 'http' or 'https', got {scheme:?}"
)));
}
}
Ok(())
}

fn map_egress_rule(rule: &EgressRule) -> CubeEgressRule {
CubeEgressRule {
name: rule.name.clone(),
Expand All @@ -1089,6 +1122,7 @@ fn map_egress_rule(rule: &EgressRule) -> CubeEgressRule {
method: rule.r#match.method.clone(),
path: rule.r#match.path.clone(),
scheme: rule.r#match.scheme.clone(),
port: rule.r#match.port,
},
action: CubeEgressRuleAction {
allow: rule.action.allow,
Expand Down Expand Up @@ -1342,6 +1376,7 @@ mod tests {
method: Some(vec!["POST".to_string()]),
path: Some("/v1/chat".to_string()),
sni: Some("api.deepseek.com".to_string()),
port: None,
},
action: EgressRuleAction {
allow: true,
Expand Down Expand Up @@ -1410,6 +1445,91 @@ mod tests {
assert!(rule["action"].get("inject").is_none());
}

fn network_with_match(rule_match: EgressRuleMatch) -> SandboxNetworkConfig {
SandboxNetworkConfig {
allow_public_traffic: None,
allow_out: None,
deny_out: None,
mask_request_host: None,
rules: Some(vec![EgressRule {
name: "r1".to_string(),
r#match: rule_match,
action: EgressRuleAction {
allow: true,
audit: None,
inject: None,
},
}]),
}
}

#[test]
fn egress_match_port_requires_scheme() {
let err = build_cube_network_config(
None,
Some(&network_with_match(EgressRuleMatch {
port: Some(8443),
..Default::default()
})),
)
.expect_err("port without scheme must be rejected");
assert!(err.to_string().contains("requires match.scheme"), "{err}");
}

#[test]
fn egress_match_port_range_enforced() {
for port in [0, -1, 65536, 99999] {
let err = build_cube_network_config(
None,
Some(&network_with_match(EgressRuleMatch {
port: Some(port),
scheme: Some("https".to_string()),
..Default::default()
})),
)
.expect_err("out-of-range port must be rejected");
assert!(err.to_string().contains("[1, 65535]"), "{err}");
}
}

#[test]
fn egress_match_invalid_scheme_rejected() {
let err = build_cube_network_config(
None,
Some(&network_with_match(EgressRuleMatch {
scheme: Some("ftp".to_string()),
..Default::default()
})),
)
.expect_err("non-http(s) scheme must be rejected");
assert!(
err.to_string().contains("must be 'http' or 'https'"),
"{err}"
);
}

#[test]
fn egress_match_valid_port_scheme_accepted() {
build_cube_network_config(
None,
Some(&network_with_match(EgressRuleMatch {
port: Some(8443),
scheme: Some("https".to_string()),
..Default::default()
})),
)
.expect("valid port+scheme pair");
// Case variants are accepted (downstream normalizes to lowercase).
build_cube_network_config(
None,
Some(&network_with_match(EgressRuleMatch {
scheme: Some("HTTPS".to_string()),
..Default::default()
})),
)
.expect("uppercase scheme is accepted");
}

#[test]
fn listed_sandbox_preserves_resources_from_cubemaster_list() {
let listed = from_cubemaster_info(SandboxInfo {
Expand Down
7 changes: 7 additions & 0 deletions CubeEgress/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ else
MAKEFLAGS += --no-print-directory
endif

.PHONY: test-lua
test-lua:
$(Q)LUA_BIN=$$(command -v luajit || command -v lua); \
test -n "$$LUA_BIN" || { echo "error: lua or luajit not installed"; exit 1; }; \
$$LUA_BIN tests/port_scheme_test.lua && \
$$LUA_BIN tests/port_scheme_extra_test.lua

.PHONY: build
build:
$(call msg,BUILD IMAGE $(IMAGE_LOCAL):$(IMAGE_TAG)-$(ARCH))
Expand Down
31 changes: 26 additions & 5 deletions CubeEgress/lua/access_phase.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
-- deployments use "skipped" + admin API for policy installation.

local policy = require "policy"
local port_scheme = require "port_scheme"

local _M = {}

Expand Down Expand Up @@ -122,8 +123,12 @@ end

-- ---------- match evaluation ----------

-- Returns true if every constraint in `m` passes against `ctx`.
local function rule_matches(m, ctx)
-- Returns true if every constraint in `m` passes against `ctx`. `is_allow`
-- selects the port/scheme semantics: an allow rule with an omitted port is
-- narrowed to the default set {80/http,443/https} (fail-closed), while a deny
-- rule with an omitted port is port-agnostic within the host (also fail-closed
-- — a custom-port allow must not bypass a broader host deny).
local function rule_matches(m, ctx, is_allow)
if type(m) ~= "table" then return false end

if m.sni ~= nil then
Expand All @@ -143,8 +148,16 @@ local function rule_matches(m, ctx)
if m.path ~= nil then
if not path_match(m.path, ctx.path) then return false end
end
if m.scheme ~= nil then
if string.lower(m.scheme) ~= ctx.scheme then return false end
-- Port and scheme form one semantic constraint whose meaning depends on
-- whether this rule allows or denies (see port_scheme.matches_deny).
local port_scheme_ok
if is_allow then
port_scheme_ok = port_scheme.matches(m.port, m.scheme, ctx.dst_port, ctx.scheme)
else
port_scheme_ok = port_scheme.matches_deny(m.port, m.scheme, ctx.dst_port, ctx.scheme)
end
if not port_scheme_ok then
return false
end
return true
end
Expand Down Expand Up @@ -294,6 +307,13 @@ local function build_ctx()
method = ngx.var.request_method,
path = ngx.var.uri,
dst_ip = dst_ip,
-- dst_port is the original sandbox-side destination port preserved
-- by TPROXY. In an IP_TRANSPARENT listener, nginx's $server_port is
-- read via getsockname() on the tproxy socket, which reports the
-- ORIGINAL dst — not the 8080/8443 listener port we bind to. Used
-- by rule_matches() to enforce match.port constraints on rules that
-- pin to a custom port (e.g. tcp/8443 for an internal API).
dst_port = tonumber(ngx.var.server_port),
scheme = ngx.var.scheme,
}
end
Expand Down Expand Up @@ -408,6 +428,7 @@ function _M.decide()
method = ctx.method,
path = ctx.path,
dst_ip = ctx.dst_ip,
dst_port = ctx.dst_port,
scheme = ctx.scheme,
policy_id = nil,
rule_id = nil,
Expand Down Expand Up @@ -467,7 +488,7 @@ function _M.decide()
decision.policy_id = p.policy_id

for _, r in ipairs(p.rules or {}) do
if rule_matches(r.match, ctx) then
if rule_matches(r.match, ctx, r.action ~= nil and r.action.allow == true) then
decision.rule_id = r.id
decision.audit_level = (r.action and r.action.audit) or "metadata"
decision.inject = r.action and r.action.inject -- consumed in Pγ
Expand Down
Loading
Loading