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
18 changes: 18 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ etcd:
# password_env: "AISIX_ETCD_PASSWORD"
dial_timeout_ms: 5000
request_timeout_ms: 5000
# Optional TLS / mTLS bundle. Required when connecting to an
# aisix.cloud DP Manager (endpoint is https:// and the CP issues
# a client cert via IssueAIDataplaneCertificate). Uncomment and
# point to your mTLS bundle.
# tls:
# ca_cert_file: "/etc/aisix/mtls/ca.crt"
# client_cert_file: "/etc/aisix/mtls/client.crt"
# client_key_file: "/etc/aisix/mtls/client.key"
# # Optional: override the TLS SNI / cert-subject-alt-name.
# # Defaults to the hostname portion of endpoints[0].
# # domain_name: "etcd.aisix.cloud"

proxy:
addr: "0.0.0.0:3000"
Expand Down Expand Up @@ -49,6 +60,13 @@ observability:
# secret_key_env: "LANGFUSE_SECRET_KEY"
# host: "https://cloud.langfuse.com"

# Managed-mode switch. Uncomment when running as an aisix.cloud
# tenant: the admin API, admin UI, and Playground will NOT be
# bound — all configuration flows from etcd via the mTLS channel
# above, driven by the aisix.cloud control plane.
# managed:
# enabled: true

cache:
backend: "memory" # memory | redis | qdrant
# redis:
Expand Down
190 changes: 180 additions & 10 deletions crates/aisix-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@ use crate::error::BootstrapError;
pub struct Config {
pub etcd: EtcdConfig,
pub proxy: ProxyConfig,
/// Admin surface. Defaulted so managed-mode configs can omit this
/// block entirely; the default values are NOT bound at runtime —
/// [`ManagedConfig::is_managed`] gates the listener.
#[serde(default)]
pub admin: AdminConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub cache: CacheConfig,
/// Optional managed-mode configuration. When `managed.enabled = true`
/// the admin API, admin UI, and Playground endpoints are **not**
/// bound — the DP is a pure etcd reader driven by the aisix.cloud
/// control plane. Missing or `enabled = false` runs standalone.
#[serde(default)]
pub managed: ManagedConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -57,6 +67,58 @@ pub struct EtcdConfig {
pub dial_timeout_ms: u64,
#[serde(default = "EtcdConfig::default_request_timeout")]
pub request_timeout_ms: u64,
/// Optional TLS / mTLS bundle used to authenticate to the etcd
/// endpoint. Required when talking to an aisix.cloud DP Manager
/// (see prd-09 §9.3.3 — the CP issues a 10-year client cert via
/// `IssueAIDataplaneCertificate`). Leave unset for plain-HTTP
/// etcd (local dev, integration tests).
#[serde(default)]
pub tls: Option<EtcdTlsConfig>,
}

/// Paths to the mTLS bundle used for etcd client auth. Files are read
/// lazily at connect time — absent files surface as a BootstrapError.
///
/// When `domain_name` is unset, callers typically derive it from the
/// first endpoint's hostname so the tonic TLS layer knows what SNI /
/// cert-subject-alt-name to match against.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EtcdTlsConfig {
/// PEM-encoded CA bundle used to verify the etcd server cert.
pub ca_cert_file: String,
/// PEM-encoded client certificate (from `IssueAIDataplaneCertificate`).
pub client_cert_file: String,
/// PEM-encoded client private key. Paired with `client_cert_file`.
pub client_key_file: String,
/// Expected server name for TLS verification. Usually the hostname
/// portion of `etcd.endpoints[0]`. Only required when the CA issues
/// certs under a different SNI than the endpoint DNS name.
#[serde(default)]
pub domain_name: Option<String>,
}

/// Optional managed-mode configuration (prd-09 §9.2.2).
///
/// When `enabled = true`, aisix runs as a tenant of aisix.cloud:
///
/// - The admin API listener is **not** bound.
/// - The admin UI is **not** served.
/// - The Playground endpoint is **not** exposed.
///
/// All configuration is read from etcd via the TLS channel (see
/// [`EtcdTlsConfig`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ManagedConfig {
pub enabled: bool,
}

impl ManagedConfig {
/// True if the DP should behave as an aisix.cloud tenant.
pub const fn is_managed(&self) -> bool {
self.enabled
}
}

