#!/bin/sh
#
# Jail management utility.

set -eu -o pipefail

. /usr/local/etc/jailctl.conf

cmd::main(){
  local usage="COMMAND [ARGS]...
FreeBSD jail management utility.
Commands:
  create            Create a new jail
  create-snapshot   Take a snapshot of a jail
  create-template   Create a template from a jail
  destroy-snapshot  Delete a jail snapshot
  destroy-template  Delete a template
  destroy           Delete a jail and its dataset
  download-release  Download and create a FreeBSD release template
  edit              Edit a jail's configuration
  exec              Run a command within the jail
  list-snapshots    List jail snapshots
  list-templates    List available templates
  list              List configured jails
  reprovision       Wipe and reprovision an OS dataset from template
  restart           Restart a jail
  rollback          Rollback a jail to a given snapshot
  shell             Run a shell within the jail
  show              Show jail configuration
  start             Start a jail
  status            Show running jail status
  stop              Stop a jail
  update-release    Update a FreeBSD release template"

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -ge 1 ] || cmd::usage 'no comand specified'
  local cmd=$1; shift

  case $cmd in
    create)                         cmd::create "$@" ;;
    create-snapshot|snapshot|snap)  cmd::create_snapshot "$@" ;;
    create-template)                cmd::create_template "$@" ;;
    destroy-snapshot|rms)           cmd::destroy_snapshot "$@" ;;
    destroy-template|rmt)           cmd::destroy_template "$@" ;;
    destroy|rm)                     cmd::destroy "$@" ;;
    download-release)               cmd::download_release "$@" ;;
    edit)                           cmd::edit "$@" ;;
    exec)                           cmd::exec "$@" ;;
    list-snapshots|lss)             cmd::list_snapshots "$@" ;;
    list-templates|lst)             cmd::list_templates "$@" ;;
    list|ls)                        cmd::list "$@" ;;
    reprovision)                    cmd::reprovision "$@" ;;
    restart)                        cmd::restart "$@" ;;
    rollback)                       cmd::rollback "$@" ;;
    shell|sh)                       cmd::shell "$@" ;;
    show)                           cmd::show "$@" ;;
    start)                          cmd::start "$@" ;;
    status)                         cmd::status "$@" ;;
    stop)                           cmd::stop "$@" ;;
    update-release)                 cmd::update_release "$@" ;;
    # The following commands are internal to jailctl. Don't run them manually.
    _create-epair)                  cmd::_create_epair "$@" ;;
    _destroy-epair)                 cmd::_destroy_epair "$@" ;;
    *)                              cmd::usage "unknown command: ${cmd}" ;;
  esac
}

################################################################################
# Standard helper functions.
################################################################################
die(){
  printf '%s: %s\n' jailctl "$*" 1>&2
  exit 1
}

warn(){
  printf '%s\n' "$*" 1>&2
}


################################################################################
# CLI-related functions.
################################################################################
cmd::help(){
  printf 'Usage: %s %s\n' jailctl "$usage"
  [ -n "${help:-}" ] && printf '%s\n' "$help"
  exit 0
}

cmd::usage(){
  [ $# -gt 0 ] && printf '%s: %s\n' "jailctl" "$1" 1>&2
  printf 'Usage: %s %s\n' jailctl "${usage}" 1>&2
  exit 2
}

cmd::getopt_help(){
  local opt
  while getopts :h opt; do
    case $opt in
      h) cmd::help ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
}

