use crate::error::{ErrorData, Result};
use crate::LoadBalancerEndpoint;
use alien_error::AlienError;
use serde::{Deserialize, Serialize};
pub const APEX_HOST_LABEL: &str = "@";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum ExposeProtocol {
Http,
Tcp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PublicEndpoint {
pub name: String,
pub port: u16,
pub protocol: ExposeProtocol,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_label: Option<String>,
#[serde(default)]
pub wildcard_subdomains: bool,
}
impl PublicEndpoint {
pub fn effective_host_label(&self) -> &str {
self.host_label.as_deref().unwrap_or(&self.name)
}
pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
validate_endpoint_name(resource_id, &self.name)?;
if let Some(host_label) = &self.host_label {
validate_endpoint_host_label(resource_id, host_label)?;
}
if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: resource_id.to_string(),
reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
}));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct WorkerPublicEndpoint {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_label: Option<String>,
#[serde(default)]
pub wildcard_subdomains: bool,
}
impl WorkerPublicEndpoint {
pub fn effective_host_label(&self) -> &str {
self.host_label.as_deref().unwrap_or(&self.name)
}
pub fn validate_for_resource(&self, resource_id: &str) -> Result<()> {
validate_endpoint_name(resource_id, &self.name)?;
if let Some(host_label) = &self.host_label {
validate_endpoint_host_label(resource_id, host_label)?;
}
if self.host_label.as_deref() == Some(APEX_HOST_LABEL) && self.wildcard_subdomains {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: resource_id.to_string(),
reason: "an apex public endpoint cannot also route wildcard subdomains".to_string(),
}));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct PublicEndpointOutput {
pub url: String,
pub host: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub wildcard_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub load_balancer_endpoint: Option<LoadBalancerEndpoint>,
}
pub fn validate_endpoint_name(resource_id: &str, name: &str) -> Result<()> {
let valid = !name.is_empty()
&& name.len() <= 63
&& !name.starts_with('-')
&& !name.ends_with('-')
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !valid {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: resource_id.to_string(),
reason:
"public endpoint name must be a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
.to_string(),
}));
}
Ok(())
}
pub fn validate_endpoint_host_label(resource_id: &str, host_label: &str) -> Result<()> {
if host_label == APEX_HOST_LABEL {
return Ok(());
}
let valid = !host_label.is_empty()
&& host_label.len() <= 63
&& !host_label.starts_with('-')
&& !host_label.ends_with('-')
&& host_label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !valid {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: resource_id.to_string(),
reason:
"public endpoint hostLabel must be '@' for apex or a single lowercase DNS label: letters, numbers, hyphens, no dots, and no leading or trailing hyphen"
.to_string(),
}));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_label_allows_apex_marker() {
validate_endpoint_host_label("gateway", APEX_HOST_LABEL).expect("apex host label");
}
#[test]
fn public_endpoint_rejects_apex_wildcard_combination() {
let endpoint = PublicEndpoint {
name: "api".to_string(),
port: 8080,
protocol: ExposeProtocol::Http,
host_label: Some(APEX_HOST_LABEL.to_string()),
wildcard_subdomains: true,
};
let error = endpoint
.validate_for_resource("gateway")
.expect_err("apex wildcard should be rejected");
assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
assert!(error.message.contains("apex"));
}
#[test]
fn worker_endpoint_rejects_apex_wildcard_combination() {
let endpoint = WorkerPublicEndpoint {
name: "api".to_string(),
host_label: Some(APEX_HOST_LABEL.to_string()),
wildcard_subdomains: true,
};
let error = endpoint
.validate_for_resource("handler")
.expect_err("apex wildcard should be rejected");
assert_eq!(error.code, "INVALID_RESOURCE_UPDATE");
assert!(error.message.contains("apex"));
}
}