view sbin/ftjail @ 245:61861d36758c

Ensure that no options are really given when no options are allowed
author Franz Glasner <fzglas.hg@dom66.de>
date Sat, 10 Sep 2022 18:16:37 +0200
parents 6e632f459818
children bdee72ff7dbd
line wrap: on
line source

#!/bin/sh
# -*- indent-tabs-mode: nil; -*-
: 'A very minimal BSD Thin Jail management tool.

:Author:    Franz Glasner
:Copyright: (c) 2022 Franz Glasner.
            All rights reserved.
:License:   BSD 3-Clause "New" or "Revised" License.
            See LICENSE for details.
            If you cannot find LICENSE see
            <https://opensource.org/licenses/BSD-3-Clause>
:ID:        @(#)@@PKGORIGIN@@ $HGid$

'

set -eu

VERSION="@@VERSION@@"

USAGE='
USAGE: ftjail [ OPTIONS ] COMMAND [ COMMAND OPTIONS ] [ ARG ... ]

OPTIONS:

  -V    Print the program name and version number to stdout and exit

  -h    Print this help message to stdout and exit

COMMANDS:

  datasets-tmpl [OPTIONS] PARENT-BASE PARENT-SKELETON NAME

    Create the ZFS template datasets, i.e. the ro base and the rw
    skeleton to be used within thin jails jails

    PARENT-BASE and PARENT-SKELETON must exist already and NAME must
    not exist.

    The datasets will not be mounted.

  mount-tmpl [ OPTIONS ] BASE-RO SKELETON-RW MOUNTPOINT

    Canonically mount the RO base and the RW skeleton into MOUNTPOINT and
    MOUNTPOINT/skeleton

    -n        Do not really mount but show what would be mounted where
    -u        Alias of -n

  umount-tmpl BASE-RO SKELETON-RW

    Unmount mounted datasets BASE-RO and SKELETON-RW

  interlink-tmpl MOUNTPOINT

    Create symbolic links between the RO base and the RW skeleton.
    Base and skeleton must be canonically mounted already.

  populate MOUNTPOINT BASETXZ

    Populate the directory in MOUNTPOINT with the base system in BASETXZ

ENVIRONMENT:

  All environment variables that affect "zfs" are effective also.

DESCRIPTION:

  All commands with the exception of "populate" require ZFS as
  filesystem.
'

# Reset to standard umask
umask 0022


#
# Ensure that no options are given
#
_ensure_no_options() {
    local _opt

    while getopts ":" _opt ; do
        [ "${_opt}" = '?' ] && { echo "ERROR: no option allowed" 1>&2; exit 2; }
    done    
}


_get_dataset_for_mountpoint() {
    : 'Use `mount -t zfs -p` to determine the ZFS dataset for a given mountpoint.

    '
    local _mountpoint
    local _ds _mount _rest

    _mountpoint="$1"

    mount -t zfs -p \
    | {
        while IFS=' '$'\t' read -r _ds _mount _rest ; do
            if [ "$_mount" = "$_mountpoint" ]; then
                echo "${_ds}"
                return 0
            fi
        done
        return 1
    }
}


#
# PARENT-BASE NAME DRY-RUN
#
command_datasets_tmpl_base() {
    local _p_base _name _dry_run

    local _ds_base

    _ensure_no_options "$@"

    _p_base="${1-}"
    _name="${2-}"
    _dry_run="${3-}"

    if [ -z "${_p_base}" ]; then
        echo "ERROR: no parent dataset for base given" >&2
        return 2
    fi
    if [ -z "${_name}" ]; then
        echo "ERROR: no name given" >&2
        return 2
    fi

    if ! zfs list -H -o mountpoint -t filesystem "${_p_base}" >/dev/null 2>/dev/null; then
        echo "ERROR: parent dataset \`${_p_base}' does not exist" >&2
        return 1
    fi
    _ds_base="${_p_base}/${_name}"
    if zfs list -H -o mountpoint -t filesystem "${_ds_base}" >/dev/null 2>/dev/null; then
        echo "ERROR: dataset \`${_ds_base}' does already exist" >&2
        return 1
    fi


    [ "${_dry_run}" = "yes" ] && return 0

    echo "Creating RO base datasets in:"
    printf "\\t%s\\n" "${_ds_base}"

    zfs create -u -o canmount=noauto "${_ds_base}"

}


#
# SKELETON NAME DRY-RUN
#
command_datasets_tmpl_skel() {
    local _p_base _name _dry_run

    local _ds_skel _child _child_zfsopts

    _ensure_no_options "$@"

    _p_skel="${1-}"
    _name="${2-}"
    _dry_run="${3-}"

    if [ -z "${_p_skel}" ]; then
        echo "ERROR: no parent dataset for skeleton given" >&2
        return 2
    fi
    if [ -z "${_name}" ]; then
        echo "ERROR: no name given" >&2
        return 2
    fi

    if ! zfs list -H -o mountpoint -t filesystem "${_p_skel}" >/dev/null 2>/dev/null; then
        echo "ERROR: parent dataset \`${_p_skel}' does not exist" >&2
        return 1
    fi
    _ds_skel="${_p_skel}/${_name}"
    if zfs list -H -o mountpoint -t filesystem "${_ds_skel}" >/dev/null 2>/dev/null; then
        echo "ERROR: dataset \`${_ds_skel}' does already exist" >&2
        return 1
    fi


    [ "${_dry_run}" = "yes" ] && return 0

    echo "Creating RW skeleton datasets in:"
    printf "\\t%s\\n" "${_ds_skel}"

    zfs create -u -o canmount=noauto "${_ds_skel}"
    zfs create -u -o canmount=off "${_ds_skel}/usr"
    #
    # XXX FIXME: What about usr/ports/distfiles
    #            We typically want to use binary packages.
    #            And if we use ports they are not in usr/ports typically.
    #
    #zfs create -u -o canmount=off "${_ds_skel}/usr/ports"
    #
    # XXX FIXME: What about home
    #
    for _child in etc home root tmp usr/local var ; do
        case "${_child}" in
            "tmp"|"var/tmp")
                _child_zfsopts="-o sync=disabled -o setuid=off"
                ;;
            "home")
                _child_zfsopts="-o setuid=off"
                ;;
            "usr/ports/distfiles")
                _child_zfsopts="-o exec=off -o setuid=off -o compression=off -o primarycache=metadata"
                ;;
            "var/mail")
                _child_zfsopts="-o atime=on -o exec=off -o setuid=off"
                ;;
            *)
                _child_zfsopts=""
                ;;
        esac
        zfs create -u -o canmount=noauto ${_child_zfsopts} "${_ds_skel}/${_child}"
    done
}