cmd::create(){
  local usage='create [-a IP] [-b] [-c CPUSET] [-d DOMAIN] [-g GATEWAY] [-k SSHKEY]
       [-m MEMLIMIT] [-n NETMASK] [-q QUOTA] [-Q OS_QUOTA] [-r NAMESERVER]
       [-s SEARCHDOMAIN] [-v VLANID] NAME TEMPLATE'
  local help="Create a new jail.
Options:
  -a IP            IPv4 address
  -b               Enable BPF device (allows DHCP, tcpdump, etc)
  -c CPUSET        CPU list for cpuset(1)
  -d DOMAIN        Host domain name
  -g GATEWAY       Default IPv4 gateway
  -k SSHKEY        Path to SSH pubkey for root's authorized_keys
  -m MEMLIMIT      Virtual memory limit
  -n NETMASK       IPv4 netmask
  -q QUOTA         Quota for delegated dataset
  -Q OS_QUOTA      Quota for root filesystem
  -r NAMESERVER    DNS resolver
  -s SEARCHDOMAIN  DNS search domain
  -v VLANID        VLAN ID number"

  local \
    bpf_enabled=false \
    cpuset \
    data_quota \
    data_quota=$DEFAULT_DATA_QUOTA \
    devfs_ruleset=$DEFAULT_DEVFS_RULESET \
    domain=$DEFAULT_DOMAIN \
    gateway \
    jail_opts \
    ip \
    memlimit \
    nameservers \
    netmask=$DEFAULT_NETMASK \
    os_quota=$DEFAULT_OS_QUOTA \
    searchdomains \
    snapshot \
    sshkey \
    vlan=$DEFAULT_VLAN \
    opt

  while getopts :a:bc:d:e:g:hk:m:n:q:Q:r:s:v: opt; do
    case $opt in
      a) ip=$OPTARG ;;
      b) bpf_enabled=true ;;
      c) cpuset=$OPTARG ;;
      d) domain=$OPTARG ;;
      e) jail_opts="${jail_opts:-}"$'\n'"  ${OPTARG};" ;;
      g) gateway=$OPTARG ;;
      h) cmd::help ;;
      k) sshkey=$OPTARG ;;
      m) memlimit=$OPTARG ;;
      n) netmask=$OPTARG ;;
      q) data_quota=$OPTARG ;;
      Q) os_quota=$OPTARG ;;
      r) nameservers="${nameservers:-} ${OPTARG}" ;;
      s) searchdomains="${searchdomains:-} ${OPTARG}" ;;
      v) vlan=$OPTARG ;;
      :) cmd::usage "missing option value: -${OPTARG}" ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  if [ -n "${ip:-}" ]; then
    : ${gateway:="${ip%.*}.1"}
  fi

  : ${nameservers:="$DEFAULT_NAMESERVERS"}

  [ $# -lt 1 ] && cmd::usage 'NAME not specified'
  [ $# -lt 2 ] && cmd::usage 'TEMPLATE not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local name=$1 template=$2

  jail::exists "$name"         && die "jail name already in use: ${name}"
  template::exists "$template" || die "no such template: ${template}"

  interface::exists "bridge${vlan}" || interface::add_vlan "$vlan"

  if [ -n "${sshkey:-}" ]; then
    [ -f "$sshkey" ] || die "ssh key ${sshkey}: file not found"
  fi

  zfs::ensure_snapshot snapshot "${JAIL_DATASET}/templates/${template}"

  # Clone template into new 'os' dataset.
  zfs create -v "${JAIL_DATASET}/${name}"
  zfs clone \
    $ZFS_OPTS \
    -o refquota="$os_quota" \
    "$snapshot" "${JAIL_DATASET}/${name}/os"

  # Create delegated 'data' dataset.
  zfs create -v \
    $ZFS_OPTS \
    -o mountpoint=none \
    -o refquota="$data_quota" \
    "${JAIL_DATASET}/${name}/data"

  # Copy timezone configuration from host.
  cp -v /etc/localtime "${JAIL_HOME}/${name}/os/etc/localtime"

  # Generate /etc/resolv.conf in the jail (word-splitting intentional).
  printf 'nameserver %s\n' $nameservers >> "${JAIL_HOME}/${name}/os/etc/resolv.conf"
  printf 'search %s\n'     "${searchdomains:-$domain}" >> "${JAIL_HOME}/${name}/os/etc/resolv.conf"

  # Get the jail's virtual interface name.
  local epair_name
  epair_name=$(interface::epair::derive_name "$name")

  # Set /etc/rc.conf values within the jail.
  sysrc -v -f "${JAIL_HOME}/${name}/os/etc/rc.conf" \
    "hostname=${name}.${domain}" \
    "ifconfig_ej_${epair_name}_name=jail0" \
    'ipv6_activate_all_interfaces=NO' \
    'syslogd_flags=-ss' \
    'sendmail_enable=NONE' \
    'dumpdev=NO' \
    'pf_enable=YES'

  echo "$DEFAULT_PF_CONF" > "${JAIL_HOME}/${name}/os/etc/pf.conf"

  if [ -n "${ip:-}" ]; then
    # If $ip set, configure /etc/rc.conf for static IP.
    sysrc -v -f "${JAIL_HOME}/${name}/os/etc/rc.conf" \
      "ifconfig_jail0=inet ${ip} netmask ${netmask}" \
      "defaultrouter=${gateway}"
  else
    # Othersie, configure /etc/rc.conf for DHCP.
    sysrc -v -f "${JAIL_HOME}/${name}/os/etc/rc.conf" "ifconfig_jail0=SYNCDHCP"
    devfs_ruleset=$BPF_ENABLED_DEVFS_RULESET
  fi

  if [ -n "${sshkey:-}" ]; then
    # If $sshkey set, enable sshd and root login.
    echo "PermitRootLogin prohibit-password" | tee -a "${JAIL_HOME}/${name}/os/etc/ssh/sshd_config"
    sysrc -v -f "${JAIL_HOME}/${name}/os/etc/rc.conf" 'sshd_enable=YES'
    # Copy the ssh key into root's authorized_keys within the jail.
    install -v -d -m 0700 "${JAIL_HOME}/${name}/os/root/.ssh"
    install -v -m 0600 "$sshkey" "${JAIL_HOME}/${name}/os/root/.ssh/authorized_keys"
  fi

  # If BPF requested, use our custom bpf ruleset.
  [ "${bpf_enabled}" = true ] && devfs_ruleset=$BPF_ENABLED_DEVFS_RULESET

  # Generate jail config file.
  local jailcfg="${JAIL_HOME}/${name}/jail.conf"
  cat <<EOF > "$jailcfg"
${name} {
  path = "${JAIL_HOME}/\$name/os";
  host.hostname = "\$name.${domain}";

  exec.prestart  = "jailctl _create-epair \$name vlan${vlan} bridge${vlan}";
  exec.created   = "zfs set jailed=on ${JAIL_DATASET}/\$name/data";
  exec.created  += "zfs jail \$name ${JAIL_DATASET}/\$name/data";
  exec.start     = "zfs mount -a";
  exec.start    += "/bin/sh /etc/rc";
  exec.stop      = "/bin/sh /etc/rc.shutdown";
  exec.stop     += "zfs list -Ho name,jailed,mounted | awk '\$2 == \\"on\\" && \$3 == \\"yes\\" {print \$1}' | xargs -rtn1 zfs unmount";
  exec.poststop  = "jailctl _destroy-epair \$name";
  exec.poststop += "rctl -r jail:\$name:";
  exec.clean;

  exec.system_user = "root";
  exec.jail_user   = "root";

  mount.devfs;
  devfs_ruleset = "${devfs_ruleset}";

  mount           = "tmpfs \$path/tmp tmpfs rw,size=1G 0 0";
  allow.mount     = true;
  allow.mount.zfs = true;
  enforce_statfs  = 1;

  vnet;
  vnet.interface = "ej_${epair_name}";
${jail_opts:-}

EOF

  ln -sv "$jailcfg" "/etc/jail.conf.d/${name}.conf"

  # Configure resource limits.
  [ -n "${cpuset:-}" ] && \
    echo "  exec.created += \"cpuset -j \$name -cl ${cpuset}\";" >> "$jailcfg"
  [ -n "${memlimit:-}" ] && \
    echo "  exec.prestart += \"rctl -a jail:\$name:memoryuse:deny=${memlimit}\";" >> "$jailcfg"

  # End jail config file.
  echo '}' >> "$jailcfg"

  # Update host's /etc/rc.conf to start the jail on boot.
  sysrc -v jail_list+="$name"

  # Start the jail.
  jail::start "$name"
}

cmd::create_snapshot(){
  local usage='create-snapshot [-d] [-o] JAIL [SNAPNAME]'
  local help='Create a jail snapshot.
Options:
  -d  Only snapshot the data dataset
  -o  Only snapshot the OS dataset'

  local opt both=true os_only=false data_only=false snapname
  while getopts :dho opt; do
    case $opt in
      d) data_only=true; both=false ;;
      h) cmd::help ;;
      o) os_only=true; both=false ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  jail=$1 snapname=${2:-}

  jail::exists "$jail" || die "no such jail: ${jail}"

  [ -n "$snapname" ] || snapname=$(date +%Y-%m-%dT%H:%M:%S)

  # Snapshot the OS dataset.
  if [ "$both" = true ] || [ "$os_only" = true ]; then
    zfs snapshot "${JAIL_DATASET}/${jail}/os@${snapname}"
  fi

  # Snapshot the data dataset.
  if [ "$both" = true ] || [ "$data_only" = true ]; then
    zfs snapshot "${JAIL_DATASET}/${jail}/data@${snapname}"
  fi
}

