Prepare, configure, and manage Firecracker microVMs in seconds!
virtualization linux microvm firecracker

Merge pull request #16 from tsirysndr/feat/tailscale

feat: integrate Tailscale support for Firecracker MicroVMs

authored by tsiry-sandratraina.com and committed by

GitHub cc53d765 3e4d4f4f

+198 -9
+1
Cargo.lock
··· 924 924 "actix-web", 925 925 "anyhow", 926 926 "env_logger", 927 + "fire-config", 927 928 "firecracker-prepare", 928 929 "firecracker-process", 929 930 "firecracker-state",
+7
crates/fire-config/src/lib.rs
··· 14 14 pub cert: Option<String>, 15 15 } 16 16 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + pub struct TailscaleOptions { 19 + pub auth_key: Option<String>, 20 + } 21 + 17 22 #[derive(Debug, Serialize, Deserialize)] 18 23 pub struct Vm { 19 24 pub vcpu: Option<u16>, ··· 26 31 pub api_socket: Option<String>, 27 32 pub mac: Option<String>, 28 33 pub ssh_keys: Option<Vec<String>>, 34 + pub tailscale: Option<TailscaleOptions>, 29 35 } 30 36 31 37 #[derive(Debug, Serialize, Deserialize)] ··· 50 56 api_socket: None, 51 57 mac: None, 52 58 ssh_keys: None, 59 + tailscale: None, 53 60 }, 54 61 etcd: None, 55 62 }
+1
crates/fire-server/Cargo.toml
··· 22 22 firecracker-vm = { path = "../firecracker-vm" } 23 23 firecracker-prepare = { path = "../firecracker-prepare" } 24 24 firecracker-process = { path = "../firecracker-process" } 25 + fire-config = { path = "../fire-config" } 25 26 serde_json = "1.0.145" 26 27 sqlx = { version = "0.8.6", features = [ 27 28 "runtime-tokio",
+14 -1
crates/fire-server/src/api/microvm.rs
··· 46 46 boot_args: None, 47 47 ssh_keys: None, 48 48 start: None, 49 + tailscale_auth_key: None, 49 50 }, 50 51 false => serde_json::from_slice::<CreateMicroVM>(&body)?, 51 52 }; ··· 141 142 #[post("/{id}/start")] 142 143 async fn start_microvm( 143 144 id: web::Path<String>, 145 + mut payload: web::Payload, 144 146 pool: web::Data<Arc<Pool<Sqlite>>>, 145 147 ) -> Result<impl Responder, actix_web::Error> { 146 148 let id = id.into_inner(); 149 + let body = read_payload!(payload); 150 + let tailscale_auth_key = match body.is_empty() { 151 + true => None, 152 + false => { 153 + let params: serde_json::Value = serde_json::from_slice(&body)?; 154 + params 155 + .get("tailscale_auth_key") 156 + .and_then(|v| v.as_str()) 157 + .map(|s| s.to_string()) 158 + } 159 + }; 147 160 let pool = pool.get_ref().clone(); 148 - let vm = services::microvm::start_microvm(pool, &id) 161 + let vm = services::microvm::start_microvm(pool, &id, tailscale_auth_key) 149 162 .await 150 163 .map_err(actix_web::error::ErrorInternalServerError)?; 151 164 Ok(HttpResponse::Ok().json(vm))
+9 -1
crates/fire-server/src/services/microvm.rs
··· 2 2 3 3 use crate::types::microvm::CreateMicroVM; 4 4 use anyhow::Error; 5 + use fire_config::TailscaleOptions; 5 6 use firecracker_state::{entity::virtual_machine::VirtualMachine, repo}; 6 7 use firecracker_vm::{constants::BRIDGE_DEV, types::VmOptions}; 7 8 use owo_colors::OwoColorize; ··· 41 42 Ok(Some(vm)) 42 43 } 43 44 44 - pub async fn start_microvm(pool: Arc<Pool<Sqlite>>, id: &str) -> Result<VirtualMachine, Error> { 45 + pub async fn start_microvm( 46 + pool: Arc<Pool<Sqlite>>, 47 + id: &str, 48 + tailscale_auth_key: Option<String>, 49 + ) -> Result<VirtualMachine, Error> { 45 50 let vm = repo::virtual_machine::find(&pool, id).await?; 46 51 if vm.is_none() { 47 52 println!("[!] No virtual machine found with the name: {}", id); ··· 79 84 ssh_keys: vm 80 85 .ssh_keys 81 86 .map(|keys| keys.split(',').map(|s| s.to_string()).collect()), 87 + tailscale: tailscale_auth_key.map(|key| TailscaleOptions { 88 + auth_key: Some(key), 89 + }), 82 90 }; 83 91 84 92 let vm = start(pool, options, Some(vm.id)).await?;
+10
crates/fire-server/src/types/microvm.rs
··· 1 + use fire_config::TailscaleOptions; 1 2 use firecracker_vm::{mac::generate_unique_mac, types::VmOptions}; 2 3 use serde::{Deserialize, Serialize}; 3 4 use utoipa::ToSchema; ··· 17 18 } 18 19 19 20 #[derive(Serialize, Deserialize, Clone, ToSchema)] 21 + pub struct StartMicroVM { 22 + pub tailscale_auth_key: Option<String>, 23 + } 24 + 25 + #[derive(Serialize, Deserialize, Clone, ToSchema)] 20 26 pub struct CreateMicroVM { 21 27 pub name: Option<String>, 22 28 pub vcpus: Option<u8>, ··· 27 33 pub boot_args: Option<String>, 28 34 pub ssh_keys: Option<Vec<String>>, 29 35 pub start: Option<bool>, 36 + pub tailscale_auth_key: Option<String>, 30 37 } 31 38 32 39 impl Into<VmOptions> for CreateMicroVM { ··· 110 117 rootfs: self.rootfs, 111 118 bootargs: self.boot_args, 112 119 mac_address: generate_unique_mac(), 120 + tailscale: self.tailscale_auth_key.map(|key| TailscaleOptions { 121 + auth_key: Some(key), 122 + }), 113 123 ..Default::default() 114 124 } 115 125 }
+16 -1
crates/firecracker-prepare/src/lib.rs
··· 184 184 &debootstrap_dir, 185 185 "sh", 186 186 "-c", 187 - "apt-get install -y systemd-resolved", 187 + "apt-get install -y systemd-resolved ca-certificates curl", 188 188 ], 189 189 true, 190 190 )?; ··· 447 447 448 448 let squashfs_root_dir = format!("{}/squashfs_root", app_dir); 449 449 rootfs::extract_squashfs(&ubuntu_file, &squashfs_root_dir)?; 450 + 451 + run_command( 452 + "cp", 453 + &["-r", "/etc/ssl", &format!("{}/etc/", squashfs_root_dir)], 454 + true, 455 + )?; 456 + run_command( 457 + "cp", 458 + &[ 459 + "-r", 460 + "/etc/ca-certificates", 461 + &format!("{}/etc/", squashfs_root_dir), 462 + ], 463 + true, 464 + )?; 450 465 451 466 run_command( 452 467 "chroot",
+5 -1
crates/firecracker-up/src/cmd/start.rs
··· 1 1 use std::process; 2 2 3 3 use anyhow::Error; 4 + use fire_config::TailscaleOptions; 4 5 use firecracker_state::repo; 5 6 use firecracker_vm::types::VmOptions; 6 7 7 8 use crate::cmd::up::up; 8 9 9 - pub async fn start(name: &str) -> Result<(), Error> { 10 + pub async fn start(name: &str, tailscale_auth_key: Option<String>) -> Result<(), Error> { 10 11 let etcd = match fire_config::read_config() { 11 12 Ok(config) => config.etcd, 12 13 Err(_) => None, ··· 46 47 ssh_keys: vm 47 48 .ssh_keys 48 49 .map(|keys| keys.split(',').map(|s| s.to_string()).collect()), 50 + tailscale: tailscale_auth_key.map(|key| TailscaleOptions { 51 + auth_key: Some(key), 52 + }), 49 53 }) 50 54 .await?; 51 55
+36 -2
crates/firecracker-up/src/main.rs
··· 44 44 .subcommand( 45 45 Command::new("start") 46 46 .arg(arg!(<name> "Name of the Firecracker MicroVM to start").required(true)) 47 + .arg( 48 + Arg::new("tailscale-auth-key") 49 + .long("tailscale-auth-key") 50 + .value_name("TAILSCALE_AUTH_KEY") 51 + .help("Tailscale auth key to connect the VM to a Tailscale network"), 52 + ) 47 53 .about("Start Firecracker MicroVM"), 48 54 ) 49 55 .subcommand( ··· 54 60 .subcommand( 55 61 Command::new("restart") 56 62 .arg(arg!(<name> "Name of the Firecracker MicroVM to restart").required(true)) 63 + .arg( 64 + Arg::new("tailscale-auth-key") 65 + .long("tailscale-auth-key") 66 + .value_name("TAILSCALE_AUTH_KEY") 67 + .help("Tailscale auth key to connect the VM to a Tailscale network"), 68 + ) 57 69 .about("Restart Firecracker MicroVM"), 58 70 ) 59 71 .subcommand( ··· 103 115 .long("ssh-keys") 104 116 .value_name("SSH_KEYS") 105 117 .help("Comma-separated list of SSH public keys to add to the VM"), 118 + ) 119 + .arg( 120 + Arg::new("tailscale-auth-key") 121 + .long("tailscale-auth-key") 122 + .value_name("TAILSCALE_AUTH_KEY") 123 + .help("Tailscale auth key to connect the VM to a Tailscale network"), 106 124 ) 107 125 .about("Start a new Firecracker MicroVM"), 108 126 ) ··· 195 213 .value_name("SSH_KEYS") 196 214 .help("Comma-separated list of SSH public keys to add to the VM"), 197 215 ) 216 + .arg( 217 + Arg::new("tailscale-auth-key") 218 + .long("tailscale-auth-key") 219 + .value_name("TAILSCALE_AUTH_KEY") 220 + .help("Tailscale auth key to connect the VM to a Tailscale network"), 221 + ) 198 222 } 199 223 200 224 #[tokio::main] ··· 218 242 } 219 243 Some(("start", args)) => { 220 244 let name = args.get_one::<String>("name").cloned().unwrap(); 221 - start(&name).await?; 245 + let tailscale_auth_key = args.get_one::<String>("tailscale-auth-key").cloned(); 246 + start(&name, tailscale_auth_key).await?; 222 247 } 223 248 Some(("restart", args)) => { 224 249 let name = args.get_one::<String>("name").cloned().unwrap(); 250 + let tailscale_auth_key = args.get_one::<String>("tailscale-auth-key").cloned(); 225 251 stop(&name).await?; 226 - start(&name).await?; 252 + start(&name, tailscale_auth_key).await?; 227 253 } 228 254 Some(("up", args)) => { 229 255 let vcpu = matches ··· 250 276 let ssh_keys = args 251 277 .get_one::<String>("ssh-keys") 252 278 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); 279 + let tailscale_auth_key = args.get_one::<String>("tailscale-auth-key").cloned(); 253 280 let options = VmOptions { 254 281 debian: args.get_one::<bool>("debian").copied(), 255 282 alpine: args.get_one::<bool>("alpine").copied(), ··· 274 301 mac_address, 275 302 etcd: None, 276 303 ssh_keys, 304 + tailscale: tailscale_auth_key.map(|key| fire_config::TailscaleOptions { 305 + auth_key: Some(key), 306 + }), 277 307 }; 278 308 up(options).await? 279 309 } ··· 369 399 let ssh_keys = matches 370 400 .get_one::<String>("ssh-keys") 371 401 .map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); 402 + let tailscale_auth_key = matches.get_one::<String>("tailscale-auth-key").cloned(); 372 403 373 404 let options = VmOptions { 374 405 debian: Some(debian), ··· 394 425 mac_address, 395 426 etcd: None, 396 427 ssh_keys, 428 + tailscale: tailscale_auth_key.map(|key| fire_config::TailscaleOptions { 429 + auth_key: Some(key), 430 + }), 397 431 }; 398 432 up(options).await? 399 433 }
+2 -2
crates/firecracker-vm/src/guest.rs
··· 1 1 use crate::{command::run_command, constants::BRIDGE_IP}; 2 2 use anyhow::Result; 3 3 4 - pub fn configure_guest_network(key_name: &str, guest_ip: &str) -> Result<()> { 4 + pub fn configure_guest_network(key_path: &str, guest_ip: &str) -> Result<()> { 5 5 println!("[+] Configuring network in guest..."); 6 6 const MAX_RETRIES: u32 = 20; 7 7 let mut retries = 0; ··· 10 10 "ssh", 11 11 &[ 12 12 "-i", 13 - key_name, 13 + key_path, 14 14 "-o", 15 15 "StrictHostKeyChecking=no", 16 16 &format!("root@{}", guest_ip),
+4
crates/firecracker-vm/src/lib.rs
··· 17 17 mod mosquitto; 18 18 mod mqttc; 19 19 mod network; 20 + mod tailscale; 20 21 pub mod types; 21 22 22 23 pub async fn setup( ··· 106 107 let guest_ip = format!("{}.firecracker", name); 107 108 guest::configure_guest_network(&key_name, &guest_ip)?; 108 109 } 110 + 111 + tailscale::setup_tailscale(&name, options)?; 112 + 109 113 let pool = firecracker_state::create_connection_pool().await?; 110 114 111 115 let ip_file = format!("/tmp/firecracker-{}.ip", name);
+90
crates/firecracker-vm/src/tailscale.rs
··· 1 + use std::fs; 2 + 3 + use anyhow::anyhow; 4 + use anyhow::Context; 5 + use anyhow::Error; 6 + use firecracker_prepare::command::run_command_with_stdout_inherit; 7 + use owo_colors::OwoColorize; 8 + 9 + use crate::types::VmOptions; 10 + 11 + pub fn setup_tailscale(name: &str, config: &VmOptions) -> Result<(), Error> { 12 + if let Some(tailscale) = &config.tailscale { 13 + if let Some(auth_key) = &tailscale.auth_key { 14 + let len = auth_key.len(); 15 + let display_key = if len > 16 { 16 + format!("{}****{}", &auth_key[..16], &auth_key[len - 4..]) 17 + } else { 18 + return Err(anyhow!("Tailscale auth key is too short")); 19 + }; 20 + println!("[+] Setting up Tailscale with auth key: {}", display_key); 21 + let key_path = 22 + get_private_key_path().with_context(|| "Failed to get SSH private key path")?; 23 + 24 + let guest_ip = format!("{}.firecracker", name); 25 + run_ssh_command(&key_path, &guest_ip, "rm -f /etc/security/namespace.init")?; 26 + run_ssh_command( 27 + &key_path, 28 + &guest_ip, 29 + "type tailscaled || curl -fsSL https://tailscale.com/install.sh | sh", 30 + )?; 31 + run_ssh_command( 32 + &key_path, 33 + &guest_ip, 34 + &format!("tailscale up --auth-key {} --hostname {}", auth_key, name), 35 + )?; 36 + run_ssh_command( 37 + &key_path, 38 + &guest_ip, 39 + "systemctl enable --now tailscaled || true", 40 + )?; 41 + run_ssh_command(&key_path, &guest_ip, "systemctl status tailscaled || true")?; 42 + run_ssh_command(&key_path, &guest_ip, "tailscale status || true")?; 43 + println!("[+] Tailscale setup completed."); 44 + println!( 45 + "[+] You can access the VM via its Tailscale domain: {}", 46 + format!("{}.tailscale.net", name).cyan() 47 + ); 48 + return Ok(()); 49 + } 50 + } 51 + 52 + println!("[+] Tailscale auth key not provided, skipping Tailscale setup."); 53 + Ok(()) 54 + } 55 + 56 + fn run_ssh_command(key_path: &str, guest_ip: &str, command: &str) -> Result<(), Error> { 57 + run_command_with_stdout_inherit( 58 + "ssh", 59 + &[ 60 + "-i", 61 + key_path, 62 + "-o", 63 + "StrictHostKeyChecking=no", 64 + &format!("root@{}", guest_ip), 65 + command, 66 + ], 67 + false, 68 + )?; 69 + Ok(()) 70 + } 71 + 72 + fn get_private_key_path() -> Result<String, Error> { 73 + let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("Failed to get home directory"))?; 74 + let app_dir = format!("{}/.fireup", home_dir.display()); 75 + let key_name = glob::glob(format!("{}/id_rsa", app_dir).as_str()) 76 + .with_context(|| "Failed to glob ssh key files")? 77 + .last() 78 + .ok_or_else(|| anyhow!("No SSH key file found"))? 79 + .with_context(|| "Failed to get SSH key path")?; 80 + let key_name = fs::canonicalize(&key_name) 81 + .with_context(|| { 82 + format!( 83 + "Failed to resolve absolute path for SSH key: {}", 84 + key_name.display() 85 + ) 86 + })? 87 + .display() 88 + .to_string(); 89 + Ok(key_name) 90 + }
+3 -1
crates/firecracker-vm/src/types.rs
··· 1 - use fire_config::{EtcdConfig, FireConfig}; 1 + use fire_config::{EtcdConfig, FireConfig, TailscaleOptions}; 2 2 use firecracker_prepare::Distro; 3 3 4 4 use crate::constants::{BRIDGE_DEV, FC_MAC, FIRECRACKER_SOCKET}; ··· 28 28 pub mac_address: String, 29 29 pub etcd: Option<EtcdConfig>, 30 30 pub ssh_keys: Option<Vec<String>>, 31 + pub tailscale: Option<TailscaleOptions>, 31 32 } 32 33 33 34 impl From<FireConfig> for VmOptions { ··· 57 58 mac_address: vm.mac.unwrap_or(FC_MAC.into()), 58 59 etcd: config.etcd.clone(), 59 60 ssh_keys: vm.ssh_keys.clone(), 61 + tailscale: vm.tailscale.clone(), 60 62 } 61 63 } 62 64 }