diff --git a/Makefile b/Makefile index d5204793..6528947a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ IMAGES_DIR= -VRS = vr-xcon vr-bgp csr nxos routeros sros veos vjunosswitch vjunosevolved vmx vsr1000 vqfx vrp xrv xrv9k vsrx openbsd ftdv +VRS = vr-xcon vr-bgp csr nxos routeros sros veos vjunosswitch vjunosevolved vmx vsr1000 vqfx vrp xrv xrv9k vsrx openbsd ftdv freebsd VRS_PUSH = $(VRS:=-push) .PHONY: all $(VRS) $(VRS_PUSH) diff --git a/README.md b/README.md index d8a02d05..583426dd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Since the changes we made in this fork are VM specific, we added a few popular r * Juniper vJunosEvolved * Nokia SR OS * OpenBSD +* FreeBSD The rest are left untouched and can be contributed back by the community. diff --git a/freebsd/Makefile b/freebsd/Makefile new file mode 100644 index 00000000..4718bf00 --- /dev/null +++ b/freebsd/Makefile @@ -0,0 +1,17 @@ +VENDOR=FreeBSD +NAME=FreeBSD +IMAGE_FORMAT=qcow2 +IMAGE_GLOB=*.qcow2 + +# match versions like: +# freebsd-13.2-zfs-2023-04-21.qcow2 +VERSION=$(shell echo $(IMAGE) | sed -e 's/freebsd-\([0-9]\+\.[0-9]\)-.*/\1/') + +-include ../makefile-sanity.include +-include ../makefile.include + +download: + /bin/bash download.sh + +build: download + $(MAKE) docker-image \ No newline at end of file diff --git a/freebsd/README.md b/freebsd/README.md new file mode 100644 index 00000000..c25a92b4 --- /dev/null +++ b/freebsd/README.md @@ -0,0 +1,52 @@ +# vrnetlab / FreeBSD + +This is the vrnetlab docker image for FreeBSD. + +This docker image requires a custom-built FreeBSD image with pre-installed [cloud-init](https://cloudinit.readthedocs.io/en/latest/). You can download such images from https://bsd-cloud-image.org/. + +## Building the docker image + +Run `make download`. It will try to download the latest FreeBSD release from https://bsd-cloud-image.org/ to this directory. Then run `make` to build a docker image. + +If for some reasons you can't obtain an image from https://bsd-cloud-image.org/, you can build it yourself with the script from [this repository](https://github.com/goneri/pcib). + +It's been tested to boot, respond to SSH and have correct interface mapping +with the following images: + +* freebsd-13.2-zfs-2023-04-21.qcow2 + +## Usage + +``` +docker run -d --privileged --name vrnetlab/vr-freebsd: --username --password +``` + +Where: + +* `container_name` - name of the created container. +* `tag`- FreeBSD release version (e.g., 13.2). +* `username`, `password` - FreeBSD VM credentials. + +Example: + +``` +docker run -d --privileged --name my-obsd-router vrnetlab/vr-freebsd:13.2 --username admin --password admin +``` + +It will take about 1 minute for the container to boot. After that, you can try to ssh to the container's IP or telnet to port 5000 for console access. + +To obtain the container's IP run: + +``` +docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' +``` + +## Interface mapping + +Interface `vtnet0` is always configured as a management interface. Interfaces `vtnet1` to `vio16` can be used for data plane. + +## System requirements + +CPU: 1 core +RAM: 512MB +DISK: 4.0GB diff --git a/freebsd/docker/Dockerfile b/freebsd/docker/Dockerfile new file mode 100644 index 00000000..75bed9cb --- /dev/null +++ b/freebsd/docker/Dockerfile @@ -0,0 +1,34 @@ +FROM debian:bookworm-slim + +ARG DEBIAN_FRONTEND=noninteractive +ARG DISK_SIZE=4G + +RUN apt-get update -qy \ + && apt-get upgrade -qy \ + && apt-get install -y \ + bridge-utils \ + iproute2 \ + python3-ipy \ + socat \ + qemu-kvm \ + tcpdump \ + ssh \ + inetutils-ping \ + dnsutils \ + iptables \ + nftables \ + telnet \ + cloud-utils \ + sshpass \ + && rm -rf /var/lib/apt/lists/* + +ARG IMAGE +COPY $IMAGE* / +COPY *.py / +COPY --chmod=0755 backup.sh / + +RUN qemu-img resize /${IMAGE} ${DISK_SIZE} + +EXPOSE 22 5000 10000-10099 +HEALTHCHECK CMD ["/healthcheck.py"] +ENTRYPOINT ["/launch.py"] diff --git a/freebsd/docker/backup.sh b/freebsd/docker/backup.sh new file mode 100755 index 00000000..33e9a581 --- /dev/null +++ b/freebsd/docker/backup.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +DEFAULT_USER="admin" +DEFAULT_PASSWORD="admin" +BACKUP_FILE="backup.tar.gz" +BACKUP_PATH=/config/$BACKUP_FILE +REMOTE_BACKUP_PATH=/tmp/$BACKUP_FILE + +handle_args() { + # Parse options + while getopts 'u:p:' OPTION; do + case "$OPTION" in + u) + user="$OPTARG" + ;; + p) + password="$OPTARG" + ;; + ?) + usage + exit 1 + ;; + esac + done + shift "$(($OPTIND -1))" + + # Assign defaults if options weren't provided + if [ -z "$user" ] ; then + user=$DEFAULT_USER + fi + if [ -z "$password" ] ; then + password=$DEFAULT_PASSWORD + fi + + SSH_CMD="sshpass -p $password ssh -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p2022" + SCP_CMD="sshpass -p $password scp -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -P2022" + HOST="$user@localhost" + + # Parse commands + case $1 in + + backup) + backup + ;; + + restore) + restore + ;; + + *) + usage + ;; + esac +} + +usage() { + echo "Usage: $(basename $0) [-u USERNAME] [-p PASSWORD] COMMAND" + echo "Options:" + echo " -u USERNAME VM SSH username (default: admin)" + echo " -p PASSWORD VM SSH password (default: admin)" + echo + echo "Commands:" + echo " backup Backup VM /etc directory to $BACKUP_PATH" + echo " restore Restore VM /etc directory from $BACKUP_PATH" + exit 0; +} + +backup() { + echo "Backing up..." + $SSH_CMD $HOST "sudo tar zcf $REMOTE_BACKUP_PATH /etc > & /dev/null" + $SCP_CMD $HOST:$REMOTE_BACKUP_PATH $BACKUP_PATH +} + +restore() { + if [ -f "$BACKUP_PATH" ]; then + echo "Restoring from backup..." + # Put backup file to VM, untar, and reboot. + $SCP_CMD $BACKUP_PATH $HOST:$REMOTE_BACKUP_PATH && $SSH_CMD $HOST "sudo tar xzf $REMOTE_BACKUP_PATH -C /" && $SSH_CMD $HOST "sudo shutdown -r now || true" + else + echo "$BACKUP_PATH not found. Nothing to restore." + fi +} + +handle_args "$@" diff --git a/freebsd/docker/launch.py b/freebsd/docker/launch.py new file mode 100755 index 00000000..6cfd180d --- /dev/null +++ b/freebsd/docker/launch.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +import datetime +import logging +import os +import re +import signal +import subprocess +import sys + + +import vrnetlab + +BACKUP_FILE = "/config/backup.tar.gz" + + +def handle_SIGCHLD(signal, frame): + os.waitpid(-1, os.WNOHANG) + + +def handle_SIGTERM(signal, frame): + sys.exit(0) + + +signal.signal(signal.SIGINT, handle_SIGTERM) +signal.signal(signal.SIGTERM, handle_SIGTERM) +signal.signal(signal.SIGCHLD, handle_SIGCHLD) + +TRACE_LEVEL_NUM = 9 +logging.addLevelName(TRACE_LEVEL_NUM, "TRACE") + + +def trace(self, message, *args, **kws): + # Yes, logger takes its '*args' as 'args'. + if self.isEnabledFor(TRACE_LEVEL_NUM): + self._log(TRACE_LEVEL_NUM, message, args, **kws) + + +logging.Logger.trace = trace + + +class FreeBSD_vm(vrnetlab.VM): + def __init__( + self, + hostname, + username, + password, + nics, + conn_mode, + ): + for e in os.listdir("/"): + if re.search(".qcow2$", e): + disk_image = "/" + e + + super(FreeBSD_vm, self).__init__( + username, password, disk_image=disk_image, ram=512 + ) + + self.num_nics = nics + self.hostname = hostname + self.conn_mode = conn_mode + self.nic_type = "virtio-net-pci" + + self.image_name = "cloud_init.iso" + self.create_boot_image() + + self.qemu_args.extend(["-cdrom", "/" + self.image_name]) + + def create_boot_image(self): + """Creates a cloud-init iso image with a bootstrap configuration""" + + with open("/bootstrap_config.yaml", "w") as cfg_file: + cfg_file.write("#cloud-config\n") + cfg_file.write(f"hostname: {self.hostname}\n") + cfg_file.write(f"fqdn: {self.hostname}\n") + cfg_file.write("users:\n") + cfg_file.write(f" - name: {self.username}\n") + cfg_file.write(' sudo: "ALL=(ALL) NOPASSWD: ALL"\n') + cfg_file.write(" groups: wheel\n") + cfg_file.write(f" home: /usr/home/{self.username}\n") + cfg_file.write(" shell: /bin/tcsh\n") + cfg_file.write(f" plain_text_passwd: {self.password}\n") + cfg_file.write(" lock_passwd: false\n") + cfg_file.write("ssh_pwauth: true\n") + cfg_file.write("disable_root: false\n") + cfg_file.write("timezone: UTC\n") + # Disable cloud-init for the subsequent boots + cfg_file.write("runcmd:\n") + cfg_file.write(" - sed -i '' '/cloudinit_enable=\"YES\"/s/YES/NONE/' /etc/rc.conf\n") + + with open("/network_config.yaml", "w") as net_cfg_file: + net_cfg_file.write("version: 2\n") + net_cfg_file.write("ethernets:\n") + net_cfg_file.write(" vtnet0:\n") + net_cfg_file.write(" addresses: [10.0.0.15/24]\n") + net_cfg_file.write(" gateway4: 10.0.0.2\n") + + cloud_localds_args = [ + "cloud-localds", + "-v", + "--network-config=/network_config.yaml", + "/" + self.image_name, + "/bootstrap_config.yaml", + ] + + subprocess.Popen(cloud_localds_args) + + def restore_backup(self): + """Restore saved backup if there is one""" + + if not os.path.exists(BACKUP_FILE): + self.logger.trace(f"Backup file {BACKUP_FILE} not found") + return + + self.logger.trace(f"Backup file {BACKUP_FILE} exists") + + subprocess.run( + f"/backup.sh -u {self.username} -p {self.password} restore", + shell=True, + check=True, + ) + + def bootstrap_spin(self): + """This function should be called periodically to do work.""" + + if self.spins > 600: + # too many spins with no result -> give up + self.stop() + self.start() + return + + (ridx, match, res) = self.tn.expect([b"login: "], 1) + if match: # got a match! + if ridx == 0: # login + + self.logger.debug("matched, login: ") + self.wait_write("", wait=None) + + self.restore_backup() + + self.running = True + # close telnet connection + self.tn.close() + # startup time? + startup_time = datetime.datetime.now() - self.start_time + self.logger.info("Startup complete in: %s", startup_time) + return + + # no match, if we saw some output from the router it's probably + # booting, so let's give it some more time + if res != b"": + self.logger.trace("OUTPUT: %s" % res.decode()) + # reset spins if we saw some output + self.spins = 0 + + self.spins += 1 + + return + + def gen_mgmt(self): + """ + Augment the parent class function to change the PCI bus + """ + # call parent function to generate the mgmt interface + res = super(FreeBSD_vm, self).gen_mgmt() + + # we need to place mgmt interface on the same bus with other interfaces in FreeBSD, + # otherwise, it will be assigned the last index by the OS, + # and not the first (i.e., vio0) as desired + if "bus=pci.1" not in res[-3]: + res[-3] = res[-3] + ",bus=pci.1" + return res + + +class FreeBSD(vrnetlab.VR): + def __init__(self, hostname, username, password, nics, conn_mode): + super(FreeBSD, self).__init__(username, password) + self.vms = [FreeBSD_vm(hostname, username, password, nics, conn_mode)] + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="") + parser.add_argument( + "--trace", action="store_true", help="enable trace level logging" + ) + parser.add_argument("--username", default="admin", help="Username") + parser.add_argument("--password", default="admin", help="Password") + parser.add_argument("--hostname", default="freebsd", help="VM Hostname") + parser.add_argument("--nics", type=int, default=16, help="Number of NICS") + parser.add_argument( + "--connection-mode", + default="tc", + help="Connection mode to use in the datapath", + ) + args = parser.parse_args() + + LOG_FORMAT = "%(asctime)s: %(module)-10s %(levelname)-8s %(message)s" + logging.basicConfig(format=LOG_FORMAT) + logger = logging.getLogger() + + logger.setLevel(logging.DEBUG) + if args.trace: + logger.setLevel(1) + + vr = FreeBSD( + args.hostname, + args.username, + args.password, + args.nics, + args.connection_mode, + ) + vr.start() diff --git a/freebsd/download.sh b/freebsd/download.sh new file mode 100755 index 00000000..aa9bd13d --- /dev/null +++ b/freebsd/download.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Webpage with links to images +base_url="https://bsd-cloud-image.org" + +# Download the webpage content +webpage_content=$(curl -s "$base_url") + +# Find URLs that match the pattern "freebsd*zfs*.qcow2" and select the most recent version +download_url=$(echo "$webpage_content" | grep -oE 'https?://[^"]+freebsd[^"]+zfs[^"]+\.qcow2' | sort | tail -n 1) + +# Extract the filename from the URL +filename=$(basename "$download_url") + +# Check if the file already exists in the current directory +if [ -e "$filename" ]; then + echo "File $filename already exists. Skipping download." +else + # Download the URL + curl -O "$download_url" + echo "Download complete: $filename" +fi \ No newline at end of file