cmd::create_template(){
  local usage='create-template NAME JAIL'
  local help='Create a template from a jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'NAME not specified'
  [ $# -lt 2 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local name=$1 jail=$2

  jail::exists "$jail"     || die "no such jail: ${jail}"
  template::exists "$name" && die "template already exists: $name"
  jail::running "$jail"    && die "refusing to create template while jail is running: ${jail}"

  local snapname
  snapname=$(date +%Y-%m-%dT%H:%M:%S)

  zfs snapshot "${JAIL_DATASET}/${jail}/os@${snapname}"
  zfs send "${JAIL_DATASET}/${jail}/os@${snapname}" | zfs receive -v "${JAIL_DATASET}/templates/${name}"
  zfs destroy "${JAIL_DATASET}/${jail}/os@${snapname}"
}

cmd::destroy_snapshot(){
  local usage='destroy-snapshot [-y] JAIL SNAPSHOT'
  local help="Delete a jail snapshot
Options:
  -y  Don't prompt for confirmation"

  local noconfirm=false answer opt

  while getopts :hy opt; do
    case $opt in
      h) cmd::help ;;
      y) noconfirm=true ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -lt 2 ] && cmd::usage 'SNAPSHOT not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local jail=$1 snapshot=$2

  jail::exists "$jail" || die "no such jail: ${jail}"

  local datasets=''

  if [ "${snapshot#*@}" != "$snapshot" ]; then
    # If the snapshot name contains '@', then we have something like 'os@snapname'.
    zfs::dataset_exists "${JAIL_DATASET}/${jail}/${snapshot}" && \
      datasets="${JAIL_DATASET}/${jail}/${snapshot}"
  else
    # Otherwise, check if either os or data dataset contains a matching snapshot.
    zfs::dataset_exists "${JAIL_DATASET}/${jail}/os@${snapshot}" && \
      datasets="${JAIL_DATASET}/${jail}/os@${snapshot}"

    zfs::dataset_exists "${JAIL_DATASET}/${jail}/data@${snapshot}" && \
      datasets="${datasets} ${JAIL_DATASET}/${jail}/data@${snapshot}"
  fi

  [ -n "$datasets" ] || die "no such snapshot for jail ${jail}: ${snapshot}"

  if [ "$noconfirm" != true ]; then
    read -rp "Really destroy ${jail} snapshot ${snapshot}? (y/N) " answer
    case $answer in
      [yY]|[yY][eE][sS]) : ;;
      *) die 'operation cancelled' ;;
    esac
  fi

  local dataset
  for dataset in $datasets; do
    zfs destroy -v "$dataset"
  done
}