#
# "datasets-tmpl" -- create the ZFS dataset tree
#
# PARENT-BASE PARENT-SKELETON NAME
#
command_datasets_tmpl() {
    # parent ZFS dataset -- child ZFS dataset name
    local _p_base _p_skel _name
    local _zfsopts
    local _ds_base _ds_skel

    _ensure_no_options "$@"

    _zfsopts="-u -o canmount=noauto"

    _p_base="${1-}"
    _p_skel="${2-}"
    _name="${3-}"

    # Check preconditions
    command_datasets_tmpl_base "${_p_base}" "${_name}" "yes" || return
    command_datasets_tmpl_skel "${_p_skel}" "${_name}" "yes" || return

    # Really do it
    command_datasets_tmpl_base "${_p_base}" "${_name}" || return
    command_datasets_tmpl_skel "${_p_skel}" "${_name}" || return
    return 0
}


#
# "populate-tmpl" -- populate the datasets with content from a FreeBSD base.txz
#
# command_populate_tmpl mountpoint basetxz
#
command_populate_tmpl() {
    # MOUNTPOINT -- base.txz
    local _mp _basetxz

    local _dir

    _ensure_no_options "$@"

    _mp="${1-}"
    _basetxz="${2-}"

    if [ -z "${_mp}" ]; then
        echo "ERROR: no mountpoint given" >&2
        return 2
    fi
    if [ -z "${_basetxz}" ]; then
        echo "ERROR: no base.txz given" >&2
        return 2
    fi
    if [ ! -d "${_mp}" ]; then
        echo "ERROR: mountpoint \`${_mp}' does not exist" >&2
        return 1
    fi
    if [ ! -r "${_basetxz}" ]; then
        echo "ERROR: file \`${_basetxz}' is not readable" >&2
        return 1
    fi

    #
    # Handle /var/empty separately later: could be already there and
    # mounted read-only.
    #
    echo "Extracting RO base ..."
    tar -C "${_mp}" --exclude=./etc --exclude=./root --exclude=./tmp --exclude=./usr/local --exclude=./var --no-safe-writes -xJp -f "${_basetxz}" || return
    # "home" is not part of base
    for _dir in etc root tmp usr/local var ; do
        echo "Extracting RW skeleton: ${_dir} ..."
        tar -C "${_mp}/skeleton" --include="./${_dir}" --exclude=./root/.cshrc --exclude=./root/.profile -xJp -f "${_basetxz}" || return
    done
    # In the original archive they are archived as hardlinks: make symlinks here
    (cd "${_mp}/skeleton/root" && ln -s ../../.profile .profile) || return
    (cd "${_mp}/skeleton/root" && ln -s ../../.cshrc .cshrc) || return

    find "${_mp}/boot" -type f -delete || true
}


