use std::fmt;
use std::net::Ipv4Addr;
use isoloom_render::trunks;
pub(crate) use isoloom_spec::appliance::MGMT_CIDR;
use isoloom_spec::appliance::is_switch;
use isoloom_spec::model::{Appliance, Machine, Spec};
use isoloom_spec::validate::Cidr;
use isoloom_spec::vlans;
pub(crate) const MGMT_NETWORK: &str = "isoloom-mgmt";
const DYNAMIPS_DOCKERFILE: &str = "# Dynamips, the Cisco 7200 emulator (Ubuntu's package, in its default sources), pinned to\n# Ubuntu 24.04's versions; the IOS image is the user's.\nFROM ubuntu:24.04\nRUN apt-get update && apt-get install -y --no-install-recommends dynamips=0.2.14-1build4 'iproute2=6.1.0-1ubuntu6*' && rm -rf /var/lib/apt/lists/*\n";
pub(crate) fn appliances(spec: &Spec) -> Vec<(&String, &Machine, Appliance)> {
spec.machines.iter().filter_map(|(n, m)| Some((n, m, m.docker.as_ref()?.appliance?))).collect()
}
const MGMT: Cidr = Cidr {
base: u32::from_be_bytes([10, 255, 255, 0]),
len: 24,
};
fn position(spec: &Spec, name: &str) -> usize {
appliances(spec).iter().position(|(n, _, _)| n.as_str() == name).unwrap_or(0)
}
pub(crate) fn mgmt_address(spec: &Spec, name: &str) -> Ipv4Addr {
Ipv4Addr::from(MGMT.base + 10 + u32::try_from(position(spec, name)).unwrap_or(0))
}
fn iol_pid(spec: &Spec, name: &str) -> usize {
position(spec, name) + 1
}
fn interface(kind: Appliance, k: usize) -> String {
match kind {
Appliance::CiscoIol | Appliance::CiscoIolL2 => format!("Ethernet{}/{}", k / 4, k % 4),
Appliance::CiscoVios | Appliance::CiscoViosL2 => format!("GigabitEthernet0/{k}"),
Appliance::CiscoCsr1000v | Appliance::CiscoC8000v => format!("GigabitEthernet{}", k + 1),
Appliance::CiscoDynamips => {
let (slot, port) = dynamips_port(k);
format!("FastEthernet{slot}/{port}")
}
}
}
fn dynamips_port(k: usize) -> (usize, usize) {
if k == 1 { (0, 0) } else { (k / 2, k % 2) }
}
fn mask(len: u8) -> Ipv4Addr {
Ipv4Addr::from(u32::MAX.checked_shl(32 - u32::from(len.min(32))).unwrap_or(0))
}
pub(crate) struct Port {
pub(crate) network: String,
pub(crate) address: Option<Ipv4Addr>,
role: Role,
}
enum Role {
Network,
Access(u16),
Trunk(String, Vec<u16>),
}
pub(crate) fn ports(spec: &Spec, name: &str, m: &Machine, address: &impl Fn(&str, u8) -> Ipv4Addr) -> Vec<Port> {
let Some(lan) = vlans::switched_by(spec, name) else {
return m
.networks
.iter()
.map(|(net, octet)| Port {
network: net.clone(),
address: Some(address(net, *octet)),
role: Role::Network,
})
.collect();
};
let mut out: Vec<Port> = vlans::of_lan(spec, lan)
.map(|(net, id)| Port {
network: net.clone(),
address: Some(trunks::switch_address(spec, net)),
role: Role::Access(id),
})
.collect();
for t in trunks::containers(spec).iter().filter(|t| t.lan == lan) {
out.push(Port {
network: trunks::link_network(t),
address: None,
role: Role::Trunk(t.machine.clone(), t.vlans.iter().map(|v| v.id).collect()),
});
}
out
}
pub(crate) struct File {
pub(crate) name: &'static str,
pub(crate) inside: &'static str,
pub(crate) contents: String,
}
pub(crate) struct Wiring {
pub(crate) ports: Vec<Port>,
pub(crate) files: Vec<File>,
pub(crate) own_config: &'static str,
pub(crate) firmware: Option<&'static str>,
pub(crate) environment: Vec<(String, String)>,
pub(crate) entrypoint: Vec<String>,
pub(crate) privileged: bool,
pub(crate) build: Option<&'static str>,
}
struct StartupConfig<'a> {
spec: &'a Spec,
name: &'a str,
kind: Appliance,
ports: &'a [Port],
}
impl StartupConfig<'_> {
fn iol(&self) -> bool {
matches!(self.kind, Appliance::CiscoIol | Appliance::CiscoIolL2)
}
fn management(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"no aaa new-model\nip cef\n!\nvrf definition isoloom-mgmt\n description Isoloom management\n address-family ipv4\n exit-address-family\n!\ninterface Ethernet0/0\n{}vrf forwarding isoloom-mgmt\n description Isoloom management\n ip address {} {}\n no shutdown\n!\n",
if is_switch(self.kind) { " no switchport\n " } else { " " },
mgmt_address(self.spec, self.name),
mask(MGMT.len),
)
}
fn vlans(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let access: Vec<(&str, u16)> = self
.ports
.iter()
.filter_map(|p| match p.role {
Role::Access(id) => Some((p.network.as_str(), id)),
Role::Network | Role::Trunk(..) => None,
})
.collect();
if access.is_empty() {
return Ok(());
}
f.write_str("vtp mode transparent\n!\n")?;
for (net, id) in access {
write!(f, "vlan {id}\n name {}\n!\n", net.chars().take(32).collect::<String>())?;
}
Ok(())
}
fn interface(&self, f: &mut fmt::Formatter<'_>, k: usize, port: &Port) -> fmt::Result {
writeln!(f, "interface {}", interface(self.kind, k))?;
match &port.role {
Role::Network => {
writeln!(f, " description {}", port.network)?;
let block = self.spec.networks.get(&port.network).and_then(|n| Cidr::parse(&n.cidr));
if let (false, Some(addr), Some(block)) = (is_switch(self.kind), port.address, block) {
writeln!(f, " ip address {addr} {}", mask(block.len))?;
}
}
Role::Access(id) => write!(f, " description {}\n switchport mode access\n switchport access vlan {id}\n", port.network)?,
Role::Trunk(machine, ids) => {
let ids = ids.iter().map(u16::to_string).collect::<Vec<_>>().join(",");
write!(
f,
" description trunk to {machine}\n switchport trunk encapsulation dot1q\n switchport mode trunk\n switchport trunk allowed vlan {ids}\n"
)?;
}
}
f.write_str(" no shutdown\n!\n")
}
}
impl fmt::Display for StartupConfig<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"hostname {}\n!\nno ip domain lookup\nip domain name lab\n!\nusername admin privilege 15 secret admin\n!\n",
self.name
)?;
if self.iol() {
self.management(f)?;
}
self.vlans(f)?;
for (k, port) in self.ports.iter().enumerate() {
self.interface(f, k + 1, port)?;
}
if self.iol() {
write!(f, "ip route vrf isoloom-mgmt 0.0.0.0 0.0.0.0 Ethernet0/0 {}\n!\n", MGMT.gateway())?;
}
f.write_str("ip ssh version 2\n!\nline vty 0 4\n login local\n transport input ssh telnet\n!\n")
}
}
pub(crate) fn wiring(spec: &Spec, name: &str, m: &Machine, kind: Appliance, address: impl Fn(&str, u8) -> Ipv4Addr) -> Wiring {
let ports = ports(spec, name, m, &address);
let cfg = StartupConfig {
spec,
name,
kind,
ports: &ports,
}
.to_string();
match kind {
Appliance::CiscoIol | Appliance::CiscoIolL2 => iol_wiring(iol_pid(spec, name), ports, cfg),
Appliance::CiscoVios | Appliance::CiscoViosL2 | Appliance::CiscoCsr1000v | Appliance::CiscoC8000v => vrnetlab_wiring(name, kind, ports, cfg),
Appliance::CiscoDynamips => dynamips_wiring(ports, cfg),
}
}
fn iol_wiring(pid: usize, ports: Vec<Port>, cfg: String) -> Wiring {
let slots: Vec<(usize, usize, usize)> = (1..=ports.len()).map(|k| (k, k / 4, k % 4)).collect();
let iouyap: Vec<String> = slots.iter().map(|(k, slot, port)| format!("[513:{slot}/{port}]\neth_dev = eth{k}\n")).collect();
let netmap: Vec<String> = slots.iter().map(|(_, slot, port)| format!("{pid}:{slot}/{port} 513:{slot}/{port}\n")).collect();
Wiring {
ports,
files: vec![
File {
name: "base.txt",
inside: "/iol/base.txt",
contents: cfg,
},
File {
name: "NETMAP",
inside: "/iol/NETMAP",
contents: format!("{pid}:0/0 513:0/0\n{}", netmap.concat()),
},
File {
name: "iouyap.ini",
inside: "/iol/iouyap.ini",
contents: format!(
"[default]\nbase_port = 49000\nnetmap = /iol/NETMAP\n[513:0/0]\neth_dev = eth0\n{}",
iouyap.concat()
),
},
],
own_config: "/iol/own.txt",
firmware: None,
environment: vec![("IOL_PID".into(), pid.to_string())],
entrypoint: shell("cat /iol/base.txt /iol/own.txt > /iol/config.txt 2>/dev/null; echo end >> /iol/config.txt; exec /entrypoint.sh"),
privileged: false,
build: None,
}
}
fn vrnetlab_wiring(name: &str, kind: Appliance, ports: Vec<Port>, cfg: String) -> Wiring {
let mut environment = vec![("CLAB_INTFS".into(), ports.len().to_string()), ("CONNECTION_MODE".into(), "tc".into())];
if matches!(kind, Appliance::CiscoVios | Appliance::CiscoViosL2) {
environment.push(("CLAB_MGMT_PASSTHROUGH".into(), "true".into()));
}
Wiring {
ports,
files: vec![base_cfg(cfg)],
own_config: "/config/own.cfg",
firmware: None,
environment,
entrypoint: shell(&format!(
"cat /config/base.cfg /config/own.cfg > /config/startup-config.cfg 2>/dev/null; echo end >> /config/startup-config.cfg; exec uv run /launch.py --username admin --password admin --hostname {name} --connection-mode tc --trace"
)),
privileged: true,
build: None,
}
}
fn dynamips_wiring(ports: Vec<Port>, cfg: String) -> Wiring {
let n = ports.len();
let slots = (2..=n).map(|k| dynamips_port(k).0).max().unwrap_or(0);
let adapters = (1..=slots).map(|slot| format!(" -p {slot}:PA-2FE-TX"));
let bindings = (1..=n).map(|k| {
let (slot, port) = dynamips_port(k);
format!(" -s {slot}:{port}:linux_eth:eth{k}")
});
let args: Vec<String> = std::iter::once("-P 7200 -r 512 -T 2000 -C /config/startup-config.cfg".to_owned())
.chain(adapters)
.chain(bindings)
.collect();
Wiring {
ports,
files: vec![base_cfg(cfg)],
own_config: "/config/own.cfg",
firmware: Some("/firmware/ios.bin"),
environment: vec![("DYNAMIPS_ARGS".into(), args.concat())],
entrypoint: shell(
"cat /config/base.cfg /config/own.cfg > /config/startup-config.cfg 2>/dev/null; echo end >> /config/startup-config.cfg; cd /tmp && exec dynamips $DYNAMIPS_ARGS /firmware/ios.bin",
),
privileged: false,
build: Some(DYNAMIPS_DOCKERFILE),
}
}
fn base_cfg(cfg: String) -> File {
File {
name: "base.cfg",
inside: "/config/base.cfg",
contents: cfg,
}
}
fn shell(script: &str) -> Vec<String> {
vec!["/bin/sh".into(), "-c".into(), script.into()]
}
pub(crate) fn sidecar_commands(ports: usize) -> Vec<String> {
(1..=ports).map(|k| format!("ip addr flush dev eth{k}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_management_block_is_the_shared_constant() {
assert_eq!(Cidr::parse(MGMT_CIDR), Some(MGMT));
}
#[test]
fn a_mask_has_its_prefix_length_in_ones() {
assert_eq!(mask(24), Ipv4Addr::new(255, 255, 255, 0));
assert_eq!(mask(0), Ipv4Addr::UNSPECIFIED);
assert_eq!(mask(32), Ipv4Addr::BROADCAST);
}
#[test]
fn interfaces_are_named_as_each_os_names_them() {
assert_eq!(interface(Appliance::CiscoIol, 5), "Ethernet1/1");
assert_eq!(interface(Appliance::CiscoVios, 2), "GigabitEthernet0/2");
assert_eq!(interface(Appliance::CiscoC8000v, 1), "GigabitEthernet2");
assert_eq!(interface(Appliance::CiscoDynamips, 1), "FastEthernet0/0");
assert_eq!(interface(Appliance::CiscoDynamips, 3), "FastEthernet1/1");
}
}