cmd::destroy_template(){
  local usage='destroy-template [-y] TEMPLATE'
  local help="Delete a jail template.
Options:
  -y  Don't prompt for confirmation
Notes:
  A template cannot be deleted while its clones still exist."

  local noconfirm=false answer opt

  while getopts :hy opt; do
    case $opt in
      h) cmd::help ;;
      y) noconfirm=true ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'TEMPLATE not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  template::exists "$jail" || die "no such template: ${jail}"

  if [ "$noconfirm" != true ]; then
    read -rp "Really destroy template ${jail}? (y/N) " answer
    case $answer in
      [yY]|[yY][eE][sS]) : ;;
      *) die 'operation cancelled' ;;
    esac
  fi

  zfs destroy -v -r "${JAIL_DATASET}/templates/${jail}"
}

cmd::destroy(){
  local usage='destroy [-y] JAIL'
  local help="Delete a jail and its dataset.
Options:
  -y  Don't prompt for confirmation"

  local noconfirm=false answer opt

  while getopts :hy opt; do
    case $opt in
      h) cmd::help ;;
      y) noconfirm=true ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail" || die "no such jail: ${jail}"

  if [ "$noconfirm" != true ]; then
    read -rp "Really destroy jail ${jail}? (y/N) " answer
    case $answer in
      [yY]|[yY][eE][sS]) : ;;
      *) die 'operation cancelled' ;;
    esac
  fi

  jail::running "$jail" && jail::stop "$jail"

  # Delete jail config file.
  rm "/etc/jail.conf.d/${jail}.conf" "${JAIL_HOME}/${jail}/jail.conf"

  # Remove the jail from the autostart list.
  sysrc -v jail_list-="$jail" ||:

  # Destroy the jail's dataset.
  zfs destroy -v -f -r "${JAIL_DATASET}/${jail}"
}

cmd::download_release() {
  local usage='download-release RELEASE'
  local help='Download and create a FreeBSD release template.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'RELEASE not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local release=$1

  local template
  template::release2name template "$release"

  template::exists "$template" && die "template already exists: ${template}"
  template::download_release "$release"
}

cmd::edit(){
  local usage='edit JAIL'
  local help='Edit jail configuration.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail" || die "no such jail: ${jail}"
  "$EDITOR" "${JAIL_HOME}/${jail}/jail.conf"
}

cmd::exec(){
  local usage='exec JAIL COMMAND...'
  local help='Run a command within the jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -lt 2 ] && cmd::usage 'COMMAND not specified'
  jail=$1; shift

  jail::exists "$jail" || die "no such jail: ${jail}"

  jail::exec "$jail" "$@"
}