#
# _do_mount dataset mountpoint dry-run mount-natural
#
_do_mount() {
    local _dsname _mountpoint _dry_run _mount_natural
    local _name _mp _canmount _mounted
    local _rootds_mountpoint _relative_mp _real_mp

    _dsname="${1}"
    _mountpoint="${2}"
    _dry_run="${3}"
    _mount_natural="${4}"

    if [ -z "${_dsname}" ]; then
        echo "ERROR: no dataset given" >&2
        return 2
    fi

    _rootds_mountpoint="$(zfs list -H -o mountpoint -t filesystem "${_dsname}")"  || \
        { echo "ERROR: root dataset \`${_dsname}' does not exist" >&2; return 1; }

    if [ -z "${_mountpoint}" ]; then
        if [ "${_mount_natural}" = "yes" ]; then
            _mountpoint="${_rootds_mountpoint}"
        else
            echo "ERROR: no mountpoint given" >&2
            return 2
        fi
    else
        if [ "${_mount_natural}" = "yes" ]; then
            echo "ERROR: Cannot have a custom mountpoint when mount-natural is activated" >&2
            return 2
        fi
    fi

    # Eventually remove a trailing slash
    _mountpoint="${_mountpoint%/}"
    if [ -z "${_mountpoint}" ]; then
        echo "ERROR: would mount over the root filesystem" >&2
        return 1
    fi

    zfs list -H -o name,mountpoint,canmount,mounted -s mountpoint -t filesystem -r "${_dsname}" \
    | {
        while IFS=$'\t' read -r _name _mp _canmount _mounted ; do
            # Skip filesystems that are already mounted
            [ "${_mounted}" = "yes" ] && continue
            # Skip filesystems that must not be mounted
            [ "${_canmount}" = "off" ] && continue
            case "${_mp}" in
                "none"|"legacy")
                    # Do nothing for filesystem with unset or legacy mountpoints
                    ;;
                "${_rootds_mountpoint}"|"${_rootds_mountpoint}/"*)
                    #
                    # Handle only mountpoints that have a mountpoint below
                    # the parent datasets mountpoint
                    #

                    # Determine the mountpoint relative to the parent mountpoint
                    _relative_mp="${_mp#${_rootds_mountpoint}}"
                    # Eventually remove a trailing slash
                    _relative_mp="${_relative_mp%/}"
                    # The real effective full mountpoint
                    _real_mp="${_mountpoint}${_relative_mp}"

                    #
                    # Consistency and sanity check: computed real mountpoint must
                    # be equal to the configured mountpoint when no custom mountpoint
                    # is given.
                    #
                    if [ "${_mount_natural}" = "yes" ]; then
                        if [ "${_real_mp}" != "${_mp}" ]; then
                            echo "ERROR: mountpoint mismatch" >&2
                            return 1
                        fi
                    fi

                    if [ "${_dry_run}" = "yes" ]; then
                        echo "Would mount ${_name} on ${_real_mp}"
                    else
                        mkdir -p "${_real_mp}" 1> /dev/null 2> /dev/null || \
                            { echo "ERROR: cannot create mountpoint ${_real_mp}" >&2; return 1; }
                        echo "Mounting ${_name} on ${_real_mp}"
                        mount -t zfs "${_name}" "${_real_mp}" || return 1
                    fi
                    ;;
                *)
                    echo "Skipping ${_name} because its configured ZFS mountpoint is not relative to given root dataset" 2>&1
                    ;;
            esac
        done

        return 0
    }
}


#
# "mount-tmpl" -- recursively mount a base and skeleton datasets including subordinate datasets
#
# command_mount_tmpl base-ro skeleton-rw  mountpoint
#
command_mount_tmpl() {
    local _ds_base _ds_skel _mountpoint

    local _opt_dry_run _opt

    _opt_dry_run=""

    while getopts "nu" _opt ; do
        case ${_opt} in
            n|u)
                _opt_dry_run="yes"
                ;;
            \?|:)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    _ds_base="${1-}"
    _ds_skel="${2-}"
    _mountpoint="${3-}"

    _do_mount "${_ds_base}" "${_mountpoint}" "${_opt_dry_run}" "" || return
    if [ "${_opt_dry_run}" != "yes" ]; then
        if [ ! -d "${_mountpoint}/skeleton" ]; then
            mkdir "${_mountpoint}/skeleton" || return
        fi
    fi
    _do_mount "${_ds_skel}" "${_mountpoint}/skeleton" "${_opt_dry_run}" "" || return

    return 0
}


