view sbin/ftjail @ 250:c4d835ccb4ae

Implement configure for symlinked skeletons. Automatically decide if "etc" is a symlink to "skeleton/etc".
author Franz Glasner <fzglas.hg@dom66.de>
date Sun, 11 Sep 2022 13:58:08 +0200
parents a91e1c5173cc
children 7a6c03445ba1
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

    -L        Mount the skeleton into a "skeleton" subdirectory
    -P        Mount the skeleton directly over the base
    -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 [ OPTIONS ] MOUNTPOINT BASETXZ

    Populate the directory in MOUNTPOINT with the base system in BASETXZ

    -L        Populate having a "skeleton" subdirectory within the mountpoint
    -P        Populate directly into the mountpoint

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

    local _opt_dry_run

    local _ds_base _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

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

    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


    [ "${_opt_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

    local _opt_dry_run

    local _ds_skel _child _child_zfsopts _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

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

    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


    [ "${_opt_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 -n "${_p_base}" "${_name}" || return
    command_datasets_tmpl_skel -n "${_p_skel}" "${_name}" || 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 _opt_symlink

    local _opt _dir

    _opt_symlink=""

    while getopts "LP" _opt ; do
        case ${_opt} in
            L)
                _opt_symlink="yes"
                ;;
            P)
                _opt_symlink="no"
                ;;
            \?)
                return 2;
                ;;
        esac
    done
    shift $((OPTIND-1))
    OPTIND=1

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

    _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

    if [ "${_opt_symlink}" = "yes" ]; then
        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 proper symlinks here
        (cd "${_mp}/skeleton/root" && ln -s ../../.profile .profile) || return
        (cd "${_mp}/skeleton/root" && ln -s ../../.cshrc .cshrc) || return
    else
        echo "Extracting base ..."
        tar -C "${_mp}" --exclude=./root/.cshrc --exclude=./root/.profile --no-safe-writes -xJp -f "${_basetxz}" || return
        # In the original archive they are archived as hardlinks: make proper symlinks here
        (cd "${_mp}/root" && ln -s ../.profile .profile) || return
        (cd "${_mp}/root" && ln -s ../.cshrc .cshrc) || return        
    fi

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


#
# _do_mount dataset mountpoint dry-run mount-natural childs-only
#
_do_mount() {
    local _dsname _mountpoint _dry_run _mount_natural _childs_only

    local _name _mp _canmount _mounted
    local _rootds_mountpoint _relative_mp _real_mp

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

    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" 1>&2
                            return 1
                        fi
                    fi

                    if [ \( "${_childs_only}" = "yes" \) -a \( "${_name}" = "${_dsname}" \) ]; then
                        echo "Skipping ${_name} because mounting childs only" 1>&2
                    else
                        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}" 1>&2; return 1; }
                            echo "Mounting ${_name} on ${_real_mp}"
                            mount -t zfs "${_name}" "${_real_mp}" || return 1
                        fi
                    fi
                    ;;
                *)
                    echo "Skipping ${_name} because its configured ZFS mountpoint is not relative to given root dataset" 1>&2
                    ;;
            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_symlink

    local _opt

    _opt_dry_run=""
    _opt_symlink=""

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

    [ -z "${_opt_symlink}" ] && { echo "ERROR: -L or -P must be given" 1>&2; return 2; }

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

    _do_mount "${_ds_base}" "${_mountpoint}" "${_opt_dry_run}" "" "" || return
    if [ "${_opt_symlink}" = "yes" ]; then
        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
    else
        _do_mount "${_ds_skel}" "${_mountpoint}" "${_opt_dry_run}" "" "yes" || return
    fi

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

    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" 1>&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" 1>&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 "$@"
        ;;
    configure)
        echo "ERROR: use \`fjail configure' instead" 1>&2;
        exit 2
        ;;
    *)
        echo "ERROR: unknown command \`${command}'" 1>&2
        exit 2
        ;;
esac