cmd::list(){
  local usage='list [-t]'
  local help='List configured jails.
Options:
  -t  Use terse output (jail names only)'

  local file name status opt terse=false

  while getopts :th opt; do
    case $opt in
      t) terse=true ;;
      h) cmd::help ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -eq 0 ] || cmd::usage 'too many arguments'

  { [ $terse = true ] || echo 'JAIL STATUS'
    for file in "$JAIL_HOME"/*/jail.conf; do
      [ -e "$file" ] || continue

      name=$(basename "$(dirname "$file")")

      if [ $terse = true ]; then
        printf '%s\n' "$name"
      else
        if jail::running "$name"; then
          status=running
        else
          status=stopped
        fi
        printf '%s %s\n' "$name" "$status"
      fi
    done
  } | column -t
}

cmd::list_templates(){
  local usage='list-templates'
  local help='list template datasets.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -eq 0 ] || cmd::usage 'too many arguments'

  ls -1 "${JAIL_HOME}/templates"
}

cmd::list_snapshots(){
  local usage='list-snapshots JAIL'
  local help='List jail snapshots
Options:
  -d  List snapshots from data dataset only
  -o  List snapshots from OS dataset only
  -t  Use terse output'

  local opt terse=false dataset=''
  while getopts :dhot opt; do
    case $opt in
      d) dataset=/data ;;
      h) cmd::help ;;
      o) dataset=/os ;;
      t) terse=true ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail" || die "no such jail: ${jail}"

  if [ "$terse" = true ]; then
    zfs list -r -t snapshot -H -o name -s creation "${JAIL_DATASET}/${jail}${dataset}" 2>/dev/null \
      | sed 's/^.*\///' \
      | sort -k1,1 -t@ -s
  else
    { echo 'DATASET SNAPSHOT USED REFER'
      zfs list -r -t snapshot -H -o name,used,refer -s creation "${JAIL_DATASET}/${jail}${dataset}" 2>/dev/null
    } | sed 's/^.*\///' \
      | sort -k1,1 -t@ -s \
      | tr '@' ' ' \
      | column -t
  fi
}

cmd::rollback(){
  local usage='rollback JAIL SNAPSHOT'
  local help='Rollback a jail to a given snapshot.
Options:
  -d  Rollback the data dataset only
  -f  Attempt rollback even if the jail is running
  -o  Rollback the OS dataset only'

  local opt force=false both=true os_only=false data_only=false
  while getopts :dfho opt; do
    case $opt in
      d) data_only=true; both=false ;;
      f) force=true ;;
      h) cmd::help ;;
      o) os_only=true; both=false ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -lt 2 ] && cmd::usage 'SNAPSHOT not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local jail=$1 snapshot=$2

  jail::exists "$jail" || die "no such jail: ${jail}"

  # Ensure OS snapshot exists, if requested.
  if [ "$both" = true ] || [ "$os_only" = true ]; then
    zfs::dataset_exists "${JAIL_DATASET}/${jail}/os@${snapshot}" \
      || die "no such snapshot for ${jail}/os: ${snapshot}"
  fi

  # Ensure data snapshot exists, if requested.
  if [ "$both" = true ] || [ "$data_only" = true ]; then
    zfs::dataset_exists "${JAIL_DATASET}/${jail}/data@${snapshot}" \
      || die "no such snapshot for ${jail}/data: ${snapshot}"
  fi

  # Don't rollback while jail is running.
  if jail::running "$jail" && [ "$force" != true ]; then
    die "jail ${jail} is running, refusing to rollback (-f to override)"
  fi

  # Rollback the OS snapshot, if requested.
  if [ "$both" = true ] || [ "$os_only" = true ]; then
    zfs rollback -r "${JAIL_DATASET}/${jail}/os@${snapshot}"
  fi

  # Rollback the data snapshot, if requested.
  if [ "$both" = true ] || [ "$data_only" = true ]; then
    zfs rollback -r "${JAIL_DATASET}/${jail}/data@${snapshot}"
  fi
}

cmd::start(){
  local usage='start JAIL'
  local help='Start a jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail"  || die "no such jail: ${jail}"
  jail::running "$jail" && die "jail already running: ${jail}"

  jail::start "$jail"
}

cmd::stop(){
  local usage='stop JAIL'
  local help='Stop a jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail"  || die "no such jail: ${jail}"
  jail::running "$jail" || die "jail not running: ${jail}"

  jail::stop "$jail"
}

cmd::reprovision(){
  local usage='reprovision JAIL TEMPLATE'
  local help="Wipe and reprovision a jail's OS dataset from a template."

  local opt noconfirm=false running=false
  while getopts :hy opt; do
    case $opt in
      h) cmd::help ;;
      y) noconfirm=true ;;
      ?) cmd::usage "unknown option: -${OPTARG}" ;;
    esac
  done
  shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -lt 2 ] && cmd::usage 'TEMPLATE not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local jail=$1 template=$2

  jail::exists "$jail"         || die "no such jail: ${jail}"
  template::exists "$template" || die "no such template: ${template}"

  if [ "$noconfirm" != true ]; then
    read -rp "Really reprovision ${jail}? (y/N) " answer
    case $answer in
      [yY]|[yY][eE][sS]) : ;;
      *) die 'operation cancelled' ;;
    esac
  fi

  # If the jail is running, stop it.
  if jail::running "$jail"; then
    running=true
    jail::stop "$jail"
  fi

  local snapshot old_quota old_ifconfig old_defaultrouter old_hostname old_resolvconf

  # Get the latest snapshot for the template (if not specified).
  zfs::ensure_snapshot snapshot "${JAIL_DATASET}/templates/${template}"

  # Stash old configuration data.
  old_quota=$(zfs get -Hp -o value refquota "${JAIL_DATASET}/${jail}/os")
  old_hostname=$(sysrc -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" -qn hostname)
  old_ifconfig=$(sysrc -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" -qn ifconfig_jail0)
  old_defaultrouter=$(sysrc -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" -qn defaultrouter) ||:
  old_pfenable=$(sysrc -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" -qn pf_enable) ||:
  old_sshkey=$(cat "${JAIL_HOME}/${jail}/os/root/.ssh/authorized_keys") ||:
  old_resolvconf=$(cat "${JAIL_HOME}/${jail}/os/etc/resolv.conf") ||:

  # Reprovision OS dataset from template.
  zfs destroy -v -f -r "${JAIL_DATASET}/${jail}/os"
  zfs clone \
    $ZFS_OPTS \
    -o refquota="$old_quota" \
    "$snapshot" "${JAIL_DATASET}/${jail}/os"

  # Copy timezone configuration from host.
  cp -v /etc/localtime "${JAIL_HOME}/${jail}/os/etc/localtime"

  # Restore stashed configuration data.
  sysrc -v -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" \
    "hostname=${old_hostname}" \
    "ifconfig_ej_$(interface::epair::derive_name "$jail")_name=jail0" \
    "ifconfig_jail0=${old_ifconfig}" \
    'ipv6_activate_all_interfaces=NO' \
    'syslogd_flags=-ss' \
    'sendmail_enable=NONE' \
    'dumpdev=NO' \
    "pf_enable=YES"

  [ -n "$old_defaultrouter" ] && sysrc -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" "defaultrouter=${old_defaultrouter}"

  [ -n "$old_resolvconf" ] && printf '%s\n' "$old_resolvconf" | tee "${JAIL_HOME}/${jail}/os/etc/resolv.conf"

  echo "$DEFAULT_PF_CONF" > "${JAIL_HOME}/${jail}/os/etc/pf.conf"

  if [ -n "$old_sshkey" ]; then
    echo "PermitRootLogin prohibit-password" | tee -a "${JAIL_HOME}/${jail}/os/etc/ssh/sshd_config"
    sysrc -v -f "${JAIL_HOME}/${jail}/os/etc/rc.conf" 'sshd_enable=YES'
    install -v -d -m 0700 "${JAIL_HOME}/${jail}/os/root/.ssh"
    install -v -m 0600 /dev/null "${JAIL_HOME}/${jail}/os/root/.ssh/authorized_keys"
    printf '%s\n' "$old_sshkey" | tee "${JAIL_HOME}/${jail}/os/root/.ssh/authorized_keys"
  fi

  # If the jail was running, restart it.
  if [ "$running" = true ]; then
    jail::start "$jail"
  fi
}

cmd::restart(){
  local usage='restart JAIL'
  local help='Restart a jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'no jail specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail"  || die "no such jail: ${jail}"
  jail::running "$jail" || die "jail not running: ${jail}"

  jail::restart "$jail"
}

cmd::shell(){
  local usage='shell JAIL'
  local help='Run a shell within the jail.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail"  || die "no such jail: ${jail}"
  jail::running "$jail" || die "jail not running: ${jail}"

  jail::exec "$jail" /bin/sh
}

cmd::show(){
  local usage='show JAIL'
  local help='Show jail configuration.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail" || die "no such jail: ${jail}"

  printf -- '------------------------- JAIL CONFIGURATION -------------------------\n'
  cat "${JAIL_HOME}/${jail}/jail.conf"
  printf -- '\n---------------------------- ZFS DATASET -----------------------------\n'
  zfs list -o name,refquota,used,avail,mountpoint -S name \
    "${JAIL_DATASET}/${jail}/os" \
    "${JAIL_DATASET}/${jail}/data"
}

cmd::status(){
  local usage='status JAIL'
  local help='Show running jail status.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  jail::exists "$jail"  || die "no such jail: ${jail}"
  jail::running "$jail" || die "jail not running: ${jail}"

  printf -- '---------------------------- JAIL STATUS -----------------------------\n'
  jls -j "$jail" -h jid name path osrelease host.hostname 2>/dev/null | column -t
  printf -- '\n---------------------------- ZFS DATASET -----------------------------\n'
  zfs list -o name,refquota,used,avail,mountpoint -S name \
    "${JAIL_DATASET}/${jail}/os" \
    "${JAIL_DATASET}/${jail}/data" \
    | sed "s|^${JAIL_DATASET}/${jail}/||" \
    | column -t
  printf -- '\n--------------------------- RESOURCE USAGE ---------------------------\n'
  rctl -h -u "jail:${jail}:" \
    | grep -E '^(maxproc|memoryuse|openfiles|pcpu|memoryuse|swapuse|readbps|writebps|readiops|writeiops)=' \
    | rs -c= -C' ' -T \
    | column -t
  printf -- '\n----------------------------- PROCESSES ------------------------------\n'
  ps -auxdr -J "$jail"
}

cmd::update_release(){
  local usage='update-release TEMPLATE'
  local help='Update a FreeBSD release template.'

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'TEMPLATE not specified'
  [ $# -gt 1 ] && cmd::usage 'too many arguments'
  local jail=$1

  template::exists "$jail" || die "no such template: ${jail}"
  template::update_release "$jail"
}

cmd::_create_epair(){
  local usage='_create-epair JAIL INTERFACE BRIDGE'
  local help="Create an epair for a VNET jail.
DO NOT RUN THIS COMMAND MANUALLY. It is invoked by each jail's exec.prestart."

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -lt 2 ] && cmd::usage 'INTERFACE not specified'
  [ $# -lt 3 ] && cmd::usage 'BRIDGE not specified'
  [ $# -gt 3 ] && cmd::usage 'too many arguments'
  local jail=$1 interface=$2 bridge=$3 \
    new_epair epair_name epair_bridge_mac epair_jail_mac

  interface::exists "$interface" || die "no such interface: ${interface}"
  interface::exists "$bridge"    || die "no such bridge: ${bridge}"

  epair_name=$(interface::epair::derive_name "$jail")
  interface::epair::derive_mac "$interface" "$jail" epair_bridge_mac epair_jail_mac

  new_epair=$(ifconfig epair create)
  ifconfig "$bridge" addm "$new_epair"

  ifconfig "$new_epair"       name "eb_${epair_name}" descr "jail/${jail}"
  ifconfig "eb_${epair_name}" up
  ifconfig "eb_${epair_name}" ether "$epair_bridge_mac"

  ifconfig "${new_epair%a}b" name "ej_${epair_name}"
  ifconfig "ej_${epair_name}" up
  ifconfig "ej_${epair_name}" ether "$epair_jail_mac"
}

cmd::_destroy_epair(){
  local usage='_destroy-epair JAIL'
  local help="Destroy an epair for a VNET jail.
DO NOT RUN THIS COMMAND MANUALLY. It is invoked by each jail's exec.poststop."

  cmd::getopt_help "$@"; shift $((OPTIND - 1))

  [ $# -lt 1 ] && cmd::usage 'JAIL not specified'
  [ $# -gt 2 ] && cmd::usage 'too many arguments'
  local jail=$1 epair_name

  epair_name=$(interface::epair::derive_name "$jail")
  ifconfig "eb_${epair_name}" destroy
}


################################################################################
# "Library" functions.
################################################################################
interface::add_vlan(){
  local id=$1
  ifconfig "vlan${id}" create
  ifconfig "vlan${id}" vlan "$id" vlandev "$TRUNK_INTERFACE" up
  ifconfig "bridge${id}" create
  ifconfig "bridge${id}" addm "vlan${id}" up
  sysrc -v \
    "cloned_interfaces+=vlan${id} bridge${id}" \
    "ifconfig_vlan${id}=vlan ${id} vlandev ${TRUNK_INTERFACE} up" \
    "ifconfig_bridge${id}=addm vlan${id} up"
}

interface::exists(){
  ifconfig "$1" > /dev/null 2>&1
}

interface::epair::derive_mac(){
  # Derive unique mac addresses for an epair interface, based on the physical
  # interface address + jail name.
  local iface=$1 name=$2 upvar_a=$3 upvar_b=$4
  local hwaddr checksum epair_mac_a epair_mac_b

  # Similar to /usr/share/examples/jails/jib
  hwaddr=$(ifconfig "$iface" ether | awk '/ether/,$0=$2')
  # ??:??:??:II:II:II
  epair_mac_a=${hwaddr#??:??:??} # => :II:II:II
  # => :SS:SS:II:II:II
  epair_mac_a=":$(printf '%s' "$name" | md5 | sed 's/^\(..\)\(..\).*/\1:\2/')${epair_mac_a}"
  # => NP:SS:SS:II:II:II
  case $hwaddr in
    ?2:*)    epair_mac_a="0a${epair_mac_a}"  epair_mac_b="0e${epair_mac_a}" ;;
    ?[Ee]:*) epair_mac_a="02${epair_mac_a}"  epair_mac_b="06${epair_mac_a}" ;;
    *)       epair_mac_a="02${epair_mac_a}"  epair_mac_b="0e${epair_mac_a}" ;;
  esac

  # Sanity check for duplicate MAC
  for mac in $epair_mac_a $epair_mac_b; do
    ifconfig | grep -qE "(ether|hwaddr) ${mac}" && die "MAC collision when configuring epair interface!"
  done

  setvar "$upvar_a" "$epair_mac_a"
  setvar "$upvar_b" "$epair_mac_b"
}

