Compare commits

..

5 Commits

Author SHA1 Message Date
3d4750de21 Update Rust crate tokio to 1.45.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-05-27 00:09:59 +00:00
6a7af7e6c4 Add support to bridge option on Web UI
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 21:02:02 +02:00
a8171375a8 Added REST route to get the list of bridges 2025-05-26 20:43:19 +02:00
de33c7d521 Check if qemu-img is present before startup
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-26 19:21:40 +02:00
ff372800bd Add backend bridge support 2025-05-26 19:12:18 +02:00
15 changed files with 243 additions and 33 deletions

View File

@ -108,3 +108,9 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
/// API token right path max length
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
/// Qemu image program path
pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img";
/// IP program path
pub const IP_PROGRAM: &str = "/usr/sbin/ip";

View File

@ -188,3 +188,7 @@ pub async fn number_vcpus() -> HttpResult {
pub async fn networks_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::net_list()))
}
pub async fn bridges_list() -> HttpResult {
Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
}

View File

@ -80,7 +80,9 @@ pub struct NetMacAddress {
#[serde(rename = "source")]
pub struct NetIntSourceXML {
#[serde(rename = "@network")]
pub network: String,
pub network: Option<String>,
#[serde(rename = "@bridge")]
pub bridge: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]

View File

@ -53,7 +53,8 @@ pub struct Network {
#[serde(tag = "type")]
pub enum NetworkType {
UserspaceSLIRPStack,
DefinedNetwork { network: String }, // TODO : complete network types
DefinedNetwork { network: String },
Bridge { bridge: String },
}
#[derive(serde::Serialize, serde::Deserialize)]
@ -240,7 +241,18 @@ impl VMInfo {
mac,
r#type: "network".to_string(),
source: Some(NetIntSourceXML {
network: network.to_string(),
network: Some(network.to_string()),
bridge: None,
}),
model,
filterref,
},
NetworkType::Bridge { bridge } => DomainNetInterfaceXML {
r#type: "bridge".to_string(),
mac,
source: Some(NetIntSourceXML {
network: None,
bridge: Some(bridge.to_string()),
}),
model,
filterref,
@ -468,7 +480,34 @@ impl VMInfo {
r#type: match d.r#type.as_str() {
"user" => NetworkType::UserspaceSLIRPStack,
"network" => NetworkType::DefinedNetwork {
network: d.source.as_ref().unwrap().network.to_string(),
network: d
.source
.as_ref()
.unwrap()
.network
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing source network for defined network!"
.to_string(),
)
})?
.to_string(),
},
"bridge" => NetworkType::Bridge {
bridge: d
.source
.as_ref()
.unwrap()
.bridge
.as_deref()
.ok_or_else(|| {
LibVirtStructError::DomainExtraction(
"Missing bridge name for bridge connection!"
.to_string(),
)
})?
.to_string(),
},
a => {
return Err(LibVirtStructError::DomainExtraction(format!(

View File

@ -28,7 +28,7 @@ use virtweb_backend::controllers::{
use virtweb_backend::libvirt_client::LibVirtClient;
use virtweb_backend::middlewares::auth_middleware::AuthChecker;
use virtweb_backend::nat::nat_conf_mode;
use virtweb_backend::utils::files_utils;
use virtweb_backend::utils::{exec_utils, files_utils};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@ -43,6 +43,16 @@ async fn main() -> std::io::Result<()> {
// Load additional config from file, if requested
AppConfig::parse_env_file().unwrap();
log::debug!("Checking for required programs");
exec_utils::check_program(
constants::QEMU_IMAGE_PROGRAM,
"QEMU disk image utility is required to manipulate QCow2 files!",
);
exec_utils::check_program(
constants::IP_PROGRAM,
"ip is required to access bridges information!",
);
log::debug!("Create required directory, if missing");
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
@ -131,6 +141,10 @@ async fn main() -> std::io::Result<()> {
"/api/server/networks",
web::get().to(server_controller::networks_list),
)
.route(
"/api/server/bridges",
web::get().to(server_controller::bridges_list),
)
// Auth controller
.route(
"/api/auth/local",

View File

@ -0,0 +1,10 @@
use std::path::Path;
/// Check the existence of a required program
pub fn check_program(name: &str, description: &str) {
let path = Path::new(name);
if !path.exists() {
panic!("{name} does not exist! {description}");
}
}

View File

@ -155,7 +155,7 @@ impl FileDisk {
}
DiskFormat::QCow2 => {
let mut cmd = Command::new("/usr/bin/qemu-img");
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.arg("create")
.arg("-f")
.arg("qcow2")
@ -189,12 +189,13 @@ struct QCowInfoOutput {
/// Get QCow2 virtual size
fn qcow_virt_size(path: &str) -> anyhow::Result<usize> {
// Run qemu-img
let mut cmd = Command::new("qemu-img");
let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
cmd.args(["info", path, "--output", "json", "--force-share"]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"qemu-img info failed, status: {}, stderr: {}",
"{} info failed, status: {}, stderr: {}",
constants::QEMU_IMAGE_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);

View File

@ -1,3 +1,4 @@
pub mod exec_utils;
pub mod file_disks_utils;
pub mod files_utils;
pub mod net_utils;

View File

@ -1,6 +1,8 @@
use crate::constants;
use nix::sys::socket::{AddressFamily, SockaddrLike};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::process::Command;
use std::str::FromStr;
use sysinfo::Networks;
@ -68,7 +70,7 @@ pub fn net_list() -> Vec<String> {
/// Get the list of available network interfaces associated with their IP address
pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
let addrs = nix::ifaddrs::getifaddrs().unwrap();
let addrs = nix::ifaddrs::getifaddrs()?;
let mut res = HashMap::new();
@ -136,6 +138,31 @@ pub fn net_list_and_ips() -> anyhow::Result<HashMap<String, Vec<IpAddr>>> {
Ok(res)
}
#[derive(serde::Deserialize)]
struct IPBridgeInfo {
ifname: String,
}
/// Get the list of bridge interfaces
pub fn bridges_list() -> anyhow::Result<Vec<String>> {
let mut cmd = Command::new(constants::IP_PROGRAM);
cmd.args(["-json", "link", "show", "type", "bridge"]);
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} failed, status: {}, stderr: {}",
constants::IP_PROGRAM,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
// Parse JSON result
let res: Vec<IPBridgeInfo> = serde_json::from_str(&String::from_utf8_lossy(&output.stdout))?;
Ok(res.iter().map(|ip| ip.ifname.clone()).collect())
}
#[cfg(test)]
mod tests {
use crate::utils::net_utils::{

47
virtweb_docs/BRIDGE.md Normal file
View File

@ -0,0 +1,47 @@
# Bridges
Bridges can be used to connect virtual machines to networks.
## Setup Bridge on Ubuntu
1. Install dependencies:
```bash
sudo apt install bridge-utils
```
2. Adapt your netplan configuration to set the following:
```yaml
network:
version: 2
renderer: networkd
ethernets:
enp2s0:
dhcp4: no
bridges:
br0: # Bridge name
dhcp4: yes
interfaces:
- enp2s0 # Set to your interface
```
3. Apply netplan configuration:
```bash
sudo netplan apply
```
4. Get the state and the list of bridges in the system:
```bash
sudo brctl show
# Or
ip link show type bridge
```
## Reference
[How to Configure Network Bridge in Ubuntu](https://www.tecmint.com/create-network-bridge-in-ubuntu/)

View File

@ -217,4 +217,16 @@ export class ServerApi {
})
).data;
}
/**
* Get host networks bridges list
*/
static async GetNetworksBridgesList(): Promise<string[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/server/bridges",
})
).data;
}
}

View File

@ -50,7 +50,11 @@ export interface VMNetInterfaceFilter {
parameters: VMNetInterfaceFilterParams[];
}
export type VMNetInterface = (VMNetUserspaceSLIRPStack | VMNetDefinedNetwork) &
export type VMNetInterface = (
| VMNetUserspaceSLIRPStack
| VMNetDefinedNetwork
| VMNetBridge
) &
VMNetInterfaceBase;
export interface VMNetInterfaceBase {
@ -67,6 +71,11 @@ export interface VMNetDefinedNetwork {
network: string;
}
export interface VMNetBridge {
type: "Bridge";
bridge: string;
}
interface VMInfoInterface {
name: string;
uuid?: string;

View File

@ -29,6 +29,7 @@ export function VMNetworksList(p: {
onChange?: () => void;
editable: boolean;
networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[];
}): React.ReactElement {
const addNew = () => {
@ -72,6 +73,7 @@ function NetworkInfoWidget(p: {
onChange?: () => void;
removeFromList: () => void;
networksList: NetworkInfo[];
bridgesList: string[];
networkFiltersList: NWFilter[];
}): React.ReactElement {
const confirm = useConfirm();
@ -130,6 +132,11 @@ function NetworkInfoWidget(p: {
value: "DefinedNetwork",
description: "Attach to a defined network",
},
{
label: "Host bridge",
value: "Bridge",
description: "Attach to an host's bridge",
},
]}
/>
) : (
@ -149,31 +156,53 @@ function NetworkInfoWidget(p: {
}}
/>
{/* Defined network selection */}
{p.network.type === "DefinedNetwork" && (
<SelectInput
editable={p.editable}
label="Defined network"
options={p.networksList.map((n) => {
const chars = [n.forward_mode.toString()];
if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description);
return {
label: n.name,
value: n.name,
description: chars.join(" - "),
};
})}
value={p.network.network}
onValueChange={(v) => {
if (p.network.type === "DefinedNetwork")
p.network.network = v as any;
p.onChange?.();
}}
/>
)}
{/* Bridge selection */}
{p.network.type === "Bridge" && (
<SelectInput
editable={p.editable}
label="Host bridge"
options={p.bridgesList.map((n) => {
return {
label: n,
value: n,
};
})}
value={p.network.bridge}
onValueChange={(v) => {
if (p.network.type === "Bridge") p.network.bridge = v as any;
p.onChange?.();
}}
/>
)}
{p.network.type !== "UserspaceSLIRPStack" && (
<>
<SelectInput
editable={p.editable}
label="Defined network"
options={p.networksList.map((n) => {
const chars = [n.forward_mode.toString()];
if (n.ip_v4) chars.push("IPv4");
if (n.ip_v6) chars.push("IPv6");
if (n.description) chars.push(n.description);
return {
label: n.name,
value: n.name,
description: chars.join(" - "),
};
})}
value={p.network.network}
onValueChange={(v) => {
if (p.network.type === "DefinedNetwork")
p.network.network = v as any;
p.onChange?.();
}}
/>
{/* Network Filter */}
<NWFilterSelectInput
editable={p.editable}

View File

@ -725,6 +725,11 @@ export function TokenRightsEditor(p: {
right={{ verb: "GET", path: "/api/server/networks" }}
label="Get list of network cards"
/>
<RouteRight
{...p}
right={{ verb: "GET", path: "/api/server/bridges" }}
label="Get list of network bridges"
/>
</RightsSection>
</>
);

View File

@ -38,6 +38,7 @@ interface DetailsProps {
export function VMDetails(p: DetailsProps): React.ReactElement {
const [groupsList, setGroupsList] = React.useState<string[] | undefined>();
const [isoList, setIsoList] = React.useState<IsoFile[] | undefined>();
const [bridgesList, setBridgesList] = React.useState<string[] | undefined>();
const [vcpuCombinations, setVCPUCombinations] = React.useState<
number[] | undefined
>();
@ -51,6 +52,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
const load = async () => {
setGroupsList(await GroupApi.GetList());
setIsoList(await IsoFilesApi.GetList());
setBridgesList(await ServerApi.GetNetworksBridgesList());
setVCPUCombinations(await ServerApi.NumberVCPUs());
setNetworksList(await NetworkApi.GetList());
setNetworkFiltersList(await NWFilterApi.GetList());
@ -65,6 +67,7 @@ export function VMDetails(p: DetailsProps): React.ReactElement {
<VMDetailsInner
groupsList={groupsList!}
isoList={isoList!}
bridgesList={bridgesList!}
vcpuCombinations={vcpuCombinations!}
networksList={networksList!}
networkFiltersList={networkFiltersList!}
@ -87,6 +90,7 @@ enum VMTab {
type DetailsInnerProps = DetailsProps & {
groupsList: string[];
isoList: IsoFile[];
bridgesList: string[];
vcpuCombinations: number[];
networksList: NetworkInfo[];
networkFiltersList: NWFilter[];