Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 10 additions & 9 deletions config.managed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,29 @@
# Only the bare minimum is set here:
#
# - `managed.enabled: true` always on for this image
# - `etcd.endpoints: [placeholder]` overridden at first boot by the
# `/dp/register` response
# - `etcd.endpoints: [placeholder]` overridden at boot from managed
# CP endpoint config
# - `admin.admin_keys: [placeholder]` validation requires the slot,
# but the admin listener is never
# bound in managed mode
#
# Operators inject these env vars at `docker run` time:
#
# AISIX_MANAGED__REGISTRATION_TOKEN=aisix_dp_us_east_1_AbCd…
# AISIX_MANAGED__CP_BASE_URL=https://api.us.aisix.cloud
# AISIX_MANAGED__CP_ETCD_ENDPOINT=etcd.us.aisix.cloud:7943
# AISIX_MANAGED__CP_CERT_PEM=...
# AISIX_MANAGED__CP_KEY_PEM=...
# AISIX_MANAGED__CP_CA_PEM=...
#
# Plus optionally `AISIX_PROXY__ADDR`, `AISIX_OBSERVABILITY__LOG_LEVEL`,
# `AISIX_CACHE__BACKEND`, etc. — every config field is reachable via
# `AISIX_<UPPER>__<UPPER>` (see crates/aisix-core/src/config.rs).
#
# Subsequent boots re-use the mTLS bundle written under
# `managed.mtls_dir` and skip the register round-trip entirely.
# `managed.mtls_dir`.

etcd:
# Placeholder — overwritten at first boot. The Rust bootstrap copies
# the etcd endpoint + mTLS paths from the /dp/register response into
# this field before opening the gRPC channel.
# Placeholder — overwritten at boot before opening the gRPC channel.
endpoints:
- "https://placeholder-overridden-at-register:2379"
prefix: "/aisix"
Expand Down Expand Up @@ -61,8 +62,8 @@ observability:

managed:
enabled: true
# registration_token + cp_base_url come from env vars — never bake
# secrets into the image.
# CP URL, etcd endpoint, and cert bundle come from env vars — never
# bake secrets into the image.
mtls_dir: "/var/lib/aisix/mtls"
dp_id_file: "/var/lib/aisix/dp_id"
# On-disk snapshot cache (prd-09 §9.7.2). The supervisor writes the
Expand Down
87 changes: 37 additions & 50 deletions crates/aisix-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,8 @@ pub struct EtcdTlsConfig {
pub struct ManagedConfig {
pub enabled: bool,

/// When set, aisix performs a one-shot `POST /dp/register` against
/// `cp_base_url` at boot to exchange this token for an mTLS bundle
/// and a dp_id. Subsequent boots detect the existing bundle at
/// `mtls_dir` and skip re-registration (the token is single-use).
///
/// Leave empty if the mTLS bundle is already on disk — typical for
/// configs installed via an out-of-band "download bundle" flow.
#[serde(default)]
pub registration_token: Option<String>,

/// aisix.cloud CP base URL, e.g. "https://api.us.aisix.cloud".
/// Required whenever `registration_token` is set.
/// Required for heartbeat when managed mode is enabled.
#[serde(default)]
pub cp_base_url: Option<String>,

Expand All @@ -164,9 +154,8 @@ pub struct ManagedConfig {
pub cp_etcd_endpoint: Option<String>,

/// Optional path to a PEM-encoded CA bundle the DP adds as an
/// additional trust root for outbound calls to the CP — both the
/// `/dp/register` HTTPS handshake and the etcd v3 gRPC connection
/// after registration.
/// additional trust root for outbound calls to the CP and the etcd
/// v3 gRPC connection.
///
/// In production the CP terminates TLS with a public-CA-issued
/// certificate that the system trust store already covers, so
Expand All @@ -186,9 +175,8 @@ pub struct ManagedConfig {
/// cert-via-env-var bootstrap path (cp-api's
/// /api/environments/:id/gateway_certificates endpoint, dashboard
/// CertIssueCard). When all three of `cp_cert_pem` / `cp_key_pem`
/// / `cp_ca_pem` are set, the DP skips `/dp/register` entirely:
/// the operator has already minted the bundle on the dashboard
/// and inlined it here. env_id is parsed from the cert's URI SAN
/// / `cp_ca_pem` are set, the DP materialises the operator-minted
/// dashboard bundle at boot. env_id is parsed from the cert's URI SAN
/// (`x-aisix://env/<env_id>`).
///
/// File-based variants below let operators store PEMs on disk
Expand Down Expand Up @@ -223,9 +211,8 @@ pub struct ManagedConfig {
pub cp_ca_file: Option<String>,

/// Directory where the DP persists `ca.crt`, `client.crt`,
/// `client.key` received from the register response. Files are
/// written `0600`. Parent directory must already exist and be
/// writable by the aisix process user.
/// `client.key`. Files are written `0600`. Parent directory must
/// already exist and be writable by the aisix process user.
#[serde(default = "ManagedConfig::default_mtls_dir")]
pub mtls_dir: String,

Expand Down Expand Up @@ -254,15 +241,6 @@ impl ManagedConfig {
self.enabled
}

/// True when both the token and CP URL are set — i.e. the DP
/// should attempt `/dp/register` at boot.
pub fn registration_enabled(&self) -> bool {
self.registration_token
.as_deref()
.is_some_and(|s| !s.is_empty())
&& self.cp_base_url.as_deref().is_some_and(|s| !s.is_empty())
}

/// True when the operator pre-provisioned a cert/key/CA bundle
/// via the api7ee-parity dashboard flow — either inlined as
/// PEM env vars (`cp_cert_pem` / `cp_key_pem` / `cp_ca_pem`) or
Expand Down Expand Up @@ -536,12 +514,11 @@ impl Config {
.separator("__")
// Per-key list parsing. Setting `list_separator`
// without explicit `with_list_parse_key` would force
// EVERY string env override through comma-splitting
// EVERY string env override through comma-splitting,
// which blows up secrets that happen to contain a
// comma (AISIX_MANAGED__REGISTRATION_TOKEN has been
// the visible victim) with a serde "invalid type:
// sequence, expected a string" error. Opt in only for
// fields that are actually Vec<String>.
// comma with a serde "invalid type: sequence, expected
// a string" error. Opt in only for fields that are
// actually Vec<String>.
.list_separator(",")
.with_list_parse_key("etcd.endpoints")
.with_list_parse_key("admin.admin_keys")
Expand Down Expand Up @@ -750,7 +727,7 @@ managed:
}

#[test]
fn parses_managed_block_with_register_fields() {
fn parses_managed_block_without_register_fields() {
// Mirrors the shape of the baked-in config.managed.yaml so the
// image's bootstrap template stays a valid Config; if anyone
// adds a required ManagedConfig field they have to update both
Expand Down Expand Up @@ -780,22 +757,32 @@ managed:
cfg.managed.snapshot_cache_path,
"/var/lib/aisix/config_cache.json",
);
// Token / CP URL come from env at runtime — empty here is fine.
assert!(cfg.managed.registration_token.is_none());
// CP URL comes from env at runtime — empty here is fine.
assert!(cfg.managed.cp_base_url.is_none());
assert!(!cfg.managed.registration_enabled());
}

// NOTE: the env-prefix regression (config-rs default
// prefix_separator inheriting from separator, silently dropping
// `AISIX_FOO__BAR` shaped vars) is covered end-to-end by
// aisix.cloud's Go e2e suite, which runs the DP docker image
// with `AISIX_MANAGED__REGISTRATION_TOKEN` + `AISIX_MANAGED__CP_BASE_URL`
// and asserts on registration_enabled=true via the structured
// boot log added in the same release. A unit test here would
// need std::env::set_var (forbidden by the crate-level
// #![forbid(unsafe_code)]) or an extra dev-dep for a guarded
// env helper; the downstream integration coverage is enough.
}

#[test]
fn rejects_legacy_registration_token_field() {
let f = write_yaml(
r#"
etcd:
endpoints: ["https://placeholder:2379"]
proxy:
addr: "0.0.0.0:3000"
admin:
addr: "127.0.0.1:0"
admin_keys: ["disabled"]
managed:
enabled: true
registration_token: "unused"
"#,
);
let err = Config::load_from_path(Some(f.path())).unwrap_err();
assert!(
err.to_string().contains("registration_token"),
"expected unknown legacy field error, got {err}",
);
}

#[test]
fn bedrock_endpoint_url_defaults_to_none_when_unset() {
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-obs/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//! without creating a cycle (server already depends on proxy).
//! - The SERVER crate owns the worker because telemetry batching, the
//! mTLS reqwest client, and graceful-shutdown wiring naturally live
//! alongside `register::register_and_persist` and `heartbeat::spawn`.
//! alongside cert-bundle provisioning and `heartbeat::spawn`.
//! - This module sits in `aisix-obs` (proxy already depends on it for
//! metrics + access_log + otlp_http_sink), exposes the data type and
//! the sink wrapper, and lets server-side wire up the consumer.
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-proxy/src/budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ impl BudgetClient {
/// `https://cp.aisix.cloud:9101`); `http` must be a reqwest client
/// already loaded with the DP's client cert + CA bundle. Build it
/// with `aisix_server::heartbeat::build_mtls_client` (or its
/// equivalent) using the same `MtlsBundle` `/dp/register` persisted.
/// equivalent) using the same persisted `MtlsBundle`.
pub fn new(base_url: impl Into<String>, http: reqwest::Client) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
let stale_max = std::env::var("AISIX_DP_BUDGET_STALE_MAX_SECONDS")
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ etcd-client.workspace = true
reqwest.workspace = true
rcgen.workspace = true
chrono.workspace = true
uuid.workspace = true
x509-parser = "0.16"

[dev-dependencies]
Expand Down
4 changes: 1 addition & 3 deletions crates/aisix-server/src/cert_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ use x509_parser::extensions::GeneralName;
use x509_parser::pem::parse_x509_pem;
use x509_parser::prelude::{FromDer, X509Certificate};

/// Outcome of materialising a pre-provisioned bundle. Mirrors
/// `register::Registered` minus the metadata cp-api would have
/// returned (we don't have a register response to crib from).
/// Outcome of materialising a pre-provisioned bundle.
#[derive(Debug, Clone)]
pub struct Provisioned {
/// env_id parsed from the leaf cert's URI SAN
Expand Down
5 changes: 2 additions & 3 deletions crates/aisix-server/src/heartbeat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ use tokio::sync::watch;
pub type RejectionFetcher = Arc<dyn Fn() -> Vec<RejectedEntry> + Send + Sync>;

/// File paths to the on-disk mTLS bundle the heartbeat client presents
/// to cp-api. Same three files written by `register::register_and_persist`
/// (and re-used on every subsequent boot when the bundle is already on
/// disk).
/// to cp-api. Same three files written by cert-bundle provisioning and
/// re-used on every subsequent boot when the bundle is already on disk.
///
/// `extra_ca_pem` is an optional second CA bundle the operator points
/// at via `managed.cp_ca_cert_file` — needed in e2e / on-prem
Expand Down
Loading
Loading