interface::epair::derive_name(){
  # Generate an epair(4) interface suffix, based on the jail name.
  #
  # The maximum length of a network interface name on FreeBSD is 15 characters.
  # We use a prefix of 'eb_' for the bridge side of the interface, and 'ej_'
  # for the jail side. This leaves 12 characters for a unique suffix for each
  # jail.
  #
  # If the sanitized jail name is less than 12 characters, we'll simply use it
  # for the suffix. Otherwise, we'll use the last 12 characters of the jail
  # name's SHA-1 hash.
  local name=$1 sanitized
  sanitized=$(printf '%s' "$name" | tr -dC '[:alnum:]_')

  if [ "${#sanitized}" -le 12 ]; then
    printf '%s' "$sanitized"
  else
    printf '%s' "$name" | sha1 | tail -c 12
  fi
}

jail::exec() {
  local jail=$1; shift
  jexec -l "$jail" "$@"
}

jail::exists(){
  test -f "${JAIL_HOME}/${1}/jail.conf"
}

jail::restart(){
  jail -v -f "${JAIL_HOME}/${1}/jail.conf" -rc "$1"
}

jail::running(){
  jls -j "$1" > /dev/null 2>&1
}

jail::start(){
  jail -v -f "${JAIL_HOME}/${1}/jail.conf" -c "$1"
}

