mirror of
https://github.com/NiklasGollenstede/nixos-installer.git
synced 2025-08-08 23:21:28 +02:00
init
This commit is contained in:
34
lib/setup-scripts/README.md
Normal file
34
lib/setup-scripts/README.md
Normal file
@ -0,0 +1,34 @@
|
||||
|
||||
# Host Setup Scripts
|
||||
|
||||
This is a library of bash functions, mostly for NixOS system installation.
|
||||
|
||||
The (paths to these) scripts are meant to me passed in the `scripts` argument to [`mkSystemsFalke`](../flakes.nix#mkSystemsFalke), which makes their functions available in the per-host `devShells`/`apps`.
|
||||
Host-specific nix variables are available to the bash functions as `@{...}` through [`substituteImplicit`](../scripts.nix#substituteImplicit) with the respective host as root context.
|
||||
|
||||
With the functions from here, adding a simple three-liner can be enough to do a completely automated NixOS installation:
|
||||
```bash
|
||||
function install-system {( set -eu # 1: blockDev
|
||||
prepare-installer "$@"
|
||||
do-disk-setup "$1"
|
||||
install-system-to $mnt prompt=true @{config.th.minify.topLevel:-}
|
||||
)}
|
||||
```
|
||||
|
||||
|
||||
# `install-system` Documentation
|
||||
|
||||
The above function performs the mostly automated installation of any `$HOST` from [`../../hosts/`](../../hosts/) to the local disk(s) (or image file(s)) `$DISK`.
|
||||
On a NixOS host, this script can be run by root as: `#` `( cd /etc/nixos/ && nix run .#"$HOST" -- install-system "$DISK" )`.
|
||||
|
||||
Doing an installation on non-NixOS (but Linux), where nix isn't installed for root, is a bit of a hack, but works as well.
|
||||
In this case, all `nix` commands will be run as `$SUDO_USER`, but this script and some other user-owned (or user-generated) code will (need to) be run as root.
|
||||
If that is acceptable, run with `sudo` as first argument: `$` `( cd /etc/nixos/ && nix run .#"$HOST" -- sudo install-system "$DISK" )` (And then maybe `sudo bash -c 'chown $SUDO_USER: '"$DISK"` afterwards.)
|
||||
|
||||
The `nix run` in the above commands substitutes a number of `@{`-prefixed variables based on the `$HOST` name and its configuration from [`../hosts/`](../hosts/), and then sources this script and calls the `install-system` function.
|
||||
If `$DISK` points to something in `/dev/`, then it is directly formatted and written to as block device, otherwise `$DISK` is (re-)created as raw image and then used as loop device.
|
||||
For hosts that install to multiple disks, pass a `:`-separated list of `<disk-name>=<path>` pairs (the name may be omitted only for the `default` disk).
|
||||
|
||||
Once done, the disk can be transferred -- or the image be copied -- to the final system, and should boot there.
|
||||
If the host's hardware target allows, a resulting image can also be passed to [`register-vbox`](../lib/setup-scripts/maintenance.sh#register-vbox) to create a bootable VirtualBox instance for the current user.
|
||||
The "Installation" section of each host's documentation should contain host specific details, if any.
|
19
lib/setup-scripts/default.nix
Normal file
19
lib/setup-scripts/default.nix
Normal file
@ -0,0 +1,19 @@
|
||||
dirname: inputs: let
|
||||
|
||||
getNamedScriptFiles = dir: builtins.removeAttrs (builtins.listToAttrs (map (name: let
|
||||
match = builtins.match ''^(.*)[.]sh([.]md)?$'' name;
|
||||
in if (match != null) then {
|
||||
name = builtins.head match; value = "${dir}/${name}";
|
||||
} else { name = ""; value = null; }) (builtins.attrNames (builtins.readDir dir)))) [ "" ];
|
||||
|
||||
inherit (inputs.config) prefix;
|
||||
|
||||
replacePrefix = if prefix == "wip" then (x: x) else (builtins.mapAttrs (name: path: (
|
||||
builtins.toFile name (builtins.replaceStrings
|
||||
[ "@{config.wip." "@{#config.wip." "@{!config.wip." ]
|
||||
[ "@{config.${prefix}." "@{#config.${prefix}." "@{!config.${prefix}." ]
|
||||
(builtins.readFile path)
|
||||
)
|
||||
)));
|
||||
|
||||
in replacePrefix (getNamedScriptFiles dirname)
|
154
lib/setup-scripts/disk.sh
Normal file
154
lib/setup-scripts/disk.sh
Normal file
@ -0,0 +1,154 @@
|
||||
|
||||
##
|
||||
# Disk Partitioning and Formatting
|
||||
##
|
||||
|
||||
## Prepares the disks of the target system for the copying of files.
|
||||
function do-disk-setup { # 1: diskPaths
|
||||
|
||||
mnt=/tmp/nixos-install-@{config.networking.hostName} ; mkdir -p "$mnt" ; prepend_trap "rmdir $mnt" EXIT # »mnt=/run/user/0/...« would be more appropriate, but »nixos-install« does not like the »700« permissions on »/run/user/0«
|
||||
|
||||
partition-disks "$1"
|
||||
# ... block layers would go here ...
|
||||
source @{config.wip.installer.postPartitionCommands!writeText.postPartitionCommands}
|
||||
format-partitions
|
||||
source @{config.wip.installer.postFormatCommands!writeText.postFormatCommands}
|
||||
prepend_trap "unmount-system $mnt" EXIT ; mount-system $mnt
|
||||
source @{config.wip.installer.postMountCommands!writeText.postMountCommands}
|
||||
|
||||
}
|
||||
|
||||
## Partitions all »config.installer.disks« to ensure that all (correctly) specified »{config.installer.partitions« exist.
|
||||
function partition-disks { { # 1: diskPaths
|
||||
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then beQuiet=/dev/stdout ; fi
|
||||
declare -g -A blockDevs=( ) # this ends up in the caller's scope
|
||||
local path ; for path in ${1/:/ } ; do
|
||||
name=${path/=*/} ; if [[ $name != "$path" ]] ; then path=${path/$name=/} ; else name=primary ; fi
|
||||
if [[ ${blockDevs[$name]:-} ]] ; then echo "Path for block device $name specified more than once. Duplicate definition: $path" ; exit 1 ; fi
|
||||
blockDevs[$name]=$path
|
||||
done
|
||||
|
||||
local name ; for name in "@{!config.wip.installer.disks!attrsAsBashEvalSets[@]}" ; do
|
||||
if [[ ! ${blockDevs[$name]:-} ]] ; then echo "Path for block device $name not provided" ; exit 1 ; fi
|
||||
if [[ ! ${blockDevs[$name]} =~ ^(/dev/.*)$ ]] ; then
|
||||
local outFile=${blockDevs[$name]} ; ( set -eu
|
||||
eval "@{config.wip.installer.disks!attrsAsBashEvalSets[$name]}" # _size
|
||||
install -o root -g root -m 640 -T /dev/null "$outFile" && fallocate -l "$_size" "$outFile"
|
||||
) && blockDevs[$name]=$(losetup --show -f "$outFile") && prepend_trap "losetup -d ${blockDevs[$name]}" EXIT # NOTE: this must not be inside a sub-shell!
|
||||
else
|
||||
if [[ ! "$(blockdev --getsize64 "${blockDevs[$name]}")" ]] ; then echo "Block device $name does not exist at ${blockDevs[$name]}" ; exit 1 ; fi
|
||||
blockDevs[$name]=$(realpath "${blockDevs[$name]}")
|
||||
fi
|
||||
done
|
||||
|
||||
} ; ( set -eu
|
||||
|
||||
for name in "@{!config.wip.installer.disks!attrsAsBashEvalSets[@]}" ; do (
|
||||
eval "@{config.wip.installer.disks!attrsAsBashEvalSets[$name]}" # _name ; _size ; _serial ; _alignment ; _mbrParts ; _extraFDiskCommands
|
||||
if [[ $_serial ]] ; then
|
||||
actual=$(udevadm info --query=property --name="${blockDevs[$name]}" | grep -oP 'ID_SERIAL_SHORT=\K.*')
|
||||
if [[ $_serial != "$actual" ]] ; then echo "Block device ${blockDevs[$name]} does not match the serial declared for $name" ; exit 1 ; fi
|
||||
fi
|
||||
|
||||
sgdisk=( --zap-all ) # delete existing part tables
|
||||
for partDecl in "@{config.wip.installer.partitionList!listAsBashEvalSets[@]}" ; do
|
||||
eval "$partDecl" # _name ; _disk ; _type ; _size ; _index
|
||||
if [[ $_disk != "$name" ]] ; then exit ; fi # i.e. continue
|
||||
if [[ $_position =~ ^[0-9]+$ ]] ; then alignment=1 ; else alignment=$_alignment ; fi # if position is an absolute number, start precisely there
|
||||
sgdisk+=( -a "$alignment" -n "${_index:-0}":"$_position":+"$_size" -t 0:"$_type" -c 0:"$_name" )
|
||||
done
|
||||
|
||||
if [[ $_mbrParts ]] ; then
|
||||
sgdisk+=( --hybrid "$_mbrParts" ) # --hybrid: create MBR in addition to GPT; $_mbrParts: make these GPT part 1 MBR parts 2[3[4]]
|
||||
fi
|
||||
|
||||
sgdisk "${sgdisk[@]}" "${blockDevs[$name]}" >$beQuiet # running all at once is much faster
|
||||
|
||||
if [[ $_mbrParts ]] ; then
|
||||
printf "
|
||||
M # edit hybrid MBR
|
||||
d;1 # delete parts 1 (GPT)
|
||||
|
||||
# move the selected »mbrParts« to slots 1[2[3]] instead of 2[3[4]] (by re-creating part1 in the last sector, then sorting)
|
||||
n;p;1 # new ; primary ; part1
|
||||
$(( $(blockSectorCount "${blockDevs[$name]}") - 1)) # start (size 1sec)
|
||||
x;f;r # expert mode ; fix order ; return
|
||||
d;$(( (${#_mbrParts} + 1) / 2 + 1 )) # delete ; part(last)
|
||||
|
||||
# create GPT part (spanning primary GPT area) as last part
|
||||
n;p;4 # new ; primary ; part4
|
||||
1;33 # start ; end
|
||||
t;4;ee # type ; part4 ; GPT
|
||||
|
||||
${_extraFDiskCommands}
|
||||
p;w;q # print ; write ; quit
|
||||
" | perl -pe 's/^ *| *(#.*)?$//g' | perl -pe 's/\n\n+| *; */\n/g' | fdisk "${blockDevs[$name]}" &>$beQuiet
|
||||
fi
|
||||
|
||||
partprobe "${blockDevs[$name]}"
|
||||
) ; done
|
||||
sleep 1 # sometimes partitions aren't quite made available yet (TODO: wait "for udev to settle" instead?)
|
||||
)}
|
||||
|
||||
## For each filesystem in »config.fileSystems« whose ».device« is in »/dev/disk/by-partlabel/«, this creates the specified file system on that partition.
|
||||
function format-partitions {( set -eu
|
||||
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then beQuiet=/dev/stdout ; fi
|
||||
for fsDecl in "@{config.fileSystems!attrsAsBashEvalSets[@]}" ; do (
|
||||
eval "$fsDecl" # _name ; _device ; _fsType ; _formatOptions ; ...
|
||||
if [[ $_device != /dev/disk/by-partlabel/* ]] ; then exit ; fi # i.e. continue
|
||||
blockDev=$(realpath "$_device") ; if [[ $blockDev == /dev/sd* ]] ; then
|
||||
blockDev=$( shopt -s extglob ; echo "${blockDev%%+([0-9])}" )
|
||||
else
|
||||
blockDev=$( shopt -s extglob ; echo "${blockDev%%p+([0-9])}" )
|
||||
fi
|
||||
if [[ ' '"${blockDevs[@]}"' ' != *' '"$blockDev"' '* ]] ; then echo "Partition alias $_device does not point at one of the target disks ${blockDevs[@]}" ; exit 1 ; fi
|
||||
mkfs.${_fsType} ${_formatOptions} "${_device}" >$beQuiet
|
||||
partprobe "${_device}"
|
||||
) ; done
|
||||
)}
|
||||
|
||||
## Mounts all file systems as it would happen during boot, but at path prefix »$mnt«.
|
||||
function mount-system {( set -eu # 1: mnt, 2?: fstabPath
|
||||
# mount --all --fstab @{config.system.build.toplevel.outPath}/etc/fstab --target-prefix "$1" -o X-mount.mkdir # (»--target-prefix« is not supported on Ubuntu 20.04)
|
||||
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel.outPath}/etc/fstab"}
|
||||
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
|
||||
if [[ ! $target || $target == none ]] ; then continue ; fi
|
||||
options=,$options, ; options=${options//,ro,/,}
|
||||
if [[ $options =~ ,r?bind, ]] || [[ $type == overlay ]] ; then continue ; fi
|
||||
if ! mountpoint -q "$mnt"/"$target" ; then
|
||||
mkdir -p "$mnt"/"$target"
|
||||
mount -t $type -o "${options:1:(-1)}" "$source" "$mnt"/"$target"
|
||||
fi
|
||||
done
|
||||
# Since bind mounts may depend on other mounts not only for the target (which the sort takes care of) but also for the source, do all bind mounts last. This would break if there was a different bind mountpoint within a bind-mounted target.
|
||||
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 | while read source target type options numbers ; do
|
||||
if [[ ! $target || $target == none ]] ; then continue ; fi
|
||||
options=,$options, ; options=${options//,ro,/,}
|
||||
if [[ $options =~ ,r?bind, ]] || [[ $type == overlay ]] ; then : ; else continue ; fi
|
||||
if ! mountpoint -q "$mnt"/"$target" ; then
|
||||
mkdir -p "$mnt"/"$target"
|
||||
if [[ $type == overlay ]] ; then
|
||||
options=${options//,workdir=/,workdir=$mnt\/} ; options=${options//,upperdir=/,upperdir=$mnt\/} # work and upper dirs must be in target, lower dirs are probably store paths
|
||||
workdir=$(<<<"$options" grep -o -P ',workdir=\K[^,]+' || true) ; if [[ $workdir ]] ; then mkdir -p "$workdir" ; fi
|
||||
upperdir=$(<<<"$options" grep -o -P ',upperdir=\K[^,]+' || true) ; if [[ $upperdir ]] ; then mkdir -p "$upperdir" ; fi
|
||||
else
|
||||
source=$mnt/$source ; if [[ ! -e $source ]] ; then mkdir -p "$source" ; fi
|
||||
fi
|
||||
mount -t $type -o "${options:1:(-1)}" "$source" "$mnt"/"$target"
|
||||
fi
|
||||
done
|
||||
)}
|
||||
|
||||
## Unmounts all file systems (that would be mounted during boot / by »mount-system«).
|
||||
function unmount-system {( set -eu # 1: mnt, 2?: fstabPath
|
||||
mnt=$1 ; fstabPath=${2:-"@{config.system.build.toplevel.outPath}/etc/fstab"}
|
||||
<$fstabPath grep -v '^#' | LC_ALL=C sort -k2 -r | while read source target rest ; do
|
||||
if [[ ! $target || $target == none ]] ; then continue ; fi
|
||||
if mountpoint -q "$mnt"/"$target" ; then
|
||||
umount "$mnt"/"$target"
|
||||
fi
|
||||
done
|
||||
)}
|
||||
|
||||
## Given a block device path, returns the number of 512byte sectors it can hold.
|
||||
function blockSectorCount { printf %s "$(( $(blockdev --getsize64 "$1") / 512 ))" ; }
|
57
lib/setup-scripts/install.sh
Normal file
57
lib/setup-scripts/install.sh
Normal file
@ -0,0 +1,57 @@
|
||||
|
||||
##
|
||||
# NixOS Installation
|
||||
##
|
||||
|
||||
## Ensures that the installer gets called by root and with an argument, includes a hack to make installation work when nix isn't installed for root, and enables debugging (if requested).
|
||||
function prepare-installer { # ...
|
||||
|
||||
beQuiet=/dev/null ; if [[ ${debug:=} ]] ; then set -x ; beQuiet=/dev/stdout ; fi
|
||||
|
||||
if [[ "$(id -u)" != '0' ]] ; then echo 'Script must be run in a root (e.g. in a »sudo --preserve-env=SSH_AUTH_SOCK -i«) shell.' ; exit ; fi
|
||||
if [[ ${SUDO_USER:-} ]] ; then function nix {( args=("$@") ; su - "$SUDO_USER" -c "$(declare -p args)"' ; nix "${args[@]}"' )} ; fi
|
||||
|
||||
: ${1:?"Required: Target disk or image paths."}
|
||||
|
||||
if [[ $debug ]] ; then set +e ; set -E ; trap 'code= ; bash -l || code=$? ; if [[ $code ]] ; then exit $code ; fi' ERR ; fi # On error, instead of exiting straight away, open a shell to allow diagnosing/fixing the issue. Only exit if that shell reports failure (e.g. CtrlC + CtrlD). Unfortunately, the exiting has to be repeated for level of each nested sub-shells.
|
||||
|
||||
}
|
||||
|
||||
## Copies the system's dependencies to the disks mounted at »$mnt« and installs the bootloader. If »$inspect« is set, a root shell will be opened in »$mnt« afterwards.
|
||||
# »$topLevel« may point to an alternative top-level dependency to install.
|
||||
function install-system-to {( # 1: mnt, 2?: inspect, 3?: topLevel
|
||||
mnt=$1 ; inspect=${2:-} ; topLevel=${3:-}
|
||||
targetSystem=@{config.system.build.toplevel}
|
||||
trap - EXIT # start with empty traps for sub-shell
|
||||
|
||||
for dir in dev/ sys/ run/ ; do mkdir -p $mnt/$dir ; mount tmpfs -t tmpfs $mnt/$dir ; prepend_trap "while umount -l $mnt/$dir 2>$beQuiet ; do : ; done" EXIT ; done # proc/ run/
|
||||
mkdir -p -m 755 $mnt/nix/var ; mkdir -p -m 1775 $mnt/nix/store
|
||||
if [[ ${SUDO_USER:-} ]] ; then chown $SUDO_USER: $mnt/nix/store $mnt/nix/var ; fi
|
||||
|
||||
( set -x ; time nix copy --no-check-sigs --to $mnt ${topLevel:-$targetSystem} )
|
||||
ln -sT $(realpath $targetSystem) $mnt/run/current-system
|
||||
mkdir -p -m 755 $mnt/nix/var/nix/profiles ; ln -sT $(realpath $targetSystem) $mnt/nix/var/nix/profiles/system
|
||||
mkdir -p $mnt/etc/ ; [[ -e $mnt/etc/NIXOS ]] || touch $mnt/etc/NIXOS
|
||||
|
||||
if [[ $(cat /run/current-system/system 2>/dev/null || echo "x86_64-linux") != "@{config.preface.hardware}"-linux ]] ; then # cross architecture installation
|
||||
mkdir -p $mnt/run/binfmt ; cp -a {,$mnt}/run/binfmt/"@{config.preface.hardware}"-linux || true
|
||||
# Ubuntu (by default) expects the "interpreter" at »/usr/bin/qemu-@{config.preface.hardware}-static«.
|
||||
fi
|
||||
|
||||
if [[ ${SUDO_USER:-} ]] ; then chown -R root:root $mnt/nix ; chown :30000 $mnt/nix/store ; fi
|
||||
|
||||
mount -o bind /nix/store $mnt/nix/store # all the things required to _run_ the system are copied, but (may) need some more things to initially install it
|
||||
code=0 ; TMPDIR=/tmp LC_ALL=C nixos-install --system ${topLevel:-$targetSystem} --no-root-passwd --no-channel-copy --root $mnt || code=$? #--debug
|
||||
umount -l $mnt/nix/store
|
||||
|
||||
if [[ $inspect ]] ; then
|
||||
if (( code != 0 )) ; then
|
||||
( set +x ; echo "Something went wrong in the last step of the installation. Inspect the output above and the system mounted in CWD to decide whether it is critical. Exit the shell with 0 to proceed, or non-zero to abort." )
|
||||
else
|
||||
( set +x ; echo "Installation done, but the system is still mounted in CWD for inspection. Exit the shell to unmount it." )
|
||||
fi
|
||||
( cd $mnt ; mnt=$mnt bash -l )
|
||||
fi
|
||||
|
||||
( mkdir -p $mnt/var/lib/systemd/timesync ; touch $mnt/var/lib/systemd/timesync/clock ) || true # save current time
|
||||
)}
|
40
lib/setup-scripts/maintenance.sh
Normal file
40
lib/setup-scripts/maintenance.sh
Normal file
@ -0,0 +1,40 @@
|
||||
|
||||
##
|
||||
# NixOS Maintenance
|
||||
##
|
||||
|
||||
## On the host and for the user it is called by, creates/registers a VirtualBox VM meant to run the shells target host. Requires the path to the target host's »diskImage« as the result of running the install script. The image file may not be deleted or moved. If »bridgeTo« is set (to a host interface name, e.g. as »eth0«), it is added as bridged network "Adapter 2" (which some hosts need).
|
||||
function register-vbox {( set -eu # 1: diskImage, 2?: bridgeTo
|
||||
diskImage=$1 ; bridgeTo=${2:-}
|
||||
vmName="nixos-@{config.networking.hostName}"
|
||||
|
||||
if [[ ! -e $diskImage.vmdk ]] ; then
|
||||
VBoxManage internalcommands createrawvmdk -filename $diskImage.vmdk -rawdisk $diskImage # pass-through
|
||||
fi
|
||||
|
||||
VBoxManage createvm --name "$vmName" --register --ostype Linux26_64
|
||||
VBoxManage modifyvm "$vmName" --memory 2048 --pae off --firmware efi
|
||||
|
||||
VBoxManage storagectl "$vmName" --name SATA --add sata --portcount 4 --bootable on --hostiocache on
|
||||
VBoxManage storageattach "$vmName" --storagectl SATA --port 0 --device 0 --type hdd --medium $diskImage.vmdk
|
||||
|
||||
if [[ $bridgeTo ]] ; then # VBoxManage list bridgedifs
|
||||
VBoxManage modifyvm "$vmName" --nic2 bridged --bridgeadapter2 $bridgeTo
|
||||
fi
|
||||
|
||||
VBoxManage modifyvm "$vmName" --uart1 0x3F8 4 --uartmode1 server /run/user/$(id -u)/$vmName.socket # (guest sets speed)
|
||||
|
||||
set +x # avoid double-echoing
|
||||
echo '# VM info:'
|
||||
echo " VBoxManage showvminfo $vmName"
|
||||
echo '# start VM:'
|
||||
echo " VBoxManage startvm $vmName --type headless"
|
||||
echo '# kill VM:'
|
||||
echo " VBoxManage controlvm $vmName poweroff"
|
||||
echo '# create TTY:'
|
||||
echo " socat UNIX-CONNECT:/run/user/$(id -u)/$vmName.socket PTY,link=/run/user/$(id -u)/$vmName.pty"
|
||||
echo '# connect TTY:'
|
||||
echo " screen /run/user/$(id -u)/$vmName.pty"
|
||||
echo '# screenshot:'
|
||||
echo " ssh $(hostname) VBoxManage controlvm $vmName screenshotpng /dev/stdout | display"
|
||||
)}
|
17
lib/setup-scripts/utils.sh
Normal file
17
lib/setup-scripts/utils.sh
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
##
|
||||
# Utilities
|
||||
##
|
||||
|
||||
## Prepends a command to a trap. Especially useful fo define »finally« commands via »prepend_trap '<command>' EXIT«.
|
||||
# NOTE: When calling this in a sub-shell whose parents already has traps installed, make sure to do »trap - trapName« first. On a new shell, this should be a no-op, but without it, the parent shell's traps will be added to the sub-shell as well (due to strange behavior of »trap -p« (in bash ~5.1.8)).
|
||||
prepend_trap() { # 1: command, ...: trapNames
|
||||
fatal() { printf "ERROR: $@\n" >&2 ; return 1 ; }
|
||||
local cmd=$1 ; shift || fatal "${FUNCNAME} usage error"
|
||||
local name ; for name in "$@" ; do
|
||||
trap -- "$( set +x
|
||||
printf '%s\n' "( ${cmd} ) || true ; "
|
||||
p3() { printf '%s\n' "${3:-}" ; } ; eval "p3 $(trap -p "${name}")"
|
||||
)" "${name}" || fatal "unable to add to trap ${name}"
|
||||
done
|
||||
} ; declare -f -t prepend_trap # required to modify DEBUG or RETURN traps
|
Reference in New Issue
Block a user