diff --git a/docs/writing-labs.md b/docs/writing-labs.md index fdeee56..64a5507 100644 --- a/docs/writing-labs.md +++ b/docs/writing-labs.md @@ -46,6 +46,8 @@ with `set -e`. Read `lib/lab.sh` for the helpers; the ones most labs use: `10.99.0.2` (name `peer`), linked to the VM's `10.99.0.1` - `hosts_add LINE`: an `/etc/hosts` line reset takes out again - `state_set KEY VALUE`, `state_get KEY`: notes for check and reset +- `sshd_restart`: restart sshd after reset puts its settings back, and + check port 22 answers (on Ubuntu, never restart `ssh.service` yourself) The peer shares the VM's files, users and `/etc`, but has its own addresses, routes, firewall and ports. It has no route beyond the VM, so diff --git a/labs/lfca/4-1-security/reset.sh b/labs/lfca/4-1-security/reset.sh index fd02c3c..a49ac8e 100644 --- a/labs/lfca/4-1-security/reset.sh +++ b/labs/lfca/4-1-security/reset.sh @@ -9,12 +9,7 @@ rm -f /root/hash-me.txt /root/secret.txt /root/secret.txt.gpg if [ -f "$LAB_STATE/ssh.tar" ]; then rm -f /etc/ssh/sshd_config.d/* tar -C /etc -xf "$LAB_STATE/ssh.tar" - if is_rhel; then - systemctl restart sshd - else - systemctl restart ssh.socket 2>/dev/null - systemctl restart ssh.service 2>/dev/null - fi + sshd_restart fi # Root's new keys and gpg's files. for k in /root/.ssh/id_*; do diff --git a/labs/lfcs/2-4-openssh/reset.sh b/labs/lfcs/2-4-openssh/reset.sh index 90d342d..a537189 100644 --- a/labs/lfcs/2-4-openssh/reset.sh +++ b/labs/lfcs/2-4-openssh/reset.sh @@ -16,12 +16,7 @@ for f in /etc/systemd/system/ssh.socket.d/*; do grep -qxF "${f##*/}" "$LAB_STATE/socket-d" 2>/dev/null || rm -f "$f" done systemctl daemon-reload -if is_rhel; then - systemctl restart sshd -else - systemctl restart ssh.socket 2>/dev/null - systemctl restart ssh.service 2>/dev/null -fi +sshd_restart # The client side. if [ -f "$LAB_STATE/root-ssh-config" ]; then diff --git a/labs/linux-terminal/1-1-files-and-directories/README.md b/labs/linux-terminal/1-1-files-and-directories/README.md new file mode 100644 index 0000000..2c0b90f --- /dev/null +++ b/labs/linux-terminal/1-1-files-and-directories/README.md @@ -0,0 +1,30 @@ +# Using Linux Terminal 1.1: Files and directories + +Making a directory tree in one command, copying with hidden files and +dates kept, hard and symbolic links, and removing directories. + +## Before you start + +The lab creates user `student` (password `Lab-Pass-1`). Work as `student` +(`sudo -iu student`), so `~` is `/home/student`. The lab only checks that +`~/lab` doesn't exist yet; `le-lab reset` removes it. + +## Tasks + +1. Create `~/lab/app/config`, `~/lab/app/logs` and `~/lab/app/data` in one + command, and copy `/etc/skel` to `~/lab/skel`, keeping its hidden files + and timestamps. +2. In `~/lab`, create `notes.txt` containing the word `hello`, a hard link + `notes.hard` and a symbolic link `notes.link` to it. Then rename + `notes.txt` to `notes.old`, and see what `cat` makes of each link. +3. Remove `~/lab/app/logs` with `rmdir`. Put a file in `~/lab/app/data`, + try `rmdir` on it, then remove that directory too. Only `config` is left + in `~/lab/app`. + +## Hints + +- `mkdir -p` with a brace list: `{config,logs,data}`. +- `cp -a`; compare `ls -la ~/lab/skel` with `ls -la /etc/skel`. +- `ln`, `ln -s`, `mv`; `ls -li` shows inode numbers and `stat -c %h` the + link count. +- `rmdir` only removes empty directories: "Directory not empty". `rm -r`. diff --git a/labs/linux-terminal/1-1-files-and-directories/check.sh b/labs/linux-terminal/1-1-files-and-directories/check.sh new file mode 100644 index 0000000..c6de38c --- /dev/null +++ b/labs/linux-terminal/1-1-files-and-directories/check.sh @@ -0,0 +1,29 @@ +H=$(state_get home) +L=$H/lab +# skel_kept: every file under /etc/skel is in ~/lab/skel, same type and +# modification time. +skel_kept() { + local f + while read -r f; do + [ "$(stat -c '%F %Y' "/etc/skel/$f")" = "$(stat -c '%F %Y' "$L/skel/$f" 2>/dev/null)" ] || return 1 + done < <(cd /etc/skel && find . -mindepth 1) +} + +task 1 "~/lab/app/config, and /etc/skel copied to ~/lab/skel with hidden files and dates" +need "$L/app/config is a directory" '[ -d "$L/app/config" ]' +need "$L/skel holds the hidden files, such as .bashrc" '[ -f "$L/skel/.bashrc" ]' +need "every file in ~/lab/skel has the same date as in /etc/skel" skel_kept + +task 2 "notes.hard and notes.link made, then notes.txt renamed to notes.old" +need "$L/notes.old contains hello" 'grep -qw hello "$L/notes.old"' +need "$L/notes.txt is gone (renamed)" '[ ! -e "$L/notes.txt" ] && [ ! -L "$L/notes.txt" ]' +need "notes.hard is a hard link to notes.old (same inode)" '[ ! -L "$L/notes.hard" ] && [ "$L/notes.hard" -ef "$L/notes.old" ]' +need "stat -c %h notes.old prints 2" '[ "$(stat -c %h "$L/notes.old")" = 2 ]' +need "notes.link is a symbolic link to notes.txt" '[ -L "$L/notes.link" ] && [ "$(basename "$(readlink "$L/notes.link")")" = notes.txt ]' +need "notes.link is broken now" '[ ! -e "$L/notes.link" ]' + +task 3 "logs and data removed: only config left in ~/lab/app" +need "$L/app exists" '[ -d "$L/app" ]' +need "$L/app/logs is gone" '[ ! -e "$L/app/logs" ]' +need "$L/app/data is gone" '[ ! -e "$L/app/data" ]' +need "ls ~/lab/app lists only config" '[ "$(ls -A "$L/app")" = config ]' diff --git a/labs/linux-terminal/1-1-files-and-directories/lab.conf b/labs/linux-terminal/1-1-files-and-directories/lab.conf new file mode 100644 index 0000000..532058a --- /dev/null +++ b/labs/linux-terminal/1-1-files-and-directories/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.1 Files and directories" +CHAPTER=/linux-terminal/1-files-and-filesystems/1-1-files-and-directories/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/1-1-files-and-directories/reset.sh b/labs/linux-terminal/1-1-files-and-directories/reset.sh new file mode 100644 index 0000000..3b7444e --- /dev/null +++ b/labs/linux-terminal/1-1-files-and-directories/reset.sh @@ -0,0 +1,3 @@ +H=$(state_get home) +[ -n "$H" ] && rm -rf "$H/lab" +true diff --git a/labs/linux-terminal/1-1-files-and-directories/setup.sh b/labs/linux-terminal/1-1-files-and-directories/setup.sh new file mode 100644 index 0000000..34d7fff --- /dev/null +++ b/labs/linux-terminal/1-1-files-and-directories/setup.sh @@ -0,0 +1,5 @@ +user_add student +H=$(getent passwd student | cut -d: -f6) +[ -e "$H/lab" ] && refuse "$H/lab already exists; the tasks create it. Remove it and start the lab again." +state_set home "$H" +true diff --git a/labs/linux-terminal/1-2-search-compare-modify/README.md b/labs/linux-terminal/1-2-search-compare-modify/README.md new file mode 100644 index 0000000..db96d7d --- /dev/null +++ b/labs/linux-terminal/1-2-search-compare-modify/README.md @@ -0,0 +1,28 @@ +# Using Linux Terminal 1.2: Searching, comparing and changing files + +grep and regular expressions, find with errors thrown away, sed with a +backup, and diff. + +## Before you start + +The lab creates user `student` (password `Lab-Pass-1`). Work as `student` +(`sudo -iu student`), so `~` is `/home/student`. The lab removes the files +the tasks create if they exist, and `le-lab reset` removes them again. + +## Tasks + +1. Write the names of all users whose shell is `/bin/bash` to + `~/bash-users.txt`, one per line, and nothing else. +2. As `student`, find every regular file under `/etc` bigger than 100 KiB + and save the list in `~/big-etc.txt`, with any permission errors thrown + away: none on the screen and none in the file. +3. Copy `/etc/ssh/sshd_config` to `~/sshd.copy`, then use `sed` to change + `#Port 22` to `Port 2222` in the copy, keeping a `.bak` of it. `diff + ~/sshd.copy.bak ~/sshd.copy` shows just that one line changed. + +## Hints + +- `grep '/bin/bash$' /etc/passwd`, then `cut -d: -f1`; compare `wc -l` + with `grep -c`. +- `find /etc -type f -size +100k`, and `2>/dev/null` for the errors. +- `sed -i.bak 's/^#Port 22$/Port 2222/'`; diff shows a `c` change. diff --git a/labs/linux-terminal/1-2-search-compare-modify/check.sh b/labs/linux-terminal/1-2-search-compare-modify/check.sh new file mode 100644 index 0000000..5f0a53b --- /dev/null +++ b/labs/linux-terminal/1-2-search-compare-modify/check.sh @@ -0,0 +1,35 @@ +H=$(state_get home) +B=$H/big-etc.txt +norm() { sed 's#//*#/#g; s/[[:space:]]*$//' "$@"; } +# Each listed path is a regular file under /etc bigger than 100 KiB. +all_big() { + local p + while read -r p; do + case $p in /etc/*) ;; *) return 1 ;; esac + [ -n "$(find "$p" -maxdepth 0 -type f -size +100k 2>/dev/null)" ] || return 1 + done < <(norm "$B") +} +# Every such file student can find is listed. +none_missing() { + local p + while read -r p; do + norm "$B" | grep -qxF "$p" || return 1 + done < <(runuser -u student -- find /etc -type f -size +100k 2>/dev/null | norm) +} + +task 1 "~/bash-users.txt lists exactly the users with shell /bin/bash" +need "$H/bash-users.txt exists" '[ -f "$H/bash-users.txt" ]' +need "it holds the names of the users whose shell is /bin/bash, one per line, and nothing else" \ + '[ "$(norm "$H/bash-users.txt" | sort)" = "$(awk -F: "\$7 == \"/bin/bash\" {print \$1}" /etc/passwd | sort)" ]' + +task 2 "~/big-etc.txt: regular files under /etc over 100 KiB, no errors" +need "$B exists and lists something" '[ -s "$B" ]' +need "it has no Permission denied lines" '! grep -qi "permission denied" "$B"' +need "every line is a regular file under /etc bigger than 100 KiB" all_big +need "it lists every such file student can find" none_missing + +task 3 "sed changed #Port 22 to Port 2222 in ~/sshd.copy, with a .bak" +need "$H/sshd.copy.bak is an unchanged copy of /etc/ssh/sshd_config" 'cmp -s "$H/sshd.copy.bak" "$LAB_STATE/sshd_config" || cmp -s "$H/sshd.copy.bak" /etc/ssh/sshd_config' +need "$H/sshd.copy has Port 2222 and no #Port 22" 'grep -qx "Port 2222" "$H/sshd.copy" && ! grep -qx "#Port 22" "$H/sshd.copy"' +need "diff ~/sshd.copy.bak ~/sshd.copy shows one line changed, as a c change" \ + 'd=$(diff "$H/sshd.copy.bak" "$H/sshd.copy"); [ "$(wc -l <<<"$d")" = 4 ] && head -n1 <<<"$d" | grep -Eq "^[0-9]+c[0-9]+$"' diff --git a/labs/linux-terminal/1-2-search-compare-modify/lab.conf b/labs/linux-terminal/1-2-search-compare-modify/lab.conf new file mode 100644 index 0000000..8c63e7e --- /dev/null +++ b/labs/linux-terminal/1-2-search-compare-modify/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.2 Searching, comparing and changing files" +CHAPTER=/linux-terminal/1-files-and-filesystems/1-2-search-compare-modify/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/1-2-search-compare-modify/reset.sh b/labs/linux-terminal/1-2-search-compare-modify/reset.sh new file mode 100644 index 0000000..1dff830 --- /dev/null +++ b/labs/linux-terminal/1-2-search-compare-modify/reset.sh @@ -0,0 +1,3 @@ +H=$(state_get home) +[ -n "$H" ] && rm -f "$H/bash-users.txt" "$H/big-etc.txt" "$H/sshd.copy" "$H/sshd.copy.bak" +true diff --git a/labs/linux-terminal/1-2-search-compare-modify/setup.sh b/labs/linux-terminal/1-2-search-compare-modify/setup.sh new file mode 100644 index 0000000..60bde61 --- /dev/null +++ b/labs/linux-terminal/1-2-search-compare-modify/setup.sh @@ -0,0 +1,8 @@ +grep -qx '#Port 22' /etc/ssh/sshd_config || + refuse "/etc/ssh/sshd_config has no line '#Port 22' for task 3; put it back as Ubuntu ships it and start the lab again." +user_add student +H=$(getent passwd student | cut -d: -f6) +state_set home "$H" +rm -f "$H/bash-users.txt" "$H/big-etc.txt" "$H/sshd.copy" "$H/sshd.copy.bak" +cp /etc/ssh/sshd_config "$LAB_STATE/sshd_config" +true diff --git a/labs/linux-terminal/2-1-logs-and-log-rotation/README.md b/labs/linux-terminal/2-1-logs-and-log-rotation/README.md new file mode 100644 index 0000000..9bf7e6c --- /dev/null +++ b/labs/linux-terminal/2-1-logs-and-log-rotation/README.md @@ -0,0 +1,34 @@ +# Using Linux Terminal 2.1: Logs and log rotation + +Reading `/var/log/auth.log` and the journal, rotating a log of your own +with logrotate, and filtering the journal by priority. + +## Before you start + +Work as root (`sudo -i`), and write answers to the files in +`/root/lab-answers/`. The lab creates user `alice`, who then runs four +commands with `sudo`, and it logs a few messages tagged `webapp` at +different priorities. It only checks that `/var/log/webapp` doesn't exist +yet; `le-lab reset` removes it and your logrotate file for it. + +## Tasks + +1. Find the last three commands `alice` ran with `sudo`, from + `/var/log/auth.log` and from the journal, and write them to + `/root/lab-answers/1`, one per line. Both should show the same commands + at the same times. +2. Create `/var/log/webapp/access.log` with some lines in it, and a + logrotate configuration that rotates it weekly, keeps 4 compressed + copies, and creates a new empty log owned by `root:adm` with mode 640. + Force one rotation. +3. Show only error messages (and worse) from the current boot, and write + the `webapp` messages among them to `/root/lab-answers/3`. + +## Hints + +- `grep 'COMMAND=' /var/log/auth.log`, `journalctl -t sudo`; `tail -n 3`. + Pasting whole log lines into the answer file is fine. +- A file in `/etc/logrotate.d/` with `weekly`, `rotate 4`, `compress` and + `create 0640 root adm`; `logrotate -d` to test it, `logrotate -f` to + force it. `zcat` reads the old copy. +- `journalctl -p err -b`; `-t webapp` narrows it to one tag. diff --git a/labs/linux-terminal/2-1-logs-and-log-rotation/check.sh b/labs/linux-terminal/2-1-logs-and-log-rotation/check.sh new file mode 100644 index 0000000..883db5b --- /dev/null +++ b/labs/linux-terminal/2-1-logs-and-log-rotation/check.sh @@ -0,0 +1,25 @@ +# Answer lines, lowercase. +ans() { tr 'A-Z' 'a-z' <"/root/lab-answers/$1" 2>/dev/null; } +LR=$(grep -lE '/var/log/webapp/' /etc/logrotate.d/* 2>/dev/null | head -n1) +W=/var/log/webapp + +task 1 "alice's last three sudo commands in /root/lab-answers/1" +need "/root/lab-answers/1 has cat /etc/hostname" 'ans 1 | grep -qF "cat /etc/hostname"' +need "/root/lab-answers/1 has du -sh /var/log" 'ans 1 | grep -qF "du -sh /var/log"' +need "/root/lab-answers/1 has tail -n 5 /var/log/dpkg.log" 'ans 1 | grep -qF "tail -n 5 /var/log/dpkg.log"' +need "/root/lab-answers/1 has only the last three (not ls /etc/apt, the one before)" '! ans 1 | grep -qF "ls /etc/apt"' + +task 2 "logrotate rotates /var/log/webapp/access.log weekly, 4 compressed, create 640 root adm" +need "a file in /etc/logrotate.d names /var/log/webapp/access.log" '[ -n "$LR" ]' +need "logrotate -d shows it rotated weekly, keeping 4" 'logrotate -d /etc/logrotate.conf 2>&1 | grep -E "^rotating pattern: .*/var/log/webapp/" | grep -q "weekly (4 rotations)"' +need "logrotate -d reports no errors for it" '! logrotate -d "$LR" 2>&1 | grep -qi "^error"' +need "$LR compresses old copies" 'grep -Eq "^[[:space:]]*compress([[:space:]]|$)" "$LR"' +need "$LR creates the new log with 0640 root adm" 'grep -Eq "^[[:space:]]*create[[:space:]]+0?640[[:space:]]+root[[:space:]]+adm([[:space:]]|$)" "$LR"' +need "$W/access.log is a new empty log" '[ -f $W/access.log ] && [ ! -s $W/access.log ]' +need "$W/access.log is -rw-r----- root adm" '[ "$(stat -c "%a %U %G" $W/access.log)" = "640 root adm" ]' +need "the old lines are in access.log.1.gz (or access.log.1)" '{ zcat $W/access.log.1.gz 2>/dev/null || cat $W/access.log.1 2>/dev/null; } | grep -q .' + +task 3 "The webapp messages of priority err and worse in /root/lab-answers/3" +need "/root/lab-answers/3 has the error: cannot connect to database" 'ans 3 | grep -qF "cannot connect to database"' +need "/root/lab-answers/3 has the critical message: worker threads stuck" 'ans 3 | grep -qF "worker threads stuck"' +need "/root/lab-answers/3 has nothing below err (no warning, no notice)" '! ans 3 | grep -qE "cache warmed|disk use at"' diff --git a/labs/linux-terminal/2-1-logs-and-log-rotation/lab.conf b/labs/linux-terminal/2-1-logs-and-log-rotation/lab.conf new file mode 100644 index 0000000..3e7f31b --- /dev/null +++ b/labs/linux-terminal/2-1-logs-and-log-rotation/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.1 Logs and log rotation" +CHAPTER=/linux-terminal/2-system-resources/2-1-logs-and-log-rotation/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/2-1-logs-and-log-rotation/reset.sh b/labs/linux-terminal/2-1-logs-and-log-rotation/reset.sh new file mode 100644 index 0000000..9abdfe2 --- /dev/null +++ b/labs/linux-terminal/2-1-logs-and-log-rotation/reset.sh @@ -0,0 +1,12 @@ +rm -f /etc/sudoers.d/le-lab-alice +rm -rf /var/log/webapp +for f in /etc/logrotate.d/*; do + [ -f "$f" ] || continue + grep -qxF "${f##*/}" "$LAB_STATE/logrotate-d" 2>/dev/null && continue + grep -q '/var/log/webapp' "$f" && rm -f "$f" +done +sed -i '\#^"/var/log/webapp/#d' /var/lib/logrotate/status 2>/dev/null +if [ -n "$(state_get installed-rsyslog)" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge rsyslog >/dev/null 2>&1 +fi +true diff --git a/labs/linux-terminal/2-1-logs-and-log-rotation/setup.sh b/labs/linux-terminal/2-1-logs-and-log-rotation/setup.sh new file mode 100644 index 0000000..2ec5d19 --- /dev/null +++ b/labs/linux-terminal/2-1-logs-and-log-rotation/setup.sh @@ -0,0 +1,27 @@ +[ -e /var/log/webapp ] && refuse "/var/log/webapp already exists; task 2 creates it. Remove it and start the lab again." +if ! pkg_installed rsyslog; then + pkg_install rsyslog + state_set installed-rsyslog 1 +fi +systemctl enable --now --quiet rsyslog +ls /etc/logrotate.d >"$LAB_STATE/logrotate-d" +user_add alice + +# Four commands as alice with sudo, a second apart; the last three are the +# answer to task 1. +cat >/etc/sudoers.d/le-lab-alice <<'X' +alice ALL=(root) NOPASSWD: /usr/bin/ls /etc/apt, /usr/bin/cat /etc/hostname, /usr/bin/du -sh /var/log, /usr/bin/tail -n 5 /var/log/dpkg.log +X +chmod 440 /etc/sudoers.d/le-lab-alice +for c in "/usr/bin/ls /etc/apt" "/usr/bin/cat /etc/hostname" "/usr/bin/du -sh /var/log" "/usr/bin/tail -n 5 /var/log/dpkg.log"; do + runuser -u alice -- sudo -n $c >/dev/null 2>&1 || true + sleep 1 +done +rm -f /etc/sudoers.d/le-lab-alice + +# Messages for task 3, from notice to critical. +logger -t webapp -p user.notice "cache warmed in 840 ms" +logger -t webapp -p user.warning "disk use at 85 percent on /var" +logger -t webapp -p user.err "cannot connect to database at 10.0.0.5:5432" +logger -t webapp -p user.crit "all worker threads stuck, giving up" +true diff --git a/labs/linux-terminal/2-2-partitions-and-filesystems/README.md b/labs/linux-terminal/2-2-partitions-and-filesystems/README.md new file mode 100644 index 0000000..665754c --- /dev/null +++ b/labs/linux-terminal/2-2-partitions-and-filesystems/README.md @@ -0,0 +1,35 @@ +# Using Linux Terminal 2.2: Partitions and filesystems + +A GPT partition with an ext4 filesystem mounted at boot by UUID, getting a +deleted partition back, and a read-only filesystem check. + +## Before you start + +Work as root (`sudo -i`), and write answers to the files in +`/root/lab-answers/`. The lab adds a blank 2 GiB spare disk, +`/dev/loop40`, which stays attached across reboots; its first partition is +`/dev/loop40p1` (the guide's `/dev/vdb` and `/dev/vdb1`). It saves +`/etc/fstab` and puts it back on reset, which also removes the disk. + +## Tasks + +1. Give `/dev/loop40` a GPT partition table with one 1 GiB partition, make + an ext4 filesystem labeled `data` on it, and mount it at `/mnt/data` at + every boot, by UUID. `findmnt --verify` reports no errors or warnings. +2. Save a file `/mnt/data/keep.txt` containing `keep me`. Unmount the + filesystem, write its partition's start sector to + `/root/lab-answers/2`, delete the partition with fdisk, then recreate it + so that `keep.txt` comes back unchanged. Mount it again. +3. Check the filesystem fully while it's unmounted, without letting fsck + change anything, and save fsck's output in `/root/lab-answers/3`. + +## Hints + +- `fdisk /dev/loop40` (`g`, `n`, `+1G`, `w`) or `parted`; `mkfs.ext4 -L`; + `blkid` for the UUID; `systemctl daemon-reload` after editing fstab, then + `mount -a`. +- `fdisk -l /dev/loop40` shows the start; recreate with the same start and + answer **N** to "remove the signature". fdisk asks that only at a + terminal: fed from a pipe, it keeps the signature without asking. + `blkid` shows the same UUID. +- `fsck -fn /dev/loop40p1 >/root/lab-answers/3 2>&1`. diff --git a/labs/linux-terminal/2-2-partitions-and-filesystems/check.sh b/labs/linux-terminal/2-2-partitions-and-filesystems/check.sh new file mode 100644 index 0000000..579bc1a --- /dev/null +++ b/labs/linux-terminal/2-2-partitions-and-filesystems/check.sh @@ -0,0 +1,25 @@ +D=$(state_get dev) +P=${D}p1 +U=$(blkid -o value -s UUID "$P" 2>/dev/null) +# Partition size in bytes. +psize() { blockdev --getsize64 "$P" 2>/dev/null || echo 0; } + +task 1 "GPT, one 1 GiB ext4 partition labeled data, mounted at /mnt/data by UUID" +need "$D has a GPT partition table" '[ "$(blkid -o value -s PTTYPE "$D")" = gpt ]' +need "$D has one partition, $P" '[ -b "$P" ] && [ "$(lsblk -nr -o TYPE "$D" | grep -c part)" = 1 ]' +need "$P is 1 GiB" 's=$(psize); [ "$s" -ge $((1024 * 1024 * 1016)) ] && [ "$s" -le $((1024 * 1024 * 1032)) ]' +need "$P holds ext4 labeled data" '[ "$(blkid -o value -s TYPE "$P")" = ext4 ] && [ "$(blkid -o value -s LABEL "$P")" = data ]' +need "/etc/fstab mounts UUID=$U at /mnt/data" '[ -n "$U" ] && awk -v u="UUID=$U" "\$1 == u && \$2 == \"/mnt/data\" && \$3 == \"ext4\"" /etc/fstab | grep -q .' +need "findmnt --verify reports no errors or warnings" 'findmnt --verify 2>&1 | grep -q "no errors or warnings"' +need "/mnt/data is mounted from it now" '[ -n "$U" ] && [ "$(findmnt -n -o UUID /mnt/data)" = "$U" ]' + +task 2 "Partition deleted and recreated at the same start; keep.txt is back" +need "/root/lab-answers/2 holds $P's start sector" '[ "$(answer 2 | grep -o "[0-9]\+" | head -n1)" = "$(cat /sys/class/block/${P#/dev/}/start)" ]' +need "/mnt/data is mounted from $P" '[ -n "$U" ] && [ "$(findmnt -n -o UUID /mnt/data)" = "$U" ]' +need "/mnt/data/keep.txt contains keep me" '[ "$(cat /mnt/data/keep.txt)" = "keep me" ]' + +task 3 "A full, read-only fsck, its output in /root/lab-answers/3" +need "/root/lab-answers/3 has fsck's output through pass 5" 'grep -q "Pass 5" /root/lab-answers/3' +need "it ends with the file and block counts" 'grep -Eq "[0-9]+/[0-9]+ files.*, [0-9]+/[0-9]+ blocks" /root/lab-answers/3' +need "fsck ran on the unmounted filesystem" '! grep -qi "is mounted" /root/lab-answers/3' +need "fsck changed nothing: no FILE SYSTEM WAS MODIFIED" '! grep -q "MODIFIED" /root/lab-answers/3' diff --git a/labs/linux-terminal/2-2-partitions-and-filesystems/lab.conf b/labs/linux-terminal/2-2-partitions-and-filesystems/lab.conf new file mode 100644 index 0000000..2694fae --- /dev/null +++ b/labs/linux-terminal/2-2-partitions-and-filesystems/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.2 Partitions and filesystems" +CHAPTER=/linux-terminal/2-system-resources/2-2-partitions-and-filesystems/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/2-2-partitions-and-filesystems/reset.sh b/labs/linux-terminal/2-2-partitions-and-filesystems/reset.sh new file mode 100644 index 0000000..0cf6f8a --- /dev/null +++ b/labs/linux-terminal/2-2-partitions-and-filesystems/reset.sh @@ -0,0 +1,7 @@ +umount /mnt/data 2>/dev/null +if [ -f "$LAB_STATE/fstab" ]; then + cp -a "$LAB_STATE/fstab" /etc/fstab + systemctl daemon-reload +fi +[ -n "$(state_get had-mnt-data)" ] || rmdir /mnt/data 2>/dev/null +true diff --git a/labs/linux-terminal/2-2-partitions-and-filesystems/setup.sh b/labs/linux-terminal/2-2-partitions-and-filesystems/setup.sh new file mode 100644 index 0000000..d6bf601 --- /dev/null +++ b/labs/linux-terminal/2-2-partitions-and-filesystems/setup.sh @@ -0,0 +1,10 @@ +if mountpoint -q /mnt/data || [ -n "$(ls -A /mnt/data 2>/dev/null)" ]; then + refuse "/mnt/data is in use; unmount or empty it and start the lab again." +fi +grep -Eq '^[^#]*[[:space:]]/mnt/data[[:space:]]' /etc/fstab && + refuse "/etc/fstab already mounts something at /mnt/data; remove that line and start the lab again." +cp -a /etc/fstab "$LAB_STATE/fstab" +[ -d /mnt/data ] && state_set had-mnt-data 1 +dev=$(disk_add 0 2G) +state_set dev "$dev" +true diff --git a/labs/linux-terminal/2-3-crontab/README.md b/labs/linux-terminal/2-3-crontab/README.md new file mode 100644 index 0000000..2e39d57 --- /dev/null +++ b/labs/linux-terminal/2-3-crontab/README.md @@ -0,0 +1,32 @@ +# Using Linux Terminal 2.3: Crontab format and location + +A user crontab with a repeating job and a boot job, a system job in +`/etc/cron.d`, and keeping one user away from crontab. + +## Before you start + +The lab creates users `student` and `alice` (password `Lab-Pass-1`), puts +a harmless script at `/usr/local/bin/cleanup`, and makes sure cron is +running. It saves `/etc/cron.allow` and `/etc/cron.deny` (if they exist) +and puts them back on reset. Task 1 needs a few minutes of waiting and a +reboot. + +## Tasks + +1. As `student` (`sudo -iu student`), add a cron job that appends the date + to `~/ticks.log` every two minutes, and one that writes `booted` to + `~/boot.log` at every boot. Wait until `~/ticks.log` has a date or two, + then reboot and look at `~/boot.log`. +2. Make a system job that runs `/usr/local/bin/cleanup` as root at 3:15 am + on the first day of every month, in its own file under `/etc/cron.d`. +3. Stop `alice` from using crontab, without touching anyone else: her + `crontab -l` says she isn't allowed, and `student`'s still works. + +## Hints + +- `crontab -e`, `crontab -l`; `*/2 * * * *` and `@reboot`; `>>` appends, + `>` overwrites. +- Files in `/etc/cron.d` have a user field after the five time fields, and + cron skips files whose names contain a dot. +- `/etc/cron.deny` lists users who may not use crontab; a `cron.allow` + would shut out everyone it doesn't list. diff --git a/labs/linux-terminal/2-3-crontab/check.sh b/labs/linux-terminal/2-3-crontab/check.sh new file mode 100644 index 0000000..0869aab --- /dev/null +++ b/labs/linux-terminal/2-3-crontab/check.sh @@ -0,0 +1,39 @@ +H=$(state_get home) +CT=$(crontab -l -u student 2>/dev/null | grep -Ev '^[[:space:]]*(#|$)') +# The cron.d file with the cleanup job. +CD=$(grep -lE '^[[:space:]]*15[[:space:]]+0?3[[:space:]]+1[[:space:]]+\*[[:space:]]+\*[[:space:]]+root[[:space:]]+/usr/local/bin/cleanup[[:space:]]*$' /etc/cron.d/* 2>/dev/null | head -n1) +refused() { runuser -u "$1" -- crontab -l 2>&1 | grep -q "not allowed"; } +others_ok() { + local u + refused root && return 1 + for u in $(awk -F: '$3 >= 1000 && $3 < 65534 {print $1}' /etc/passwd); do + [ "$u" = alice ] && continue + refused "$u" && return 1 + done + return 0 +} +boot_log() { + local i + for i in $(seq 15); do + grep -qx booted "$H/boot.log" 2>/dev/null && return 0 + sleep 1 + done + return 1 +} + +task 1 "student's crontab: date to ~/ticks.log every two minutes, booted to ~/boot.log at boot" +need "student's crontab has a line that appends date to ~/ticks.log every two minutes" \ + 'grep -E "^(\*/2|0-59/2|0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58)[[:space:]]+\*[[:space:]]+\*[[:space:]]+\*[[:space:]]+\*[[:space:]]" <<<"$CT" | grep "date" | grep -q ">>.*ticks\.log"' +need "student's crontab has an @reboot line that writes booted to ~/boot.log" 'grep -E "^@reboot[[:space:]]" <<<"$CT" | grep -q "boot\.log"' +need "$H/ticks.log has a date in it" 'grep -Eq "20[0-9][0-9]" "$H/ticks.log"' +need "$H/boot.log contains booted (after a reboot)" boot_log + +task 2 "/etc/cron.d job: /usr/local/bin/cleanup as root at 3:15 on the 1st" +need "a file in /etc/cron.d holds 15 3 1 * * root /usr/local/bin/cleanup" '[ -n "$CD" ]' +need "its name has no dot in it" '[[ "${CD##*/}" =~ ^[A-Za-z0-9_-]+$ ]]' +need "it's owned by root and not writable by group or others" '[ "$(stat -c %U "$CD")" = root ] && [ -z "$(find "$CD" -perm /022)" ]' + +task 3 "alice can't use crontab; everyone else still can" +need "alice's crontab -l says she isn't allowed" 'refused alice' +need "student's crontab -l still works" '! refused student' +need "no other user (or root) is refused" others_ok diff --git a/labs/linux-terminal/2-3-crontab/lab.conf b/labs/linux-terminal/2-3-crontab/lab.conf new file mode 100644 index 0000000..974c003 --- /dev/null +++ b/labs/linux-terminal/2-3-crontab/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.3 Crontab format and location" +CHAPTER=/linux-terminal/2-system-resources/2-3-crontab/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/2-3-crontab/reset.sh b/labs/linux-terminal/2-3-crontab/reset.sh new file mode 100644 index 0000000..c0d1984 --- /dev/null +++ b/labs/linux-terminal/2-3-crontab/reset.sh @@ -0,0 +1,17 @@ +crontab -r -u student 2>/dev/null +crontab -r -u alice 2>/dev/null +H=$(state_get home) +[ -n "$H" ] && rm -f "$H/ticks.log" "$H/boot.log" +for f in cron.allow cron.deny; do + if [ -f "$LAB_STATE/$f" ]; then cp -a "$LAB_STATE/$f" /etc/$f; else rm -f /etc/$f; fi +done +for f in /etc/cron.d/*; do + [ -f "$f" ] || continue + grep -qxF "${f##*/}" "$LAB_STATE/cron-d" 2>/dev/null && continue + grep -q /usr/local/bin/cleanup "$f" && rm -f "$f" +done +[ -n "$(state_get made-cleanup)" ] && rm -f /usr/local/bin/cleanup +if [ -n "$(state_get installed-cron)" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge cron >/dev/null 2>&1 +fi +true diff --git a/labs/linux-terminal/2-3-crontab/setup.sh b/labs/linux-terminal/2-3-crontab/setup.sh new file mode 100644 index 0000000..f966e7d --- /dev/null +++ b/labs/linux-terminal/2-3-crontab/setup.sh @@ -0,0 +1,25 @@ +if ! pkg_installed cron; then + pkg_install cron + state_set installed-cron 1 +fi +systemctl enable --now --quiet cron +user_add student +user_add alice +crontab -r -u student 2>/dev/null || true +for f in cron.allow cron.deny; do + [ -f /etc/$f ] && cp -a /etc/$f "$LAB_STATE/$f" +done +ls /etc/cron.d >"$LAB_STATE/cron-d" +if [ ! -e /usr/local/bin/cleanup ]; then + cat >/usr/local/bin/cleanup <<'X' +#!/bin/sh +# Practice lab: stands in for a monthly cleanup job. +find /tmp -maxdepth 1 -name 'cleanup-test-*' -mtime +30 -delete +X + chmod 755 /usr/local/bin/cleanup + state_set made-cleanup 1 +fi +H=$(getent passwd student | cut -d: -f6) +state_set home "$H" +rm -f "$H/ticks.log" "$H/boot.log" +true diff --git a/labs/linux-terminal/2-4-troubleshooting-with-logs/README.md b/labs/linux-terminal/2-4-troubleshooting-with-logs/README.md new file mode 100644 index 0000000..39fc072 --- /dev/null +++ b/labs/linux-terminal/2-4-troubleshooting-with-logs/README.md @@ -0,0 +1,34 @@ +# Using Linux Terminal 2.4: Troubleshooting with logs + +Finding why a service failed, spotting a failed SSH login, and filtering +the journal by priority and by source. + +## Before you start + +Work as root (`sudo -i`), and write answers to the files in +`/root/lab-answers/`. The peer, at `10.99.0.2`, stands in for another +machine: get a shell there with `ip netns exec lepeer bash`, and this VM +is `10.99.0.1` from it. The lab also logs two error messages, one from a +program and one from the kernel. Do task 3 within an hour of starting the +lab (or reset and start again). + +## Tasks + +1. Create `/etc/systemd/system/broken.service` with + `ExecStart=/usr/local/bin/missing`, run `daemon-reload` and start it. + Find the reason it failed using only systemctl and journalctl, and write + the exit status `systemctl status` shows (such as `status=...`) to + `/root/lab-answers/1`. +2. From the peer, try to SSH to this VM (`10.99.0.1`) as a user that + doesn't exist, and write the name you used to `/root/lab-answers/2`. + Find the attempt in `/var/log/auth.log` and in the journal. +3. List every message of priority err or worse from the last hour, then + only the kernel's, and save that kernel-only list in + `/root/lab-answers/3`. + +## Hints + +- `systemctl status broken`, `journalctl -u broken`. +- `ip netns exec lepeer ssh NAME@10.99.0.1`; `grep 'Invalid user' + /var/log/auth.log`; `journalctl -u ssh --since "5 min ago"`. +- `journalctl -p err --since "1 hour ago"`; `-k` keeps only the kernel's. diff --git a/labs/linux-terminal/2-4-troubleshooting-with-logs/check.sh b/labs/linux-terminal/2-4-troubleshooting-with-logs/check.sh new file mode 100644 index 0000000..e961731 --- /dev/null +++ b/labs/linux-terminal/2-4-troubleshooting-with-logs/check.sh @@ -0,0 +1,21 @@ +START=$(state_get start) +ans() { tr 'A-Z' 'a-z' <"/root/lab-answers/$1" 2>/dev/null; } +NAME=$(head -n1 /root/lab-answers/2 2>/dev/null | tr -d '[:space:]') +invalid_in_authlog() { grep -h sshd /var/log/auth.log /var/log/auth.log.1 2>/dev/null | grep -qF "Invalid user $NAME from 10.99.0.2 "; } +invalid_in_journal() { journalctl -q -u ssh -u ssh.socket --since "$START" --no-pager | grep -qF "Invalid user $NAME from 10.99.0.2 "; } + +task 1 "broken.service started, and its failure found: 203/EXEC" +need "/etc/systemd/system/broken.service has ExecStart=/usr/local/bin/missing" 'grep -Eq "^ExecStart=/usr/local/bin/missing[[:space:]]*$" /etc/systemd/system/broken.service' +need "systemd has loaded it (daemon-reload)" '[ "$(systemctl show -p LoadState --value broken.service)" = loaded ] && [ "$(systemctl show -p NeedDaemonReload --value broken.service)" = no ]' +need "it was started and failed: journalctl -u broken shows status=203/EXEC" 'journalctl -q -u broken --since "$START" --no-pager | grep -q "status=203/EXEC"' +need "/root/lab-answers/1 has the exit status, 203/EXEC" 'ans 1 | grep -q "203"' + +task 2 "A failed SSH login from the peer as a user that doesn't exist" +need "/root/lab-answers/2 holds a user name" '[ -n "$NAME" ]' +need "$NAME isn't a user on this system" '! getent passwd "$NAME"' +need "/var/log/auth.log has Invalid user $NAME from 10.99.0.2" invalid_in_authlog +need "journalctl -u ssh shows the same line" invalid_in_journal + +task 3 "The kernel's err-or-worse messages from the last hour in /root/lab-answers/3" +need "/root/lab-answers/3 has the kernel's medium error on sector 4242" 'ans 3 | grep -qF "medium error on sector 4242"' +need "/root/lab-answers/3 has only the kernel's messages (not the backup error)" '! ans 3 | grep -qF "backup of /srv failed"' diff --git a/labs/linux-terminal/2-4-troubleshooting-with-logs/lab.conf b/labs/linux-terminal/2-4-troubleshooting-with-logs/lab.conf new file mode 100644 index 0000000..7c5ef81 --- /dev/null +++ b/labs/linux-terminal/2-4-troubleshooting-with-logs/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.4 Troubleshooting with logs" +CHAPTER=/linux-terminal/2-system-resources/2-4-troubleshooting-with-logs/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/2-4-troubleshooting-with-logs/reset.sh b/labs/linux-terminal/2-4-troubleshooting-with-logs/reset.sh new file mode 100644 index 0000000..0f4b470 --- /dev/null +++ b/labs/linux-terminal/2-4-troubleshooting-with-logs/reset.sh @@ -0,0 +1,8 @@ +systemctl stop broken.service 2>/dev/null +rm -f /etc/systemd/system/broken.service +rm -rf /etc/systemd/system/broken.service.d +systemctl daemon-reload +systemctl reset-failed broken.service 2>/dev/null +[ -n "$(state_get had-known-host)" ] || ssh-keygen -q -R 10.99.0.1 -f /root/.ssh/known_hosts >/dev/null 2>&1 +rm -f /root/.ssh/known_hosts.old +true diff --git a/labs/linux-terminal/2-4-troubleshooting-with-logs/setup.sh b/labs/linux-terminal/2-4-troubleshooting-with-logs/setup.sh new file mode 100644 index 0000000..f18091f --- /dev/null +++ b/labs/linux-terminal/2-4-troubleshooting-with-logs/setup.sh @@ -0,0 +1,10 @@ +for f in /etc/systemd/system/broken.service /usr/local/bin/missing; do + [ -e "$f" ] && refuse "$f already exists; the lab needs it absent. Remove it and start the lab again." +done +state_set start "$(date '+%Y-%m-%d %H:%M:%S')" +grep -qs '10\.99\.0\.1' /root/.ssh/known_hosts && state_set had-known-host 1 +peer_up +# One error from a program and one from the kernel, for task 3. +logger -t backup -p user.err "backup of /srv failed: no space left on device" +echo '<3>kernel: lab-disk: medium error on sector 4242 (practice lab message)' >/dev/kmsg +true diff --git a/labs/linux-terminal/3-1-ssh-keys/README.md b/labs/linux-terminal/3-1-ssh-keys/README.md new file mode 100644 index 0000000..3619c0a --- /dev/null +++ b/labs/linux-terminal/3-1-ssh-keys/README.md @@ -0,0 +1,36 @@ +# Using Linux Terminal 3.1: SSH keys + +An Ed25519 key with a passphrase, installing it for another user by hand, +and what sshd does when a home directory is too open. + +## Before you start + +The lab creates user `student` (password `Lab-Pass-1`). Make the key as +`student` (`sudo -iu student`); use `sudo` for the steps that need root. +Give the key the passphrase `Lab-Phrase-1`: `le-lab check` needs it to +test the login. The lab checks that user `bob` doesn't exist yet, and +`le-lab reset` removes him and `~/.ssh/lab_key`. + +## Tasks + +1. Make an Ed25519 key pair with a passphrase and the comment `lab-key`, + saved as `~/.ssh/lab_key`. +2. Create a user `bob` with a password, then install your lab key for him + so you can log in to `bob@localhost` with it. Ubuntu cloud images turn + off password logins over SSH, so ssh-copy-id can't log in there; use + the manual steps instead. After `eval "$(ssh-agent)"; ssh-add + ~/.ssh/lab_key`, `ssh -o BatchMode=yes -o + StrictHostKeyChecking=accept-new -i ~/.ssh/lab_key bob@localhost + whoami` prints `bob`. +3. Run `sudo chmod 777 /home/bob`, try the login again, and find out why it + fails. Then fix it. + +## Hints + +- `ssh-keygen -t ed25519 -C lab-key -f ~/.ssh/lab_key`; `ssh-keygen -l -f`. +- `useradd -m`, `passwd`; then `/home/bob/.ssh` (mode 700) and + `authorized_keys` (mode 600), both owned by `bob`, holding the `.pub` + line. BatchMode doesn't ask about a new host key either: without + `accept-new`, the first try fails with "Host key verification failed." +- `journalctl -u ssh -n 5` or `/var/log/auth.log`: "bad ownership or + modes"; `chmod 750 /home/bob`. diff --git a/labs/linux-terminal/3-1-ssh-keys/check.sh b/labs/linux-terminal/3-1-ssh-keys/check.sh new file mode 100644 index 0000000..efb020f --- /dev/null +++ b/labs/linux-terminal/3-1-ssh-keys/check.sh @@ -0,0 +1,38 @@ +H=$(state_get home) +K=$H/.ssh/lab_key +BH=$(getent passwd bob | cut -d: -f6) +# key_login: log in as bob with the lab key, unlocked into a temporary copy. +key_login() { + local d r=1 + d=$(mktemp -d) + cp "$K" "$d/k" && chmod 600 "$d/k" && ssh-keygen -q -p -P Lab-Phrase-1 -N '' -f "$d/k" >/dev/null 2>&1 && + [ "$(timeout 15 ssh -o BatchMode=yes -o IdentitiesOnly=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i "$d/k" bob@localhost whoami /dev/null)" = bob ] && r=0 + rm -rf "$d" + return $r +} +bad_modes() { + local n m="bad ownership or modes for directory $BH" + journalctl -q -u ssh --since "$(state_get start)" --no-pager 2>/dev/null | grep -qF "$m" && return 0 + n=$(cat "$LAB_STATE/auth-size" 2>/dev/null); n=${n:-0} + [ "$(stat -c %s /var/log/auth.log)" -ge "$n" ] || n=0 + tail -c +$((n + 1)) /var/log/auth.log | grep -qF "$m" +} + +task 1 "~/.ssh/lab_key: Ed25519, comment lab-key, with a passphrase" +need "$K and $K.pub exist" '[ -f "$K" ] && [ -f "$K.pub" ]' +need "ssh-keygen -l -f $K.pub ends with lab-key (ED25519)" 'ssh-keygen -l -f "$K.pub" | grep -q "lab-key (ED25519)$"' +need "$K is -rw------- and owned by student" '[ "$(stat -c "%a %U" "$K")" = "600 student" ]' +need "$K has a passphrase" '! ssh-keygen -y -P "" -f "$K"' +need "its passphrase is Lab-Phrase-1 (le-lab check needs it)" 'ssh-keygen -y -P Lab-Phrase-1 -f "$K"' + +task 2 "User bob with a password, and the lab key logs in as him" +need "user bob exists" 'id bob' +need "bob has a password: passwd -S bob shows P" '[ "$(passwd -S bob | cut -d" " -f2)" = P ]' +need "bob's authorized_keys holds the lab key" 'grep -qF "$(cut -d" " -f2 "$K.pub")" "$BH/.ssh/authorized_keys"' +need "ssh -i ~/.ssh/lab_key bob@localhost whoami prints bob" key_login + +task 3 "Home directory too open: found in sshd's log, then fixed" +need "sshd logged bad ownership or modes for directory $BH" bad_modes +need "$BH isn't writable by group or others" '[ -d "$BH" ] && [ -z "$(find "$BH" -maxdepth 0 -perm /022)" ]' +need "the key login works again" key_login diff --git a/labs/linux-terminal/3-1-ssh-keys/lab.conf b/labs/linux-terminal/3-1-ssh-keys/lab.conf new file mode 100644 index 0000000..6005068 --- /dev/null +++ b/labs/linux-terminal/3-1-ssh-keys/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.1 SSH keys" +CHAPTER=/linux-terminal/3-securing-access/3-1-ssh-keys/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/3-1-ssh-keys/reset.sh b/labs/linux-terminal/3-1-ssh-keys/reset.sh new file mode 100644 index 0000000..9a2bc59 --- /dev/null +++ b/labs/linux-terminal/3-1-ssh-keys/reset.sh @@ -0,0 +1,7 @@ +if id bob >/dev/null 2>&1; then + pkill -KILL -u bob 2>/dev/null + userdel -r bob 2>/dev/null +fi +H=$(state_get home) +[ -n "$H" ] && rm -f "$H/.ssh/lab_key" "$H/.ssh/lab_key.pub" +true diff --git a/labs/linux-terminal/3-1-ssh-keys/setup.sh b/labs/linux-terminal/3-1-ssh-keys/setup.sh new file mode 100644 index 0000000..c657731 --- /dev/null +++ b/labs/linux-terminal/3-1-ssh-keys/setup.sh @@ -0,0 +1,8 @@ +id bob >/dev/null 2>&1 && refuse "user bob already exists; task 2 creates him. Remove him (userdel -r bob) and start the lab again." +user_add student +H=$(getent passwd student | cut -d: -f6) +state_set home "$H" +rm -f "$H/.ssh/lab_key" "$H/.ssh/lab_key.pub" +state_set start "$(date '+%Y-%m-%d %H:%M:%S')" +wc -c "$LAB_STATE/auth-size" 2>/dev/null || true +true diff --git a/labs/linux-terminal/3-2-security-settings/README.md b/labs/linux-terminal/3-2-security-settings/README.md new file mode 100644 index 0000000..68b7922 --- /dev/null +++ b/labs/linux-terminal/3-2-security-settings/README.md @@ -0,0 +1,31 @@ +# Using Linux Terminal 3.2: System-wide and per-user security settings + +A process limit for one user, a message for every login shell, and a +stricter umask for one user. + +## Before you start + +The lab creates users `alice` and `student` (password `Lab-Pass-1`). +Work as root (`sudo -i`) for tasks 1 and 2, and as `student` (`sudo -iu +student`) for task 3. The lab saves `/etc/profile`, `/etc/bash.bashrc` and +the limits files, and puts them back on reset; it also removes the files +you add to `/etc/profile.d` and `/etc/security/limits.d`. + +## Tasks + +1. Limit the user `alice` to 50 processes, as both soft and hard limit, in + a file of its own. `su - alice -c 'ulimit -u; ulimit -Hu'` prints `50` + twice, and other users' limits are unchanged. +2. Make every login shell print `Authorized use only` before the prompt, + without editing `/etc/profile`. `su - alice -c true` prints the message. +3. Give `student` a umask of 077 in every interactive shell: in a new + shell, `umask` prints `0077`, and `touch ~/x; ls -l ~/x` shows + `-rw-------`. Other users keep their umask. + +## Hints + +- A file in `/etc/security/limits.d/` with `alice`, `-` (or a `soft` and a + `hard` line), `nproc` and `50`. Limits apply at the next login. +- A `.sh` file in `/etc/profile.d/` with an `echo`. +- `~/.bashrc` runs in every interactive bash (and Ubuntu's `~/.profile` + runs it for login shells); `bash` starts a new one to test. diff --git a/labs/linux-terminal/3-2-security-settings/check.sh b/labs/linux-terminal/3-2-security-settings/check.sh new file mode 100644 index 0000000..e8cddd5 --- /dev/null +++ b/labs/linux-terminal/3-2-security-settings/check.sh @@ -0,0 +1,32 @@ +H=$(state_get home) +saved=$LAB_STATE/saved +login_run() { su - "$1" -c "$2" /dev/null; } +# An interactive, non-login bash of USER's, started clean. +interactive_umask() { + local h; h=$(getent passwd "$1" | cut -d: -f6) + runuser -u "$1" -- env -i HOME="$h" USER="$1" LOGNAME="$1" PATH=/usr/bin:/bin TERM=dumb bash -i -c umask /dev/null | tail -n1 +} +new_files() { # DIR LIST: files in DIR that weren't there at setup + local f + for f in "$1"/*; do + [ -f "$f" ] || continue + grep -qxF "${f##*/}" "$2" 2>/dev/null || printf '%s\n' "$f" + done +} + +task 1 "alice limited to 50 processes, soft and hard, in a file of its own" +need "su - alice -c 'ulimit -u; ulimit -Hu' prints 50 twice" '[ "$(login_run alice "ulimit -u; ulimit -Hu" | tail -n2 | paste -sd " ")" = "50 50" ]' +need "a new file in /etc/security/limits.d sets alice's nproc" 'new_files /etc/security/limits.d "$LAB_STATE/limits-d" | xargs -r grep -hEv "^[[:space:]]*#" | grep -Eq "^[[:space:]]*alice[[:space:]].*nproc"' +need "/etc/security/limits.conf is unchanged" 'cmp -s /etc/security/limits.conf "$saved/limits.conf"' +need "other users are unchanged: student's ulimit -u isn't 50" '[ "$(login_run student "ulimit -u" | tail -n1)" != 50 ]' + +task 2 "Every login shell prints Authorized use only, from /etc/profile.d" +need "su - alice -c true prints Authorized use only" 'login_run alice true | grep -q "Authorized use only"' +need "a new .sh file in /etc/profile.d prints it" 'new_files /etc/profile.d "$LAB_STATE/profile-d" | grep "\.sh$" | xargs -r grep -l "Authorized use only" | grep -q .' +need "/etc/profile is unchanged" 'cmp -s /etc/profile "$saved/profile"' + +task 3 "student's interactive shells have umask 077" +need "a new interactive bash of student's prints umask 0077" '[ "$(interactive_umask student)" = 0077 ]' +need "a login shell of student's has umask 0077 too" '[ "$(su - student -c "bash -i -c umask" /dev/null | tail -n1)" = 0077 ]' +need "a file student touches is -rw-------" 'f=$H/.le-check-$$; runuser -u student -- env -i HOME="$H" PATH=/usr/bin:/bin bash -i -c "touch $f" /dev/null; m=$(stat -c %a "$f"); rm -f "$f"; [ "$m" = 600 ]' +need "other users keep their umask: alice's isn't 0077" '[ "$(interactive_umask alice)" != 0077 ]' diff --git a/labs/linux-terminal/3-2-security-settings/lab.conf b/labs/linux-terminal/3-2-security-settings/lab.conf new file mode 100644 index 0000000..d3c4729 --- /dev/null +++ b/labs/linux-terminal/3-2-security-settings/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.2 System-wide and per-user security settings" +CHAPTER=/linux-terminal/3-securing-access/3-2-security-settings/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/3-2-security-settings/reset.sh b/labs/linux-terminal/3-2-security-settings/reset.sh new file mode 100644 index 0000000..fea5782 --- /dev/null +++ b/labs/linux-terminal/3-2-security-settings/reset.sh @@ -0,0 +1,14 @@ +saved=$LAB_STATE/saved +for f in profile bash.bashrc; do + [ -f "$saved/$f" ] && cp -a "$saved/$f" /etc/$f +done +[ -f "$saved/limits.conf" ] && cp -a "$saved/limits.conf" /etc/security/limits.conf +for pair in "/etc/profile.d profile-d" "/etc/security/limits.d limits-d"; do + set -- $pair + [ -f "$LAB_STATE/$2" ] || continue + for f in "$1"/*; do + [ -f "$f" ] || continue + grep -qxF "${f##*/}" "$LAB_STATE/$2" || rm -f "$f" + done +done +true diff --git a/labs/linux-terminal/3-2-security-settings/setup.sh b/labs/linux-terminal/3-2-security-settings/setup.sh new file mode 100644 index 0000000..41184b2 --- /dev/null +++ b/labs/linux-terminal/3-2-security-settings/setup.sh @@ -0,0 +1,9 @@ +user_add alice +user_add student +state_set home "$(getent passwd student | cut -d: -f6)" +saved=$LAB_STATE/saved +mkdir -p "$saved" +cp -a /etc/profile /etc/bash.bashrc /etc/security/limits.conf "$saved/" +ls /etc/profile.d >"$LAB_STATE/profile-d" +ls /etc/security/limits.d >"$LAB_STATE/limits-d" +true diff --git a/labs/linux-terminal/3-3-password-rules/README.md b/labs/linux-terminal/3-3-password-rules/README.md new file mode 100644 index 0000000..862d392 --- /dev/null +++ b/labs/linux-terminal/3-3-password-rules/README.md @@ -0,0 +1,37 @@ +# Using Linux Terminal 3.3: Password complexity and expiry + +Password rules with pam_pwquality, aging and account expiry for one user, +and aging defaults for new accounts. + +## Before you start + +Work as root (`sudo -i`). The lab creates users `alice` and `pwtest` +(password `Lab-Pass-1`); try passwords out on `pwtest`, for example `echo +'pwtest:lowercase12' | chpasswd`, so alice's settings from task 2 stay as +you made them. The rules only apply when a password is set, so they can't +lock anyone out of logging in. The lab saves `/etc/pam.d/common-password`, +the pwquality settings and `/etc/login.defs`, and puts them back on reset; +reset also removes `libpam-pwquality` again if the lab started without it, +and user `dave` if you made him. + +## Tasks + +1. Install pam_pwquality and require passwords of at least 10 characters + with at least one digit and one uppercase letter, for every user + including root. `echo 'pwtest:lowercase12' | chpasswd` fails with "BAD + PASSWORD: The password contains less than 1 uppercase letters", and + `echo 'pwtest:Kettle7-Harbor' | chpasswd` succeeds. +2. Make alice's password expire every 60 days with 10 days' warning, + disable her account on the last day of next year, and make her choose a + new password at her next login. +3. Make new accounts' passwords expire after 120 days, without changing + existing users. After `useradd -m dave`, `chage -l dave` shows maximum + 120, and `chage -l alice` is unchanged. + +## Hints + +- `apt install libpam-pwquality`; a file in + `/etc/security/pwquality.conf.d/` (make the directory) with `minlen`, + `dcredit = -1`, `ucredit = -1` and `enforce_for_root`. +- `chage -M -W -E -d 0`; check with `chage -l alice`. +- `PASS_MAX_DAYS` in `/etc/login.defs`. diff --git a/labs/linux-terminal/3-3-password-rules/check.sh b/labs/linux-terminal/3-3-password-rules/check.sh new file mode 100644 index 0000000..1ebde30 --- /dev/null +++ b/labs/linux-terminal/3-3-password-rules/check.sh @@ -0,0 +1,32 @@ +sh_field() { getent shadow "$1" | cut -d: -f"$2"; } +# try PASSWORD: root's chpasswd sets it for pwtest. +try() { echo "pwtest:$1" | chpasswd >/dev/null 2>&1; } +NEXT_END=$(( $(date -ud "$(( $(date +%Y) + 1 ))-12-31" +%s) / 86400 )) +new_user_max() { + local u=lecheck$$ m + useradd -M -s /usr/sbin/nologin "$u" >/dev/null 2>&1 || return 1 + m=$(sh_field "$u" 5) + userdel "$u" >/dev/null 2>&1 + [ "$m" = 120 ] +} + +task 1 "pam_pwquality: 10 characters, a digit and an uppercase letter, for root too" +need "libpam-pwquality is installed" 'pkg_installed libpam-pwquality' +need "/etc/pam.d/common-password uses pam_pwquality" 'grep -Eq "^[[:space:]]*password[[:space:]].*pam_pwquality\.so" /etc/pam.d/common-password' +need "even root can't set xq7#lvm-pztw (no uppercase letter)" '! try "xq7#lvm-pztw"' +need "even root can't set Xq#Lvm-PzTw (no digit)" '! try "Xq#Lvm-PzTw"' +need "even root can't set Xq7#Lvm2Z (only 9 characters)" '! try "Xq7#Lvm2Z"' +need "echo pwtest:lowercase12 | chpasswd fails" '! try lowercase12' +need "echo pwtest:Kettle7-Harbor | chpasswd succeeds" 'try Kettle7-Harbor' + +task 2 "alice: maximum 60, warning 10, account expires Dec 31 next year, change at next login" +need "alice's maximum password age is 60 days" '[ "$(sh_field alice 5)" = 60 ]' +need "alice is warned 10 days before" '[ "$(sh_field alice 6)" = 10 ]' +need "alice's account expires on the last day of next year" '[ "$(sh_field alice 8)" = "$NEXT_END" ]' +need "alice must change her password at next login" '[ "$(sh_field alice 3)" = 0 ]' + +task 3 "New accounts' passwords expire after 120 days; existing users unchanged" +need "/etc/login.defs sets PASS_MAX_DAYS 120" '[ "$(awk "\$1 == \"PASS_MAX_DAYS\" {v = \$2} END {print v}" /etc/login.defs)" = 120 ]' +need "a new account gets maximum 120" new_user_max +need "existing users are unchanged: root's and pwtest's maximum are as before" '[ "$(sh_field root 5)" = "$(cat "$LAB_STATE/max-root")" ] && [ "$(sh_field pwtest 5)" = "$(cat "$LAB_STATE/max-pwtest")" ]' +need "alice's maximum isn't 120" '[ "$(sh_field alice 5)" != 120 ]' diff --git a/labs/linux-terminal/3-3-password-rules/lab.conf b/labs/linux-terminal/3-3-password-rules/lab.conf new file mode 100644 index 0000000..7d43468 --- /dev/null +++ b/labs/linux-terminal/3-3-password-rules/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.3 Password complexity and expiry" +CHAPTER=/linux-terminal/3-securing-access/3-3-password-rules/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/3-3-password-rules/reset.sh b/labs/linux-terminal/3-3-password-rules/reset.sh new file mode 100644 index 0000000..66c2057 --- /dev/null +++ b/labs/linux-terminal/3-3-password-rules/reset.sh @@ -0,0 +1,19 @@ +saved=$LAB_STATE/saved +if id dave >/dev/null 2>&1; then + pkill -KILL -u dave 2>/dev/null + userdel -r dave 2>/dev/null +fi +# Packages the task installed (pam-auth-update takes the line out again). +if [ -f "$LAB_STATE/packages" ]; then + new=$(dpkg-query -W -f '${Package}\n' | grep -Fxv -f "$LAB_STATE/packages" | + grep -E 'pwquality|crack|^wamerican$') + [ -n "$new" ] && DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $new >/dev/null 2>&1 +fi +if [ -f "$saved/common-password" ]; then + cp -a "$saved/common-password" /etc/pam.d/common-password + cp -a "$saved/login.defs" /etc/login.defs + if [ -f "$saved/pwquality.conf" ]; then cp -a "$saved/pwquality.conf" /etc/security/pwquality.conf; fi + rm -rf /etc/security/pwquality.conf.d + [ -d "$saved/pwquality.conf.d" ] && cp -a "$saved/pwquality.conf.d" /etc/security/ +fi +true diff --git a/labs/linux-terminal/3-3-password-rules/setup.sh b/labs/linux-terminal/3-3-password-rules/setup.sh new file mode 100644 index 0000000..fd7c899 --- /dev/null +++ b/labs/linux-terminal/3-3-password-rules/setup.sh @@ -0,0 +1,12 @@ +id dave >/dev/null 2>&1 && refuse "user dave already exists; task 3 creates him. Remove him (userdel -r dave) and start the lab again." +user_add alice +user_add pwtest +saved=$LAB_STATE/saved +mkdir -p "$saved" +cp -a /etc/pam.d/common-password /etc/login.defs "$saved/" +[ -f /etc/security/pwquality.conf ] && cp -a /etc/security/pwquality.conf "$saved/" +[ -d /etc/security/pwquality.conf.d ] && cp -a /etc/security/pwquality.conf.d "$saved/" +dpkg-query -W -f '${Package}\n' >"$LAB_STATE/packages" +# Aging of the existing accounts, for task 3. +for u in root pwtest; do getent shadow $u | cut -d: -f5 >"$LAB_STATE/max-$u"; done +true diff --git a/labs/linux-terminal/3-4-sudo-policies/README.md b/labs/linux-terminal/3-4-sudo-policies/README.md new file mode 100644 index 0000000..a136180 --- /dev/null +++ b/labs/linux-terminal/3-4-sudo-policies/README.md @@ -0,0 +1,34 @@ +# Using Linux Terminal 3.4: sudo policies + +A narrow passwordless rule in a drop-in file, an administrator by group +membership, and the file names sudo skips. + +## Before you start + +Work as root (`sudo -i`), and write answers to the files in +`/root/lab-answers/`. The lab creates users `alice` and `bob` (password +`Lab-Pass-1`). Edit sudoers files only with `visudo` (`visudo -f +/etc/sudoers.d/NAME`): it refuses to save a file with a mistake in it, and +a broken sudoers file stops sudo working for everyone. Keep the root shell +open while you work. The lab saves `/etc/sudoers` and puts it back on +reset, and removes the files you add to `/etc/sudoers.d`. + +## Tasks + +1. Let `alice` restart and check the status of the ssh service as root, + with no password, and nothing else. Use a drop-in file. `visudo -c` + reports every file "parsed OK"; as alice, `sudo systemctl restart ssh` + works, and `sudo id` asks for her password and then says she isn't + allowed. +2. Make `bob` an administrator who can run anything with his password, + without editing any sudoers file. +3. Name your drop-in `alice.conf` instead, and see what `sudo -l -U alice` + says. Write that to `/root/lab-answers/3`, then rename the file back. + +## Hints + +- `alice ALL=(root) NOPASSWD: /usr/bin/systemctl restart ssh, + /usr/bin/systemctl status ssh`; `sudo -l -U alice`; test as alice with + `su - alice`. +- Ubuntu's `/etc/sudoers` gives group `sudo` everything; `usermod -aG`. +- `@includedir /etc/sudoers.d` skips names with a dot or ending in `~`. diff --git a/labs/linux-terminal/3-4-sudo-policies/check.sh b/labs/linux-terminal/3-4-sudo-policies/check.sh new file mode 100644 index 0000000..ec03dfe --- /dev/null +++ b/labs/linux-terminal/3-4-sudo-policies/check.sh @@ -0,0 +1,42 @@ +SC=/usr/bin/systemctl +may() { local u=$1; shift; sudo -l -U "$u" "$@" >/dev/null 2>&1; } +# Drop-ins sudo reads (no dot, no ~) that weren't there at setup and name alice. +alice_dropin() { + local f + for f in /etc/sudoers.d/*; do + [ -f "$f" ] || continue + case ${f##*/} in *.* | *~) continue ;; esac + grep -qxF "${f##*/}" "$LAB_STATE/sudoers-d" && continue + grep -qw alice "$f" && return 0 + done + return 1 +} +only_ssh() { + ! may alice /usr/bin/id && ! may alice /bin/bash && ! may alice $SC stop ssh && + ! may alice $SC restart cron && ! may alice $SC restart ssh.socket && ! may alice $SC edit ssh +} +no_password() { + local l + l=$(sudo -l -U alice | sed -n '/may run the following/,$p') + grep -q NOPASSWD <<<"$l" && ! grep -Eq '(^|[^O])PASSWD:' <<<"$l" && + ! runuser -u alice -- sudo -n $SC status ssh 2>&1 >/dev/null /dev/null || rm -f "$f" +done +[ -f "$LAB_STATE/sudoers" ] && cp -a "$LAB_STATE/sudoers" /etc/sudoers +true diff --git a/labs/linux-terminal/3-4-sudo-policies/setup.sh b/labs/linux-terminal/3-4-sudo-policies/setup.sh new file mode 100644 index 0000000..5f144c3 --- /dev/null +++ b/labs/linux-terminal/3-4-sudo-policies/setup.sh @@ -0,0 +1,6 @@ +visudo -c >/dev/null 2>&1 || refuse "visudo -c finds a problem in the sudoers files; fix it and start the lab again." +user_add alice +user_add bob +cp -a /etc/sudoers "$LAB_STATE/sudoers" +ls -A /etc/sudoers.d >"$LAB_STATE/sudoers-d" +true diff --git a/labs/linux-terminal/3-5-users-and-groups/README.md b/labs/linux-terminal/3-5-users-and-groups/README.md new file mode 100644 index 0000000..99b7c5d --- /dev/null +++ b/labs/linux-terminal/3-5-users-and-groups/README.md @@ -0,0 +1,33 @@ +# Using Linux Terminal 3.5: Users and groups + +Creating a user with useradd, adding groups, locking an account, and +removing one and finding what it left behind. + +## Before you start + +Work as root (`sudo -i`), and write answers to the files in +`/root/lab-answers/`. The lab checks that user `hana`, UID 1500 and group +`qa` don't exist yet. It creates user `lou`, who has files outside his +home directory. Task 4 removes `lou` rather than `hana`, so that hana stays +for grading. `le-lab reset` removes hana, `qa` and lou's files. + +## Tasks + +1. With useradd, create `hana` with a home directory, bash, the comment + "Hana Ito" and UID 1500, in a new group `qa` as well as her own. Set her + a password. +2. Add hana to `adm` without removing her from `qa`. +3. Stop hana logging in by any means, without deleting anything: `passwd + -S hana` shows `L`, `chage -l hana` shows an account expiry date in the + past, and `su - hana` fails. +4. Remove `lou`'s account and home directory, then find anything he left + on the root filesystem, and write the paths to `/root/lab-answers/4`. + +## Hints + +- `groupadd`; `useradd -m -s -c -u -G`; `passwd`; check with `getent + passwd hana`, `id hana`, `passwd -S hana`. +- `usermod -aG`: without `-a`, `-G` replaces the list. +- `usermod -L` (or `passwd -l`) locks the password only; `usermod -e 1` or + `chage -E 0` expires the account, which also stops SSH keys. +- `userdel -r`, then `find / -xdev -nouser`. diff --git a/labs/linux-terminal/3-5-users-and-groups/check.sh b/labs/linux-terminal/3-5-users-and-groups/check.sh new file mode 100644 index 0000000..e1cd2da --- /dev/null +++ b/labs/linux-terminal/3-5-users-and-groups/check.sh @@ -0,0 +1,29 @@ +pw() { getent passwd hana | cut -d: -f"$1"; } +today=$(( $(date -u +%s) / 86400 )) +ans4() { cat /root/lab-answers/4 2>/dev/null; } + +task 1 "hana: UID 1500, Hana Ito, /home/hana, bash, in qa, with a password" +need "user hana exists" 'id hana' +need "getent passwd hana shows UID 1500" '[ "$(pw 3)" = 1500 ]' +need "her comment is Hana Ito" '[ "$(pw 5 | cut -d, -f1)" = "Hana Ito" ]' +need "her home is /home/hana, and it exists" '[ "$(pw 6)" = /home/hana ] && [ -d /home/hana ] && [ "$(stat -c %U /home/hana)" = hana ]' +need "her shell is /bin/bash" '[ "$(pw 7)" = /bin/bash ]' +need "she has a group of her own" 'getent group hana | grep -q . && [ "$(id -gn hana)" = hana ]' +need "id hana lists qa" 'id -nG hana | grep -qw qa' +need "she has a password set" 'getent shadow hana | cut -d: -f2 | grep -Eq "^!?\\$"' + +task 2 "hana in adm as well as qa" +need "id hana lists adm" 'id -nG hana | grep -qw adm' +need "id hana still lists qa" 'id -nG hana | grep -qw qa' + +task 3 "hana can't log in by any means; nothing deleted" +need "user hana and /home/hana still exist" 'id hana && [ -d /home/hana ]' +need "passwd -S hana shows L" '[ "$(passwd -S hana | cut -d" " -f2)" = L ]' +need "chage -l hana shows an account expiry date in the past" 'e=$(getent shadow hana | cut -d: -f8); [ -n "$e" ] && [ "$e" -le "$today" ]' +need "su - hana fails" '! su - hana -c true /dev/null 2>&1; then + pkill -KILL -u hana 2>/dev/null + userdel -r hana 2>/dev/null +fi +groupdel qa 2>/dev/null +rm -rf /var/tmp/lou-build.log /srv/lou-reports +true diff --git a/labs/linux-terminal/3-5-users-and-groups/setup.sh b/labs/linux-terminal/3-5-users-and-groups/setup.sh new file mode 100644 index 0000000..b848846 --- /dev/null +++ b/labs/linux-terminal/3-5-users-and-groups/setup.sh @@ -0,0 +1,14 @@ +id hana >/dev/null 2>&1 && refuse "user hana already exists; task 1 creates her. Remove her (userdel -r hana) and start the lab again." +getent passwd 1500 >/dev/null && refuse "UID 1500 is already taken (by $(getent passwd 1500 | cut -d: -f1)); task 1 needs it." +getent group qa >/dev/null && refuse "group qa already exists; task 1 creates it. Remove it and start the lab again." +id lou >/dev/null 2>&1 && refuse "user lou already exists; the lab creates him. Remove him and start the lab again." +user_add lou -c "Lou Park" +# Files lou leaves outside his home directory. Written by root first, +# then given to lou: root can't write to another user's file in a sticky +# directory such as /var/tmp (fs.protected_regular). +rm -rf /var/tmp/lou-build.log /srv/lou-reports +echo "build 41 finished" >/var/tmp/lou-build.log +mkdir -p /srv/lou-reports +echo "quarter,total" >/srv/lou-reports/q3.csv +chown lou:lou /var/tmp/lou-build.log /srv/lou-reports /srv/lou-reports/q3.csv +true diff --git a/labs/linux-terminal/3-6-ownership-and-permissions/README.md b/labs/linux-terminal/3-6-ownership-and-permissions/README.md new file mode 100644 index 0000000..07f1155 --- /dev/null +++ b/labs/linux-terminal/3-6-ownership-and-permissions/README.md @@ -0,0 +1,33 @@ +# Using Linux Terminal 3.6: Ownership and permissions + +A shared group directory with setgid and the sticky bit, private files in +octal, and finding setuid programs. + +## Before you start + +Work as root (`sudo -i`) for tasks 1 and 3, and as `student` (`sudo -iu +student`) for task 2. Write answers to the files in `/root/lab-answers/`. +The lab creates users `ana`, `ben` and `student` (password `Lab-Pass-1`), +and checks that `/srv/finance` and group `finance` don't exist yet; +`le-lab reset` removes them. + +## Tasks + +1. Create `/srv/finance` owned by root and the group `finance` (create the + group, with `ana` and `ben` as its members), where members can create + and edit files, new files belong to `finance`, members can't delete each + other's files, and others have no access. `ls -ld /srv/finance` shows + `drwxrws--T root finance` (mode 3770). +2. As `student`, make `~/private` a directory only you can enter, and + `~/private/notes.txt` a file only you can read and write, using octal. +3. List every setuid file on the root filesystem, find the one that lets + users change their own password, and write its full path to + `/root/lab-answers/3`. + +## Hints + +- `groupadd`, `usermod -aG` (or `gpasswd -a`); `chown root:finance`, + `chmod 3770`. Test with `su - ana -c 'touch /srv/finance/a'` and `su - + ben -c 'rm /srv/finance/a'`. +- `chmod 700`, `chmod 600`; `stat -c '%a %n'`. +- `find / -xdev -perm -4000 -type f 2>/dev/null`; `ls -l` shows the `s`. diff --git a/labs/linux-terminal/3-6-ownership-and-permissions/check.sh b/labs/linux-terminal/3-6-ownership-and-permissions/check.sh new file mode 100644 index 0000000..33a398c --- /dev/null +++ b/labs/linux-terminal/3-6-ownership-and-permissions/check.sh @@ -0,0 +1,27 @@ +H=$(state_get home) +F=/srv/finance +# shared_dir_works: ana's new file gets group finance, ben can create one +# too but can't remove ana's. +shared_dir_works() { + local a=$F/.le-check-ana-$$ b=$F/.le-check-ben-$$ r=1 + runuser -u ana -- touch "$a" 2>/dev/null && [ "$(stat -c %G "$a")" = finance ] && + runuser -u ben -- touch "$b" 2>/dev/null && + ! runuser -u ben -- rm -f "$a" 2>/dev/null && [ -e "$a" ] && r=0 + rm -f "$a" "$b" + return $r +} + +task 1 "/srv/finance: root:finance, mode 3770, shared by ana and ben" +need "group finance exists" 'getent group finance' +need "ana and ben are members of finance" 'id -nG ana | grep -qw finance && id -nG ben | grep -qw finance' +need "$F is a directory owned by root and group finance" '[ -d $F ] && [ "$(stat -c "%U %G" $F)" = "root finance" ]' +need "ls -ld $F shows drwxrws--T (mode 3770)" '[ "$(stat -c %a $F)" = 3770 ]' +need "a member's new file belongs to finance, and the other member can't delete it" shared_dir_works + +task 2 "~/private is 700 and ~/private/notes.txt is 600, owned by student" +need "$H/private is a directory with mode 700" '[ -d "$H/private" ] && [ "$(stat -c %a "$H/private")" = 700 ]' +need "$H/private/notes.txt is a file with mode 600" '[ -f "$H/private/notes.txt" ] && [ "$(stat -c %a "$H/private/notes.txt")" = 600 ]' +need "both belong to student" '[ "$(stat -c %U "$H/private" "$H/private/notes.txt" | sort -u)" = student ]' + +task 3 "The setuid program for changing passwords in /root/lab-answers/3" +need "/root/lab-answers/3 holds /usr/bin/passwd" '[ "$(answer 3)" = /usr/bin/passwd ]' diff --git a/labs/linux-terminal/3-6-ownership-and-permissions/lab.conf b/labs/linux-terminal/3-6-ownership-and-permissions/lab.conf new file mode 100644 index 0000000..b49b2a7 --- /dev/null +++ b/labs/linux-terminal/3-6-ownership-and-permissions/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.6 Ownership and permissions" +CHAPTER=/linux-terminal/3-securing-access/3-6-ownership-and-permissions/ +SYSTEMS="debian" diff --git a/labs/linux-terminal/3-6-ownership-and-permissions/reset.sh b/labs/linux-terminal/3-6-ownership-and-permissions/reset.sh new file mode 100644 index 0000000..8636d2f --- /dev/null +++ b/labs/linux-terminal/3-6-ownership-and-permissions/reset.sh @@ -0,0 +1,6 @@ +rm -rf /srv/finance +H=$(state_get home) +[ -n "$H" ] && rm -rf "$H/private" +for u in ana ben; do gpasswd -d "$u" finance >/dev/null 2>&1; done +groupdel finance 2>/dev/null +true diff --git a/labs/linux-terminal/3-6-ownership-and-permissions/setup.sh b/labs/linux-terminal/3-6-ownership-and-permissions/setup.sh new file mode 100644 index 0000000..cf0ecea --- /dev/null +++ b/labs/linux-terminal/3-6-ownership-and-permissions/setup.sh @@ -0,0 +1,9 @@ +[ -e /srv/finance ] && refuse "/srv/finance already exists; task 1 creates it. Remove it and start the lab again." +getent group finance >/dev/null && refuse "group finance already exists; task 1 creates it. Remove it and start the lab again." +user_add ana +user_add ben +user_add student +H=$(getent passwd student | cut -d: -f6) +state_set home "$H" +rm -rf "$H/private" +true diff --git a/labs/lpic-3-303/332-1-host-hardening/reset.sh b/labs/lpic-3-303/332-1-host-hardening/reset.sh index 6b12ac7..efaeaf8 100644 --- a/labs/lpic-3-303/332-1-host-hardening/reset.sh +++ b/labs/lpic-3-303/332-1-host-hardening/reset.sh @@ -1,12 +1,7 @@ if [ -f "$LAB_STATE/ssh.tar" ]; then rm -f /etc/ssh/sshd_config.d/* /etc/ssh/revoked.krl /etc/ssh/*-cert.pub tar -C /etc -xf "$LAB_STATE/ssh.tar" - if is_rhel; then - systemctl restart sshd - else - systemctl restart ssh.socket 2>/dev/null - systemctl restart ssh.service 2>/dev/null - fi + sshd_restart fi rm -rf /root/sshca /srv/jail /usr/local/bin/lab-python pkg_installed usbguard && [ ! -f "$LAB_STATE/usbguard.tar" ] && systemctl disable --now --quiet usbguard 2>/dev/null diff --git a/labs/ubuntu-desktop/1-1-network-connections/README.md b/labs/ubuntu-desktop/1-1-network-connections/README.md new file mode 100644 index 0000000..51ccf9b --- /dev/null +++ b/labs/ubuntu-desktop/1-1-network-connections/README.md @@ -0,0 +1,36 @@ +# Ubuntu Desktop 1.1: Network connections + +Making a NetworkManager profile with a fixed address, switching it to +DHCP while keeping a DNS server, and seeing where Ubuntu stores it. The +lab works on a spare interface, never on the one you're connected over. +The chapter's Wi-Fi task needs Wi-Fi hardware and isn't part of the lab. + +## Before you start + +The lab adds a spare wired interface `lab0` (a dummy interface that +comes back at every boot). If NetworkManager isn't installed, as on +Ubuntu Server, the lab installs it; it leaves the interfaces it already +finds alone, and reset removes it again. Don't activate the profile. + +## Tasks + +1. Create a profile `office` for interface `lab0` with the fixed address + 10.10.10.50/24, gateway 10.10.10.1 and DNS 10.10.10.1, without + activating it. +2. Make the profile use DHCP for its address but keep the DNS server + 10.10.10.1. Task 1 stays passed once you've done this. + +When `le-lab check` passes, remove the profile as the chapter shows, or +leave that to `le-lab reset`. + +## Hints + +- `nmcli connection add type ethernet ifname lab0 con-name office + ipv4.method manual ipv4.addresses ... ipv4.gateway ... ipv4.dns ... + connection.autoconnect no`. `nmcli -g ipv4.method,ipv4.addresses + connection show office` prints `manual` and `10.10.10.50/24`, and `sudo + ls /etc/netplan/` shows a new `90-NM-` file. +- `nmcli connection modify office ipv4.method auto`, clearing the old + address with `ipv4.addresses "" ipv4.gateway ""`, and + `ipv4.ignore-auto-dns yes` so DHCP's DNS servers aren't used. +- `nmcli connection delete office`. diff --git a/labs/ubuntu-desktop/1-1-network-connections/check.sh b/labs/ubuntu-desktop/1-1-network-connections/check.sh new file mode 100644 index 0000000..261a598 --- /dev/null +++ b/labs/ubuntu-desktop/1-1-network-connections/check.sh @@ -0,0 +1,18 @@ +get() { nmcli -g "$1" connection show office 2>/dev/null; } +uuid=$(get connection.uuid) +is_task2() { [ "$(get ipv4.method)" = auto ] && [ -z "$(get ipv4.addresses)" ] && [ -z "$(get ipv4.gateway)" ] && [ "$(get ipv4.ignore-auto-dns)" = yes ]; } + +task 1 "Profile office for lab0: 10.10.10.50/24, not active" +need "a profile office exists (nmcli connection show)" '[ -n "$uuid" ]' +need "it's for interface lab0" '[ "$(get connection.interface-name)" = lab0 ]' +need "it has the address 10.10.10.50/24 and gateway 10.10.10.1 (or DHCP after task 2)" '{ [ "$(get ipv4.method)" = manual ] && [ "$(get ipv4.addresses)" = 10.10.10.50/24 ] && [ "$(get ipv4.gateway)" = 10.10.10.1 ]; } || is_task2' +need "its DNS server is 10.10.10.1" '[ "$(get ipv4.dns)" = 10.10.10.1 ]' +need "it isn't active" '! nmcli -g NAME connection show --active | grep -qx office' +need "it's stored as /etc/netplan/90-NM-$uuid.yaml" '[ -f "/etc/netplan/90-NM-$uuid.yaml" ]' + +task 2 "office uses DHCP but keeps DNS 10.10.10.1" +need "a profile office exists" '[ -n "$uuid" ]' +need "ipv4.method is auto" '[ "$(get ipv4.method)" = auto ]' +need "the old address and gateway are cleared" '[ -z "$(get ipv4.addresses)" ] && [ -z "$(get ipv4.gateway)" ]' +need "DNS servers from DHCP are ignored (ipv4.ignore-auto-dns yes)" '[ "$(get ipv4.ignore-auto-dns)" = yes ]' +need "the DNS server is still 10.10.10.1" '[ "$(get ipv4.dns)" = 10.10.10.1 ]' diff --git a/labs/ubuntu-desktop/1-1-network-connections/lab.conf b/labs/ubuntu-desktop/1-1-network-connections/lab.conf new file mode 100644 index 0000000..860f978 --- /dev/null +++ b/labs/ubuntu-desktop/1-1-network-connections/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.1 Network connections" +CHAPTER=/ubuntu-desktop/1-networking/1-1-network-connections/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/1-1-network-connections/reset.sh b/labs/ubuntu-desktop/1-1-network-connections/reset.sh new file mode 100644 index 0000000..fa1d098 --- /dev/null +++ b/labs/ubuntu-desktop/1-1-network-connections/reset.sh @@ -0,0 +1,17 @@ +if command -v nmcli >/dev/null; then + nmcli -g UUID,NAME connection show 2>/dev/null | while IFS=: read -r uuid name; do + [ "$name" = office ] || [ "$(nmcli -g connection.interface-name connection show "$uuid" 2>/dev/null)" = lab0 ] || continue + nmcli connection delete "$uuid" >/dev/null 2>&1 + rm -f "/etc/netplan/90-NM-$uuid.yaml" + done +fi +if [ -f /etc/systemd/system/le-lab-lab0.service ]; then + systemctl disable --now --quiet le-lab-lab0.service 2>/dev/null + rm -f /etc/systemd/system/le-lab-lab0.service + systemctl daemon-reload +fi +ip link del lab0 2>/dev/null +if [ -s "$LAB_STATE/pkgs-added" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $(cat "$LAB_STATE/pkgs-added") >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/1-1-network-connections/setup.sh b/labs/ubuntu-desktop/1-1-network-connections/setup.sh new file mode 100644 index 0000000..d2a37da --- /dev/null +++ b/labs/ubuntu-desktop/1-1-network-connections/setup.sh @@ -0,0 +1,34 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +[ -e /sys/class/net/lab0 ] && refuse "an interface lab0 already exists; remove it first" +if command -v nmcli >/dev/null; then + systemctl is-active --quiet NetworkManager || refuse "NetworkManager is installed but not running; start it first" + nmcli -g NAME connection show | grep -qx office && refuse "a NetworkManager profile office already exists; delete it first" + nmcli -g connection.interface-name connection show 2>/dev/null | grep -qx lab0 && refuse "a NetworkManager profile for lab0 already exists; delete it first" +else + # Ubuntu Server: NetworkManager ships with every wired interface + # unmanaged unless netplan hands it over, so the connection stays + # with systemd-networkd. + installed_pkgs >"$LAB_STATE/pkgs-before" + pkg_install network-manager + comm -13 "$LAB_STATE/pkgs-before" <(installed_pkgs) >"$LAB_STATE/pkgs-added" + systemctl enable --now --quiet NetworkManager + for i in $(seq 20); do nmcli general >/dev/null 2>&1 && break; sleep 0.5; done +fi + +cat >/etc/systemd/system/le-lab-lab0.service <<'UNIT' +[Unit] +Description=Spare interface lab0 for a LINUXexpert practice lab +Before=network-pre.target NetworkManager.service +Wants=network-pre.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/sh -c 'ip link show lab0 >/dev/null 2>&1 || ip link add lab0 type dummy' +ExecStop=/bin/sh -c 'ip link del lab0 2>/dev/null || true' + +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable --now --quiet le-lab-lab0.service diff --git a/labs/ubuntu-desktop/1-2-layer-2/README.md b/labs/ubuntu-desktop/1-2-layer-2/README.md new file mode 100644 index 0000000..34d16ab --- /dev/null +++ b/labs/ubuntu-desktop/1-2-layer-2/README.md @@ -0,0 +1,38 @@ +# Ubuntu Desktop 1.2: Layer 2: MAC addresses, ARP and broadcast + +Finding MAC addresses and the maker's prefix, watching an ARP exchange, +and checking that an address is free before you use it. + +## Before you start + +The lab sets up a second machine, the peer, at `10.99.0.2` (name `peer`). +It's on the same network as this VM's interface `le-host` (`10.99.0.1`), +so the two can talk directly over Ethernet. Two of the addresses +`10.99.0.21`, `10.99.0.22` and `10.99.0.23` are also in use on that +network; one is free. `iputils-arping` isn't installed. Write answers to +the files in `/root/lab-answers/`. + +## Tasks + +1. Find your machine's MAC address and the maker's prefix in it, and your + default gateway's MAC address. Use the interface your default route + goes out of. Write your MAC on line 1 of `/root/lab-answers/1`, the + prefix (its first three bytes) on line 2, and the gateway's MAC on + line 3. +2. Empty the neighbor table, then watch the ARP exchange as you ping the + peer. Write the peer's MAC address, from its reply, to + `/root/lab-answers/2`. +3. Check which of `10.99.0.21`, `10.99.0.22` and `10.99.0.23` is free + before giving it to a new machine (install `iputils-arping` first). + Write the free address to `/root/lab-answers/3`. + +## Hints + +- `ip -br link`; `ip route | grep default` gives the gateway's IP; after + `ping -c1` to it, `ip neigh show` lists it with `lladdr` and its MAC. +- `sudo ip neigh flush dev le-host`, then `sudo tcpdump -i le-host -e -n + arp` in one terminal and `ping -c1 10.99.0.2` in another: look for + `Request who-has` to `ff:ff:ff:ff:ff:ff` and `Reply ... is-at`. +- `sudo arping -D -c 2 -I le-host ADDRESS; echo $?` prints `Received 0 + response(s)` and `0` for a free address, and a reply and `1` for one in + use. diff --git a/labs/ubuntu-desktop/1-2-layer-2/check.sh b/labs/ubuntu-desktop/1-2-layer-2/check.sh new file mode 100644 index 0000000..11f0759 --- /dev/null +++ b/labs/ubuntu-desktop/1-2-layer-2/check.sh @@ -0,0 +1,21 @@ +norm() { tr A-Z a-z | tr -d '[:space:]' | tr '-' ':'; } +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | norm; } +dev=$(ip route show default | awk '{for (i = 1; i < NF; i++) if ($i == "dev") {print $(i + 1); exit}}') +gw=$(ip route show default | awk '{print $3; exit}') +mac=$(cat "/sys/class/net/$dev/address" 2>/dev/null) +gw_mac() { + ping -c1 -W2 "$gw" >/dev/null 2>&1 + ip neigh show "$gw" dev "$dev" | awk '{for (i = 1; i < NF; i++) if ($i == "lladdr") print $(i + 1)}' | head -n1 +} + +task 1 "Your MAC, its maker's prefix, and the gateway's MAC" +need "line 1 of /root/lab-answers/1 is the MAC of $dev" '[ -n "$mac" ] && [ "$(line 1 1)" = "$mac" ]' +need "line 2 is the maker's prefix (its first three bytes)" '[ "$(line 1 2 | tr -d :)" = "$(printf %s "$mac" | cut -c1-8 | tr -d :)" ]' +need "line 3 is the MAC of the gateway $gw" 'g=$(gw_mac); [ -n "$g" ] && [ "$(line 1 3)" = "$g" ]' + +task 2 "The peer's MAC from its ARP reply" +need "/root/lab-answers/2 is the peer's MAC" '[ "$(line 2 1)" = "$(in_peer cat /sys/class/net/le-peer/address)" ]' + +task 3 "The free address found with arping" +need "iputils-arping is installed" 'pkg_installed iputils-arping' +need "/root/lab-answers/3 is the free address" '[ "$(line 3 1)" = "$(state_get free)" ]' diff --git a/labs/ubuntu-desktop/1-2-layer-2/lab.conf b/labs/ubuntu-desktop/1-2-layer-2/lab.conf new file mode 100644 index 0000000..8f54da1 --- /dev/null +++ b/labs/ubuntu-desktop/1-2-layer-2/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.2 Layer 2: MAC addresses, ARP and broadcast" +CHAPTER=/ubuntu-desktop/1-networking/1-2-layer-2/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/1-2-layer-2/reset.sh b/labs/ubuntu-desktop/1-2-layer-2/reset.sh new file mode 100644 index 0000000..5851e69 --- /dev/null +++ b/labs/ubuntu-desktop/1-2-layer-2/reset.sh @@ -0,0 +1,11 @@ +if [ -f /etc/systemd/system/le-lab-l2.service ]; then + systemctl disable --quiet le-lab-l2.service 2>/dev/null + rm -f /etc/systemd/system/le-lab-l2.service + systemctl daemon-reload +fi +if [ -n "$(state_get had-arping)" ]; then + pkg_installed iputils-arping || DEBIAN_FRONTEND=noninteractive apt-get -y -qq install iputils-arping >/dev/null 2>&1 +elif pkg_installed iputils-arping; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge iputils-arping >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/1-2-layer-2/setup.sh b/labs/ubuntu-desktop/1-2-layer-2/setup.sh new file mode 100644 index 0000000..9342218 --- /dev/null +++ b/labs/ubuntu-desktop/1-2-layer-2/setup.sh @@ -0,0 +1,28 @@ +[ -e /etc/systemd/system/le-lab-l2.service ] && refuse "le-lab-l2.service exists; run le-lab reset first" +ip route show default | grep -q . || refuse "this VM has no default route; task 1 needs one" +if pkg_installed iputils-arping; then + state_set had-arping 1 + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge iputils-arping >/dev/null +fi +peer_up +# The peer gets a fixed MAC address and two of the three candidate +# addresses, again at every boot. +free=$((21 + RANDOM % 3)) +state_set free "10.99.0.$free" +used=$(for n in 21 22 23; do [ "$n" = "$free" ] || printf ' 10.99.0.%s/24' "$n"; done) +cat >/etc/systemd/system/le-lab-l2.service </dev/null; done; true' + +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable --now --quiet le-lab-l2.service diff --git a/labs/ubuntu-desktop/2-1-lts-releases/README.md b/labs/ubuntu-desktop/2-1-lts-releases/README.md new file mode 100644 index 0000000..9d0a774 --- /dev/null +++ b/labs/ubuntu-desktop/2-1-lts-releases/README.md @@ -0,0 +1,31 @@ +# Ubuntu Desktop 2.1: LTS releases and version numbers + +Finding this system's release and point release, when its support ends, +and which Ubuntu releases are still supported. + +## Before you start + +The lab installs `distro-info` if it's missing and changes nothing else. +Write your answers to the files in `/root/lab-answers/`, one item per +line. + +## Tasks + +1. Find this system's release, code name and point release. Write the + point release (such as `24.04.5`) on line 1 of `/root/lab-answers/1` + and the code name on line 2. +2. Find the date standard support ends for this release, and for the + latest LTS. Write them on lines 1 and 2 of `/root/lab-answers/2`, as + `YYYY-MM-DD` (or at least the month, such as `May 2029`). +3. List every Ubuntu release still supported today, and say which of them + are LTS releases. Write one release per line to `/root/lab-answers/3` + as its version number, followed by `LTS` for the LTS ones (such as + `24.04 LTS`). Leave out the release still in development. + +## Hints + +- `lsb_release -a`, `/etc/os-release`. +- The `eol` column of `/usr/share/distro-info/ubuntu.csv`; + `hwe-support-status --verbose`; `ubuntu-distro-info --lts`. +- `ubuntu-distro-info --supported --fullname`, and `--devel` for the + release still being developed. diff --git a/labs/ubuntu-desktop/2-1-lts-releases/check.sh b/labs/ubuntu-desktop/2-1-lts-releases/check.sh new file mode 100644 index 0000000..c0144e2 --- /dev/null +++ b/labs/ubuntu-desktop/2-1-lts-releases/check.sh @@ -0,0 +1,36 @@ +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; } +csv=/usr/share/distro-info/ubuntu.csv +. /etc/os-release +eol_of() { awk -F, -v s="$1" '$3 == s {print $6}' "$csv"; } +# month LINE: YYYY-MM from an answer such as 2029-05-31 or May 2029. +month() { + local a=$1 + if [[ $a =~ ^([0-9]{4}-[0-9]{2}) ]]; then printf '%s' "${BASH_REMATCH[1]}"; return; fi + date -d "1 $a" +%Y-%m 2>/dev/null +} +# The supported releases as "VERSION" or "VERSION LTS", sorted. +expected() { + local skip=${1:-} + ubuntu-distro-info --supported --fullname 2>/dev/null | while read -r _ ver rest; do + [ -n "$skip" ] && [ "$(ubuntu-distro-info --series="$skip" -r 2>/dev/null | cut -d' ' -f1)" = "$ver" ] && continue + case $rest in LTS*) echo "$ver LTS" ;; *) echo "$ver" ;; esac + done | sort +} +given() { + grep -v '^[[:space:]]*$' /root/lab-answers/3 2>/dev/null | while read -r l; do + v=$(grep -Eo '[0-9]{2}\.[0-9]{2}' <<<"$l" | head -n1) + [ -n "$v" ] || continue + if grep -qi 'lts' <<<"$l"; then echo "$v LTS"; else echo "$v"; fi + done | sort -u +} + +task 1 "This system's point release and code name" +need "line 1 of /root/lab-answers/1 is the point release ($VERSION_ID.x)" '[ "$(line 1 1)" = "${VERSION%% *}" ]' +need "line 2 is the code name" 'line 1 2 | tr A-Z a-z | grep -qw "$VERSION_CODENAME"' + +task 2 "End of standard support: this release and the latest LTS" +need "line 1 of /root/lab-answers/2 is this release's end of support" '[ "$(month "$(line 2 1)")" = "$(eol_of "$VERSION_CODENAME" | cut -c1-7)" ]' +need "line 2 is the latest LTS's end of support" '[ "$(month "$(line 2 2)")" = "$(eol_of "$(ubuntu-distro-info --lts)" | cut -c1-7)" ]' + +task 3 "Every supported release, LTS ones marked" +need "/root/lab-answers/3 lists exactly the supported releases, with LTS marked and the development release left out" '[ "$(given)" = "$(expected "$(ubuntu-distro-info --devel 2>/dev/null)")" ]' diff --git a/labs/ubuntu-desktop/2-1-lts-releases/lab.conf b/labs/ubuntu-desktop/2-1-lts-releases/lab.conf new file mode 100644 index 0000000..36c2bbc --- /dev/null +++ b/labs/ubuntu-desktop/2-1-lts-releases/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.1 LTS releases and version numbers" +CHAPTER=/ubuntu-desktop/2-open-source-community/2-1-lts-releases/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/2-1-lts-releases/reset.sh b/labs/ubuntu-desktop/2-1-lts-releases/reset.sh new file mode 100644 index 0000000..204f7a0 --- /dev/null +++ b/labs/ubuntu-desktop/2-1-lts-releases/reset.sh @@ -0,0 +1,4 @@ +if [ -n "$(state_get added-distro-info)" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge distro-info >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/2-1-lts-releases/setup.sh b/labs/ubuntu-desktop/2-1-lts-releases/setup.sh new file mode 100644 index 0000000..da0471c --- /dev/null +++ b/labs/ubuntu-desktop/2-1-lts-releases/setup.sh @@ -0,0 +1,4 @@ +if ! command -v ubuntu-distro-info >/dev/null; then + pkg_install distro-info + state_set added-distro-info 1 +fi diff --git a/labs/ubuntu-desktop/2-3-open-source-licenses/README.md b/labs/ubuntu-desktop/2-3-open-source-licenses/README.md new file mode 100644 index 0000000..bf3643a --- /dev/null +++ b/labs/ubuntu-desktop/2-3-open-source-licenses/README.md @@ -0,0 +1,30 @@ +# Ubuntu Desktop 2.3: Open source licenses + +Reading the licenses of installed packages, telling copyleft from +permissive, and finding which archive component a package comes from. + +## Before you start + +The lab changes nothing. Write your answers to the files in +`/root/lab-answers/`, one item per line. + +## Tasks + +1. Find the license of the `bash` package and of the `openssl` package, + and say which of them could be built into a closed-source product. + Write bash's main license on line 1 of `/root/lab-answers/1`, + openssl's on line 2, and the package that could be built in on line 3. +2. Find which component `ubuntu-drivers-common` comes from, and which + component NVIDIA's driver `nvidia-driver-535` is in. Write them on + lines 1 and 2 of `/root/lab-answers/2`. +3. Count how many installed packages carry a GPL license of any version, + and write the number to `/root/lab-answers/3`. + +## Hints + +- `grep '^License:' /usr/share/doc/PACKAGE/copyright`; a few files in a + package can have other licenses than the main one. +- `apt-cache show PACKAGE | grep -m1 Section`: a section with no prefix + is in `main`, otherwise the prefix is the component. +- `grep -l` over `/usr/share/doc/*/copyright`, then `wc -l`. Rough + counts are fine. diff --git a/labs/ubuntu-desktop/2-3-open-source-licenses/check.sh b/labs/ubuntu-desktop/2-3-open-source-licenses/check.sh new file mode 100644 index 0000000..6418f31 --- /dev/null +++ b/labs/ubuntu-desktop/2-3-open-source-licenses/check.sh @@ -0,0 +1,15 @@ +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr A-Z a-z; } +component() { local s; s=$(apt-cache show "$1" 2>/dev/null | awk '$1 == "Section:" {print $2; exit}'); [ -n "$s" ] || return; case $s in */*) printf '%s' "${s%%/*}" ;; *) printf main ;; esac; } +gpl_count() { grep -l '^License: .*GPL' /usr/share/doc/*/copyright 2>/dev/null | wc -l; } + +task 1 "Licenses of bash and openssl" +need "line 1 of /root/lab-answers/1 is bash's license (GPL version 3 or later)" 'line 1 1 | grep -Eq "gpl.?(v)?3"' +need "line 2 is openssl's license (Apache 2.0)" 'line 1 2 | grep -Eq "apache"' +need "line 3 is the package that could be built into a closed-source product" '[ "$(line 1 3)" = openssl ]' + +task 2 "Components of ubuntu-drivers-common and nvidia-driver-535" +need "line 1 of /root/lab-answers/2 is ubuntu-drivers-common's component" 'c=$(component ubuntu-drivers-common); [ -n "$c" ] && [ "$(line 2 1)" = "$c" ]' +need "line 2 is nvidia-driver-535's component" 'c=$(component nvidia-driver-535); [ -n "$c" ] && [ "$(line 2 2)" = "$c" ]' + +task 3 "Number of installed packages under a GPL license" +need "/root/lab-answers/3 has the count (within 10%)" 'a=$(line 3 1); n=$(gpl_count); [[ $a =~ ^[0-9]+$ ]] && [ $((a * 10)) -ge $((n * 9)) ] && [ $((a * 10)) -le $((n * 11)) ]' diff --git a/labs/ubuntu-desktop/2-3-open-source-licenses/lab.conf b/labs/ubuntu-desktop/2-3-open-source-licenses/lab.conf new file mode 100644 index 0000000..b1b5c93 --- /dev/null +++ b/labs/ubuntu-desktop/2-3-open-source-licenses/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.3 Open source licenses" +CHAPTER=/ubuntu-desktop/2-open-source-community/2-3-open-source-licenses/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/2-3-open-source-licenses/reset.sh b/labs/ubuntu-desktop/2-3-open-source-licenses/reset.sh new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/labs/ubuntu-desktop/2-3-open-source-licenses/reset.sh @@ -0,0 +1 @@ +true diff --git a/labs/ubuntu-desktop/2-3-open-source-licenses/setup.sh b/labs/ubuntu-desktop/2-3-open-source-licenses/setup.sh new file mode 100644 index 0000000..d6f7293 --- /dev/null +++ b/labs/ubuntu-desktop/2-3-open-source-licenses/setup.sh @@ -0,0 +1,2 @@ +# Nothing to set up: every task is a finding. +true diff --git a/labs/ubuntu-desktop/3-1-boot-problems/README.md b/labs/ubuntu-desktop/3-1-boot-problems/README.md new file mode 100644 index 0000000..07f2520 --- /dev/null +++ b/labs/ubuntu-desktop/3-1-boot-problems/README.md @@ -0,0 +1,45 @@ +# Ubuntu Desktop 3.1: Boot problems: GRUB 2 and the initramfs + +Making the GRUB menu show at every boot with kernel messages instead of +the logo, recovery mode, getting a system back from `grub rescue>`, and +rebuilding the initramfs. Tasks 2 and 3 need the VM's console. + +## Before you start + +The lab saves `/etc/default/grub` and `/etc/default/grub.d/` so reset can +put them back (and runs `update-grub` if they changed). It changes +nothing else. On cloud images, +`/etc/default/grub.d/50-cloudimg-settings.cfg` is read after +`/etc/default/grub` and sets `GRUB_TIMEOUT=0`. + +## Tasks + +1. Make the GRUB menu appear for 10 seconds at every boot, and make every + boot show kernel messages instead of the logo (no `quiet` or `splash` + on the kernel command line). Reboot to see it. +2. Boot once into recovery mode, get a root shell, and make sure `/` is + writable. Not graded: in that shell, `cat /proc/cmdline` contains + `recovery`, `systemctl is-active friendly-recovery.service` prints + `active`, and `touch /root/recovery-test` succeeds. +3. Rename `/boot/grub` to `/boot/grub.bak`, reboot, and get the system + booted from `grub rescue>`. Then repair it for good. Not graded: after + `sudo mv /boot/grub.bak /boot/grub`, `sudo grub-install DISK` prints + `Installation finished. No error reported.`, and the next reboot shows + the normal menu. Take a snapshot of the VM before this one. +4. Rebuild the initramfs of every installed kernel. + +## Hints + +- `GRUB_TIMEOUT_STYLE=menu`, `GRUB_TIMEOUT=10` and + `GRUB_CMDLINE_LINUX_DEFAULT`, in `/etc/default/grub` or a file in + `/etc/default/grub.d/` that sorts after the cloud image's; then `sudo + update-grub`. After a reboot, `cat /proc/cmdline`. +- In the menu: Advanced options, then the `(recovery mode)` entry, then + `root`. `mount -o remount,rw /` if `/` is read-only. +- At `grub rescue>`: `set` shows the prefix GRUB looked in; `ls + (hd0,gpt16)/` finds `grub.bak` (on cloud images `/boot` is its own + partition). Then `set prefix=(hd0,gpt16)/grub.bak`, `set + root=(hd0,gpt16)`, `insmod normal`, `normal`. `lsblk` shows the disk + for `grub-install`. +- `sudo update-initramfs -u -k all` prints a `Generating` line for each + kernel in `ls /boot/vmlinuz-*`. diff --git a/labs/ubuntu-desktop/3-1-boot-problems/check.sh b/labs/ubuntu-desktop/3-1-boot-problems/check.sh new file mode 100644 index 0000000..67178aa --- /dev/null +++ b/labs/ubuntu-desktop/3-1-boot-problems/check.sh @@ -0,0 +1,35 @@ +cfg=/boot/grub/grub.cfg +# Effective settings: /etc/default/grub, then grub.d/*.cfg, as +# grub-mkconfig reads them. +setting() { + ( + . /etc/default/grub >/dev/null 2>&1 + for f in /etc/default/grub.d/*.cfg; do [ -e "$f" ] && . "$f" >/dev/null 2>&1; done + eval "printf '%s' \"\${$1:-}\"" + ) +} +first_linux() { awk '$1 == "linux" {print; exit}' "$cfg"; } +kernels_rebuilt() { + local k v n=0 + for k in /boot/vmlinuz-*; do + [ -e "$k" ] || continue + v=${k#/boot/vmlinuz-} + [ "/boot/initrd.img-$v" -nt "$LAB_STATE/since" ] || return 1 + n=$((n + 1)) + done + [ "$n" -gt 0 ] +} + +task 1 "GRUB menu for 10 seconds, no quiet or splash" +need "the settings read by update-grub give GRUB_TIMEOUT_STYLE=menu" '[ "$(setting GRUB_TIMEOUT_STYLE)" = menu ] || [ -z "$(setting GRUB_TIMEOUT_STYLE)" ]' +need "the settings read by update-grub give GRUB_TIMEOUT=10" '[ "$(setting GRUB_TIMEOUT)" = 10 ]' +need "the settings have no quiet or splash on the command line" '! printf "%s %s" "$(setting GRUB_CMDLINE_LINUX_DEFAULT)" "$(setting GRUB_CMDLINE_LINUX)" | grep -Ewq "quiet|splash"' +need "grub.cfg shows the menu for 10 seconds (update-grub)" 'grep -Eq "^[[:space:]]*set timeout=10$" $cfg && ! grep -Eq "timeout_style=(hidden|countdown)" $cfg' +need "grub.cfg boots the default entry without quiet or splash" '[ -n "$(first_linux)" ] && ! first_linux | grep -Ewq "quiet|splash"' +need "this boot ran without quiet or splash (/proc/cmdline; reboot)" '! grep -Ewq "quiet|splash" /proc/cmdline' + +task 2 "Boot into recovery mode and get a writable root shell" self +task 3 "Boot from grub rescue>, then repair GRUB for good" self + +task 4 "The initramfs of every installed kernel rebuilt" +need "every /boot/initrd.img-VERSION is newer than the lab's start (update-initramfs)" kernels_rebuilt diff --git a/labs/ubuntu-desktop/3-1-boot-problems/lab.conf b/labs/ubuntu-desktop/3-1-boot-problems/lab.conf new file mode 100644 index 0000000..3897ea5 --- /dev/null +++ b/labs/ubuntu-desktop/3-1-boot-problems/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.1 Boot problems: GRUB 2 and the initramfs" +CHAPTER=/ubuntu-desktop/3-installing/3-1-boot-problems/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/3-1-boot-problems/reset.sh b/labs/ubuntu-desktop/3-1-boot-problems/reset.sh new file mode 100644 index 0000000..1430023 --- /dev/null +++ b/labs/ubuntu-desktop/3-1-boot-problems/reset.sh @@ -0,0 +1,19 @@ +# GRUB's files back where task 3 found them. +[ ! -e /boot/grub ] && [ -d /boot/grub.bak ] && mv /boot/grub.bak /boot/grub +rm -f /root/recovery-test +if [ -f "$LAB_STATE/default/grub" ]; then + changed= + cmp -s "$LAB_STATE/default/grub" /etc/default/grub || changed=1 + if [ -d "$LAB_STATE/default/grub.d" ]; then + diff -r "$LAB_STATE/default/grub.d" /etc/default/grub.d >/dev/null 2>&1 || changed=1 + elif [ -e /etc/default/grub.d ]; then + changed=1 + fi + if [ -n "$changed" ]; then + cp -a "$LAB_STATE/default/grub" /etc/default/grub + rm -rf /etc/default/grub.d + [ -d "$LAB_STATE/default/grub.d" ] && cp -a "$LAB_STATE/default/grub.d" /etc/default/grub.d + update-grub >/dev/null 2>&1 + fi +fi +true diff --git a/labs/ubuntu-desktop/3-1-boot-problems/setup.sh b/labs/ubuntu-desktop/3-1-boot-problems/setup.sh new file mode 100644 index 0000000..679259c --- /dev/null +++ b/labs/ubuntu-desktop/3-1-boot-problems/setup.sh @@ -0,0 +1,10 @@ +command -v update-grub >/dev/null || refuse "this lab needs GRUB 2 with update-grub" +[ -f /boot/grub/grub.cfg ] || refuse "/boot/grub/grub.cfg is missing; this lab needs a system that boots with GRUB 2 from /boot/grub" +[ -e /boot/grub.bak ] && refuse "/boot/grub.bak exists; put it back or remove it first" +command -v update-initramfs >/dev/null || refuse "this lab needs initramfs-tools (update-initramfs)" +mkdir -p "$LAB_STATE/default" +cp -a /etc/default/grub "$LAB_STATE/default/" +[ -d /etc/default/grub.d ] && cp -a /etc/default/grub.d "$LAB_STATE/default/" +# Task 4 looks for initramfs images newer than this. +sleep 1 +touch "$LAB_STATE/since" diff --git a/labs/ubuntu-desktop/3-3-bootable-usb/README.md b/labs/ubuntu-desktop/3-3-bootable-usb/README.md new file mode 100644 index 0000000..27bc418 --- /dev/null +++ b/labs/ubuntu-desktop/3-3-bootable-usb/README.md @@ -0,0 +1,32 @@ +# Ubuntu Desktop 3.3: A bootable USB drive + +Checking the signature on Ubuntu's checksum file, seeing a changed copy +fail, and writing an ISO image to a drive with `dd`. A spare disk stands +in for the USB drive. + +## Before you start + +The lab adds an empty spare disk, `/dev/loop40` (64 MiB), and an empty +folder `/root/usb` to work in. The VM needs to reach +`releases.ubuntu.com`. `xorriso` isn't installed. + +## Tasks + +1. In `/root/usb`, download `SHA256SUMS` and `SHA256SUMS.gpg` for Ubuntu + 24.04 (`wget https://releases.ubuntu.com/24.04/SHA256SUMS + https://releases.ubuntu.com/24.04/SHA256SUMS.gpg`) and check the + signature. +2. Copy `SHA256SUMS` to `/root/usb/SHA256SUMS.copy`, change one character + of the copy, and check its signature again. +3. Make a small ISO with the volume label `TEST` (install `xorriso`, then + `xorriso -as mkisofs -V TEST -o test.iso SOMEDIR`) and write it to the + spare disk `/dev/loop40` with `dd`. + +## Hints + +- `gpgv --keyring /usr/share/keyrings/ubuntu-archive-keyring.gpg + SHA256SUMS.gpg SHA256SUMS` should print `Good signature`. +- Change a single letter or digit, for example with `sed -i '1s/^./0/'` + or in an editor; gpgv should now print `BAD signature`. +- `dd if=test.iso of=/dev/loop40 bs=4M conv=fsync`, then `blkid + /dev/loop40` shows `LABEL="TEST"` and `TYPE="iso9660"`. diff --git a/labs/ubuntu-desktop/3-3-bootable-usb/check.sh b/labs/ubuntu-desktop/3-3-bootable-usb/check.sh new file mode 100644 index 0000000..e8a2906 --- /dev/null +++ b/labs/ubuntu-desktop/3-3-bootable-usb/check.sh @@ -0,0 +1,17 @@ +K=/usr/share/keyrings/ubuntu-archive-keyring.gpg +U=/root/usb + +task 1 "SHA256SUMS downloaded and its signature good" +need "/root/usb/SHA256SUMS and SHA256SUMS.gpg exist" '[ -s $U/SHA256SUMS ] && [ -s $U/SHA256SUMS.gpg ]' +need "SHA256SUMS lists the 24.04 images" 'grep -q "ubuntu-24\.04.*\.iso" $U/SHA256SUMS' +need "gpgv says the signature is good" 'gpgv --keyring $K $U/SHA256SUMS.gpg $U/SHA256SUMS' + +task 2 "A copy with one character changed fails the check" +need "/root/usb/SHA256SUMS.copy exists" '[ -f $U/SHA256SUMS.copy ]' +need "it differs from SHA256SUMS in exactly one character" '[ "$(stat -c %s $U/SHA256SUMS.copy)" = "$(stat -c %s $U/SHA256SUMS)" ] && [ "$(cmp -l $U/SHA256SUMS $U/SHA256SUMS.copy | wc -l)" = 1 ]' +need "gpgv reports a bad signature for it" 'gpgv --keyring $K $U/SHA256SUMS.gpg $U/SHA256SUMS.copy 2>&1 | grep -q "BAD signature"' + +task 3 "An ISO labeled TEST written to /dev/loop40" +need "xorriso is installed" 'command -v xorriso' +need "/dev/loop40 holds an ISO 9660 filesystem" '[ "$(blkid -p -s TYPE -o value /dev/loop40)" = iso9660 ]' +need "its label is TEST" '[ "$(blkid -p -s LABEL -o value /dev/loop40)" = TEST ]' diff --git a/labs/ubuntu-desktop/3-3-bootable-usb/lab.conf b/labs/ubuntu-desktop/3-3-bootable-usb/lab.conf new file mode 100644 index 0000000..be0501a --- /dev/null +++ b/labs/ubuntu-desktop/3-3-bootable-usb/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.3 A bootable USB drive" +CHAPTER=/ubuntu-desktop/3-installing/3-3-bootable-usb/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/3-3-bootable-usb/reset.sh b/labs/ubuntu-desktop/3-3-bootable-usb/reset.sh new file mode 100644 index 0000000..8b61477 --- /dev/null +++ b/labs/ubuntu-desktop/3-3-bootable-usb/reset.sh @@ -0,0 +1,8 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +rm -rf /root/usb /root/test.iso +if [ -z "$(state_get had-xorriso)" ] && [ -f "$LAB_STATE/pkgs-before" ]; then + # xorriso and the libraries it brought in. + added=$(comm -13 "$LAB_STATE/pkgs-before" <(installed_pkgs) | grep -E '^(xorriso|libburn|libisofs|libisoburn|libjte)' || true) + [ -n "$added" ] && DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $added >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/3-3-bootable-usb/setup.sh b/labs/ubuntu-desktop/3-3-bootable-usb/setup.sh new file mode 100644 index 0000000..6060c8d --- /dev/null +++ b/labs/ubuntu-desktop/3-3-bootable-usb/setup.sh @@ -0,0 +1,8 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +[ -e /root/usb ] && refuse "/root/usb already exists; move it away first" +command -v gpgv >/dev/null || pkg_install gpgv +[ -f /usr/share/keyrings/ubuntu-archive-keyring.gpg ] || refuse "this lab needs /usr/share/keyrings/ubuntu-archive-keyring.gpg (package ubuntu-keyring)" +pkg_installed xorriso && state_set had-xorriso 1 +installed_pkgs >"$LAB_STATE/pkgs-before" +disk_add 0 64M >/dev/null +mkdir /root/usb diff --git a/labs/ubuntu-desktop/3-4-proprietary-drivers/README.md b/labs/ubuntu-desktop/3-4-proprietary-drivers/README.md new file mode 100644 index 0000000..faf254f --- /dev/null +++ b/labs/ubuntu-desktop/3-4-proprietary-drivers/README.md @@ -0,0 +1,32 @@ +# Ubuntu Desktop 3.4: Proprietary GPU and Wi-Fi drivers + +Finding out whether a machine needs a proprietary driver, where those +drivers live in the archive, the Secure Boot state, and which kernel +driver each network device uses. Nothing gets installed: the lab runs +on a VM, which has no hardware that needs these drivers. + +## Before you start + +The lab installs `ubuntu-drivers-common` (the `ubuntu-drivers` command) +if it's missing; reset removes it again. Write your answers to the files +in `/root/lab-answers/`, one item per line. + +## Tasks + +1. Find out whether this machine needs any proprietary driver. Write + `yes` or `no` to `/root/lab-answers/1`. +2. Find which component the NVIDIA 580 driver (`nvidia-driver-580`) and + the Broadcom STA Wi-Fi driver (`bcmwl-kernel-source`) are in, and what + Secure Boot state this machine is in. Write the two components on + lines 1 and 2 of `/root/lab-answers/2`, and on line 3 `enabled`, + `disabled` or `no EFI`. +3. For each network device, find the kernel driver in use. Write the + drivers to `/root/lab-answers/3`, one line per network device. + +## Hints + +- `ubuntu-drivers devices` prints nothing when every device works with + open source drivers. +- `apt-cache show PACKAGE | grep -m1 Section`; `mokutil --sb-state`. +- `lspci -k` shows a `Kernel driver in use:` line under each network + controller. diff --git a/labs/ubuntu-desktop/3-4-proprietary-drivers/check.sh b/labs/ubuntu-desktop/3-4-proprietary-drivers/check.sh new file mode 100644 index 0000000..3c76f34 --- /dev/null +++ b/labs/ubuntu-desktop/3-4-proprietary-drivers/check.sh @@ -0,0 +1,31 @@ +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr A-Z a-z; } +component() { local s; s=$(apt-cache show "$1" 2>/dev/null | awk '$1 == "Section:" {print $2; exit}'); [ -n "$s" ] || return; case $s in */*) printf '%s' "${s%%/*}" ;; *) printf main ;; esac; } +needs_driver() { [ -n "$(timeout 120 ubuntu-drivers devices 2>/dev/null | grep -v '^[[:space:]]*$')" ]; } +sb_ok() { + local a; a=$(line 2 3); s=$(mokutil --sb-state 2>&1) + case $s in + *"SecureBoot enabled"*) [ "$a" = enabled ] ;; + *"SecureBoot disabled"*) [ "$a" = disabled ] ;; + *) [[ $a =~ (no|not|bios|legacy|unsupported|efi) ]] && [ "$a" != enabled ] && [ "$a" != disabled ] ;; + esac +} +# PCI network controllers and the driver each uses, as lspci -k shows it. +net_drivers() { lspci -k 2>/dev/null | awk '/^[^[:space:]]/ {net = ($0 ~ /(Ethernet|Network) controller/)} net && /Kernel driver in use:/ {print $NF}'; } +# Accepted names: lspci's, and the network interfaces' own drivers. +ok_drivers() { + net_drivers + for d in /sys/class/net/*/device/driver; do [ -e "$d" ] && basename "$(readlink -f "$d")"; done +} +given() { grep -v '^[[:space:]]*$' /root/lab-answers/3 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/.*:[[:space:]]*//'; } + +task 1 "Whether this machine needs a proprietary driver" +need "/root/lab-answers/1 says yes or no, matching ubuntu-drivers devices" 'a=$(line 1 1); if needs_driver; then [ "$a" = yes ]; else [ "$a" = no ]; fi' + +task 2 "Components of the NVIDIA and Broadcom drivers, Secure Boot state" +need "line 1 of /root/lab-answers/2 is nvidia-driver-580's component" 'c=$(component nvidia-driver-580); [ -n "$c" ] && [ "$(line 2 1)" = "$c" ]' +need "line 2 is bcmwl-kernel-source's component" 'c=$(component bcmwl-kernel-source); [ -n "$c" ] && [ "$(line 2 2)" = "$c" ]' +need "line 3 is this machine's Secure Boot state (mokutil --sb-state)" sb_ok + +task 3 "Kernel driver of each network device" +need "/root/lab-answers/3 has a line for each network device" '[ "$(given | wc -l)" -ge "$(net_drivers | wc -l)" ] && [ "$(given | wc -l)" -ge 1 ]' +need "every line is a network device's driver in use" '[ -z "$(comm -23 <(given | sort -u) <(ok_drivers | sort -u))" ]' diff --git a/labs/ubuntu-desktop/3-4-proprietary-drivers/lab.conf b/labs/ubuntu-desktop/3-4-proprietary-drivers/lab.conf new file mode 100644 index 0000000..94a4d81 --- /dev/null +++ b/labs/ubuntu-desktop/3-4-proprietary-drivers/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.4 Proprietary GPU and Wi-Fi drivers" +CHAPTER=/ubuntu-desktop/3-installing/3-4-proprietary-drivers/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/3-4-proprietary-drivers/reset.sh b/labs/ubuntu-desktop/3-4-proprietary-drivers/reset.sh new file mode 100644 index 0000000..f0c2ddf --- /dev/null +++ b/labs/ubuntu-desktop/3-4-proprietary-drivers/reset.sh @@ -0,0 +1,4 @@ +if [ -s "$LAB_STATE/pkgs-added" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $(cat "$LAB_STATE/pkgs-added") >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/3-4-proprietary-drivers/setup.sh b/labs/ubuntu-desktop/3-4-proprietary-drivers/setup.sh new file mode 100644 index 0000000..a369bee --- /dev/null +++ b/labs/ubuntu-desktop/3-4-proprietary-drivers/setup.sh @@ -0,0 +1,6 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +if ! command -v ubuntu-drivers >/dev/null; then + installed_pkgs >"$LAB_STATE/pkgs-before" + pkg_install ubuntu-drivers-common + comm -13 "$LAB_STATE/pkgs-before" <(installed_pkgs) >"$LAB_STATE/pkgs-added" +fi diff --git a/labs/ubuntu-desktop/3-5-lvm/README.md b/labs/ubuntu-desktop/3-5-lvm/README.md new file mode 100644 index 0000000..a41553d --- /dev/null +++ b/labs/ubuntu-desktop/3-5-lvm/README.md @@ -0,0 +1,31 @@ +# Ubuntu Desktop 3.5: LVM volumes, filesystems and snapshots + +A volume group on a spare disk, an ext4 logical volume mounted at boot, +growing it onto a second disk while it's mounted, and getting a deleted +file back by merging a snapshot. + +## Before you start + +The lab adds two empty spare disks: `/dev/loop40` (1 GiB, the first) and +`/dev/loop41` (2 GiB, the second). They come back at every boot. The LVM +tools are installed. + +## Tasks + +1. Make a volume group `vg1` from the first spare disk and an 800 MiB + logical volume `home2` with ext4, mounted at `/mnt/home2` at every + boot. +2. Grow `home2` by 1.5 GiB, adding the second disk to the group first if + needed, without unmounting it. +3. Create a file `/mnt/home2/notes.txt`, snapshot `home2`, delete + `notes.txt`, and get it back by merging the snapshot. + +## Hints + +- `pvcreate`, `vgcreate`, `lvcreate -L 800M -n`, `mkfs.ext4`; an + `/etc/fstab` line for `/dev/mapper/vg1-home2` (or its UUID), then + `systemctl daemon-reload` and `mount -a`. +- `vgs vg1` shows the free space; `vgextend`, then `lvextend -r -L +1.5G` + grows the filesystem too. `df -h /mnt/home2` should show about 2.3G. +- `lvcreate -s -L 200M -n NAME vg1/home2`; unmount, `lvconvert --merge + vg1/NAME`, mount again. `lvs vg1` no longer lists the snapshot. diff --git a/labs/ubuntu-desktop/3-5-lvm/check.sh b/labs/ubuntu-desktop/3-5-lvm/check.sh new file mode 100644 index 0000000..1182108 --- /dev/null +++ b/labs/ubuntu-desktop/3-5-lvm/check.sh @@ -0,0 +1,26 @@ +lv_mib() { lvs --noheadings --units m --nosuffix -o lv_size "vg1/$1" 2>/dev/null | awk '{printf "%d", $1}'; } +in_fstab() { findmnt -s -n "$1" >/dev/null || [ -f "/etc/systemd/system/$(systemd-escape -p --suffix=mount "$1")" ]; } +pv_vg() { pvs --noheadings -o vg_name "$1" 2>/dev/null | tr -d ' '; } +merged() { + find /etc/lvm/archive -name 'vg1_*.vg' -newer "$LAB_STATE/since" -print0 2>/dev/null | + xargs -0 -r grep -l "lvconvert --merge" | grep -q . +} + +task 1 "vg1 on the first disk, home2 (ext4) mounted at /mnt/home2 at boot" +need "volume group vg1 exists" vgs vg1 +need "/dev/loop40 is in vg1" '[ "$(pv_vg /dev/loop40)" = vg1 ]' +need "logical volume vg1/home2 exists" lvs vg1/home2 +need "home2 is at least 800 MiB" '[ "$(lv_mib home2)" -ge 796 ]' +need "home2 holds an ext4 filesystem" '[ "$(blkid -s TYPE -o value /dev/vg1/home2)" = ext4 ]' +need "/mnt/home2 is mounted from home2" '[ "$(findmnt -n -o SOURCE /mnt/home2)" = /dev/mapper/vg1-home2 ]' +need "/mnt/home2 is mounted at boot (/etc/fstab)" in_fstab /mnt/home2 + +task 2 "home2 grown by 1.5 GiB onto both disks" +need "home2 is 800 MiB + 1.5 GiB (2336 MiB)" '[ "$(lv_mib home2)" -ge 2330 ] && [ "$(lv_mib home2)" -le 2340 ]' +need "both spare disks are in vg1" '[ "$(pv_vg /dev/loop40)" = vg1 ] && [ "$(pv_vg /dev/loop41)" = vg1 ]' +need "the filesystem on /mnt/home2 has grown too (about 2.3G)" '[ "$(df -BM --output=size /mnt/home2 | tail -n1 | tr -dc 0-9)" -ge 2200 ]' + +task 3 "notes.txt back from a merged snapshot" +need "/mnt/home2/notes.txt exists" '[ -f /mnt/home2/notes.txt ]' +need "a snapshot of home2 was merged (lvconvert --merge)" merged +need "vg1 has no snapshot left" '! lvs --noheadings -o lv_attr vg1 2>/dev/null | grep -Eq "^[[:space:]]*[sS]"' diff --git a/labs/ubuntu-desktop/3-5-lvm/lab.conf b/labs/ubuntu-desktop/3-5-lvm/lab.conf new file mode 100644 index 0000000..c0dc3be --- /dev/null +++ b/labs/ubuntu-desktop/3-5-lvm/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.5 LVM volumes, filesystems and snapshots" +CHAPTER=/ubuntu-desktop/3-installing/3-5-lvm/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/3-5-lvm/reset.sh b/labs/ubuntu-desktop/3-5-lvm/reset.sh new file mode 100644 index 0000000..04bce8a --- /dev/null +++ b/labs/ubuntu-desktop/3-5-lvm/reset.sh @@ -0,0 +1,10 @@ +# Unmount home2 and take its line out of /etc/fstab (by name or UUID); +# the spare disks themselves are removed afterwards. +uuid=$(blkid -s UUID -o value /dev/vg1/home2 2>/dev/null) +[ -n "$uuid" ] && sed -i "/$uuid/d" /etc/fstab +sed -i -E '/vg1-home2|vg1\/home2|[[:space:]]\/mnt\/home2[[:space:]]/d' /etc/fstab +findmnt -rn -o TARGET,SOURCE | awk '$2 ~ /vg1-/ {print $1}' | sort -r | xargs -r -n1 umount -l +systemctl daemon-reload +vgchange -an vg1 >/dev/null 2>&1 +rmdir /mnt/home2 2>/dev/null +true diff --git a/labs/ubuntu-desktop/3-5-lvm/setup.sh b/labs/ubuntu-desktop/3-5-lvm/setup.sh new file mode 100644 index 0000000..7e21672 --- /dev/null +++ b/labs/ubuntu-desktop/3-5-lvm/setup.sh @@ -0,0 +1,8 @@ +command -v vgcreate >/dev/null || pkg_install lvm2 +vgs vg1 >/dev/null 2>&1 && refuse "a volume group vg1 already exists; remove it first" +findmnt /mnt/home2 >/dev/null && refuse "something is mounted at /mnt/home2; unmount it first" +grep -Eq '^[^#]*[[:space:]]/mnt/home2[[:space:]]' /etc/fstab && refuse "/etc/fstab already mounts /mnt/home2; take that line out first" +disk_add 0 1G >/dev/null +disk_add 1 2G >/dev/null +# Task 3 looks at LVM's metadata archive from here on. +touch "$LAB_STATE/since" diff --git a/labs/ubuntu-desktop/4-1-listing-and-upgrading/README.md b/labs/ubuntu-desktop/4-1-listing-and-upgrading/README.md new file mode 100644 index 0000000..04ba874 --- /dev/null +++ b/labs/ubuntu-desktop/4-1-listing-and-upgrading/README.md @@ -0,0 +1,37 @@ +# Ubuntu Desktop 4.1: Listing and upgrading packages + +Counting what's installed, upgrading everything but one held package, +reading apt's history, and holding a snap's updates. + +## Before you start + +The lab changes nothing; reset takes away the holds you add. The VM needs +to reach the Ubuntu archive (and the snap store for task 4). Write +answers to the files in `/root/lab-answers/`. + +## Tasks + +1. Count the installed deb packages, and list the snaps. Write the count + to `/root/lab-answers/1`. +2. Bring every deb package up to date, holding back one package that has + an update (or any installed package, if none has). +3. Find out when the last apt command that upgraded packages was run, and + by whom. Write its `Start-Date` (date and time) on line 1 of + `/root/lab-answers/3`, and on line 2 the user, `root`, or + `unattended-upgrade` for the automatic daily run. +4. Stop a snap from updating for two days, then undo it. If `snap list` + shows no snaps (as on a server), install the small `hello` snap first. + Not graded: check that `snap list NAME` shows `held` in Notes, and + that after `--unhold` it doesn't. + +## Hints + +- `dpkg -l | grep -c '^ii'`; `snap list`. +- `apt update`, `apt list --upgradable`, `apt-mark hold`, `apt upgrade`. + `apt-mark showhold` shows the hold, and its `dpkg -l` line starts with + `hi`. Updates that `apt upgrade` reports as deferred due to phasing may + stay. +- `/var/log/apt/history.log`: the last entry with an `Upgrade:` line, its + `Start-Date`, `Commandline` and `Requested-By` (only there when the + command ran through sudo). +- `snap refresh --hold=48h NAME`, `snap refresh --unhold NAME`. diff --git a/labs/ubuntu-desktop/4-1-listing-and-upgrading/check.sh b/labs/ubuntu-desktop/4-1-listing-and-upgrading/check.sh new file mode 100644 index 0000000..fa7f49f --- /dev/null +++ b/labs/ubuntu-desktop/4-1-listing-and-upgrading/check.sh @@ -0,0 +1,46 @@ +new_holds() { comm -13 "$LAB_STATE/holds-before" <(apt-mark showhold | sort); } +# The last history.log entry with an Upgrade: line that started before +# the answer was written: "YYYY-MM-DD HH:MM:SS WHO". +last_upgrade() { + local cut f + cut=$(date -r /root/lab-answers/3 '+%Y-%m-%d %H:%M:%S' 2>/dev/null) || return 1 + for f in $(ls /var/log/apt/history.log.*.gz 2>/dev/null | sort -t. -k3,3nr) /var/log/apt/history.log; do + zcat -f "$f" 2>/dev/null + done | awk -v cut="$cut" ' + /^Start-Date:/ {d = $2 " " $3; cmd = ""; req = ""; up = 0} + /^Commandline:/ {cmd = $0} + /^Requested-By:/ {req = $2} + /^Upgrade:/ {up = 1} + /^End-Date:/ { + if (up && d <= cut) { + who = req != "" ? req : (cmd ~ /unattended-upgrade/ ? "unattended-upgrade" : "root") + last = d " " who + } + } + END {if (last == "") exit 1; print last}' +} +when_ok() { + local l e; l=$(sed -n 1p /root/lab-answers/3 2>/dev/null); e=$(last_upgrade) || return 1 + grep -qF -- "${e%% *}" <<<"$l" && grep -qF -- "$(cut -d' ' -f2 <<<"$e" | cut -c1-5)" <<<"$l" +} +who_ok() { + local a e; a=$(sed -n 2p /root/lab-answers/3 2>/dev/null | tr A-Z a-z | tr -d '[:space:]'); e=$(last_upgrade | cut -d' ' -f3) + case $e in + unattended-upgrade) [[ $a =~ unattended|automatic|daily ]] ;; + *) [ -n "$e" ] && [ "$a" = "$e" ] ;; + esac +} + +task 1 "Number of installed deb packages" +need "/root/lab-answers/1 has the count of installed debs (within 5%)" 'a=$(answer 1); [[ $a =~ ^[0-9]+$ ]] && for n in "$(state_get debs)" "$(dpkg -l | grep -c "^ii")"; do [ $((a * 100)) -ge $((n * 95)) ] && [ $((a * 100)) -le $((n * 105)) ] && exit 0; done; exit 1' + +task 2 "Everything upgraded except one held package" +need "a package is on hold (apt-mark showhold)" '[ -n "$(new_holds)" ]' +need "dpkg -l shows the held package as hi" 'for p in $(new_holds); do [ "$(dpkg-query -W -f "\${db:Status-Abbrev}" "$p" 2>/dev/null | cut -c1-2)" = hi ] && exit 0; done; exit 1' +need "apt upgrade has nothing left to do apart from the hold (and phased updates)" 'apt-get -s -o Debug::NoLocking=1 --with-new-pkgs upgrade 2>/dev/null | grep -q "^0 upgraded, 0 newly installed"' + +task 3 "When and by whom the last upgrade was run" +need "line 1 of /root/lab-answers/3 is the Start-Date (date and time) of the last upgrade" when_ok +need "line 2 is who ran it" who_ok + +task 4 "Hold a snap's updates for two days, then undo it" self diff --git a/labs/ubuntu-desktop/4-1-listing-and-upgrading/lab.conf b/labs/ubuntu-desktop/4-1-listing-and-upgrading/lab.conf new file mode 100644 index 0000000..d33619b --- /dev/null +++ b/labs/ubuntu-desktop/4-1-listing-and-upgrading/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.1 Listing and upgrading packages" +CHAPTER=/ubuntu-desktop/4-applications/4-1-listing-and-upgrading/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/4-1-listing-and-upgrading/reset.sh b/labs/ubuntu-desktop/4-1-listing-and-upgrading/reset.sh new file mode 100644 index 0000000..36559e1 --- /dev/null +++ b/labs/ubuntu-desktop/4-1-listing-and-upgrading/reset.sh @@ -0,0 +1,7 @@ +for p in $(comm -13 "$LAB_STATE/holds-before" <(apt-mark showhold | sort) 2>/dev/null); do + apt-mark unhold "$p" >/dev/null 2>&1 +done +if snap list hello >/dev/null 2>&1 && ! grep -qx hello "$LAB_STATE/snaps-before" 2>/dev/null; then + snap remove --purge hello >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/4-1-listing-and-upgrading/setup.sh b/labs/ubuntu-desktop/4-1-listing-and-upgrading/setup.sh new file mode 100644 index 0000000..a494f17 --- /dev/null +++ b/labs/ubuntu-desktop/4-1-listing-and-upgrading/setup.sh @@ -0,0 +1,3 @@ +apt-mark showhold | sort >"$LAB_STATE/holds-before" +snap list 2>/dev/null | awk 'NR > 1 {print $1}' >"$LAB_STATE/snaps-before" +state_set debs "$(dpkg -l | grep -c '^ii')" diff --git a/labs/ubuntu-desktop/4-2-debs-and-snaps/README.md b/labs/ubuntu-desktop/4-2-debs-and-snaps/README.md new file mode 100644 index 0000000..d7f6ddd --- /dev/null +++ b/labs/ubuntu-desktop/4-2-debs-and-snaps/README.md @@ -0,0 +1,34 @@ +# Ubuntu Desktop 4.2: Debs and snaps + +Telling a deb from a snap, replacing a deb with the snap of the same +program while keeping its settings, and getting a removed snap's data +back from its snapshot. + +## Before you start + +The lab changes nothing, but checks that the `htop` snap isn't installed. +Reset puts the `htop` deb back if you had it (on Ubuntu Server, removing +it also removes the `ubuntu-server` metapackage; reset puts that back +too). The VM needs to reach the snap store. Write answers to the files in +`/root/lab-answers/`. + +## Tasks + +1. Find out whether Firefox on this machine is a deb or a snap. Write + `deb`, `snap` or `none` (if it isn't installed, as on a server) to + `/root/lab-answers/1`. +2. Install the htop deb, give it a setting (in htop, F2 Setup, Display + options, turn on Tree view, then quit with F10), then replace it with + the snap, keeping the setting. +3. Remove the htop snap and get its data back. + +## Hints + +- `dpkg -l firefox` shows the transitional `1snap1` version, a real + version, or nothing; `snap list firefox`. +- The deb keeps its settings in `~/.config/htop/htoprc`. A snap sees its + own home folder, `~/snap/htop/current/`, which exists once you've run + the snap. Copy the file to `~/snap/htop/current/.config/htop/`. +- `snap remove htop` saves a snapshot of its data; `snap saved`, `snap + install htop`, `snap restore SET`. Then `ls ~/snap/htop/*/.config/htop/` + shows `htoprc` again. diff --git a/labs/ubuntu-desktop/4-2-debs-and-snaps/check.sh b/labs/ubuntu-desktop/4-2-debs-and-snaps/check.sh new file mode 100644 index 0000000..915cbce --- /dev/null +++ b/labs/ubuntu-desktop/4-2-debs-and-snaps/check.sh @@ -0,0 +1,30 @@ +firefox_kind() { + if snap list firefox >/dev/null 2>&1; then echo snap + elif dpkg-query -W -f '${db:Status-Abbrev} ${Version}' firefox 2>/dev/null | grep -Eq '^ii .*' && + ! dpkg-query -W -f '${Version}' firefox | grep -q snap; then echo deb + else echo none + fi +} +snap_htoprc_tree() { + local f + for f in /root/snap/htop/*/.config/htop/htoprc /home/*/snap/htop/*/.config/htop/htoprc; do + [ -f "$f" ] && grep -Eq '(^|\.)tree_view=1' "$f" && return 0 + done + return 1 +} +new_snapshot() { + snap saved htop 2>/dev/null | awk 'NR > 1 && $2 == "htop" {print $1}' | grep -vxF -f "$LAB_STATE/old-snapshots" | grep -q . +} + +task 1 "Whether Firefox is a deb or a snap" +need "/root/lab-answers/1 says $(firefox_kind)" 'a=$(answer 1); e=$(firefox_kind); [ "$a" = "$e" ] || { [ "$e" = none ] && [[ $a =~ ^(neither|not.installed|no)$ ]]; }' + +task 2 "htop replaced by its snap, Tree view setting kept" +need "the htop deb is gone (dpkg -l htop has no ii line)" '[ "$(dpkg-query -W -f "\${db:Status-Abbrev}" htop 2>/dev/null | cut -c1-2)" != ii ]' +need "the htop snap is installed" 'snap list htop' +need "the snap's htoprc has Tree view on (tree_view=1)" snap_htoprc_tree + +task 3 "htop snap removed and its data restored" +need "snap saved lists an htop snapshot made during the lab" new_snapshot +need "the htop snap is installed again" 'snap list htop' +need "its htoprc is back, with Tree view on" snap_htoprc_tree diff --git a/labs/ubuntu-desktop/4-2-debs-and-snaps/lab.conf b/labs/ubuntu-desktop/4-2-debs-and-snaps/lab.conf new file mode 100644 index 0000000..f011cfc --- /dev/null +++ b/labs/ubuntu-desktop/4-2-debs-and-snaps/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.2 Debs and snaps" +CHAPTER=/ubuntu-desktop/4-applications/4-2-debs-and-snaps/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/4-2-debs-and-snaps/reset.sh b/labs/ubuntu-desktop/4-2-debs-and-snaps/reset.sh new file mode 100644 index 0000000..43d7b58 --- /dev/null +++ b/labs/ubuntu-desktop/4-2-debs-and-snaps/reset.sh @@ -0,0 +1,15 @@ +export DEBIAN_FRONTEND=noninteractive +snap list htop >/dev/null 2>&1 && snap remove --purge htop >/dev/null 2>&1 +for set in $(snap saved htop 2>/dev/null | awk 'NR > 1 && $2 == "htop" {print $1}'); do + grep -qxF "$set" "$LAB_STATE/old-snapshots" 2>/dev/null || snap forget "$set" >/dev/null 2>&1 +done +if [ -n "$(state_get had-htop)" ]; then + pkg_installed htop || apt-get -y -qq install htop >/dev/null 2>&1 + if [ -n "$(state_get had-ubuntu-server)" ] && ! pkg_installed ubuntu-server; then + apt-get -y -qq install ubuntu-server >/dev/null 2>&1 + fi +elif pkg_installed htop; then + apt-get -y -qq purge htop >/dev/null 2>&1 +fi +[ -z "$(state_get had-htoprc)" ] && rm -f /root/.config/htop/htoprc +true diff --git a/labs/ubuntu-desktop/4-2-debs-and-snaps/setup.sh b/labs/ubuntu-desktop/4-2-debs-and-snaps/setup.sh new file mode 100644 index 0000000..dda9aa8 --- /dev/null +++ b/labs/ubuntu-desktop/4-2-debs-and-snaps/setup.sh @@ -0,0 +1,8 @@ +command -v snap >/dev/null || refuse "this lab needs snapd (apt install snapd)" +snap list htop >/dev/null 2>&1 && refuse "the htop snap is installed; remove it first (snap remove --purge htop)" +pkg_installed htop && state_set had-htop 1 +pkg_installed ubuntu-server && state_set had-ubuntu-server 1 +[ -e /root/.config/htop/htoprc ] && state_set had-htoprc 1 +# Snapshots of htop from before the lab don't count for task 3. +snap saved htop 2>/dev/null | awk 'NR > 1 && $2 == "htop" {print $1}' >"$LAB_STATE/old-snapshots" +true diff --git a/labs/ubuntu-desktop/4-3-package-sources/README.md b/labs/ubuntu-desktop/4-3-package-sources/README.md new file mode 100644 index 0000000..303d348 --- /dev/null +++ b/labs/ubuntu-desktop/4-3-package-sources/README.md @@ -0,0 +1,31 @@ +# Ubuntu Desktop 4.3: Packages from more than one source + +Adding and removing a PPA, installing a downloaded `.deb` with dpkg, and +switching a snap between channels. + +## Before you start + +The lab makes sure `git` is installed and the `hello` deb isn't (reset +puts it back if you had it). The VM needs to reach the Ubuntu archive, +Launchpad and the snap store. Write answers to the files in +`/root/lab-answers/`. + +## Tasks + +1. Add the PPA `ppa:git-core/ppa`, find which version of git it offers, + then remove the PPA without upgrading git. Write the PPA's git version + to `/root/lab-answers/1`. +2. Download the hello package with `apt-get download hello` (in `/root`), + and install the file with dpkg. +3. Install the hello snap from the edge channel, then go back to stable. + +## Hints + +- `add-apt-repository ppa:git-core/ppa` (it runs `apt update` for you); + `apt-cache policy git` shows a `ppa.launchpadcontent.net` line and the + Candidate. `add-apt-repository --remove ppa:git-core/ppa` takes it out + again; afterwards `/etc/apt/sources.list.d/` has only `ubuntu.sources`. +- `dpkg -i hello_*.deb`; `dpkg -l hello` starts with `ii` and `hello` + prints `Hello, world!`. +- `snap install hello --edge`, `snap refresh hello --stable`; `snap list + --all hello` shows both revisions. diff --git a/labs/ubuntu-desktop/4-3-package-sources/check.sh b/labs/ubuntu-desktop/4-3-package-sources/check.sh new file mode 100644 index 0000000..2abc3f4 --- /dev/null +++ b/labs/ubuntu-desktop/4-3-package-sources/check.sh @@ -0,0 +1,26 @@ +gitver=$(state_get git-version) +ppa_listed() { grep -rhs '^[^#]*git-core/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ | grep -q .; } +# apt (not dpkg) installed hello since setup. +apt_installed_hello() { + awk -v s="$(state_get since)" ' + /^Start-Date:/ {d = $2 " " $3; in_lab = (d >= s)} + in_lab && /^Install:/ && / hello:/ {ok = 1} + END {exit !ok}' /var/log/apt/history.log 2>/dev/null +} +revisions() { snap list --all hello 2>/dev/null | awk 'NR > 1 && $1 == "hello"' | wc -l; } + +task 1 "git-core PPA's git version found, PPA removed, git not upgraded" +need "/root/lab-answers/1 has the PPA's git version (newer, with ppa in it)" 'a=$(answer 1); [[ $a == *ppa* ]] && dpkg --compare-versions "$a" gt "$gitver"' +need "the PPA is no longer in apt's sources" '! ppa_listed' +need "git is still the version from before ($gitver)" '[ "$(dpkg-query -W -f "\${Version}" git)" = "$gitver" ]' +need "git's candidate is the archive's version again (apt update)" '! apt-cache policy git | grep -q "Candidate:.*ppa"' + +task 2 "hello installed from a downloaded .deb with dpkg" +need "hello is installed (dpkg -l shows ii)" '[ "$(dpkg-query -W -f "\${db:Status-Abbrev}" hello 2>/dev/null | cut -c1-2)" = ii ]' +need "hello prints Hello, world!" '[ "$(/usr/bin/hello)" = "Hello, world!" ]' +need "it was installed with dpkg, not apt" '! apt_installed_hello' + +task 3 "hello snap back on stable after edge" +need "the hello snap is installed" 'snap list hello' +need "it tracks latest/stable" '[ "$(snap list hello | awk "NR == 2 {print \$4}")" = latest/stable ]' +need "snap list --all shows two revisions (edge and stable)" '[ "$(revisions)" -ge 2 ]' diff --git a/labs/ubuntu-desktop/4-3-package-sources/lab.conf b/labs/ubuntu-desktop/4-3-package-sources/lab.conf new file mode 100644 index 0000000..11df64b --- /dev/null +++ b/labs/ubuntu-desktop/4-3-package-sources/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.3 Packages from more than one source" +CHAPTER=/ubuntu-desktop/4-applications/4-3-package-sources/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/4-3-package-sources/reset.sh b/labs/ubuntu-desktop/4-3-package-sources/reset.sh new file mode 100644 index 0000000..39a5c52 --- /dev/null +++ b/labs/ubuntu-desktop/4-3-package-sources/reset.sh @@ -0,0 +1,16 @@ +export DEBIAN_FRONTEND=noninteractive +removed= +for f in $(grep -rls 'git-core/ppa' /etc/apt/sources.list.d/ 2>/dev/null); do rm -f "$f"; removed=1; done +[ -n "$removed" ] && apt-get -qq update >/dev/null 2>&1 +gitver=$(state_get git-version) +if [ -n "$gitver" ] && [ "$(dpkg-query -W -f '${Version}' git 2>/dev/null)" != "$gitver" ]; then + apt-get -y -qq install --allow-downgrades "git=$gitver" "git-man=$gitver" >/dev/null 2>&1 +fi +if [ -n "$(state_get had-hello)" ]; then + apt-get -y -qq install hello >/dev/null 2>&1 +elif pkg_installed hello; then + apt-get -y -qq purge hello >/dev/null 2>&1 +fi +snap list hello >/dev/null 2>&1 && snap remove --purge hello >/dev/null 2>&1 +rm -f /root/hello_*.deb +true diff --git a/labs/ubuntu-desktop/4-3-package-sources/setup.sh b/labs/ubuntu-desktop/4-3-package-sources/setup.sh new file mode 100644 index 0000000..c698961 --- /dev/null +++ b/labs/ubuntu-desktop/4-3-package-sources/setup.sh @@ -0,0 +1,15 @@ +command -v snap >/dev/null || refuse "this lab needs snapd (apt install snapd)" +command -v add-apt-repository >/dev/null || pkg_install software-properties-common +grep -rqs 'git-core/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ && + refuse "the git-core PPA is already set up; remove it first (add-apt-repository --remove ppa:git-core/ppa)" +snap list hello >/dev/null 2>&1 && refuse "the hello snap is installed; remove it first (snap remove --purge hello)" +apt-cache policy git 2>/dev/null | grep -q 'Candidate:.*ppa' && refuse "git's candidate version comes from a PPA; remove the PPA first" + +pkg_installed git || pkg_install git +state_set git-version "$(dpkg-query -W -f '${Version}' git)" +if pkg_installed hello; then + state_set had-hello 1 + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge hello >/dev/null +fi +# Task 2 looks in apt's history from here on. +state_set since "$(date '+%Y-%m-%d %H:%M:%S')" diff --git a/labs/ubuntu-desktop/4-4-package-descriptions/README.md b/labs/ubuntu-desktop/4-4-package-descriptions/README.md new file mode 100644 index 0000000..e5c7a8d --- /dev/null +++ b/labs/ubuntu-desktop/4-4-package-descriptions/README.md @@ -0,0 +1,28 @@ +# Ubuntu Desktop 4.4: Package descriptions + +Finding which package owns a file, reading a package's description +without installing it, and finding what depends on a library. + +## Before you start + +The lab changes nothing. Write your answers to the files in +`/root/lab-answers/`, one item per line. + +## Tasks + +1. Find which package provides `/usr/bin/lsblk`, and how many files that + package installs. Write the package's name on line 1 of + `/root/lab-answers/1` and the count on line 2. +2. Find the component, maintainer and download size of the `synaptic` + package, without installing it. Write the component on line 1 of + `/root/lab-answers/2`, the maintainer's name on line 2 and the download + size on line 3. +3. Before removing `libncursesw6`, find which installed packages depend on + it. Write their names to `/root/lab-answers/3`, one per line. + +## Hints + +- `dpkg -S`, `dpkg -L PACKAGE | wc -l`. +- `apt show` (or `apt-cache show`): the component is the part of + `Section:` before the slash, or `main` if there's no slash. +- `apt-cache rdepends --installed`; it lists some names twice. diff --git a/labs/ubuntu-desktop/4-4-package-descriptions/check.sh b/labs/ubuntu-desktop/4-4-package-descriptions/check.sh new file mode 100644 index 0000000..7518a0a --- /dev/null +++ b/labs/ubuntu-desktop/4-4-package-descriptions/check.sh @@ -0,0 +1,19 @@ +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; } +field() { apt-cache show "$1" 2>/dev/null | awk -v f="$2:" '$1 == f { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }'; } +component() { local s; s=$(field synaptic Section); case $s in */*) printf '%s' "${s%%/*}" ;; *) printf main ;; esac; } +owner=$(dpkg -S /usr/bin/lsblk 2>/dev/null | cut -d: -f1) +rdeps() { apt-cache rdepends --installed libncursesw6 2>/dev/null | sed -n '/^Reverse Depends:/,$p' | sed '1d; s/^[[:space:]|]*//; s/:.*//' | sort -u; } +listed() { grep -v '^[[:space:]]*$' /root/lab-answers/3 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/:.*//' | sort -u; } + +task 1 "Owner of /usr/bin/lsblk and its file count" +need "line 1 of /root/lab-answers/1 is the package that provides /usr/bin/lsblk" '[ -n "$owner" ] && [ "$(line 1 1)" = "$owner" ]' +need "line 2 is the number of files it installs (dpkg -L)" '[ "$(line 1 2)" = "$(dpkg -L "$owner" | wc -l)" ] || [ "$(line 1 2)" = "$(dpkg -L "$owner" | while read -r f; do [ -d "$f" ] || echo; done | wc -l)" ]' + +task 2 "synaptic's component, maintainer and download size" +need "line 1 of /root/lab-answers/2 is synaptic's component" '[ "$(line 2 1 | tr A-Z a-z)" = "$(component)" ]' +need "line 2 is its maintainer" 'm=$(line 2 2); [ ${#m} -ge 5 ] && field synaptic Maintainer | grep -qiF -- "$m"' +need "line 3 is its download size" 'kb=$(( ($(field synaptic Size) + 512) / 1000 )); line 2 3 | grep -Eq "(^|[^0-9])($kb|$((kb - 1))|$((kb + 1))|$(field synaptic Size))([^0-9]|$)"' + +task 3 "Installed packages that depend on libncursesw6" +need "/root/lab-answers/3 lists at least three packages" '[ "$(listed | wc -l)" -ge 3 ]' +need "every package listed depends on libncursesw6 and is installed" '[ -z "$(comm -23 <(listed) <(rdeps))" ]' diff --git a/labs/ubuntu-desktop/4-4-package-descriptions/lab.conf b/labs/ubuntu-desktop/4-4-package-descriptions/lab.conf new file mode 100644 index 0000000..bf9bb1e --- /dev/null +++ b/labs/ubuntu-desktop/4-4-package-descriptions/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.4 Package descriptions" +CHAPTER=/ubuntu-desktop/4-applications/4-4-package-descriptions/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/4-4-package-descriptions/reset.sh b/labs/ubuntu-desktop/4-4-package-descriptions/reset.sh new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/labs/ubuntu-desktop/4-4-package-descriptions/reset.sh @@ -0,0 +1 @@ +true diff --git a/labs/ubuntu-desktop/4-4-package-descriptions/setup.sh b/labs/ubuntu-desktop/4-4-package-descriptions/setup.sh new file mode 100644 index 0000000..d6f7293 --- /dev/null +++ b/labs/ubuntu-desktop/4-4-package-descriptions/setup.sh @@ -0,0 +1,2 @@ +# Nothing to set up: every task is a finding. +true diff --git a/labs/ubuntu-desktop/4-5-update-tools/README.md b/labs/ubuntu-desktop/4-5-update-tools/README.md new file mode 100644 index 0000000..1cb99e1 --- /dev/null +++ b/labs/ubuntu-desktop/4-5-update-tools/README.md @@ -0,0 +1,32 @@ +# Ubuntu Desktop 4.5: Update tools + +Widening what unattended-upgrades installs, purging a package so that +nothing of it is left, and asking aptitude why a library is installed. + +## Before you start + +The lab makes sure `nano` is installed, and adds a comment line to its +configuration file `/etc/nanorc` (a stand-in for a change you once made). +It saves `/etc/apt/apt.conf.d/50unattended-upgrades` so reset can put it +back. `aptitude` isn't installed. Write answers to the files in +`/root/lab-answers/`. + +## Tasks + +1. Make unattended-upgrades also install updates from `noble-updates`, + and check what it would install now. +2. Remove `nano` so that nothing of it is left, then install it again. +3. Install aptitude and find out which installed package depends on + `libnl-3-200`. Write that package's name to `/root/lab-answers/3`. + +## Hints + +- The `Allowed-Origins` list in + `/etc/apt/apt.conf.d/50unattended-upgrades`; `sudo unattended-upgrade + --dry-run --debug | grep 'Allowed origins'` should include + `a=noble-updates`. +- `apt remove` keeps configuration files; `dpkg -l nano` then shows `rc`. + `apt purge` takes them too, so `/etc/nanorc` is gone until you install + nano again. +- `aptitude why libnl-3-200`: the line starting with `i` that names + `libnl-3-200` gives the package that depends on it. diff --git a/labs/ubuntu-desktop/4-5-update-tools/check.sh b/labs/ubuntu-desktop/4-5-update-tools/check.sh new file mode 100644 index 0000000..ec957f9 --- /dev/null +++ b/labs/ubuntu-desktop/4-5-update-tools/check.sh @@ -0,0 +1,19 @@ +. /etc/os-release +dpkg_did() { + local since; since=$(state_get since) + awk -v s="$since" -v a="$1" -v p="$2" \ + '($1 " " $2) >= s && $3 == a && ($4 == p || index($4, p ":") == 1) {ok = 1} END {exit !ok}' /var/log/dpkg.log 2>/dev/null +} +rdeps() { apt-cache rdepends --installed libnl-3-200 2>/dev/null | sed -n '/^Reverse Depends:/,$p' | sed '1d; s/^[[:space:]|]*//; s/:.*//' | sort -u; } + +task 1 "unattended-upgrades allows $VERSION_CODENAME-updates" +need "unattended-upgrade --dry-run lists a=$VERSION_CODENAME-updates in its allowed origins" 'timeout 120 unattended-upgrade --dry-run --debug 2>&1 | grep "Allowed origins" | grep -q "a=$VERSION_CODENAME-updates"' + +task 2 "nano purged, then installed again" +need "dpkg's log shows nano purged" 'dpkg_did purge nano' +need "nano is installed again" 'pkg_installed nano' +need "/etc/nanorc is back as the package ships it" '[ -f /etc/nanorc ] && ! grep -q "^# le-lab:" /etc/nanorc' + +task 3 "aptitude installed, dependent of libnl-3-200 found" +need "aptitude is installed" 'command -v aptitude' +need "/root/lab-answers/3 names an installed package that depends on libnl-3-200" 'a=$(answer 3); [ -n "$a" ] && rdeps | grep -qx -- "$a"' diff --git a/labs/ubuntu-desktop/4-5-update-tools/lab.conf b/labs/ubuntu-desktop/4-5-update-tools/lab.conf new file mode 100644 index 0000000..1a73a63 --- /dev/null +++ b/labs/ubuntu-desktop/4-5-update-tools/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.5 Update tools" +CHAPTER=/ubuntu-desktop/4-applications/4-5-update-tools/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/4-5-update-tools/reset.sh b/labs/ubuntu-desktop/4-5-update-tools/reset.sh new file mode 100644 index 0000000..5b251d0 --- /dev/null +++ b/labs/ubuntu-desktop/4-5-update-tools/reset.sh @@ -0,0 +1,17 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +# unattended-upgrades as it was, without files added for it. +if [ -f "$LAB_STATE/50unattended-upgrades" ]; then + cp -a "$LAB_STATE/50unattended-upgrades" /etc/apt/apt.conf.d/50unattended-upgrades + for f in /etc/apt/apt.conf.d/*; do + grep -qx "${f##*/}" "$LAB_STATE/aptconf-files" && continue + grep -qiE 'Unattended-Upgrade|Origins' "$f" && rm -f "$f" + done +fi +# Packages the tasks installed (aptitude and what it brought in). +if [ -z "$(state_get had-aptitude)" ] && [ -f "$LAB_STATE/pkgs-before" ]; then + added=$(comm -13 "$LAB_STATE/pkgs-before" <(installed_pkgs)) + [ -n "$added" ] && DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $added >/dev/null 2>&1 +fi +pkg_installed nano || DEBIAN_FRONTEND=noninteractive apt-get -y -qq install nano >/dev/null 2>&1 +[ -f /etc/nanorc ] && sed -i '/^# le-lab:/d' /etc/nanorc +true diff --git a/labs/ubuntu-desktop/4-5-update-tools/setup.sh b/labs/ubuntu-desktop/4-5-update-tools/setup.sh new file mode 100644 index 0000000..8166440 --- /dev/null +++ b/labs/ubuntu-desktop/4-5-update-tools/setup.sh @@ -0,0 +1,16 @@ +installed_pkgs() { dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' 2>/dev/null | awk '$1 ~ /^.i/ {print $2}' | sort -u; } +. /etc/os-release +command -v unattended-upgrade >/dev/null || refuse "this lab needs unattended-upgrades installed (apt install unattended-upgrades)" +[ -f /etc/apt/apt.conf.d/50unattended-upgrades ] || refuse "/etc/apt/apt.conf.d/50unattended-upgrades is missing; reinstall unattended-upgrades" +unattended-upgrade --dry-run --debug 2>&1 | grep 'Allowed origins' | grep -q "a=$VERSION_CODENAME-updates" && + refuse "unattended-upgrades already allows $VERSION_CODENAME-updates; comment it out in /etc/apt/apt.conf.d/ first" +pkg_installed libnl-3-200 || refuse "this lab needs libnl-3-200 installed" + +cp -a /etc/apt/apt.conf.d/50unattended-upgrades "$LAB_STATE/50unattended-upgrades" +ls /etc/apt/apt.conf.d/ >"$LAB_STATE/aptconf-files" +pkg_installed aptitude && state_set had-aptitude 1 +pkg_installed nano || pkg_install nano +installed_pkgs >"$LAB_STATE/pkgs-before" +echo '# le-lab: a local change; it goes away when nano is purged' >>/etc/nanorc +# Task 2 looks in dpkg's log from here on. +state_set since "$(date '+%Y-%m-%d %H:%M:%S')" diff --git a/labs/ubuntu-desktop/5-1-pki/README.md b/labs/ubuntu-desktop/5-1-pki/README.md new file mode 100644 index 0000000..f0acbbf --- /dev/null +++ b/labs/ubuntu-desktop/5-1-pki/README.md @@ -0,0 +1,37 @@ +# Ubuntu Desktop 5.1: PKI on the desktop + +Reading a web site's certificate, making your own CA and a server +certificate signed by it, trusting the CA system-wide, and taking it out +of the trust store again. + +## Before you start + +The lab makes an empty folder `/root/pki` to work in, makes sure +`openssl` and `curl` are installed, and adds a small hook to +`update-ca-certificates` that notes which certificates it adds, so the +check can see that task 2 was done after task 3 undoes it. The VM needs +to reach `ubuntu.com`. Write answers to the files in `/root/lab-answers/`. + +## Tasks + +1. Find which CA issued the certificate of `ubuntu.com`, and when it + expires. Write the issuer's organization (or its CN) on line 1 of + `/root/lab-answers/1` and the expiry date on line 2. +2. In `/root/pki`, make a root CA (`ca.crt`, `ca.key`) and a server + certificate for `localhost` signed by it (`server.crt`, `server.key`), + serve it with `openssl s_server -accept 4433 -cert server.crt -key + server.key -www`, and make curl trust it system-wide. +3. Remove your CA from the system trust store again, and see curl fail. + +## Hints + +- `openssl s_client -connect ubuntu.com:443 -servername ubuntu.com + /dev/null | openssl x509 -noout -issuer -enddate`. +- `openssl req -x509 -newkey ...` for the CA; `openssl req -newkey` and + `openssl x509 -req -CA ca.crt -CAkey ca.key` for the server, with + `subjectAltName=DNS:localhost`. Copy `ca.crt` to + `/usr/local/share/ca-certificates/` (the name must end in `.crt`) and + run `update-ca-certificates`; `curl https://localhost:4433/` then works + without `--cacert`. +- Delete the file and run `update-ca-certificates --fresh`; curl now + fails with `curl: (60) SSL certificate problem`. diff --git a/labs/ubuntu-desktop/5-1-pki/check.sh b/labs/ubuntu-desktop/5-1-pki/check.sh new file mode 100644 index 0000000..a27cf4a --- /dev/null +++ b/labs/ubuntu-desktop/5-1-pki/check.sh @@ -0,0 +1,50 @@ +P=/root/pki +line() { sed -n "${2}p" "/root/lab-answers/$1" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'; } +site=$(timeout 15 openssl s_client -connect ubuntu.com:443 -servername ubuntu.com /dev/null | openssl x509 -noout -issuer -enddate 2>/dev/null) +issuer_ok() { + local a; a=$(line 1 1 | tr A-Z a-z); [ ${#a} -ge 2 ] || return 1 + grep '^issuer=' <<<"$site" | sed 's/^issuer=//' | tr ',' '\n' | sed -n 's/^[[:space:]]*\(O\|CN\)[[:space:]]*=[[:space:]]*//p' | + tr A-Z a-z | while read -r v; do [ "$v" = "$a" ] || [[ $a == *"$v"* ]] && echo ok; done | grep -q ok +} +expiry_ok() { + local e a; e=$(grep '^notAfter=' <<<"$site" | cut -d= -f2); a=$(line 1 2) + [ -n "$e" ] && [ -n "$a" ] && [ "$(date -u -d "$a" +%F 2>/dev/null)" = "$(date -u -d "$e" +%F)" ] +} +fp() { openssl x509 -noout -fingerprint -sha256 -in "$1" 2>/dev/null; } +was_trusted() { [ -f "$P/ca.crt" ] && grep -qxF "$(fp "$P/ca.crt")" "$LAB_STATE/ca-added" 2>/dev/null; } +trusted_now() { openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -no-CApath -no-CAstore "$P/server.crt" >/dev/null 2>&1; } + +# A server of the check's own with your certificate and key, since +# yours on 4433 doesn't survive a reboot. +port='' +if [ -f "$P/server.crt" ] && [ -f "$P/server.key" ]; then + for p in $(seq 44330 44339); do listening "$p" || { port=$p; break; }; done + if [ -n "$port" ]; then + openssl s_server -accept "$port" -cert "$P/server.crt" -key "$P/server.key" -www -quiet /dev/null 2>&1 & + srv=$! + for i in $(seq 25); do listening "$port" && break; sleep 0.2; done + fi +fi +curl_ok() { [ -n "$port" ] && curl -s -o /dev/null --max-time 10 "$@" "https://localhost:$port/"; } +curl_rc() { [ -n "$port" ] || { echo none; return; }; curl -s -o /dev/null --max-time 10 "https://localhost:$port/"; echo $?; } + +task 1 "Issuer and expiry date of ubuntu.com's certificate" +need "the VM can reach ubuntu.com:443" '[ -n "$site" ]' +need "line 1 of /root/lab-answers/1 is the issuer's organization or CN" issuer_ok +need "line 2 is the expiry date" expiry_ok + +task 2 "Your CA and a localhost certificate, trusted system-wide" +need "/root/pki has ca.crt, server.crt and server.key" '[ -f $P/ca.crt ] && [ -f $P/server.crt ] && [ -f $P/server.key ]' +need "ca.crt is a CA certificate (CA:TRUE)" 'openssl x509 -in $P/ca.crt -noout -ext basicConstraints | grep -q "CA:TRUE"' +need "server.crt is valid for localhost" 'openssl x509 -in $P/server.crt -noout -checkhost localhost | grep -q "does match"' +need "server.crt is signed by ca.crt" 'openssl verify -CAfile $P/ca.crt -no-CApath -no-CAstore $P/server.crt' +need "a server with server.crt and server.key works for curl --cacert ca.crt" 'curl_ok --cacert $P/ca.crt' +need "update-ca-certificates added ca.crt to the system trust store" was_trusted + +task 3 "Your CA out of the trust store again" +need "your CA was in the trust store first (task 2)" was_trusted +need "the system trust store no longer has your CA" '! trusted_now' +need "curl to a server with your certificate fails with error 60" '[ "$(curl_rc)" = 60 ]' + +[ -n "${srv:-}" ] && kill "$srv" 2>/dev/null +true diff --git a/labs/ubuntu-desktop/5-1-pki/lab.conf b/labs/ubuntu-desktop/5-1-pki/lab.conf new file mode 100644 index 0000000..4bcbc0e --- /dev/null +++ b/labs/ubuntu-desktop/5-1-pki/lab.conf @@ -0,0 +1,3 @@ +TITLE="5.1 PKI on the desktop" +CHAPTER=/ubuntu-desktop/5-security/5-1-pki/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/5-1-pki/reset.sh b/labs/ubuntu-desktop/5-1-pki/reset.sh new file mode 100644 index 0000000..2aa6f43 --- /dev/null +++ b/labs/ubuntu-desktop/5-1-pki/reset.sh @@ -0,0 +1,13 @@ +rm -f /etc/ca-certificates/update.d/le-lab-log +changed= +while read -r f; do + grep -qxF "$f" "$LAB_STATE/local-cas" 2>/dev/null && continue + rm -f "$f"; changed=1 +done < <(find /usr/local/share/ca-certificates -type f 2>/dev/null) +[ -n "$changed" ] && update-ca-certificates --fresh >/dev/null 2>&1 +# Servers on 4433 that task 2 started. +for pid in $(ss -Hltnp 'sport = :4433' 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u); do + [ "$(cat /proc/$pid/comm 2>/dev/null)" = openssl ] && kill "$pid" 2>/dev/null +done +rm -rf /root/pki +true diff --git a/labs/ubuntu-desktop/5-1-pki/setup.sh b/labs/ubuntu-desktop/5-1-pki/setup.sh new file mode 100644 index 0000000..7dc1b16 --- /dev/null +++ b/labs/ubuntu-desktop/5-1-pki/setup.sh @@ -0,0 +1,20 @@ +[ -e /root/pki ] && refuse "/root/pki already exists; move it away first" +command -v update-ca-certificates >/dev/null || pkg_install ca-certificates +command -v openssl >/dev/null || pkg_install openssl +command -v curl >/dev/null || pkg_install curl +mkdir -p /usr/local/share/ca-certificates +find /usr/local/share/ca-certificates -type f | sort >"$LAB_STATE/local-cas" +mkdir -p /etc/ca-certificates/update.d +cat >/etc/ca-certificates/update.d/le-lab-log </dev/null >>"$LAB_STATE/ca-added" ;; + esac +done +exit 0 +HOOK +chmod 755 /etc/ca-certificates/update.d/le-lab-log +mkdir /root/pki diff --git a/labs/ubuntu-desktop/5-3-acls/README.md b/labs/ubuntu-desktop/5-3-acls/README.md new file mode 100644 index 0000000..8ec31cd --- /dev/null +++ b/labs/ubuntu-desktop/5-3-acls/README.md @@ -0,0 +1,33 @@ +# Ubuntu Desktop 5.3: ACLs + +Giving one extra user and one group access to a shared folder with ACLs, +default ACLs for new files, and copying a file with its ACL. + +## Before you start + +The lab creates users `alice` and `bob` (password `Lab-Pass-1`) and group +`designers`, with bob in it, as the chapter does. It installs the `acl` +tools (`getfacl`, `setfacl`) if they're missing. `/srv/shared` doesn't +exist yet. + +## Tasks + +1. Create `/srv/shared`, owned by you (your login user, or root) with mode + 750, where alice can list and read files and the group designers can + also create them. +2. Make every file created in `/srv/shared` from now on readable and + writable by designers and readable by alice. +3. As bob, create `/srv/shared/new`. Then copy it to `/tmp` so that its + ACL comes along (it belongs to bob, so use sudo). Ubuntu empties `/tmp` + at every boot, so do this task last and check it before you reboot. + +## Hints + +- `setfacl -m u:alice:rx,g:designers:rwx`; `getfacl -c` shows the entries + and the mask. `sudo -u bob touch /srv/shared/t` should work and `sudo -u + alice touch /srv/shared/t2` should be denied. +- Default entries: `setfacl -d -m ...`. Test with `sudo -u bob touch + /srv/shared/new` and `getfacl -c /srv/shared/new` (look at + `#effective:`). +- `cp -a`, or `cp --preserve=all`; `ls -l` shows a `+` after the + permissions of a file with an ACL. diff --git a/labs/ubuntu-desktop/5-3-acls/check.sh b/labs/ubuntu-desktop/5-3-acls/check.sh new file mode 100644 index 0000000..48059ad --- /dev/null +++ b/labs/ubuntu-desktop/5-3-acls/check.sh @@ -0,0 +1,39 @@ +D=/srv/shared +acl_of() { getfacl -cp "$1" 2>/dev/null; } +# eff FILE ENTRY: the effective permissions of an ACL entry such as +# group:designers (its permissions masked by the mask). +eff() { + getfacl -cpe "$1" 2>/dev/null | awk -v e="$2:" 'index($0, e) == 1 { + n = split($0, f, "#effective:"); if (n > 1) { gsub(/[[:space:]]/, "", f[2]); print f[2] } + else { sub(/.*:/, "", f[1]); gsub(/[[:space:]]/, "", f[1]); print f[1] } exit }' +} +try_touch() { + local f="$D/.le-check-$1-$$" + runuser -u "$1" -- touch "$f" 2>/dev/null || return 1 + rm -f "$f" +} + +task 1 "/srv/shared: mode 750, alice reads, designers create" +need "/srv/shared is a directory" '[ -d "$D" ]' +need "it's owned by you, not by alice or bob" '! [[ "$(stat -c %U "$D")" =~ ^(alice|bob)$ ]]' +need "its owner has rwx, its group r-x and others nothing (750)" 'acl_of "$D" | grep -qx "user::rwx" && acl_of "$D" | grep -qx "group::r-x" && acl_of "$D" | grep -qx "other::---"' +need "the ACL gives alice r-x" 'acl_of "$D" | grep -qx "user:alice:r-x"' +need "the ACL gives designers rwx" 'acl_of "$D" | grep -qx "group:designers:rwx"' +need "the mask is rwx" 'acl_of "$D" | grep -qx "mask::rwx"' +need "alice can list it" 'runuser -u alice -- ls "$D"' +need "bob can create files in it" 'try_touch bob' +need "alice can't create files in it" '! try_touch alice' + +task 2 "New files: designers rw-, alice r--" +probe="$D/.le-check-new-$$" +runuser -u bob -- touch "$probe" 2>/dev/null +need "bob can create a file in /srv/shared" '[ -f "$probe" ]' +need "designers can read and write it (rw-)" '[ "$(eff "$probe" group:designers)" = rw- ]' +need "alice can read it (r--)" '[ "$(eff "$probe" user:alice)" = r-- ]' +rm -f "$probe" + +task 3 "/tmp/new copied with its ACL" +need "/srv/shared/new exists and belongs to bob" '[ "$(stat -c %U "$D/new" 2>/dev/null)" = bob ]' +need "/tmp/new exists" '[ -f /tmp/new ]' +need "/tmp/new still belongs to bob" '[ "$(stat -c %U /tmp/new)" = bob ]' +need "/tmp/new has the ACL entries for alice and designers" 'acl_of /tmp/new | grep -q "^user:alice:" && acl_of /tmp/new | grep -q "^group:designers:"' diff --git a/labs/ubuntu-desktop/5-3-acls/lab.conf b/labs/ubuntu-desktop/5-3-acls/lab.conf new file mode 100644 index 0000000..d2e9259 --- /dev/null +++ b/labs/ubuntu-desktop/5-3-acls/lab.conf @@ -0,0 +1,3 @@ +TITLE="5.3 ACLs" +CHAPTER=/ubuntu-desktop/5-security/5-3-acls/ +SYSTEMS="debian" diff --git a/labs/ubuntu-desktop/5-3-acls/reset.sh b/labs/ubuntu-desktop/5-3-acls/reset.sh new file mode 100644 index 0000000..224d3bf --- /dev/null +++ b/labs/ubuntu-desktop/5-3-acls/reset.sh @@ -0,0 +1,8 @@ +rm -rf /srv/shared /tmp/new +# The users go after this script; take bob out of the group first. +gpasswd -d bob designers >/dev/null 2>&1 +groupdel designers 2>/dev/null +if [ -n "$(state_get added-acl)" ]; then + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge acl >/dev/null 2>&1 +fi +true diff --git a/labs/ubuntu-desktop/5-3-acls/setup.sh b/labs/ubuntu-desktop/5-3-acls/setup.sh new file mode 100644 index 0000000..daf5fb5 --- /dev/null +++ b/labs/ubuntu-desktop/5-3-acls/setup.sh @@ -0,0 +1,13 @@ +[ -e /srv/shared ] && refuse "/srv/shared already exists; move it away first" +[ -e /tmp/new ] && refuse "/tmp/new already exists; remove it first" +for u in alice bob; do id "$u" >/dev/null 2>&1 && refuse "user $u already exists; remove it first"; done +getent group designers >/dev/null && refuse "group designers already exists; remove it first" + +if ! pkg_installed acl; then + pkg_install acl + state_set added-acl 1 +fi +groupadd designers +user_add alice +user_add bob +usermod -aG designers bob diff --git a/labs/ubuntu-server/1-1-systemd-services/README.md b/labs/ubuntu-server/1-1-systemd-services/README.md new file mode 100644 index 0000000..5f2ea1b --- /dev/null +++ b/labs/ubuntu-server/1-1-systemd-services/README.md @@ -0,0 +1,30 @@ +# Ubuntu Server 1.1: Services with systemd + +Stopping a service from starting, masking it, writing your own unit, and +changing it with a drop-in. + +## Before you start + +The lab doesn't install anything: installing nginx is part of task 1. It +checks that nothing listens on port 8080 yet and that there's no +`hello.service`. + +## Tasks + +1. Install nginx, stop it from starting at boot, then make sure it can't be + started at all. Undo both, so nginx is enabled and running again. +2. Write a service `hello.service` that runs `/usr/bin/python3 -m + http.server 8080` from `/tmp` as the user `nobody`, enabled at boot. + `curl -s localhost:8080 | head -1` prints HTML. +3. With a drop-in, make `hello` restart 3 seconds after it fails. After + `sudo kill -9` of its main PID, it's active again within a few seconds. + +## Hints + +- `systemctl disable`, `mask`, `unmask`, `enable --now`; `is-enabled`, + `is-active`. +- A unit in `/etc/systemd/system/` with `User=`, `WorkingDirectory=`, + `ExecStart=` and `[Install]` `WantedBy=multi-user.target`; + `systemctl daemon-reload`, `enable --now`. `ps -o user= -C python3`. +- `sudo systemctl edit hello`, `Restart=on-failure`, `RestartSec=3`; + `systemctl cat hello`, `systemctl show hello -p MainPID`. diff --git a/labs/ubuntu-server/1-1-systemd-services/check.sh b/labs/ubuntu-server/1-1-systemd-services/check.sh new file mode 100644 index 0000000..b822307 --- /dev/null +++ b/labs/ubuntu-server/1-1-systemd-services/check.sh @@ -0,0 +1,31 @@ +hpid() { systemctl show -p MainPID --value hello.service 2>/dev/null; } +# web_up: something answers on localhost:8080 within about 20 seconds +# (python's http.server can take a while to start listening). +web_up() { + local i + for i in $(seq 20); do + curl -s --max-time 2 http://localhost:8080/ | grep -qi '/dev/null)" != masked ]' +need "nginx is enabled at boot" enabled nginx +need "nginx is running" active nginx + +task 2 "hello.service: http.server on 8080 from /tmp as nobody, at boot" +need "hello.service exists" 'systemctl cat hello.service' +need "hello.service is enabled at boot" enabled hello.service +need "hello.service is running" active hello.service +need "it runs python3 -m http.server 8080" 'systemctl show -p ExecStart --value hello.service | grep -Eq "python3.* -m +http\.server +8080"' +need "its main process runs as nobody" '[ "$(ps -o user= -p "$(hpid)" | tr -d " ")" = nobody ]' +need "its main process works in /tmp" '[ "$(readlink /proc/$(hpid)/cwd)" = /tmp ]' +need "curl -s localhost:8080 prints HTML" web_up + +task 3 "A drop-in restarts hello 3 seconds after it fails" +need "hello.service has a drop-in" 'systemctl show -p DropInPaths --value hello.service | grep -q "hello\.service\.d/"' +need "it restarts after a failure (Restart=)" 'case $(systemctl show -p Restart --value hello.service) in on-failure|always|on-abnormal|on-abort) true ;; *) false ;; esac' +need "it waits 3 seconds first (RestartSec=3)" '[ "$(systemctl show -p RestartUSec --value hello.service)" = 3s ]' diff --git a/labs/ubuntu-server/1-1-systemd-services/lab.conf b/labs/ubuntu-server/1-1-systemd-services/lab.conf new file mode 100644 index 0000000..c0a08a3 --- /dev/null +++ b/labs/ubuntu-server/1-1-systemd-services/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.1 Services with systemd" +CHAPTER=/ubuntu-server/1-servers-and-services/1-1-systemd-services/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/1-1-systemd-services/reset.sh b/labs/ubuntu-server/1-1-systemd-services/reset.sh new file mode 100644 index 0000000..0a5d86d --- /dev/null +++ b/labs/ubuntu-server/1-1-systemd-services/reset.sh @@ -0,0 +1,20 @@ +systemctl disable --now --quiet hello.service 2>/dev/null +rm -rf /etc/systemd/system/hello.service /etc/systemd/system/hello.service.d +systemctl unmask --quiet nginx.service 2>/dev/null +systemctl daemon-reload +systemctl reset-failed hello.service 2>/dev/null +# The packages installed since the lab started, and only those. +if [ -s "$LAB_STATE/pkgs-before" ]; then + new=$(dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u | + comm -13 "$LAB_STATE/pkgs-before" - | grep -Ev '^linux-') + if [ -n "$new" ]; then + say "Removing the packages installed since the lab started..." + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $new >/dev/null 2>&1 + fi +fi +if [ -n "$(state_get had-nginx)" ]; then + systemctl enable --now --quiet nginx 2>/dev/null +else + rm -rf /etc/nginx /var/log/nginx /var/www/html/index.nginx-debian.html +fi +true diff --git a/labs/ubuntu-server/1-1-systemd-services/setup.sh b/labs/ubuntu-server/1-1-systemd-services/setup.sh new file mode 100644 index 0000000..a816f8a --- /dev/null +++ b/labs/ubuntu-server/1-1-systemd-services/setup.sh @@ -0,0 +1,6 @@ +[ -e /etc/systemd/system/hello.service ] && refuse "/etc/systemd/system/hello.service already exists; move it away and start again" +ss -Hltn 'sport = :8080' | grep -q . && refuse "something already listens on port 8080; stop it and start again" +# Reset removes the packages installed from here on, and only those. +dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u >"$LAB_STATE/pkgs-before" +pkg_installed nginx && state_set had-nginx 1 +true diff --git a/labs/ubuntu-server/1-2-remote-backups/README.md b/labs/ubuntu-server/1-2-remote-backups/README.md new file mode 100644 index 0000000..e5c98bc --- /dev/null +++ b/labs/ubuntu-server/1-2-remote-backups/README.md @@ -0,0 +1,41 @@ +# Ubuntu Server 1.2: Automated remote backups + +A backup of `/etc` to another machine with rsync over SSH, as a user other +than root, a nightly systemd timer, and a restore that keeps owners. + +## Before you start + +The peer, at `10.99.0.2` (name `peer`), is the backup server, s2. It runs +its own sshd, and shares this VM's users and files: an account you create +here exists there too, and the peer's `/srv/backups` is this VM's. Reach it +with `ssh peer`; get a shell in its network with `sudo ip netns exec lepeer +bash`. + +The lab writes an `/etc/motd` for task 3; look at its owner and mode with +`ls -l /etc/motd` before you start. + +## Tasks + +1. As root, back up `/etc` to `/srv/backups/s1-etc/` on the peer as a user + `bkp` (not root), using rsync over SSH with a dedicated key, + `/root/.ssh/backup_key`. `sudo rsync -avn --delete -M--fake-super -e + "ssh -i /root/.ssh/backup_key" /etc/ bkp@peer:/srv/backups/s1-etc/` + lists no files to send and ends with `(DRY RUN)`. +2. Run that backup every day at 1:15 am with a systemd timer that catches + up after downtime. `systemctl list-timers` shows your timer with the + next run at 01:15 (plus any random delay); `sudo systemctl start` of the + service ends with "Finished" in its journal. +3. Delete `/etc/motd`, then restore it from the peer. After the restore, + `ls -l /etc/motd` shows the file with its original owner and mode. + +## Hints + +- `ssh-keygen -N "" -f /root/.ssh/backup_key`; `useradd -m`, `install -d + -o bkp -g bkp`, `~bkp/.ssh/authorized_keys`. Connect once by hand with + `-o StrictHostKeyChecking=accept-new` so the host key is known. +- `rsync -a --delete -M--fake-super -e "ssh -i KEY -o BatchMode=yes"`: the + trailing slash on `/etc/` matters. +- A `Type=oneshot` service and a `.timer` with `OnCalendar=`, + `Persistent=true`; enable the timer. `systemd-analyze calendar`, + `systemctl list-timers`, `journalctl -u`. +- The same rsync the other way, with `-M--fake-super`. diff --git a/labs/ubuntu-server/1-2-remote-backups/check.sh b/labs/ubuntu-server/1-2-remote-backups/check.sh new file mode 100644 index 0000000..bd01a62 --- /dev/null +++ b/labs/ubuntu-server/1-2-remote-backups/check.sh @@ -0,0 +1,42 @@ +K=/root/.ssh/backup_key +B=/srv/backups/s1-etc +SSH="ssh -i $K -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" +# What a dry run would still send: changed files, not just directory times. +pending() { + timeout 60 rsync -ani --delete -M--fake-super -e "$SSH" /etc/ bkp@10.99.0.2:$B/ 2>/dev/null | + grep -Ev '^\.d\.\.t\.+ ' >"$LAB_STATE/pending" + [ "${PIPESTATUS[0]}" -eq 0 ] +} +# The backup timers: timers whose service runs rsync to the peer. +timers() { + local t s x f + for t in $(systemctl list-unit-files --type=timer --no-legend | awk '{ print $1 }'); do + s=$(systemctl show -p Unit --value "$t") + x=$(systemctl show -p ExecStart --value "$s" 2>/dev/null | sed -n 's/.*path=\([^ ;]*\).*/\1/p') + { systemctl cat "$s" 2>/dev/null; [ -f "$x" ] && cat "$x"; } | grep -q 'rsync' || continue + echo "$t" + done +} +t1() { timers | head -n1; } + +task 1 "/etc backed up to the peer as bkp with rsync and its own key" +need "user bkp exists and isn't root" 'id bkp && [ "$(id -u bkp)" != 0 ]' +need "/root/.ssh/backup_key is a key without a passphrase" 'ssh-keygen -y -P "" -f $K' +need "bkp's authorized_keys holds the backup key" 'h=$(getent passwd bkp | cut -d: -f6); grep -qF "$(awk "{ print \$2 }" $K.pub)" $h/.ssh/authorized_keys' +need "root logs in to the peer as bkp with the key" 'timeout 15 $SSH bkp@10.99.0.2 true /dev/null | grep -q "^Finished"' + +task 3 "/etc/motd deleted and restored with its owner and mode" +need "/etc/motd exists" test -f /etc/motd +need "it's the file the lab wrote" '[ "$(md5sum /dev/null +rm -f /etc/systemd/system/le-lab-peer-sshd.service +# Units and scripts made since the lab started: the backup's. +for f in /etc/systemd/system/*.timer /etc/systemd/system/*.service; do + [ -f "$f" ] || continue + n=${f##*/} + case $n in le-lab-*) continue ;; esac + grep -qxF "$n" "$LAB_STATE/units-before" 2>/dev/null && continue + systemctl disable --now --quiet "$n" 2>/dev/null + rm -rf "$f" "$f.d" +done +systemctl daemon-reload +for f in /usr/local/sbin/* /usr/local/bin/*; do + [ -f "$f" ] || continue + grep -qxF "${f##*/}" "$LAB_STATE/local-before" 2>/dev/null && continue + grep -q rsync "$f" 2>/dev/null && rm -f "$f" +done +rm -f /var/lib/systemd/timers/stamp-*backup* +rm -f /root/.ssh/backup_key /root/.ssh/backup_key.pub +[ -f /root/.ssh/known_hosts ] && ssh-keygen -R 10.99.0.2 >/dev/null 2>&1 && ssh-keygen -R peer >/dev/null 2>&1 +rm -f /root/.ssh/known_hosts.old +pkill -KILL -u bkp 2>/dev/null +userdel -r bkp 2>/dev/null +rm -rf /srv/backups +if [ -f "$LAB_STATE/motd.orig" ]; then cp -a "$LAB_STATE/motd.orig" /etc/motd; else rm -f /etc/motd; fi +true diff --git a/labs/ubuntu-server/1-2-remote-backups/setup.sh b/labs/ubuntu-server/1-2-remote-backups/setup.sh new file mode 100644 index 0000000..517ac9f --- /dev/null +++ b/labs/ubuntu-server/1-2-remote-backups/setup.sh @@ -0,0 +1,35 @@ +id bkp >/dev/null 2>&1 && refuse "a user bkp already exists; remove it and start again" +for p in /root/.ssh/backup_key /srv/backups; do + [ -e "$p" ] && refuse "$p already exists; move it away and start again" +done +command -v rsync >/dev/null || pkg_install rsync +ls /etc/systemd/system >"$LAB_STATE/units-before" +ls /usr/local/sbin /usr/local/bin 2>/dev/null >"$LAB_STATE/local-before" || true +# Task 3's file. +if [ -e /etc/motd ]; then cp -a /etc/motd "$LAB_STATE/motd.orig"; fi +printf 'Welcome to s1. Backups run nightly.\n' >/etc/motd +chown root:adm /etc/motd +chmod 640 /etc/motd +# A new file has a new inode or birth time, even with the old mtime. +state_set motd-id "$(stat -c "%i %w" /etc/motd)" +state_set motd-sum "$(md5sum /etc/systemd/system/le-lab-peer-sshd.service <<'X' +[Unit] +Description=Practice lab sshd on the peer +Requires=le-lab-peer.service +After=le-lab-peer.service network.target + +[Service] +NetworkNamespacePath=/run/netns/lepeer +ExecStartPre=/bin/mkdir -p /run/sshd +ExecStart=/usr/sbin/sshd -D -o ListenAddress=10.99.0.2:22 -o PidFile=none +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target +X +systemctl daemon-reload +systemctl enable --now --quiet le-lab-peer-sshd.service diff --git a/labs/ubuntu-server/1-3-file-servers/README.md b/labs/ubuntu-server/1-3-file-servers/README.md new file mode 100644 index 0000000..90903b7 --- /dev/null +++ b/labs/ubuntu-server/1-3-file-servers/README.md @@ -0,0 +1,51 @@ +# Ubuntu Server 1.3: File servers: NFS and Samba + +An NFS export for one client, mounted at boot, with root squashed, and a +Samba share for one group. + +## Before you start + +The lab adds a second machine, `peer` (`10.99.0.2`), linked to this VM's +lab address `10.99.0.1` on interface `le-host`. + +- **NFS:** this VM is both s1, the server, and s2, the client: it mounts + its own export through `10.99.0.1`, the way another machine would, so + the server sees the client as `10.99.0.1`. That's s2's address here. + The traffic never leaves the VM. The lab orders the NFS server before + network mounts, so the mount comes up at boot and goes down first at + shutdown. +- **Samba:** the peer is s2, the client. Run its commands with `sudo ip + netns exec lepeer`, for example `sudo ip netns exec lepeer smbclient + //10.99.0.1/docs -U dana -c ls`. Its traffic comes in over `le-host`, so + open the firewall for Samba if it's running. + +The lab installs `smbclient`, and creates users `pat` (the same UID on +both machines, since they share their users), `dana` (in group `docs`) +and `eli` (not in it), all with password `Lab-Pass-1`. + +## Tasks + +1. On s1, export `/srv/projects` read-write to s2 (`10.99.0.1`) only. On + s2, mount it from `10.99.0.1` at `/mnt/projects` at every boot. + `showmount -e 10.99.0.1` lists `/srv/projects` with that address, and + `findmnt /mnt/projects` shows type `nfs4`. +2. Make the export writable for `pat`, but not for root on s2. As pat, + `touch /mnt/projects/f` works and `ls -l /srv/projects` shows the file + as pat's; `sudo touch /mnt/projects/x` fails with "Permission denied", + because root counts as nobody. +3. On s1, share `/srv/samba/docs` as `docs`, writable only by members of + the Linux group `docs`, and give the member `dana` the Samba password + `Lab-Pass-1`. `testparm -s` shows the `[docs]` section; from the peer, + dana can list the share and put a file there, and `eli` (who has no + Samba password) gets `NT_STATUS_ACCESS_DENIED`. + +## Hints + +- `apt install nfs-kernel-server`; `/etc/exports` (no space before the + parentheses), `exportfs -ra`, `exportfs -v`, `showmount -e`; a line in + `/etc/fstab` with `_netdev`, then `systemctl daemon-reload`, `mount -a`. +- `root_squash` is the default; `chown`, `chmod 755`; `su - pat`. +- `apt install samba`; a `[docs]` section with `path`, `valid users = + @docs`, `read only = no`; `chgrp docs`, `chmod 2770`; `smbpasswd -a`, + `pdbedit -L`, `testparm -s`, `systemctl restart smbd`. `ufw allow + Samba`. diff --git a/labs/ubuntu-server/1-3-file-servers/check.sh b/labs/ubuntu-server/1-3-file-servers/check.sh new file mode 100644 index 0000000..0ba1225 --- /dev/null +++ b/labs/ubuntu-server/1-3-file-servers/check.sh @@ -0,0 +1,27 @@ +# The clients /srv/projects is exported to, one per line, with options. +clients() { exportfs -s 2>/dev/null | awk '$1 == "/srv/projects" { for (i = 2; i <= NF; i++) print $i }'; } +in_fstab() { findmnt -s -n "$1" >/dev/null || [ -e "/etc/systemd/system/$(systemd-escape -p --suffix=mount "$1")" ]; } +smb() { timeout 20 ip netns exec lepeer smbclient //10.99.0.1/docs "$@" 2>&1 /dev/null | awk "/^\[docs\]/ { f = 1; next } /^\[/ { f = 0 } f && /path = \/srv\/samba\/docs\/?$/ { ok = 1 } END { exit !ok }"' +need "smbd is running and enabled" 'active smbd && enabled smbd' +need "dana has a Samba password" 'pdbedit -L 2>/dev/null | grep -q "^dana:"' +need "from the peer, dana can list the share" 'smb -U dana%Lab-Pass-1 -c ls | grep -q "blocks available"' +need "from the peer, dana can put a file in the share" 'n=le-check-$$; smb -U dana%Lab-Pass-1 -c "put /etc/hostname $n" >/dev/null; [ -f /srv/samba/docs/$n ]; r=$?; rm -f /srv/samba/docs/$n; exit $r' +need "from the peer, eli gets NT_STATUS_ACCESS_DENIED" 'smb -U eli%x -c ls | grep -q NT_STATUS_ACCESS_DENIED' +need "eli can't put a file in the share" 'n=le-check-$$; smb -U eli%x -c "put /etc/hostname $n" >/dev/null; [ ! -e /srv/samba/docs/$n ]; r=$?; rm -f /srv/samba/docs/$n; exit $r' diff --git a/labs/ubuntu-server/1-3-file-servers/lab.conf b/labs/ubuntu-server/1-3-file-servers/lab.conf new file mode 100644 index 0000000..f92155f --- /dev/null +++ b/labs/ubuntu-server/1-3-file-servers/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.3 File servers: NFS and Samba" +CHAPTER=/ubuntu-server/1-servers-and-services/1-3-file-servers/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/1-3-file-servers/reset.sh b/labs/ubuntu-server/1-3-file-servers/reset.sh new file mode 100644 index 0000000..5e6bf06 --- /dev/null +++ b/labs/ubuntu-server/1-3-file-servers/reset.sh @@ -0,0 +1,35 @@ +findmnt -n /mnt/projects >/dev/null 2>&1 && { umount /mnt/projects 2>/dev/null || umount -l /mnt/projects; } +awk '/^[[:space:]]*#/ || NF < 2 { print; next } $2 == "/mnt/projects" || $2 == "/mnt/projects/" { next } { print }' /etc/fstab >/etc/fstab.le-lab && + cat /etc/fstab.le-lab >/etc/fstab +rm -f /etc/fstab.le-lab +sed -i '\#^[[:space:]]*/srv/projects[[:space:]]#d' /etc/exports 2>/dev/null +for f in /etc/exports.d/*.exports; do + [ -f "$f" ] && sed -i '\#^[[:space:]]*/srv/projects[[:space:]]#d' "$f" +done +command -v exportfs >/dev/null && exportfs -ra 2>/dev/null +command -v smbpasswd >/dev/null && smbpasswd -x dana >/dev/null 2>&1 +if [ -f "$LAB_STATE/smb.conf.orig" ]; then cp -a "$LAB_STATE/smb.conf.orig" /etc/samba/smb.conf; fi +for u in nfs-server smbd nmbd; do + grep -qx "$u" "$LAB_STATE/enabled-before" 2>/dev/null && continue + systemctl disable --now "$u" >/dev/null 2>&1 +done +rm -rf /etc/systemd/system/nfs-server.service.d/le-lab.conf +rmdir /etc/systemd/system/nfs-server.service.d 2>/dev/null +systemctl daemon-reload +# The packages installed since the lab started, and only those. +if [ -s "$LAB_STATE/pkgs-before" ]; then + new=$(dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u | + comm -13 "$LAB_STATE/pkgs-before" - | grep -Ev '^linux-') + if [ -n "$new" ]; then + say "Removing the packages installed since the lab started..." + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $new >/dev/null 2>&1 + fi +fi +if [ -f "$LAB_STATE/ufw.tar" ]; then + rm -rf /etc/ufw && tar -C /etc -xf "$LAB_STATE/ufw.tar" + ufw reload >/dev/null +fi +rm -rf /srv/projects /srv/samba/docs +rmdir /srv/samba /mnt/projects 2>/dev/null +groupdel docs 2>/dev/null +true diff --git a/labs/ubuntu-server/1-3-file-servers/setup.sh b/labs/ubuntu-server/1-3-file-servers/setup.sh new file mode 100644 index 0000000..be1dfd1 --- /dev/null +++ b/labs/ubuntu-server/1-3-file-servers/setup.sh @@ -0,0 +1,29 @@ +for p in /srv/projects /srv/samba/docs /mnt/projects; do + [ -e "$p" ] && refuse "$p already exists; move it away and start again" +done +getent group docs >/dev/null && refuse "a group docs already exists; remove it and start again" +# Reset removes the packages installed from here on, and only those. +dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u >"$LAB_STATE/pkgs-before" +for u in nfs-server smbd nmbd; do + enabled "$u" && printf '%s\n' "$u" >>"$LAB_STATE/enabled-before" +done +touch "$LAB_STATE/enabled-before" +[ -f /etc/samba/smb.conf ] && cp -a /etc/samba/smb.conf "$LAB_STATE/smb.conf.orig" +if command -v ufw >/dev/null && ufw status | grep -q 'Status: active'; then + tar -C /etc -cf "$LAB_STATE/ufw.tar" ufw +fi +pkg_installed smbclient || pkg_install smbclient +peer_up +groupadd docs +user_add pat +user_add dana -G docs +user_add eli +# The server and client are one machine for NFS: start the server before +# network mounts are made, and stop it only after they're unmounted. +mkdir -p /etc/systemd/system/nfs-server.service.d +cat >/etc/systemd/system/nfs-server.service.d/le-lab.conf <<'X' +[Unit] +Wants=remote-fs-pre.target +Before=remote-fs-pre.target +X +systemctl daemon-reload diff --git a/labs/ubuntu-server/1-4-web-app-and-database/README.md b/labs/ubuntu-server/1-4-web-app-and-database/README.md new file mode 100644 index 0000000..01bb45a --- /dev/null +++ b/labs/ubuntu-server/1-4-web-app-and-database/README.md @@ -0,0 +1,39 @@ +# Ubuntu Server 1.4: A web application with a database + +Apache, PHP and MySQL: a database with a read-only user, a PHP page in a +virtual host of its own, and finding a broken database login in the logs. + +## Before you start + +The lab doesn't install anything: installing Apache, PHP and MySQL is part +of task 1. It checks that nothing listens on port 80 yet. Reset removes +the packages installed during the lab again, with MySQL's databases. + +## Tasks + +1. Install Apache, PHP and MySQL. Create a database `shop` with a table + `item` holding two rows, and a user `shopapp` (at `localhost`) who can + only read it. `mysql -u shopapp -p shop -e 'SELECT * FROM item;'` shows + the two rows, and `mysql -u shopapp -p shop -e 'DELETE FROM item;'` + fails with "DELETE command denied". +2. Serve a PHP page that lists the items, as a virtual host named + `shop.example.test` with its own error log. `curl -H 'Host: + shop.example.test' http://localhost/` prints the two items; `apache2ctl + -S` lists `shop.example.test`; `curl http://localhost/` still shows + Ubuntu's default page. +3. Break the page by changing the database password in it, so that `curl -s + -o /dev/null -w '%{http_code}' -H 'Host: shop.example.test' + http://localhost/` prints `500`, and find the cause from the logs alone: + the site's error log shows "Access denied for user + 'shopapp'@'localhost'". Then put it back. + +## Hints + +- `apt install apache2 mysql-server php libapache2-mod-php php-mysql`; + `sudo mysql`, `CREATE DATABASE`, `CREATE TABLE`, `INSERT`, `CREATE USER + ... IDENTIFIED BY`, `GRANT SELECT ON shop.* TO`. +- A file in `/etc/apache2/sites-available/` with ``, + `ServerName`, `DocumentRoot`, `DirectoryIndex index.php`, `ErrorLog + ${APACHE_LOG_DIR}/...`; `a2ensite`, `apache2ctl configtest`, `systemctl + reload apache2`. In PHP, `new mysqli(...)`, `query()`, `fetch_assoc()`. +- `tail` the site's error log in `/var/log/apache2/`. diff --git a/labs/ubuntu-server/1-4-web-app-and-database/check.sh b/labs/ubuntu-server/1-4-web-app-and-database/check.sh new file mode 100644 index 0000000..fefaa82 --- /dev/null +++ b/labs/ubuntu-server/1-4-web-app-and-database/check.sh @@ -0,0 +1,49 @@ +q() { mysql -N -B -e "$1" 2>/dev/null; } +grants() { q "SHOW GRANTS FOR 'shopapp'@'localhost'"; } +# The shop site's configuration file, from apache2ctl -S. +site_conf() { apache2ctl -S 2>/dev/null | awk '/namevhost shop\.example\.test / { sub(/.*\(/, ""); sub(/:[0-9]+\)$/, ""); print; exit }'; } +# Its ErrorLog, as a path. +site_log() { + local f l + f=$(site_conf); [ -f "$f" ] || return 1 + l=$(awk 'tolower($1) == "errorlog" { print $2; exit }' "$f" | tr -d '"') + [ -n "$l" ] || return 1 + l=${l//'${APACHE_LOG_DIR}'/\/var\/log\/apache2} + case $l in /*) ;; *) l=/etc/apache2/$l ;; esac + echo "$l" +} +page() { curl -s --max-time 10 -H 'Host: shop.example.test' http://localhost/; } +code() { curl -s -o /dev/null --max-time 10 -w '%{http_code}' -H 'Host: shop.example.test' http://localhost/; } +# Every row of shop.item shows up in the page by at least one text value. +items_shown() { + local p row v found + p=$(page) + [ "$(q 'SELECT COUNT(*) FROM shop.item')" -ge 2 ] || return 1 + while IFS= read -r row; do + found= + while IFS= read -r v; do + [ "${#v}" -ge 2 ] && ! [[ $v =~ ^[0-9.]+$ ]] && grep -qF -- "$v" <<<"$p" && found=1 + done < <(tr '\t' '\n' <<<"$row") + [ -n "$found" ] || return 1 + done < <(q 'SELECT * FROM shop.item') +} + +task 1 "Database shop, table item with two rows, read-only user shopapp" +need "Apache, PHP and MySQL are installed" 'pkg_installed apache2 && pkg_installed mysql-server && ls /etc/apache2/mods-enabled/php*.load' +need "MySQL is running" 'active mysql' +need "shop.item holds two rows" '[ "$(q "SELECT COUNT(*) FROM shop.item")" = 2 ]' +need "user shopapp@localhost exists" '[ "$(q "SELECT COUNT(*) FROM mysql.user WHERE user = '"'"'shopapp'"'"' AND host = '"'"'localhost'"'"'")" = 1 ]' +need "shopapp may SELECT from shop" 'grants | grep -Eq "GRANT SELECT ON .shop.\.(\*|.item.) TO"' +need "shopapp may do nothing else" '! grants | grep -Eiq "ALL PRIVILEGES|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER"' + +task 2 "A PHP page lists the items at virtual host shop.example.test" +need "Apache is running" 'active apache2' +need "apache2ctl -S lists shop.example.test" 'apache2ctl -S 2>/dev/null | grep -q "namevhost shop\.example\.test "' +need "the site has its own error log" 'l=$(site_log) && [ "$l" != /var/log/apache2/error.log ]' +need "curl -H Host: shop.example.test http://localhost/ shows both items" items_shown +need "curl http://localhost/ still shows Ubuntu's default page" 'curl -s --max-time 10 http://localhost/ | grep -q "Apache2 Ubuntu Default Page"' + +task 3 "The broken password found in the site's log, and put back" +need "the site's error log shows Access denied for user 'shopapp'@'localhost'" 'grep -q "Access denied for user '"'"'shopapp'"'"'@'"'"'localhost'"'"'" "$(site_log)"' +need "the page works again (HTTP 200)" '[ "$(code)" = 200 ]' +need "it shows both items" items_shown diff --git a/labs/ubuntu-server/1-4-web-app-and-database/lab.conf b/labs/ubuntu-server/1-4-web-app-and-database/lab.conf new file mode 100644 index 0000000..b396565 --- /dev/null +++ b/labs/ubuntu-server/1-4-web-app-and-database/lab.conf @@ -0,0 +1,3 @@ +TITLE="1.4 A web application with a database" +CHAPTER=/ubuntu-server/1-servers-and-services/1-4-web-app-and-database/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/1-4-web-app-and-database/reset.sh b/labs/ubuntu-server/1-4-web-app-and-database/reset.sh new file mode 100644 index 0000000..9f48a75 --- /dev/null +++ b/labs/ubuntu-server/1-4-web-app-and-database/reset.sh @@ -0,0 +1,34 @@ +if command -v mysql >/dev/null && active mysql; then + mysql -e "DROP DATABASE IF EXISTS shop; DROP USER IF EXISTS 'shopapp'@'localhost';" 2>/dev/null +fi +# Sites and web directories made since the lab started. +for f in /etc/apache2/sites-available/*; do + [ -e "$f" ] || continue + n=${f##*/} + grep -qxF "$n" "$LAB_STATE/sites-before" 2>/dev/null && continue + a2dissite -q "${n%.conf}" >/dev/null 2>&1 + rm -f "$f" +done +for d in /var/www/*; do + [ -e "$d" ] || continue + grep -qxF "${d##*/}" "$LAB_STATE/www-before" 2>/dev/null && continue + rm -rf "$d" +done +[ -n "$(state_get had-apache)" ] && systemctl reload apache2 2>/dev/null +# The packages installed since the lab started, and only those. +if [ -s "$LAB_STATE/pkgs-before" ]; then + new=$(dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u | + comm -13 "$LAB_STATE/pkgs-before" - | grep -Ev '^linux-') + if [ -n "$new" ]; then + say "Removing the packages installed since the lab started..." + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $new >/dev/null 2>&1 + fi +fi +if [ -z "$(state_get had-mysql)" ]; then + rm -rf /var/lib/mysql /var/lib/mysql-files /var/lib/mysql-keyring /var/log/mysql /etc/mysql +fi +if [ -z "$(state_get had-apache)" ]; then + rm -rf /etc/apache2 /var/log/apache2 /var/www/html/index.html + rmdir /var/www/html 2>/dev/null +fi +true diff --git a/labs/ubuntu-server/1-4-web-app-and-database/setup.sh b/labs/ubuntu-server/1-4-web-app-and-database/setup.sh new file mode 100644 index 0000000..b4c2d2a --- /dev/null +++ b/labs/ubuntu-server/1-4-web-app-and-database/setup.sh @@ -0,0 +1,8 @@ +ss -Hltn 'sport = :80' | grep -q . && refuse "something already listens on port 80; stop it and start again" +# Reset removes the packages installed from here on, and only those. +dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u >"$LAB_STATE/pkgs-before" +pkg_installed mysql-server && state_set had-mysql 1 +pkg_installed apache2 && state_set had-apache 1 +ls /var/www >"$LAB_STATE/www-before" 2>/dev/null || true +ls /etc/apache2/sites-available >"$LAB_STATE/sites-before" 2>/dev/null || true +true diff --git a/labs/ubuntu-server/2-1-automated-installs/README.md b/labs/ubuntu-server/2-1-automated-installs/README.md new file mode 100644 index 0000000..9054a64 --- /dev/null +++ b/labs/ubuntu-server/2-1-automated-installs/README.md @@ -0,0 +1,35 @@ +# Ubuntu Server 2.1: PXE, autoinstall and cloud-init + +Writing and checking a cloud-init user-data file and an autoinstall file. + +Booting a new VM from the files, and the chapter's PXE task, need more +than one VM and aren't part of this lab: try them on your own VMs once the +files pass here. + +## Before you start + +The lab makes the directory `/root/cloud` for your two files. This VM +came from a cloud image, so `cloud-init` is already installed. + +## Tasks + +1. Write `/root/cloud/user-data` that creates a user `deploy` with your + SSH public key and sudo rights, installs `htop`, and writes the text + `built by cloud-init` to `/etc/motd`. Check it: `sudo cloud-init schema + -c /root/cloud/user-data` says "Valid schema". +2. Write an autoinstall file `/root/cloud/autoinstall.yaml` for a server + named `db1` with a user `dbadmin` (password hash made with `openssl + passwd -6`), SSH installed with password logins off, the LVM layout, + and the package `postgresql`. `sudo cloud-init schema -c` accepts it, + it starts with `#cloud-config`, everything is under `autoinstall:` with + `version: 1`, and the password is a `$6$` hash. + +## Hints + +- `#cloud-config` on the first line; `users:` with `name`, `groups: + [sudo]` or `sudo: "ALL=(ALL) NOPASSWD:ALL"`, `shell`, + `ssh_authorized_keys`; `packages:`; `write_files:` with `path` and + `content`. `ssh-keygen` if you have no key yet; `cat ~/.ssh/id_ed25519.pub`. +- `autoinstall:` with `version: 1`, `identity:` (`hostname`, `username`, + `password`), `ssh:` (`install-server`, `allow-pw`), `storage:` `layout:` + `name: lvm`, `packages:`. Quote the hash: it contains `$`. diff --git a/labs/ubuntu-server/2-1-automated-installs/check.sh b/labs/ubuntu-server/2-1-automated-installs/check.sh new file mode 100644 index 0000000..7e03a24 --- /dev/null +++ b/labs/ubuntu-server/2-1-automated-installs/check.sh @@ -0,0 +1,46 @@ +UD=/root/cloud/user-data AI=/root/cloud/autoinstall.yaml +valid() { cloud-init schema -c "$1" 2>&1 | grep -q "Valid schema"; } +# py FILE CODE: run CODE with the YAML of FILE as d; true if it prints ok. +py() { + python3 -I - "$1" "$2" <<'X' 2>/dev/null | grep -qx ok +import subprocess, sys, yaml +d = yaml.safe_load(open(sys.argv[1])) +def key_ok(k): + r = subprocess.run(["ssh-keygen", "-l", "-f", "-"], input=k.encode(), capture_output=True) + return r.returncode == 0 +def lst(v): + return v if isinstance(v, list) else ([] if v is None else [v]) +def user(name): + for u in lst(d.get("users")): + if isinstance(u, dict) and u.get("name") == name: + return u + return {} +def groups(u): + g = u.get("groups", []) + return [x.strip() for x in (g.split(",") if isinstance(g, str) else lst(g))] +if eval(sys.argv[2]): + print("ok") +X +} +motd_ok='any(isinstance(f, dict) and f.get("path") == "/etc/motd" and "built by cloud-init" in str(f.get("content", "")) for f in lst(d.get("write_files"))) or any("built by cloud-init" in str(c) and "/etc/motd" in str(c) for c in lst(d.get("runcmd")) + lst(d.get("bootcmd")))' + +task 1 "user-data: user deploy with a key and sudo, htop, /etc/motd" +need "/root/cloud/user-data exists" test -f $UD +need "it starts with #cloud-config" '[ "$(head -n1 $UD | tr -d "\r ")" = "#cloud-config" ]' +need "cloud-init schema -c says Valid schema" valid $UD +need "it creates user deploy" 'py $UD "bool(user(\"deploy\"))"' +need "deploy gets an SSH public key" 'py $UD "any(key_ok(k) for k in lst(user(\"deploy\").get(\"ssh_authorized_keys\")))"' +need "deploy gets sudo rights" 'py $UD "\"ALL\" in str(user(\"deploy\").get(\"sudo\", \"\")) or \"sudo\" in groups(user(\"deploy\"))"' +need "it installs htop" 'py $UD "\"htop\" in [str(p) for p in lst(d.get(\"packages\"))]"' +need "it writes built by cloud-init to /etc/motd" 'py $UD "$motd_ok"' + +task 2 "autoinstall: db1, dbadmin with a \$6\$ hash, SSH, LVM, postgresql" +need "/root/cloud/autoinstall.yaml exists" test -f $AI +need "it starts with #cloud-config" '[ "$(head -n1 $AI | tr -d "\r ")" = "#cloud-config" ]' +need "cloud-init schema -c accepts it" valid $AI +need "everything is under autoinstall:, with version: 1" 'py $AI "list(d) == [\"autoinstall\"] and d[\"autoinstall\"].get(\"version\") == 1"' +need "the hostname is db1 and the user dbadmin" 'py $AI "d[\"autoinstall\"][\"identity\"][\"hostname\"] == \"db1\" and d[\"autoinstall\"][\"identity\"][\"username\"] == \"dbadmin\""' +need "the password is a \$6\$ hash" 'py $AI "str(d[\"autoinstall\"][\"identity\"][\"password\"]).startswith(\"\$6\$\") and len(str(d[\"autoinstall\"][\"identity\"][\"password\"]).split(\"\$\")) == 4"' +need "SSH is installed, with password logins off" 'py $AI "d[\"autoinstall\"][\"ssh\"][\"install-server\"] is True and d[\"autoinstall\"][\"ssh\"][\"allow-pw\"] is False"' +need "the storage layout is lvm" 'py $AI "d[\"autoinstall\"][\"storage\"][\"layout\"][\"name\"] == \"lvm\""' +need "it installs postgresql" 'py $AI "\"postgresql\" in [str(p) for p in lst(d[\"autoinstall\"].get(\"packages\"))]"' diff --git a/labs/ubuntu-server/2-1-automated-installs/lab.conf b/labs/ubuntu-server/2-1-automated-installs/lab.conf new file mode 100644 index 0000000..375f6fe --- /dev/null +++ b/labs/ubuntu-server/2-1-automated-installs/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.1 PXE, autoinstall and cloud-init" +CHAPTER=/ubuntu-server/2-deploying/2-1-automated-installs/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/2-1-automated-installs/reset.sh b/labs/ubuntu-server/2-1-automated-installs/reset.sh new file mode 100644 index 0000000..04420c9 --- /dev/null +++ b/labs/ubuntu-server/2-1-automated-installs/reset.sh @@ -0,0 +1,3 @@ +rm -f /root/cloud/user-data /root/cloud/autoinstall.yaml +[ -n "$(state_get made-dir)" ] && rm -rf /root/cloud +true diff --git a/labs/ubuntu-server/2-1-automated-installs/setup.sh b/labs/ubuntu-server/2-1-automated-installs/setup.sh new file mode 100644 index 0000000..47604da --- /dev/null +++ b/labs/ubuntu-server/2-1-automated-installs/setup.sh @@ -0,0 +1,6 @@ +command -v cloud-init >/dev/null || refuse "cloud-init isn't installed; install it with: sudo apt install cloud-init" +for f in /root/cloud/user-data /root/cloud/autoinstall.yaml; do + [ -e "$f" ] && refuse "$f already exists; move it away and start again" +done +[ -d /root/cloud ] || { mkdir -p /root/cloud; state_set made-dir 1; } +true diff --git a/labs/ubuntu-server/2-2-meeting-requirements/README.md b/labs/ubuntu-server/2-2-meeting-requirements/README.md new file mode 100644 index 0000000..0cdc274 --- /dev/null +++ b/labs/ubuntu-server/2-2-meeting-requirements/README.md @@ -0,0 +1,37 @@ +# Ubuntu Server 2.2: Checking a system meets requirements + +A script that checks CPUs, memory, disk space and the release, whether +the machine can run VMs, and what already listens on the ports an +application wants. + +## Before you start + +The lab starts two services of its own that listen on ports the next +application wants, as on a server someone else set up. Reset deletes +your `check.sh` again. Write answers to the files in `/root/lab-answers/`. + +## Tasks + +1. Write a script `check.sh` in your home directory that prints PASS or + FAIL for each of: at least 2 CPUs, at least 2 GiB of total memory, at + least 10 GiB free on `/`, and Ubuntu 24.04, one line each, in that + order. Each result must match what `nproc`, `free -g`, `df -h /` and + `/etc/os-release` show by hand. `free -g` rounds down, so a VM given + exactly 2 GiB shows `1` and fails the memory test. +2. Find out whether this machine could host KVM guests itself (write + `yes` or `no` to `/root/lab-answers/2-kvm`), and whether it's a virtual + machine (write what `systemd-detect-virt` prints, such as `kvm` or + `none`, to `/root/lab-answers/2-virt`). +3. Find whether anything already listens on TCP port 80 or 5432, and on + which addresses. Write one line per listener to `/root/lab-answers/3`: + the port, the address and the program's name, such as + `8080 127.0.0.1 python3`. + +## Hints + +- `nproc`, `free -g` (or `MemTotal` in `/proc/meminfo`), `df -BG /` (or + `df --output=avail`), `. /etc/os-release` and `$VERSION_ID`; `[ ... ]`, + `if`, `echo`. +- `lscpu | grep -i virtualization`, `grep -Ec 'vmx|svm' /proc/cpuinfo`; + `systemd-detect-virt`. +- `sudo ss -tlnp`; `0.0.0.0` means every IPv4 address. diff --git a/labs/ubuntu-server/2-2-meeting-requirements/check.sh b/labs/ubuntu-server/2-2-meeting-requirements/check.sh new file mode 100644 index 0000000..4b2ed25 --- /dev/null +++ b/labs/ubuntu-server/2-2-meeting-requirements/check.sh @@ -0,0 +1,44 @@ +# The script: in the student's home, or root's. +script() { + local h u + u=$(state_get student) + [ -n "$u" ] && h=$(getent passwd "$u" | cut -d: -f6) && [ -f "$h/check.sh" ] && { echo "$h/check.sh"; return; } + [ -f /root/check.sh ] && { echo /root/check.sh; return; } + for h in /home/*/check.sh; do [ -f "$h" ] && { echo "$h"; return; }; done + return 1 +} +out() { timeout 30 bash "$(script)" 2>/dev/null | grep -v '^[[:space:]]*$'; } +ok() { if eval "$1"; then echo PASS; else echo FAIL; fi; } +expected() { + ok '[ "$(nproc)" -ge 2 ]' + ok '[ "$(free -g | awk "/^Mem:/ { print \$2 }")" -ge 2 ]' + ok '[ "$(df -BG --output=avail / | tail -n1 | tr -dc 0-9)" -ge 10 ]' + ok '. /etc/os-release && [ "$ID" = ubuntu ] && [ "$VERSION_ID" = 24.04 ]' +} +results() { out | grep -Eo 'PASS|FAIL' ; } +kvm_truth() { if grep -Eqw 'vmx|svm' /proc/cpuinfo; then echo yes; else echo no; fi; } +# has_line PORT ADDRESS PROGRAM: a line of the answer has all three. +has_line() { + awk -v p="$1" -v a="$2" -v n="$3" ' + { l = tolower($0); hp = ha = hn = 0 + for (i = 1; i <= NF; i++) if ($i == p || $i ~ (":" p "$")) hp = 1 + if (index(l, a)) ha = 1 + if (a == "0.0.0.0" && (l ~ /(^|[ :])\*([ :]|$)/ || l ~ /all|any|every/)) ha = 1 + if (index(l, n)) hn = 1 + if (hp && ha && hn) f = 1 } + END { exit !f }' /root/lab-answers/3 +} + +task 1 "check.sh prints four PASS or FAIL lines that match the system" +need "check.sh is in your home directory" script +need "bash ~/check.sh prints four lines" '[ "$(out | wc -l)" -eq 4 ]' +need "each line says PASS or FAIL" '[ "$(results | wc -l)" -eq 4 ]' +need "the results match nproc, free -g, df and /etc/os-release, in that order" '[ "$(results)" = "$(expected)" ]' + +task 2 "KVM support and virtualization found" +need "/root/lab-answers/2-kvm says yes or no, correctly" '[ "$(answer 2-kvm)" = "$(kvm_truth)" ]' +need "/root/lab-answers/2-virt matches systemd-detect-virt" '[ "$(answer 2-virt)" = "$(systemd-detect-virt 2>/dev/null)" ]' + +task 3 "The listeners on ports 80 and 5432 found" +need "/root/lab-answers/3 gives port 80, its address 0.0.0.0, and the program" 'has_line 80 0.0.0.0 intranetd' +need "/root/lab-answers/3 gives port 5432, its address 127.0.0.1, and the program" 'has_line 5432 127.0.0.1 reportdb' diff --git a/labs/ubuntu-server/2-2-meeting-requirements/lab.conf b/labs/ubuntu-server/2-2-meeting-requirements/lab.conf new file mode 100644 index 0000000..feda947 --- /dev/null +++ b/labs/ubuntu-server/2-2-meeting-requirements/lab.conf @@ -0,0 +1,3 @@ +TITLE="2.2 Checking a system meets requirements" +CHAPTER=/ubuntu-server/2-deploying/2-2-meeting-requirements/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/2-2-meeting-requirements/reset.sh b/labs/ubuntu-server/2-2-meeting-requirements/reset.sh new file mode 100644 index 0000000..6ed98fb --- /dev/null +++ b/labs/ubuntu-server/2-2-meeting-requirements/reset.sh @@ -0,0 +1,10 @@ +systemctl disable --now --quiet le-lab-intranetd.service le-lab-reportdb.service 2>/dev/null +rm -f /etc/systemd/system/le-lab-intranetd.service /etc/systemd/system/le-lab-reportdb.service +systemctl daemon-reload +rm -f /usr/local/lib/le-lab/listener.py +rmdir /usr/local/lib/le-lab 2>/dev/null +# Task 1's script. +u=$(state_get student) +h=$(getent passwd "${u:-root}" | cut -d: -f6) +rm -f "$h/check.sh" /root/check.sh +true diff --git a/labs/ubuntu-server/2-2-meeting-requirements/setup.sh b/labs/ubuntu-server/2-2-meeting-requirements/setup.sh new file mode 100644 index 0000000..f147594 --- /dev/null +++ b/labs/ubuntu-server/2-2-meeting-requirements/setup.sh @@ -0,0 +1,42 @@ +for p in 80 5432; do + ss -Hltn "sport = :$p" | grep -q . && refuse "something already listens on port $p; stop it and start again" +done +u=${SUDO_USER:-root} +h=$(getent passwd "$u" | cut -d: -f6) +for f in "$h/check.sh" /root/check.sh; do + [ -e "$f" ] && refuse "$f already exists (task 1 writes it); move it away and start again" +done +state_set student "$u" +mkdir -p /usr/local/lib/le-lab +cat >/usr/local/lib/le-lab/listener.py <<'X' +# A TCP listener with its own program name: listener.py NAME ADDRESS PORT. +import socket, sys + +name, addr, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) +with open("/proc/self/comm", "w") as f: + f.write(name) +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind((addr, port)) +s.listen(16) +while True: + c, _ = s.accept() + c.close() +X +mk() { + cat >/etc/systemd/system/le-lab-$1.service </dev/null) +U=$(state_get student) +H=$(getent passwd "$U" | cut -d: -f6) +SSHOPT=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR) +# login USER [ssh options]: you log in as USER with your own key. +login() { local t=$1; shift; timeout 15 runuser -u "$U" -- ssh "${SSHOPT[@]}" "$@" "$t@localhost" 'echo le-shell-ok' /dev/null | grep -q le-shell-ok; } +# has_key USER: USER's authorized_keys holds one of your public keys. +has_key() { + local h k f + h=$(getent passwd "$1" | cut -d: -f6) + f=$h/.ssh/authorized_keys + [ -f "$f" ] || return 1 + for k in "$H"/.ssh/id_*.pub; do + [ -f "$k" ] || continue + grep -qF "$(awk '{ print $2 }' "$k")" "$f" && return 0 + done + return 1 +} +has_password() { [ "$(passwd -S "$1" 2>/dev/null | awk '{ print $2 }')" = P ]; } + +task 1 "ann and ben with keys; only group remote, with ann and you, logs in" +need "users ann and ben exist" 'id ann && id ben' +need "ann and ben have passwords" 'has_password ann && has_password ben' +need "ann's and ben's authorized_keys hold your key" 'has_key ann && has_key ben' +need "sshd -T shows allowgroups remote" 'grep -Eq "^allowgroups( .*)? remote( |$)" <<<"$T"' +need "ann and your account are in group remote" 'id -nG ann | grep -qw remote && id -nG "$U" | grep -qw remote' +need "ben isn't in group remote" '! id -nG ben | grep -qw remote' +need "ssh -o BatchMode=yes ann@localhost works with your key" 'login ann' +need "the same for ben fails" '! login ben' + +task 2 "No password logins and no root logins, from a drop-in that wins" +need "sshd -T shows passwordauthentication no" 'grep -qx "passwordauthentication no" <<<"$T"' +need "sshd -T shows permitrootlogin no" 'grep -qx "permitrootlogin no" <<<"$T"' +need "a file in /etc/ssh/sshd_config.d/ sets PasswordAuthentication no" 'grep -Eiqs "^[[:space:]]*PasswordAuthentication[[:space:]]+no" /etc/ssh/sshd_config.d/*.conf' +need "ssh without a key gets Permission denied (publickey)" 'timeout 15 runuser -u "$U" -- ssh "${SSHOPT[@]}" -o PubkeyAuthentication=no ann@localhost true 2>&1 /dev/null || rm -f "$f" +done +rmdir /etc/systemd/system/ssh.socket.d 2>/dev/null +systemctl daemon-reload +# Restarting the socket restarts sshd with the old settings too. Restarting +# ssh.service as well, or too often, trips its start limit and leaves +# nothing on port 22, so clear the counter and check. +for i in 1 2 3; do + systemctl reset-failed ssh.service ssh.socket 2>/dev/null + systemctl restart ssh.socket 2>/dev/null + sleep 2 + ss -Hltn 'sport = :22' | grep -q . && ! systemctl is-failed --quiet ssh.service && break +done +for x in ann ben; do + pkill -KILL -u "$x" 2>/dev/null + userdel -r "$x" 2>/dev/null +done +groupdel remote 2>/dev/null +true diff --git a/labs/ubuntu-server/3-1-remote-ssh/setup.sh b/labs/ubuntu-server/3-1-remote-ssh/setup.sh new file mode 100644 index 0000000..6d4bbc2 --- /dev/null +++ b/labs/ubuntu-server/3-1-remote-ssh/setup.sh @@ -0,0 +1,7 @@ +u=${SUDO_USER:-} +[ -n "$u" ] && [ "$u" != root ] || refuse "start this lab with sudo from your own account (task 1 lets only group remote log in, and your account must be in it)" +for x in ann ben; do id "$x" >/dev/null 2>&1 && refuse "a user $x already exists; remove it and start again"; done +getent group remote >/dev/null && refuse "a group remote already exists; remove it and start again" +state_set student "$u" +tar -C /etc -cf "$LAB_STATE/ssh.tar" ssh +ls /etc/systemd/system/ssh.socket.d >"$LAB_STATE/socket-d" 2>/dev/null || true diff --git a/labs/ubuntu-server/3-2-regular-and-system-users/README.md b/labs/ubuntu-server/3-2-regular-and-system-users/README.md new file mode 100644 index 0000000..1a75125 --- /dev/null +++ b/labs/ubuntu-server/3-2-regular-and-system-users/README.md @@ -0,0 +1,34 @@ +# Ubuntu Server 3.2: Regular and system users + +Telling login accounts from system accounts, a system user for a service, +and a service that runs as it. + +## Before you start + +The lab adds a few accounts so the lists in task 1 have something to tell +apart: `maria` (a regular user with a shell), `kiosk` (a regular UID with +no login shell) and `buildbot` (a system UID with a shell). Write answers +to the files in `/root/lab-answers/`, one account name per line. + +## Tasks + +1. List every account that can log in with a shell, into + `/root/lab-answers/1-shells`, and every account with a UID of 1000 or + more, into `/root/lab-answers/1-uids`. +2. Create a system user `reportsvc` with its own group, a data directory + `/var/lib/reportsvc` that only it can write to, and no login. + `sudo -u reportsvc touch /var/lib/reportsvc/ok` works and + `sudo -u reportsvc touch /etc/ok` fails. +3. Run a `python3 -m http.server 8080` service as `reportsvc` with a + systemd unit, started at boot. `curl -s localhost:8080 | head -1` + prints HTML. + +## Hints + +- `/etc/passwd`: the UID is field 3 and the shell field 7. + `grep -v -E 'nologin|false|sync$' /etc/passwd`, `awk -F: '$3>=1000'`, + `cut -d: -f1`. +- `useradd --system --user-group --shell /usr/sbin/nologin`, `id`, + `getent passwd`; `install -d -o -g -m 755`. +- `User=`, `WorkingDirectory=` in a unit in `/etc/systemd/system/`; + `systemctl enable --now`; `ps -o user= -C python3`. diff --git a/labs/ubuntu-server/3-2-regular-and-system-users/check.sh b/labs/ubuntu-server/3-2-regular-and-system-users/check.sh new file mode 100644 index 0000000..572b152 --- /dev/null +++ b/labs/ubuntu-server/3-2-regular-and-system-users/check.sh @@ -0,0 +1,38 @@ +# names FILE: the account names in FILE, sorted, one per line. +names() { tr -s ' ,\t' '\n' <"$1" 2>/dev/null | sed 's/:.*//' | grep -v '^$' | sort -u; } +shells() { grep -v -E 'nologin|false|sync$' /etc/passwd | cut -d: -f1 | sort -u; } +uids() { awk -F: '$3 >= 1000 { print $1 }' /etc/passwd | sort -u; } +# same_set FILE CMD: FILE names the same accounts as CMD prints (sync is +# optional, since its shell is /bin/sync). +same_set() { [ -s "$1" ] && [ "$(names "$1" | grep -vx sync)" = "$($2 | grep -vx sync)" ]; } +# The service: the process listening on 8080. +port_pid() { ss -Hltnp 'sport = :8080' | grep -o 'pid=[0-9]*' | head -n1 | cut -d= -f2; } +web_up() { + local i + for i in $(seq 20); do + curl -s --max-time 2 http://localhost:8080/ | grep -qi '/dev/null && continue + systemctl disable --now --quiet "$n" 2>/dev/null + rm -rf "$f" "$f.d" +done +systemctl daemon-reload +pkill -KILL -u reportsvc 2>/dev/null +userdel -r reportsvc 2>/dev/null +groupdel reportsvc 2>/dev/null +rm -rf /var/lib/reportsvc +true diff --git a/labs/ubuntu-server/3-2-regular-and-system-users/setup.sh b/labs/ubuntu-server/3-2-regular-and-system-users/setup.sh new file mode 100644 index 0000000..2cd5e0e --- /dev/null +++ b/labs/ubuntu-server/3-2-regular-and-system-users/setup.sh @@ -0,0 +1,7 @@ +id reportsvc >/dev/null 2>&1 && refuse "a user reportsvc already exists; remove it and start again" +[ -e /var/lib/reportsvc ] && refuse "/var/lib/reportsvc already exists; move it away and start again" +ss -Hltn 'sport = :8080' | grep -q . && refuse "something already listens on port 8080; stop it and start again" +ls /etc/systemd/system >"$LAB_STATE/units-before" +user_add maria +user_add kiosk -s /usr/sbin/nologin +user_add buildbot -r -s /bin/bash diff --git a/labs/ubuntu-server/3-3-firewalls/README.md b/labs/ubuntu-server/3-3-firewalls/README.md new file mode 100644 index 0000000..fca6c11 --- /dev/null +++ b/labs/ubuntu-server/3-3-firewalls/README.md @@ -0,0 +1,54 @@ +# Ubuntu Server 3.3: Firewalls: ufw and iptables + +ufw with a default policy, a rule for one address and a rate limit, and a +plain iptables firewall that lasts over a reboot. + +## Before you start + +This VM is s1. The peer, at `10.99.0.2` (name `peer`), is s2: it sits on +this VM's second network, `10.99.0.0/24`, where this VM is `10.99.0.1` on +interface `le-host`. It has a second address, `10.99.0.3`, which stands in +for "any other machine". Get a shell there with `sudo ip netns exec lepeer +bash`; from it, `curl --interface 10.99.0.3` sends from the second +address. The peer runs its own sshd and a test web server on +`10.99.0.2:8000`, and has no ufw. + +On this VM the lab runs a web server on port 80. It saves ufw's rules and +turns ufw off, so you start from scratch; reset puts them back. Keep a +console open: a firewall mistake can cut off SSH. + +The peer is a network namespace, rebuilt at every boot, so its rules can't +be kept by `iptables-persistent` (which would also replace ufw on this +VM). Instead, save them to `/etc/netns/lepeer/iptables.rules`; the lab's +`le-lab-peer-iptables.service`, once enabled, loads that file into the +peer at boot. + +## Tasks + +1. On s1, with ufw: deny incoming by default, allow SSH from anywhere, and + allow HTTP only from s2's address. Enable it. `sudo ufw status verbose` + shows `deny (incoming)` and the two rules; `curl http://10.99.0.1/` + works from the peer and times out from `10.99.0.3`; your SSH session + is still alive. +2. Remove the HTTP rule by its number, then rate-limit SSH. `sudo ufw + status numbered` no longer lists port 80 and shows `LIMIT IN` for SSH; + seven quick `nc -z 10.99.0.1 22` from the peer leave `[UFW LIMIT + BLOCK]` lines in `/var/log/ufw.log`. +3. On s2, without ufw, use iptables to allow only loopback traffic, SSH, + ping and replies to its own connections, dropping everything else, and + keep the rules over a reboot. After a reboot, `sudo ip netns exec lepeer + iptables -S INPUT` shows `-P INPUT DROP` and your rules, and `ping -c1 + 10.99.0.2` from s1 works. + +## Hints + +- `ufw default deny incoming`, `ufw allow OpenSSH`, `ufw allow from ADDRESS + to any port 80 proto tcp`, `ufw enable`, `ufw status verbose`. +- `ufw status numbered`, `ufw delete N`, `ufw limit OpenSSH`; + `for i in $(seq 7); do nc -z -w1 10.99.0.1 22; done`, `grep 'LIMIT + BLOCK' /var/log/ufw.log`. +- `iptables -A INPUT -i lo -j ACCEPT`, `-m conntrack --ctstate + ESTABLISHED,RELATED`, `-p tcp --dport 22`, `-p icmp`, `iptables -P INPUT + DROP`, all run in the peer. `sudo ip netns exec lepeer iptables-save | + sudo tee /etc/netns/lepeer/iptables.rules`; `systemctl enable + le-lab-peer-iptables`. diff --git a/labs/ubuntu-server/3-3-firewalls/check.sh b/labs/ubuntu-server/3-3-firewalls/check.sh new file mode 100644 index 0000000..63e8b9a --- /dev/null +++ b/labs/ubuntu-server/3-3-firewalls/check.sh @@ -0,0 +1,38 @@ +# The peer's second address, in case something took it away. +ip -n lepeer addr add 10.99.0.3/24 dev le-peer 2>/dev/null +# from ADDRESS: curl's exit code for s1's web server from the peer's +# ADDRESS (0 works, 28 timed out). +from() { in_peer curl -s -o /dev/null --max-time 4 --interface "$1" http://10.99.0.1/; echo $?; } +S=$(ufw status 2>/dev/null) +# ufw.log since the lab started. +newlog() { + local n; n=$(state_get ufwlog-size); n=${n:-0} + [ "$(stat -c %s /var/log/ufw.log 2>/dev/null || echo 0)" -ge "$n" ] || n=0 + tail -c +$((n + 1)) /var/log/ufw.log 2>/dev/null +} +P=$(in_peer iptables -S INPUT 2>/dev/null) + +task 1 "ufw on, denying incoming, allowing SSH from anywhere" +need "ufw is active" 'grep -q "Status: active" <<<"$S"' +need "ufw starts at boot" 'grep -Eq "^ENABLED=yes" /etc/ufw/ufw.conf' +need "incoming traffic is denied by default" 'ufw status verbose | grep -Eq "Default: (deny|reject) \(incoming\)"' +need "SSH is allowed from anywhere" 'grep -Eq "^(22(/tcp)?|OpenSSH)( \(OpenSSH\))?[[:space:]]+(ALLOW|LIMIT)( IN)?[[:space:]]+Anywhere" <<<"$S"' +need "HTTP from 10.99.0.3 (any other machine) times out" '[ "$(from 10.99.0.3)" = 28 ]' + +task 2 "No HTTP rule; SSH rate-limited, and the limit seen in ufw.log" +need "ufw is active" 'grep -q "Status: active" <<<"$S"' +need "ufw has no rule for port 80" '! grep -Eq "^(80|80/tcp|WWW|Apache|Nginx)[[:space:]( ]" <<<"$S"' +need "SSH is LIMIT" 'grep -Eq "^(22(/tcp)?|OpenSSH)( \(OpenSSH\))?[[:space:]]+LIMIT" <<<"$S"' +need "HTTP from the peer now times out" '[ "$(from 10.99.0.2)" = 28 ]' +need "/var/log/ufw.log has [UFW LIMIT BLOCK] lines for the peer's SSH" 'newlog | grep "\[UFW LIMIT BLOCK\]" | grep "SRC=10\.99\.0\.2 " | grep -q "DPT=22 "' + +task 3 "The peer's iptables: loopback, SSH, ping and replies only, at boot" +need "/etc/netns/lepeer/iptables.rules holds iptables rules" 'in_peer iptables-restore --test /etc/netns/lepeer/iptables.rules' +need "le-lab-peer-iptables.service is enabled" enabled le-lab-peer-iptables.service +need "the peer's INPUT policy is DROP" 'grep -qx -- "-P INPUT DROP" <<<"$P"' +need "the peer drops its test web server's port 8000" '[ "$(curl -s -o /dev/null --max-time 4 http://10.99.0.2:8000/; echo $?)" = 28 ]' +need "ping -c1 10.99.0.2 works" 'ping -c1 -W2 10.99.0.2' +need "the peer answers SSH" 'timeout 8 ssh-keyscan -T 5 10.99.0.2 2>/dev/null | grep -q .' +need "the peer reaches its own services over loopback" 'in_peer curl -s --max-time 3 http://10.99.0.2:8000/ | grep -q "Peer test page"' +# From the second address: task 2's rate limit counts per address. +need "the peer gets replies to its own connections" 'in_peer nc -z -w 4 -s 10.99.0.3 10.99.0.1 22' diff --git a/labs/ubuntu-server/3-3-firewalls/lab.conf b/labs/ubuntu-server/3-3-firewalls/lab.conf new file mode 100644 index 0000000..04c38fa --- /dev/null +++ b/labs/ubuntu-server/3-3-firewalls/lab.conf @@ -0,0 +1,3 @@ +TITLE="3.3 Firewalls: ufw and iptables" +CHAPTER=/ubuntu-server/3-securing-access/3-3-firewalls/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/3-3-firewalls/reset.sh b/labs/ubuntu-server/3-3-firewalls/reset.sh new file mode 100644 index 0000000..89dd3ef --- /dev/null +++ b/labs/ubuntu-server/3-3-firewalls/reset.sh @@ -0,0 +1,17 @@ +for u in le-lab-web le-lab-peer-sshd le-lab-peer-web le-lab-peer-iptables; do + systemctl disable --now --quiet "$u.service" 2>/dev/null + rm -rf "/etc/systemd/system/$u.service" "/etc/systemd/system/$u.service.d" +done +systemctl daemon-reload +rm -rf /srv/le-web /srv/le-peer-web /usr/local/lib/le-lab/httpd.py /etc/netns/lepeer/iptables.rules +rmdir /usr/local/lib/le-lab /etc/netns/lepeer /etc/netns 2>/dev/null +# ufw as it was. +ufw --force disable >/dev/null 2>&1 +if [ -f "$LAB_STATE/ufw.tar" ]; then + rm -rf /etc/ufw + tar -C /etc -xf "$LAB_STATE/ufw.tar" +fi +if [ -n "$(state_get ufw-was-active)" ]; then + ufw --force enable >/dev/null +fi +true diff --git a/labs/ubuntu-server/3-3-firewalls/setup.sh b/labs/ubuntu-server/3-3-firewalls/setup.sh new file mode 100644 index 0000000..8aa2f75 --- /dev/null +++ b/labs/ubuntu-server/3-3-firewalls/setup.sh @@ -0,0 +1,98 @@ +command -v ufw >/dev/null || refuse "ufw isn't installed; install it with: sudo apt install ufw" +pkg_installed iptables-persistent && refuse "iptables-persistent is installed, which replaces ufw; remove it (sudo apt purge iptables-persistent), install ufw, and start again" +ss -Hltn 'sport = :80' | grep -q . && refuse "something already listens on port 80; stop it and start again" +pkg_installed netcat-openbsd || pkg_install netcat-openbsd +# ufw's rules, saved for reset; then start with ufw off and no rules. +ufw status | grep -q 'Status: active' && state_set ufw-was-active 1 +tar -C /etc -cf "$LAB_STATE/ufw.tar" ufw default/ufw +ufw --force reset >/dev/null +ufw --force disable >/dev/null +find /etc/ufw -maxdepth 1 -name '*.rules.[0-9]*' -delete +state_set ufwlog-size "$(stat -c %s /var/log/ufw.log 2>/dev/null || echo 0)" + +peer_up +ip -n lepeer addr add 10.99.0.3/24 dev le-peer 2>/dev/null || true +mkdir -p /srv/le-web /srv/le-peer-web /usr/local/lib/le-lab /etc/netns/lepeer +echo 'Lab web server on s1' >/srv/le-web/index.html +echo 'Peer test page' >/srv/le-peer-web/index.html +cat >/usr/local/lib/le-lab/httpd.py <<'X' +# A static web server like "python3 -m http.server PORT --bind ADDRESS +# --directory DIR", without its reverse DNS lookup of the address, which +# can take seconds. +import argparse, functools, http.server, socketserver + +ap = argparse.ArgumentParser() +ap.add_argument("port", type=int) +ap.add_argument("--bind", default="") +ap.add_argument("--directory", default=".") +a = ap.parse_args() + + +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + +handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=a.directory) +Server((a.bind, a.port), handler).serve_forever() +X +cat >/etc/systemd/system/le-lab-web.service <<'X' +[Unit] +Description=Practice lab web server on port 80 +After=network.target + +[Service] +ExecStart=/usr/bin/python3 /usr/local/lib/le-lab/httpd.py 80 --directory /srv/le-web + +[Install] +WantedBy=multi-user.target +X +cat >/etc/systemd/system/le-lab-peer-sshd.service <<'X' +[Unit] +Description=Practice lab sshd on the peer +Requires=le-lab-peer.service +After=le-lab-peer.service network.target + +[Service] +NetworkNamespacePath=/run/netns/lepeer +ExecStartPre=/bin/mkdir -p /run/sshd +ExecStart=/usr/sbin/sshd -D -o ListenAddress=10.99.0.2:22 -o PidFile=none +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target +X +cat >/etc/systemd/system/le-lab-peer-web.service <<'X' +[Unit] +Description=Practice lab test web server on the peer +Requires=le-lab-peer.service +After=le-lab-peer.service + +[Service] +NetworkNamespacePath=/run/netns/lepeer +ExecStartPre=-/usr/sbin/ip addr add 10.99.0.3/24 dev le-peer +ExecStart=/usr/bin/python3 /usr/local/lib/le-lab/httpd.py 8000 --bind 10.99.0.2 --directory /srv/le-peer-web + +[Install] +WantedBy=multi-user.target +X +cat >/etc/systemd/system/le-lab-peer-iptables.service <<'X' +[Unit] +Description=Practice lab: the peer's iptables rules +Requires=le-lab-peer.service +After=le-lab-peer.service +ConditionPathExists=/etc/netns/lepeer/iptables.rules + +[Service] +Type=oneshot +RemainAfterExit=yes +NetworkNamespacePath=/run/netns/lepeer +ExecStart=/usr/sbin/iptables-restore /etc/netns/lepeer/iptables.rules + +[Install] +WantedBy=multi-user.target +X +systemctl daemon-reload +systemctl enable --now --quiet le-lab-web.service le-lab-peer-sshd.service le-lab-peer-web.service diff --git a/labs/ubuntu-server/4-1-processes-and-services/README.md b/labs/ubuntu-server/4-1-processes-and-services/README.md new file mode 100644 index 0000000..e1fd560 --- /dev/null +++ b/labs/ubuntu-server/4-1-processes-and-services/README.md @@ -0,0 +1,33 @@ +# Ubuntu Server 4.1: Processes and services + +A service's processes and who they run as, niceness, and signals. + +## Before you start + +The lab doesn't install anything: installing nginx is part of task 1. Do +task 2 as your own user, not root, and check before the `sleep` ends, +about 16 minutes after you start it. Nothing here lasts over a reboot, so +don't reboot before you check. Write answers to the files in +`/root/lab-answers/`. + +## Tasks + +1. Install nginx. Find its master process and its workers, the user each + runs as, and the unit they belong to. Write the master's user to + `/root/lab-answers/1-master`, the workers' user to + `/root/lab-answers/1-workers`, and the unit to `/root/lab-answers/1-unit`. +2. As your own user, start `sleep 1000` in the background with niceness 5, + then change it to 12, then try to set it back to 5 without sudo (that + fails with "Permission denied"). +3. Make nginx reread its configuration with a signal rather than + `systemctl`: the master keeps its PID and the workers get new ones. Then + stop it so that it stays stopped. + +## Hints + +- `ps -C nginx -o pid,ppid,user,unit,cmd`, `pgrep -a nginx`, + `systemctl status nginx`. +- `nice -n 5 sleep 1000 &`, `renice -n 12 -p PID`, `ps -o ni= -p PID`. +- `sudo kill -HUP` of the master PID (`systemctl show -p MainPID nginx`), + then `pgrep -a nginx`; `sudo systemctl stop nginx`, `systemctl + is-active nginx`. diff --git a/labs/ubuntu-server/4-1-processes-and-services/check.sh b/labs/ubuntu-server/4-1-processes-and-services/check.sh new file mode 100644 index 0000000..52daccc --- /dev/null +++ b/labs/ubuntu-server/4-1-processes-and-services/check.sh @@ -0,0 +1,17 @@ +# PIDs of "sleep 1000" processes that aren't root's, with niceness 12. +sleep12() { ps -eo pid=,ni=,user=,args= | awk '$2 == 12 && $3 != "root" && $4 == "sleep" && $5 == "1000" && NF == 5 { print $1 }'; } + +task 1 "nginx's master, workers and unit found" +need "nginx is installed" pkg_installed nginx +need "/root/lab-answers/1-master names the master's user, root" '[ "$(answer 1-master)" = root ]' +need "/root/lab-answers/1-workers names the workers' user, www-data" '[ "$(answer 1-workers)" = www-data ]' +need "/root/lab-answers/1-unit names the unit, nginx.service" 'case $(answer 1-unit) in nginx|nginx.service) true ;; *) false ;; esac' + +task 2 "sleep 1000 of yours, at niceness 12" +need "a sleep 1000 runs as a user other than root" 'ps -eo user=,args= | awk "\$1 != \"root\" && \$2 == \"sleep\" && \$3 == \"1000\"" | grep -q .' +need "its niceness is 12" '[ -n "$(sleep12)" ]' + +task 3 "nginx reloaded by a signal, then stopped" +need "nginx is installed" pkg_installed nginx +need "nginx is stopped (systemctl is-active says inactive)" '[ "$(systemctl is-active nginx)" = inactive ]' +need "no nginx processes are left" '! pgrep -x nginx' diff --git a/labs/ubuntu-server/4-1-processes-and-services/lab.conf b/labs/ubuntu-server/4-1-processes-and-services/lab.conf new file mode 100644 index 0000000..ea8caa7 --- /dev/null +++ b/labs/ubuntu-server/4-1-processes-and-services/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.1 Processes and services" +CHAPTER=/ubuntu-server/4-processes/4-1-processes-and-services/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/4-1-processes-and-services/reset.sh b/labs/ubuntu-server/4-1-processes-and-services/reset.sh new file mode 100644 index 0000000..093dce1 --- /dev/null +++ b/labs/ubuntu-server/4-1-processes-and-services/reset.sh @@ -0,0 +1,17 @@ +# The sleep from task 2. +ps -eo pid=,user=,args= | awk '$2 != "root" && $3 == "sleep" && $4 == "1000" && NF == 4 { print $1 }' | xargs -r kill 2>/dev/null +# The packages installed since the lab started, and only those. +if [ -s "$LAB_STATE/pkgs-before" ]; then + new=$(dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u | + comm -13 "$LAB_STATE/pkgs-before" - | grep -Ev '^linux-') + if [ -n "$new" ]; then + say "Removing the packages installed since the lab started..." + DEBIAN_FRONTEND=noninteractive apt-get -y -qq purge $new >/dev/null 2>&1 + fi +fi +if [ -z "$(state_get had-nginx)" ]; then + rm -rf /etc/nginx /var/log/nginx /var/www/html/index.nginx-debian.html +elif [ -n "$(state_get nginx-was-active)" ]; then + systemctl start nginx 2>/dev/null +fi +true diff --git a/labs/ubuntu-server/4-1-processes-and-services/setup.sh b/labs/ubuntu-server/4-1-processes-and-services/setup.sh new file mode 100644 index 0000000..40a8955 --- /dev/null +++ b/labs/ubuntu-server/4-1-processes-and-services/setup.sh @@ -0,0 +1,5 @@ +# Reset removes the packages installed from here on, and only those. +dpkg-query -W -f '${db:Status-Abbrev} ${Package}\n' | awk '$1 == "ii" { print $2 }' | sort -u >"$LAB_STATE/pkgs-before" +pkg_installed nginx && state_set had-nginx 1 +active nginx && state_set nginx-was-active 1 +true diff --git a/labs/ubuntu-server/4-2-systemd-units/README.md b/labs/ubuntu-server/4-2-systemd-units/README.md new file mode 100644 index 0000000..c6fc184 --- /dev/null +++ b/labs/ubuntu-server/4-2-systemd-units/README.md @@ -0,0 +1,33 @@ +# Ubuntu Server 4.2: Listing systemd units + +Counting and listing units by state, finding failed services, and masking. + +## Before you start + +Like a server you don't know, this VM has a problem waiting: the lab adds +a service that fails at every boot. It doesn't tell you its name. Write +answers to the files in `/root/lab-answers/`. + +## Tasks + +1. Count the services that are running, and write the number to + `/root/lab-answers/1`. List the services that are installed but + disabled, one per line, in `/root/lab-answers/1-disabled`. +2. Write a service whose `ExecStart=` points at a program that doesn't + exist, start it, and find it the way you would on a server you don't + know. You'll find the lab's failed service too: write its name and the + status code `systemctl status` shows for it (such as `1/FAILURE`) to + `/root/lab-answers/2`. Then delete both units, run `daemon-reload` and + `reset-failed`, so that `systemctl --failed` lists `0 loaded units`. +3. Mask `apport`, then list every masked unit into + `/root/lab-answers/3`. Unmask it afterwards, so `systemctl is-enabled + apport` prints `enabled` again. + +## Hints + +- `systemctl list-units --type=service --state=running --no-legend`, + `wc -l`; `systemctl list-unit-files --type=service --state=disabled`. +- `systemctl --failed`, `systemctl status NAME`, `systemctl cat NAME`, + `journalctl -u NAME`; `systemctl reset-failed`. +- `systemctl mask`, `systemctl list-unit-files --state=masked`, + `systemctl unmask`. diff --git a/labs/ubuntu-server/4-2-systemd-units/check.sh b/labs/ubuntu-server/4-2-systemd-units/check.sh new file mode 100644 index 0000000..97e16d2 --- /dev/null +++ b/labs/ubuntu-server/4-2-systemd-units/check.sh @@ -0,0 +1,22 @@ +running() { systemctl list-units --type=service --state=running --no-legend | wc -l; } +disabled() { systemctl list-unit-files --type=service --state=disabled --no-legend | awk '{ print $1 }'; } +# How many of the disabled services the answer leaves out. +missing() { disabled | grep -vxFf <(tr -s ' \t' '\n' /dev/null)" != masked ]' +need "systemctl is-enabled apport prints enabled" '[ "$(systemctl is-enabled apport 2>/dev/null)" = enabled ]' diff --git a/labs/ubuntu-server/4-2-systemd-units/lab.conf b/labs/ubuntu-server/4-2-systemd-units/lab.conf new file mode 100644 index 0000000..fa33543 --- /dev/null +++ b/labs/ubuntu-server/4-2-systemd-units/lab.conf @@ -0,0 +1,3 @@ +TITLE="4.2 Listing systemd units" +CHAPTER=/ubuntu-server/4-processes/4-2-systemd-units/ +SYSTEMS="debian" diff --git a/labs/ubuntu-server/4-2-systemd-units/reset.sh b/labs/ubuntu-server/4-2-systemd-units/reset.sh new file mode 100644 index 0000000..d4ec627 --- /dev/null +++ b/labs/ubuntu-server/4-2-systemd-units/reset.sh @@ -0,0 +1,17 @@ +# The lab's service, and any service made since the lab started. +for f in /etc/systemd/system/*.service; do + [ -e "$f" ] || [ -L "$f" ] || continue + n=${f##*/} + case $n in le-lab-*) continue ;; esac + grep -qxF "$n" "$LAB_STATE/units-before" 2>/dev/null && continue + # A mask is a link to /dev/null; unmask apport below. + [ "$(readlink "$f")" = /dev/null ] && continue + systemctl disable --now --quiet "$n" 2>/dev/null + rm -rf "$f" "$f.d" +done +systemctl disable --quiet inventory-sync.service 2>/dev/null +rm -f /etc/systemd/system/inventory-sync.service +[ "$(systemctl is-enabled apport 2>/dev/null)" = masked ] && systemctl unmask --quiet apport +systemctl daemon-reload +systemctl reset-failed +true diff --git a/labs/ubuntu-server/4-2-systemd-units/setup.sh b/labs/ubuntu-server/4-2-systemd-units/setup.sh new file mode 100644 index 0000000..bda4dd3 --- /dev/null +++ b/labs/ubuntu-server/4-2-systemd-units/setup.sh @@ -0,0 +1,19 @@ +pkg_installed apport || refuse "apport isn't installed (task 3 masks it); install it with: sudo apt install apport" +[ "$(systemctl is-enabled apport 2>/dev/null)" = enabled ] || refuse "apport.service isn't enabled; run: sudo systemctl unmask apport; sudo systemctl enable apport" +[ -e /etc/systemd/system/inventory-sync.service ] && refuse "/etc/systemd/system/inventory-sync.service already exists; move it away and start again" +ls /etc/systemd/system >"$LAB_STATE/units-before" +cat >/etc/systemd/system/inventory-sync.service <<'X' +[Unit] +Description=Inventory sync agent +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/opt/inventory/bin/inventory-sync --daemon + +[Install] +WantedBy=multi-user.target +X +systemctl daemon-reload +systemctl enable --quiet inventory-sync.service +systemctl start inventory-sync.service 2>/dev/null || true diff --git a/lib/lab.sh b/lib/lab.sh index b255b71..a08de77 100644 --- a/lib/lab.sh +++ b/lib/lab.sh @@ -217,6 +217,26 @@ hosts_add() { hosts_remove() { sed -i '/^# le-lab begin/,/^# le-lab end/d' /etc/hosts; } +# ---- sshd ------------------------------------------------------------------- + +# sshd_restart: restart sshd with its current settings, for resets that put +# its configuration back. On Ubuntu that means restarting ssh.socket, which +# restarts ssh.service too; restarting both, or doing it too often, trips the +# service's start limit and leaves nothing on port 22. So clear the counters, +# restart once, and check that port 22 answers, up to three times. +sshd_restart() { + local unit=sshd i + is_rhel || unit=ssh.socket + for i in 1 2 3; do + systemctl reset-failed "$unit" ssh.service 2>/dev/null + systemctl restart "$unit" 2>/dev/null + sleep 2 + listening 22 tcp && ! systemctl is-failed --quiet "$(pick sshd ssh.service)" && return 0 + done + warn "sshd isn't listening on port 22; check 'systemctl status $unit'" + return 1 +} + # ---- users ------------------------------------------------------------------ # user_add NAME [useradd options]: create a lab user if missing, with bash