jail::stop(){
  jail -v -f "${JAIL_HOME}/${1}/jail.conf" -r "$1"
}

template::exists(){
  zfs list -H "${JAIL_DATASET}/templates/${1}" > /dev/null 2>&1
}

template::download_release(){
  # Download a given FreeBSD release and create a template jail.
  local release=$1 arch base_tarball template

  arch=$(uname -p)
  base_tarball="https://download.freebsd.org/releases/${arch}/${release}/base.txz"

  template::release2name template "$release"

  zfs create -v -p $ZFS_OPTS "${JAIL_DATASET}/templates/${template}"

  if ! fetch "$base_tarball" -o - | tar xzf - -C "${JAIL_HOME}/templates/${template}"; then
    zfs destroy -v "${JAIL_DATASET}/templates/${template}"
    die "failed to extract base tarball for ${release}"
  fi

  template::update_release "$template"
}

template::release2name(){
  # Convert a FreeBSD release version to a template name.
  # e.g. for "13.2-RELEASE", return "freebsd13.2".
  setvar "$1" "freebsd${2%-*}"
}

template::update_release(){
  # Run freebsd-update within a the given template and take a fresh snapshot.
  local template=$1 snapshot

  PAGER=/bin/cat freebsd-update -b "${JAIL_HOME}/templates/${template}" --not-running-from-cron fetch install

  snapshot=$("${JAIL_HOME}/templates/${template}/bin/freebsd-version")
  template::exists "${template}@${snapshot}" || zfs snapshot "${JAIL_DATASET}/templates/${template}@${snapshot}"
}

zfs::ensure_snapshot(){
  # If the given zfs dataset is a snapshot, return as-is.
  # Otherwise, get the latest snapshot from the given dataset and return that.
  local upvar=$1 dataset=$2 latest

  if [ "${dataset#*@}" = "$dataset" ]; then
    latest=$(zfs list -t snapshot -o name -s creation -H "$dataset" | tail -1)
    setvar "$upvar" "$latest"
  else
    zfs list -t snapshot -H "$dataset" > /dev/null 2>&1 || die "no such snapshot: ${dataset}"
    setvar "$upvar" "$dataset"
  fi
}

zfs::dataset_exists(){
  zfs list "$1" > /dev/null 2>&1
}


cmd::main "$@"