|
|
|
|
@@ -1,11 +1,8 @@
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use log::warn;
|
|
|
|
|
use crate::{config::OPNsenseShell, Error};
|
|
|
|
|
use opnsense_config_xml::{
|
|
|
|
|
Frontend, HAProxy, HAProxyBackend, HAProxyHealthCheck, HAProxyServer, OPNsense,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::{config::OPNsenseShell, Error};
|
|
|
|
|
use std::{collections::HashSet, sync::Arc};
|
|
|
|
|
|
|
|
|
|
pub struct LoadBalancerConfig<'a> {
|
|
|
|
|
opnsense: &'a mut OPNsense,
|
|
|
|
|
@@ -31,7 +28,7 @@ impl<'a> LoadBalancerConfig<'a> {
|
|
|
|
|
match &mut self.opnsense.opnsense.haproxy.as_mut() {
|
|
|
|
|
Some(haproxy) => f(haproxy),
|
|
|
|
|
None => unimplemented!(
|
|
|
|
|
"Adding a backend is not supported when haproxy config does not exist yet"
|
|
|
|
|
"Cannot configure load balancer when haproxy config does not exist yet"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -40,21 +37,67 @@ impl<'a> LoadBalancerConfig<'a> {
|
|
|
|
|
self.with_haproxy(|haproxy| haproxy.general.enabled = enabled as i32);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_backend(&mut self, backend: HAProxyBackend) {
|
|
|
|
|
warn!("TODO make sure this new backend does not refer non-existing entities like servers or health checks");
|
|
|
|
|
self.with_haproxy(|haproxy| haproxy.backends.backends.push(backend));
|
|
|
|
|
/// Configures a service by removing any existing service on the same port
|
|
|
|
|
/// and then adding the new definition. This ensures idempotency.
|
|
|
|
|
pub fn configure_service(
|
|
|
|
|
&mut self,
|
|
|
|
|
frontend: Frontend,
|
|
|
|
|
backend: HAProxyBackend,
|
|
|
|
|
servers: Vec<HAProxyServer>,
|
|
|
|
|
healthcheck: Option<HAProxyHealthCheck>,
|
|
|
|
|
) {
|
|
|
|
|
self.remove_service_by_bind_address(&frontend.bind);
|
|
|
|
|
self.remove_servers(&servers);
|
|
|
|
|
|
|
|
|
|
self.add_new_service(frontend, backend, servers, healthcheck);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_frontend(&mut self, frontend: Frontend) {
|
|
|
|
|
self.with_haproxy(|haproxy| haproxy.frontends.frontend.push(frontend));
|
|
|
|
|
// Remove the corresponding real servers based on their name if they already exist.
|
|
|
|
|
fn remove_servers(&mut self, servers: &[HAProxyServer]) {
|
|
|
|
|
let server_names: HashSet<_> = servers.iter().map(|s| s.name.clone()).collect();
|
|
|
|
|
self.with_haproxy(|haproxy| {
|
|
|
|
|
haproxy
|
|
|
|
|
.servers
|
|
|
|
|
.servers
|
|
|
|
|
.retain(|s| !server_names.contains(&s.name));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_healthcheck(&mut self, healthcheck: HAProxyHealthCheck) {
|
|
|
|
|
self.with_haproxy(|haproxy| haproxy.healthchecks.healthchecks.push(healthcheck));
|
|
|
|
|
/// Removes a service and its dependent components based on the frontend's bind address.
|
|
|
|
|
/// This performs a cascading delete of the frontend, backend, servers, and health check.
|
|
|
|
|
fn remove_service_by_bind_address(&mut self, bind_address: &str) {
|
|
|
|
|
self.with_haproxy(|haproxy| {
|
|
|
|
|
let Some(old_frontend) = remove_frontend_by_bind_address(haproxy, bind_address) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(old_backend) = remove_backend(haproxy, old_frontend) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
remove_healthcheck(haproxy, &old_backend);
|
|
|
|
|
remove_linked_servers(haproxy, &old_backend);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_servers(&mut self, mut servers: Vec<HAProxyServer>) {
|
|
|
|
|
self.with_haproxy(|haproxy| haproxy.servers.servers.append(&mut servers));
|
|
|
|
|
/// Adds the components of a new service to the HAProxy configuration.
|
|
|
|
|
/// This function de-duplicates servers by name to prevent configuration errors.
|
|
|
|
|
fn add_new_service(
|
|
|
|
|
&mut self,
|
|
|
|
|
frontend: Frontend,
|
|
|
|
|
backend: HAProxyBackend,
|
|
|
|
|
servers: Vec<HAProxyServer>,
|
|
|
|
|
healthcheck: Option<HAProxyHealthCheck>,
|
|
|
|
|
) {
|
|
|
|
|
self.with_haproxy(|haproxy| {
|
|
|
|
|
if let Some(check) = healthcheck {
|
|
|
|
|
haproxy.healthchecks.healthchecks.push(check);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
haproxy.servers.servers.extend(servers);
|
|
|
|
|
haproxy.backends.backends.push(backend);
|
|
|
|
|
haproxy.frontends.frontend.push(frontend);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn reload_restart(&self) -> Result<(), Error> {
|
|
|
|
|
@@ -82,3 +125,262 @@ impl<'a> LoadBalancerConfig<'a> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remove_frontend_by_bind_address(haproxy: &mut HAProxy, bind_address: &str) -> Option<Frontend> {
|
|
|
|
|
let pos = haproxy
|
|
|
|
|
.frontends
|
|
|
|
|
.frontend
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|f| f.bind == bind_address);
|
|
|
|
|
|
|
|
|
|
match pos {
|
|
|
|
|
Some(pos) => Some(haproxy.frontends.frontend.remove(pos)),
|
|
|
|
|
None => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remove_backend(haproxy: &mut HAProxy, old_frontend: Frontend) -> Option<HAProxyBackend> {
|
|
|
|
|
let default_backend = old_frontend.default_backend?;
|
|
|
|
|
let pos = haproxy
|
|
|
|
|
.backends
|
|
|
|
|
.backends
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|b| b.uuid == default_backend);
|
|
|
|
|
|
|
|
|
|
match pos {
|
|
|
|
|
Some(pos) => Some(haproxy.backends.backends.remove(pos)),
|
|
|
|
|
None => None, // orphaned frontend, shouldn't happen
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remove_healthcheck(haproxy: &mut HAProxy, backend: &HAProxyBackend) {
|
|
|
|
|
if let Some(uuid) = &backend.health_check.content {
|
|
|
|
|
haproxy
|
|
|
|
|
.healthchecks
|
|
|
|
|
.healthchecks
|
|
|
|
|
.retain(|h| h.uuid != *uuid);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Remove the backend's servers. This assumes servers are not shared between services.
|
|
|
|
|
fn remove_linked_servers(haproxy: &mut HAProxy, backend: &HAProxyBackend) {
|
|
|
|
|
if let Some(server_uuids_str) = &backend.linked_servers.content {
|
|
|
|
|
let server_uuids_to_remove: HashSet<_> = server_uuids_str.split(',').collect();
|
|
|
|
|
haproxy
|
|
|
|
|
.servers
|
|
|
|
|
.servers
|
|
|
|
|
.retain(|s| !server_uuids_to_remove.contains(s.uuid.as_str()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use crate::config::DummyOPNSenseShell;
|
|
|
|
|
use assertor::*;
|
|
|
|
|
use opnsense_config_xml::{
|
|
|
|
|
Frontend, HAProxy, HAProxyBackend, HAProxyBackends, HAProxyFrontends, HAProxyHealthCheck,
|
|
|
|
|
HAProxyHealthChecks, HAProxyId, HAProxyServer, HAProxyServers, MaybeString, OPNsense,
|
|
|
|
|
};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use super::LoadBalancerConfig;
|
|
|
|
|
|
|
|
|
|
static SERVICE_BIND_ADDRESS: &str = "192.168.1.1:80";
|
|
|
|
|
static OTHER_SERVICE_BIND_ADDRESS: &str = "192.168.1.1:443";
|
|
|
|
|
|
|
|
|
|
static SERVER_ADDRESS: &str = "1.1.1.1:80";
|
|
|
|
|
static OTHER_SERVER_ADDRESS: &str = "1.1.1.1:443";
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn configure_service_should_add_all_service_components_to_haproxy() {
|
|
|
|
|
let mut opnsense = given_opnsense();
|
|
|
|
|
let mut load_balancer = given_load_balancer(&mut opnsense);
|
|
|
|
|
let (healthcheck, servers, backend, frontend) =
|
|
|
|
|
given_service(SERVICE_BIND_ADDRESS, SERVER_ADDRESS);
|
|
|
|
|
|
|
|
|
|
load_balancer.configure_service(
|
|
|
|
|
frontend.clone(),
|
|
|
|
|
backend.clone(),
|
|
|
|
|
servers.clone(),
|
|
|
|
|
Some(healthcheck.clone()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_haproxy_configured_with(
|
|
|
|
|
opnsense,
|
|
|
|
|
vec![frontend],
|
|
|
|
|
vec![backend],
|
|
|
|
|
servers,
|
|
|
|
|
vec![healthcheck],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn configure_service_should_replace_service_on_same_bind_address() {
|
|
|
|
|
let (healthcheck, servers, backend, frontend) =
|
|
|
|
|
given_service(SERVICE_BIND_ADDRESS, SERVER_ADDRESS);
|
|
|
|
|
let mut opnsense = given_opnsense_with(given_haproxy(
|
|
|
|
|
vec![frontend.clone()],
|
|
|
|
|
vec![backend.clone()],
|
|
|
|
|
servers.clone(),
|
|
|
|
|
vec![healthcheck.clone()],
|
|
|
|
|
));
|
|
|
|
|
let mut load_balancer = given_load_balancer(&mut opnsense);
|
|
|
|
|
|
|
|
|
|
let (updated_healthcheck, updated_servers, updated_backend, updated_frontend) =
|
|
|
|
|
given_service(SERVICE_BIND_ADDRESS, OTHER_SERVER_ADDRESS);
|
|
|
|
|
|
|
|
|
|
load_balancer.configure_service(
|
|
|
|
|
updated_frontend.clone(),
|
|
|
|
|
updated_backend.clone(),
|
|
|
|
|
updated_servers.clone(),
|
|
|
|
|
Some(updated_healthcheck.clone()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_haproxy_configured_with(
|
|
|
|
|
opnsense,
|
|
|
|
|
vec![updated_frontend],
|
|
|
|
|
vec![updated_backend],
|
|
|
|
|
updated_servers,
|
|
|
|
|
vec![updated_healthcheck],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn configure_service_should_keep_existing_service_on_different_bind_addresses() {
|
|
|
|
|
let (healthcheck, servers, backend, frontend) =
|
|
|
|
|
given_service(SERVICE_BIND_ADDRESS, SERVER_ADDRESS);
|
|
|
|
|
let (other_healthcheck, other_servers, other_backend, other_frontend) =
|
|
|
|
|
given_service(OTHER_SERVICE_BIND_ADDRESS, OTHER_SERVER_ADDRESS);
|
|
|
|
|
let mut opnsense = given_opnsense_with(given_haproxy(
|
|
|
|
|
vec![frontend.clone()],
|
|
|
|
|
vec![backend.clone()],
|
|
|
|
|
servers.clone(),
|
|
|
|
|
vec![healthcheck.clone()],
|
|
|
|
|
));
|
|
|
|
|
let mut load_balancer = given_load_balancer(&mut opnsense);
|
|
|
|
|
|
|
|
|
|
load_balancer.configure_service(
|
|
|
|
|
other_frontend.clone(),
|
|
|
|
|
other_backend.clone(),
|
|
|
|
|
other_servers.clone(),
|
|
|
|
|
Some(other_healthcheck.clone()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_haproxy_configured_with(
|
|
|
|
|
opnsense,
|
|
|
|
|
vec![frontend, other_frontend],
|
|
|
|
|
vec![backend, other_backend],
|
|
|
|
|
[servers, other_servers].concat(),
|
|
|
|
|
vec![healthcheck, other_healthcheck],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn assert_haproxy_configured_with(
|
|
|
|
|
opnsense: OPNsense,
|
|
|
|
|
frontends: Vec<Frontend>,
|
|
|
|
|
backends: Vec<HAProxyBackend>,
|
|
|
|
|
servers: Vec<HAProxyServer>,
|
|
|
|
|
healthchecks: Vec<HAProxyHealthCheck>,
|
|
|
|
|
) {
|
|
|
|
|
let haproxy = opnsense.opnsense.haproxy.as_ref().unwrap();
|
|
|
|
|
assert_that!(haproxy.frontends.frontend).contains_exactly(frontends);
|
|
|
|
|
assert_that!(haproxy.backends.backends).contains_exactly(backends);
|
|
|
|
|
assert_that!(haproxy.servers.servers).is_equal_to(servers);
|
|
|
|
|
assert_that!(haproxy.healthchecks.healthchecks).contains_exactly(healthchecks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_opnsense() -> OPNsense {
|
|
|
|
|
OPNsense::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_opnsense_with(haproxy: HAProxy) -> OPNsense {
|
|
|
|
|
let mut opnsense = OPNsense::default();
|
|
|
|
|
opnsense.opnsense.haproxy = Some(haproxy);
|
|
|
|
|
|
|
|
|
|
opnsense
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_load_balancer<'a>(opnsense: &'a mut OPNsense) -> LoadBalancerConfig<'a> {
|
|
|
|
|
let opnsense_shell = Arc::new(DummyOPNSenseShell {});
|
|
|
|
|
if opnsense.opnsense.haproxy.is_none() {
|
|
|
|
|
opnsense.opnsense.haproxy = Some(HAProxy::default());
|
|
|
|
|
}
|
|
|
|
|
LoadBalancerConfig::new(opnsense, opnsense_shell)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_service(
|
|
|
|
|
bind_address: &str,
|
|
|
|
|
server_address: &str,
|
|
|
|
|
) -> (
|
|
|
|
|
HAProxyHealthCheck,
|
|
|
|
|
Vec<HAProxyServer>,
|
|
|
|
|
HAProxyBackend,
|
|
|
|
|
Frontend,
|
|
|
|
|
) {
|
|
|
|
|
let healthcheck = given_healthcheck();
|
|
|
|
|
let servers = vec![given_server(server_address)];
|
|
|
|
|
let backend = given_backend();
|
|
|
|
|
let frontend = given_frontend(bind_address);
|
|
|
|
|
(healthcheck, servers, backend, frontend)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_haproxy(
|
|
|
|
|
frontends: Vec<Frontend>,
|
|
|
|
|
backends: Vec<HAProxyBackend>,
|
|
|
|
|
servers: Vec<HAProxyServer>,
|
|
|
|
|
healthchecks: Vec<HAProxyHealthCheck>,
|
|
|
|
|
) -> HAProxy {
|
|
|
|
|
HAProxy {
|
|
|
|
|
frontends: HAProxyFrontends {
|
|
|
|
|
frontend: frontends,
|
|
|
|
|
},
|
|
|
|
|
backends: HAProxyBackends { backends },
|
|
|
|
|
servers: HAProxyServers { servers },
|
|
|
|
|
healthchecks: HAProxyHealthChecks { healthchecks },
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_frontend(bind_address: &str) -> Frontend {
|
|
|
|
|
Frontend {
|
|
|
|
|
uuid: "uuid".into(),
|
|
|
|
|
id: HAProxyId::default(),
|
|
|
|
|
enabled: 1,
|
|
|
|
|
name: format!("frontend_{bind_address}"),
|
|
|
|
|
bind: bind_address.into(),
|
|
|
|
|
default_backend: Some("backend-uuid".into()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_backend() -> HAProxyBackend {
|
|
|
|
|
HAProxyBackend {
|
|
|
|
|
uuid: "backend-uuid".into(),
|
|
|
|
|
id: HAProxyId::default(),
|
|
|
|
|
enabled: 1,
|
|
|
|
|
name: "backend_192.168.1.1:80".into(),
|
|
|
|
|
linked_servers: MaybeString::from("server-uuid"),
|
|
|
|
|
health_check_enabled: 1,
|
|
|
|
|
health_check: MaybeString::from("healthcheck-uuid"),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_server(address: &str) -> HAProxyServer {
|
|
|
|
|
HAProxyServer {
|
|
|
|
|
uuid: "server-uuid".into(),
|
|
|
|
|
id: HAProxyId::default(),
|
|
|
|
|
name: address.into(),
|
|
|
|
|
address: Some(address.into()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn given_healthcheck() -> HAProxyHealthCheck {
|
|
|
|
|
HAProxyHealthCheck {
|
|
|
|
|
uuid: "healthcheck-uuid".into(),
|
|
|
|
|
name: "healthcheck".into(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|