impl EtcdConfig {
Expand Down Expand Up @@ -98,14 +160,35 @@ impl ProxyConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminConfig {
#[serde(default = "AdminConfig::default_addr")]
pub addr: String,
/// Statically-provisioned admin keys. A request is authorised if it
/// presents any of these via `Authorization: Bearer <k>` or `x-api-key`.
#[serde(default)]
pub admin_keys: Vec<String>,
#[serde(default)]
pub tls: Option<TlsConfig>,
}

impl AdminConfig {
fn default_addr() -> String {
// Intentionally non-routable. Managed-mode configs never bind
// this; standalone configs are rejected by `Config::validate`
// if they leave it at the default without overriding.
"127.0.0.1:0".into()
}
}

impl Default for AdminConfig {
fn default() -> Self {
Self {
addr: Self::default_addr(),
admin_keys: Vec::new(),
tls: None,
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
Expand Down Expand Up @@ -291,23 +374,31 @@ impl Config {
"etcd.endpoints must contain at least one endpoint".into(),
));
}
if self.admin.admin_keys.is_empty() {
return Err(BootstrapError::Config(
"admin.admin_keys must contain at least one key".into(),
));
// In managed mode the admin listener is not bound, so requiring
// admin_keys or a valid admin.addr would be punishing the user
// for fields that aren't going to be used. In standalone mode
// we keep the original invariants.
if !self.managed.is_managed() {
if self.admin.admin_keys.is_empty() {
return Err(BootstrapError::Config(
"admin.admin_keys must contain at least one key \
(required when managed.enabled is false)"
.into(),
));
}
if self.admin.addr.parse::<std::net::SocketAddr>().is_err() {
return Err(BootstrapError::Config(format!(
"admin.addr invalid socket address: {}",
self.admin.addr
)));
Comment on lines +389 to +393

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AdminConfig::default_addr() sets 127.0.0.1:0, and Config::validate() only checks that admin.addr parses (in standalone mode). This allows a standalone config that omits admin.addr (but sets admin_keys) to bind the admin listener on an ephemeral port, which contradicts the comment and is operationally surprising. Consider either (a) defaulting to the previous explicit port, or (b) rejecting port 0 / the default value in validate() when managed.enabled is false with a clear error message.

Suggested change
if self.admin.addr.parse::<std::net::SocketAddr>().is_err() {
return Err(BootstrapError::Config(format!(
"admin.addr invalid socket address: {}",
self.admin.addr
)));
let admin_addr = self
.admin
.addr
.parse::<std::net::SocketAddr>()
.map_err(|_| {
BootstrapError::Config(format!(
"admin.addr invalid socket address: {}",
self.admin.addr
))
})?;
if admin_addr.port() == 0 {
return Err(BootstrapError::Config(
"admin.addr must use an explicit non-zero port \
(required when managed.enabled is false)"
.into(),
));

Copilot uses AI. Check for mistakes.
}
}
if self.proxy.addr.parse::<std::net::SocketAddr>().is_err() {
return Err(BootstrapError::Config(format!(
"proxy.addr invalid socket address: {}",
self.proxy.addr
)));
}
if self.admin.addr.parse::<std::net::SocketAddr>().is_err() {
return Err(BootstrapError::Config(format!(
"admin.addr invalid socket address: {}",
self.admin.addr
)));
}
Ok(())
}
}
Expand Down Expand Up @@ -412,4 +503,83 @@ bogus_field: 1
let err = Config::load_from_path(Some(f.path())).unwrap_err();
assert!(err.to_string().contains("bogus_field"));
}

#[test]
fn managed_mode_lets_admin_fields_be_omitted() {
// A managed-mode config is the minimum aisix.cloud tenant
// shape: etcd + tls + proxy + managed.enabled = true. Admin
// keys / addr are fine to leave out entirely because the
// admin surface is never bound.
let f = write_yaml(
r#"
etcd:
endpoints: ["https://etcd.aisix.cloud:2379"]
prefix: "/aisix"
tls:
ca_cert_file: "/etc/aisix/mtls/ca.crt"
client_cert_file: "/etc/aisix/mtls/client.crt"
client_key_file: "/etc/aisix/mtls/client.key"
proxy:
addr: "0.0.0.0:3000"
managed:
enabled: true
"#,
);
let cfg = Config::load_from_path(Some(f.path())).unwrap();
assert!(cfg.managed.is_managed());
assert_eq!(
cfg.etcd.tls.as_ref().unwrap().client_cert_file,
"/etc/aisix/mtls/client.crt"
);
assert!(cfg.admin.admin_keys.is_empty());
}

#[test]
fn standalone_still_requires_admin_keys_even_with_managed_false() {
// managed.enabled = false (or missing) must keep the original
// "admin_keys must be non-empty" invariant. Otherwise a user
// accidentally dropping admin_keys would silently lose auth
// on their admin listener.
let f = write_yaml(
r#"
etcd:
endpoints: ["http://127.0.0.1:2379"]
proxy:
addr: "0.0.0.0:3000"
admin:
addr: "127.0.0.1:3001"
admin_keys: []
managed:
enabled: false
"#,
);
let err = Config::load_from_path(Some(f.path())).unwrap_err();
assert!(err.to_string().contains("admin.admin_keys"));
}

#[test]
fn parses_etcd_tls_block() {
let f = write_yaml(
r#"
etcd:
endpoints: ["https://etcd.aisix.cloud:2379"]
tls:
ca_cert_file: "/a.crt"
client_cert_file: "/c.crt"
client_key_file: "/c.key"
domain_name: "etcd.aisix.cloud"
proxy:
addr: "0.0.0.0:3000"
admin:
addr: "127.0.0.1:3001"
admin_keys: ["k1"]
"#,
);
let cfg = Config::load_from_path(Some(f.path())).unwrap();
let tls = cfg.etcd.tls.expect("tls parsed");
assert_eq!(tls.ca_cert_file, "/a.crt");
assert_eq!(tls.client_cert_file, "/c.crt");
assert_eq!(tls.client_key_file, "/c.key");
assert_eq!(tls.domain_name.as_deref(), Some("etcd.aisix.cloud"));
}
}
4 changes: 2 additions & 2 deletions crates/aisix-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ pub mod resource;
pub mod snapshot;

pub use config::{
AdminConfig, CacheBackend, CacheConfig, Config, EtcdConfig, LangfuseConfig,
ObservabilityConfig, ProxyConfig, TlsConfig,
AdminConfig, CacheBackend, CacheConfig, Config, EtcdConfig, EtcdTlsConfig, LangfuseConfig,
ManagedConfig, ObservabilityConfig, ProxyConfig, TlsConfig,
};
pub use error::{
AdminError, AdminErrorEnvelope, BootstrapError, ProxyError, ProxyErrorEnvelope, RateLimitScope,
Expand Down
Loading
Loading