#
# _do_umount dataset
#
_do_umount() {
    local _dsname
    local _name _mp _rest
    local _rootds_mountpoint

    _dsname="${1}"
    [ -z "${_dsname}" ] && { echo "ERROR: no dataset given" >&2; return 2; }

    # Just determine whether the given dataset name exists
    _rootds_mountpoint="$(zfs list -H -o mountpoint -t filesystem "${_dsname}")" || \
        { echo "ERROR: dataset not found" >&2; return 1; }

    mount -t zfs -p \
    | grep -E "^${_dsname}(/|\s)" \
    | sort -n -r \
    | {
        while IFS=' '$'\t' read -r _name _mp _rest ; do
            echo "Umounting ${_name} on ${_mp}"
            umount "${_mp}" || return 1
        done
        return 0
    }
}


#
# "umount-tmpl" -- umount skeleton and base datasets
#
# command_umount_tmpl ds-base ds-skeleton
#
command_umount_tmpl() {
    local _ds_base _ds_skel

    _ensure_no_options "$@"

    _ds_base="${1-}"
    _ds_skel="${2-}"

    [ -z "${_ds_base}" ] && { echo "ERROR: no RO base dataset given" >&2; return 2; }
    [ -z "${_ds_skel}" ] && { echo "ERROR: no RW skeleton dataset given" >&2; return 2; }

    _do_umount "${_ds_skel}" || return
    _do_umount "${_ds_base}" || return

    return 0
}


#
# "interlink-tmpl" -- create links from base to skeleton
#
# command_interlink_tmpl mountpint
#
command_interlink_tmpl() {
    local _mountpoint

    local _dir _dirpart _basepart

    _ensure_no_options "$@"

    _mountpoint="${1-}"

    [ -z "${_mountpoint}" ] && { echo "ERROR: no mountpoint given" 2>&1; return 2; }
    [ -d "${_mountpoint}" ] || { echo "ERROR: mountpoint \`${_mountpoint}' does not exist" 2>&1; return 1; }
    [ -d "${_mountpoint}/skeleton" ] || { echo "WARNING: skeleton is not mounted at \`${_mountpoint}/skeleton'" 2>&1; }

    for _dir in etc home root tmp usr/local var ; do
        case "${_dir}" in
            "usr/local")
                _dirpart="$(dirname "${_dir}")"
                _basepart="$(basename "${_dir}")"
                [ -d "${_mountpoint}/${_dirpart}" ] || mkdir "${_mountpoint}/${_dirpart}" || return
                ( cd "${_mountpoint}/${_dirpart}" && ln -s "../skeleton/${_dir}" "${_basepart}" ) || return
                ;;
            *)
                ( cd "${_mountpoint}" && ln -s "skeleton/${_dir}" "${_dir}" ) || return
                ;;
        esac
    done
    return 0
}


#
# Global option handling
#
while getopts "Vh" _opt ; do
    case ${_opt} in
        V)
            printf 'ftjail v%s (rv:%s)\n' "${VERSION}" '@@HGREVISION@@'
            exit 0
            ;;
        h)
            echo "${USAGE}"
            exit 0
            ;;
        \?)
            exit 2;
            ;;
        *)
            echo "ERROR: option handling failed" >&2
            exit 2
            ;;
    esac
done

#
# Reset the Shell's option handling system to prepare for handling
# command-local options.
#
shift $((OPTIND-1))
OPTIND=1

test $# -gt 0 || { echo "ERROR: no command given" >&2; exit 2; }

command="$1"
shift

case "${command}" in
    datasets-tmpl)
        command_datasets_tmpl "$@"
        ;;
    mount-tmpl)
        command_mount_tmpl "$@"
        ;;
    umount-tmpl|unmount-tmpl)
        command_umount_tmpl "$@"
        ;;
    interlink-tmpl)
        command_interlink_tmpl "$@"
        ;;
    populate-tmpl)
        command_populate_tmpl "$@"
        ;;
    *)
        echo "ERROR: unknown command \`${command}'" >&2
        exit 2
        ;;
esac