//! Nginx configuration renderer using Handlebars templates use handlebars::Handlebars; use serde::Serialize; use tracing::{debug, error}; /// Default virtual host template const DEFAULT_VHOST_TEMPLATE: &str = include_str!("templates/default.hbs"); /// Upstream template const UPSTREAM_TEMPLATE: &str = r#"{{#each upstreams}} upstream {{name}} { {{#each servers}} server {{address}}{{#if weight}} weight={{weight}}{{/if}}{{#if backup}} backup{{/if}}{{#if down}} down{{/if}}; {{/each}} {{#if keepalive_connections}} keepalive {{keepalive_connections}}; {{/if}} } {{/each}} "#; /// Configuration renderer pub struct ConfigRenderer { handlebars: Handlebars<'static>, } /// Virtual host data for template #[derive(Serialize)] struct VirtualHostData { id: String, name: String, server_name: String, listen_port: u32, ssl_enabled: bool, ssl_certificate_path: String, ssl_certificate_key_path: String, http2_enabled: bool, locations: Vec, } /// Location data for template #[derive(Serialize)] struct LocationData { path: String, proxy_pass: String, upstream_name: String, root: String, index: String, custom_headers: Vec, } /// Header data for template #[derive(Serialize)] struct HeaderData { name: String, value: String, always: bool, } /// Upstream data for template #[derive(Serialize)] struct UpstreamData { id: String, name: String, algorithm: String, servers: Vec, keepalive_connections: u32, } /// Upstream server data for template #[derive(Serialize)] struct UpstreamServerData { address: String, weight: u32, backup: bool, down: bool, } impl ConfigRenderer { /// Create a new config renderer pub fn new() -> Self { let mut handlebars = Handlebars::new(); // Register built-in templates handlebars .register_template_string("default_vhost", DEFAULT_VHOST_TEMPLATE) .expect("Failed to register default_vhost template"); handlebars .register_template_string("upstreams", UPSTREAM_TEMPLATE) .expect("Failed to register upstreams template"); Self { handlebars } } /// Render full configuration from ConfigUpdate pub fn render_config( &self, config: &nxmesh_proto::ConfigUpdate, ) -> Result> { debug!("Rendering configuration with Handlebars"); let mut output = String::new(); // Render upstreams if !config.upstreams.is_empty() { let upstreams_data: Vec = config .upstreams .iter() .map(|u| UpstreamData { id: u.id.clone(), name: u.name.clone(), algorithm: algorithm_name(u.algorithm).to_string(), servers: u .servers .iter() .map(|s| UpstreamServerData { address: s.address.clone(), weight: s.weight, backup: s.backup, down: s.down, }) .collect(), keepalive_connections: u.keepalive_connections, }) .collect(); let upstreams_json = serde_json::json!({ "upstreams": upstreams_data }); let rendered = self .handlebars .render("upstreams", &upstreams_json) .map_err(|e| format!("Template render error: {}", e))?; output.push_str(&rendered); output.push('\n'); } // Render virtual hosts for vh in &config.virtual_hosts { let vh_data = self.convert_virtual_host(vh, config)?; let rendered = self .handlebars .render("default_vhost", &vh_data) .map_err(|e| format!("Template render error: {}", e))?; output.push_str(&rendered); output.push('\n'); } debug!("Configuration rendered successfully"); Ok(output) } /// Convert proto VirtualHost to template data fn convert_virtual_host( &self, vh: &nxmesh_proto::VirtualHost, config: &nxmesh_proto::ConfigUpdate, ) -> Result> { // Build certificate paths if SSL is enabled let (cert_path, key_path) = if vh.ssl_enabled && !vh.ssl_certificate_id.is_empty() { if let Some(cert) = config.certificates.get(&vh.ssl_certificate_id) { ( format!("/etc/nginx/certs/{}.crt", cert.id), format!("/etc/nginx/certs/{}.key", cert.id), ) } else { ( format!("/etc/nginx/certs/{}.crt", vh.ssl_certificate_id), format!("/etc/nginx/certs/{}.key", vh.ssl_certificate_id), ) } } else { (String::new(), String::new()) }; // Convert locations let locations: Vec = vh .locations .iter() .map(|loc| LocationData { path: loc.path.clone(), proxy_pass: loc.proxy_pass.clone(), upstream_name: loc.upstream_id.clone(), root: loc.root.clone(), index: loc.index.clone(), custom_headers: loc .custom_headers .iter() .map(|h| HeaderData { name: h.name.clone(), value: h.value.clone(), always: h.always, }) .collect(), }) .collect(); Ok(VirtualHostData { id: vh.id.clone(), name: vh.name.clone(), server_name: vh.server_name.clone(), listen_port: vh.listen_port, ssl_enabled: vh.ssl_enabled, ssl_certificate_path: cert_path, ssl_certificate_key_path: key_path, http2_enabled: vh.http2_enabled, locations, }) } } impl Default for ConfigRenderer { fn default() -> Self { Self::new() } } /// Convert algorithm enum to name fn algorithm_name(algorithm: i32) -> &'static str { match algorithm { 1 => "least_conn", 2 => "ip_hash", 3 => "weighted", _ => "round_robin", } } #[cfg(test)] mod tests { use super::*; #[test] fn test_render_config() { let renderer = ConfigRenderer::new(); let config = nxmesh_proto::ConfigUpdate { config_id: "test".to_string(), version: 1, virtual_hosts: vec![nxmesh_proto::VirtualHost { id: "vh1".to_string(), name: "Test Site".to_string(), server_name: "example.com".to_string(), listen_port: 80, ssl_enabled: false, ssl_certificate_id: "".to_string(), http2_enabled: false, http3_enabled: false, locations: vec![nxmesh_proto::Location { path: "/".to_string(), proxy_pass: "http://backend".to_string(), upstream_id: "".to_string(), root: "".to_string(), index: "".to_string(), custom_headers: vec![], rewrite_rules: vec![], custom_directives: Default::default(), }], custom_directives: Default::default(), }], upstreams: vec![nxmesh_proto::Upstream { id: "up1".to_string(), name: "backend".to_string(), algorithm: 0, servers: vec![nxmesh_proto::UpstreamServer { address: "127.0.0.1:8080".to_string(), weight: 1, backup: false, down: false, max_fails: 1, fail_timeout_seconds: 10, }], health_check: None, keepalive_connections: 0, }], certificates: Default::default(), global_settings: None, }; let result = renderer.render_config(&config).unwrap(); assert!(result.contains("upstream backend")); assert!(result.contains("server_name example.com")); assert!(result.contains("proxy_pass http://backend")); } #[test] fn test_render_with_ssl() { let renderer = ConfigRenderer::new(); let config = nxmesh_proto::ConfigUpdate { config_id: "test".to_string(), version: 1, virtual_hosts: vec![nxmesh_proto::VirtualHost { id: "vh1".to_string(), name: "Test Site".to_string(), server_name: "example.com".to_string(), listen_port: 443, ssl_enabled: true, ssl_certificate_id: "cert-123".to_string(), http2_enabled: false, http3_enabled: false, locations: vec![nxmesh_proto::Location { path: "/".to_string(), proxy_pass: "".to_string(), upstream_id: "".to_string(), root: "/var/www".to_string(), index: "index.html".to_string(), custom_headers: vec![nxmesh_proto::Header { name: "X-Frame-Options".to_string(), value: "DENY".to_string(), always: true, }], rewrite_rules: vec![], custom_directives: Default::default(), }], custom_directives: Default::default(), }], upstreams: vec![], certificates: std::collections::HashMap::from([( "cert-123".to_string(), nxmesh_proto::Certificate { id: "cert-123".to_string(), domain: "example.com".to_string(), certificate_pem: "".to_string(), private_key_pem: "".to_string(), expires_at: 0, }, )]), global_settings: None, }; let result = renderer.render_config(&config).unwrap(); assert!(result.contains("listen 443 ssl")); assert!(result.contains("ssl_certificate")); assert!(result.contains("root /var/www")); assert!(result.contains("add_header X-Frame-Options")); } }