From 62c4bec0a7cba2a78da2f53300c1cb02db84a329 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 19 Nov 2022 22:20:56 +0100 Subject: [PATCH] Add custom system-wide scripts --- root/usr/local/bin/auto-chroot | 81 +++++++ root/usr/local/bin/cron-notify | 11 + root/usr/local/bin/incremental-backup | 37 ++++ root/usr/local/bin/tamper-check | 292 ++++++++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100755 root/usr/local/bin/auto-chroot create mode 100755 root/usr/local/bin/cron-notify create mode 100755 root/usr/local/bin/incremental-backup create mode 100755 root/usr/local/bin/tamper-check diff --git a/root/usr/local/bin/auto-chroot b/root/usr/local/bin/auto-chroot new file mode 100755 index 0000000..0e875ae --- /dev/null +++ b/root/usr/local/bin/auto-chroot @@ -0,0 +1,81 @@ +#!/bin/sh + +yes_no() { + while true; do + printf "$1 (y/n): " + read -r yn + case $yn in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no";; + esac + done +} + +# Ensure we run as root +if [ "$EUID" -ne 0 ]; then + echo "Must be ran as root" + exit 1 +fi + +# Take NEWROOT as 1st argument +if [ $# -ge 1 ]; then + NEWROOT="$1" +else + echo "Provide newroot directory" + exit 1 +fi + +# Take chroot user as 2nd argument, default to root +if [ $# -ge 2 ]; then + USERNAME="$2" +else + USERNAME="root" +fi + +# Check if given NEWROOT is already mounted, if it is +# set REMOUNT to the mount source, so that we can remount +# it once we're done. +df_out=$(df --output=source,target | grep -w "$NEWROOT") +if [ -n "$df_out" ]; then + REMOUNT="$(echo $df_out | awk '{print $1}')" +else + # If the target isn't mounted already, check + # if user gave $3 (mount location) + if [ $# -ge 3 ]; then + mount "$3" "$NEWROOT" + else + # If user didn't give mount location, try to + # mount according to fstab + if [ -n "$(grep -w "$NEWROOT" /etc/fstab)" ]; then + mount "$NEWROOT" + else + # Ask for user confirmation to ensure that filesystem + # is ready for chroot in given NEWROOT, exit if not + yes_no "$NEWROOT wasn't mounted, is your filesystem in place?" || exit 1 + fi + fi +fi + +# Mount necessary directories for chroot to be possible +mount --types proc /proc "$NEWROOT/proc" +mount --rbind /sys "$NEWROOT/sys" +mount --make-rslave "$NEWROOT/sys" +mount --rbind /dev "$NEWROOT/dev" +mount --make-rslave "$NEWROOT/dev" + +# Use /bin/su for chrooting with --login to also run +# /etc/profile and ~/.profile or ~/.zprofile +chroot "$NEWROOT" "/bin/su" "$USERNAME" --login + +# Unmount recursively mounted directories +umount -l "$NEWROOT/dev" +umount -l "$NEWROOT/sys" +umount -l "$NEWROOT/proc" +umount -R "$NEWROOT" + +# Remount partition according to fstab if REMOUT is set +# in order to leave the filesystem in the state it was +if [ -n "$REMOUNT" ]; then + mount "$REMOUNT" "$NEWROOT" +fi diff --git a/root/usr/local/bin/cron-notify b/root/usr/local/bin/cron-notify new file mode 100755 index 0000000..6e352df --- /dev/null +++ b/root/usr/local/bin/cron-notify @@ -0,0 +1,11 @@ +#!/bin/sh +# Crontab requires DISPLAY and XDG_RUNTIME_HOME +# to be set when running notify-send, this script +# makes defines those to make it eaiser to send +# notifications from crontab without cluttering it +# It sets "Cron Notification" title, rest of the +# arguments are passed to notfiy-send + +XDG_RUNTIME_DIR="/run/user/$(id -u)" \ +DISPLAY=:0 \ +notify-send "$@" diff --git a/root/usr/local/bin/incremental-backup b/root/usr/local/bin/incremental-backup new file mode 100755 index 0000000..6aba77a --- /dev/null +++ b/root/usr/local/bin/incremental-backup @@ -0,0 +1,37 @@ +#!/bin/bash + +# Script to perform incremental backups using rsync +# It is often ran as crontab rule for automated backup solution +# +# This script will respect .rsync-filter files, which can be used +# to define custom exclude rules for files/dirs in which it is present + +if [ $# -lt 2 ]; then + echo "Invalid amount of arguments passed!" + echo "Arguments: [Source path] [Backup path]" + echo " Source path: directory to be backed up, usually '/'" + echo " Backup path: directory to back up to (destination), usually mounted drive" + exit +fi + +SOURCE_DIR="$1" +BACKUP_DIR="$2" +DATETIME="$(date '+%Y-%m-%d_%H:%M:%S')" +BACKUP_PATH="${BACKUP_DIR}/${DATETIME}" +LATEST_LINK="${BACKUP_DIR}/latest" + +mkdir -p "$BACKUP_DIR" + +rsync -avHAXS \ + --delete \ + --filter='dir-merge /.rsync-filter' \ + --link-dest "${LATEST_LINK}" \ + "${@:3}" "${SOURCE_DIR}/" "${BACKUP_PATH}" + +# Only attempt to override the symlink if we made new backup_path +# user might've passed --dry-run option in which case we wouldn't +# want to override latest symlink to non-existent location +if [ -d "${BACKUP_PATH}" ]; then + rm "${LATEST_LINK}" 2>/dev/null + ln -s "${BACKUP_PATH}" "${LATEST_LINK}" +fi diff --git a/root/usr/local/bin/tamper-check b/root/usr/local/bin/tamper-check new file mode 100755 index 0000000..82159ca --- /dev/null +++ b/root/usr/local/bin/tamper-check @@ -0,0 +1,292 @@ +#!/bin/python3 +import json +import subprocess +import sys +import argparse +from pathlib import Path + +try: + import colorama +except ImportError: + from unittest.mock import Mock + class NoReprMock(Mock): + __repr__ = lambda self: "" + colorama = NoReprMock() + +colorama.init(autoreset=True) + + +# default path to the JSON file that stores known file checksums +# this can be overridden by using `--checksum-file=path` flag +CHECKSUM_FILE = Path('/usr/local/share/tamper-check/checksums.json') + + +def _yes_no(text: str, add_yn: bool = True) -> bool: + if add_yn: + text += ' (y/n): ' + while True: + user_inp = input(text).lower() + if user_inp in ('y', 'yes'): + return True + elif user_inp in ('n', 'no'): + return False + + +def _get_checksum_dict(checksum_file: Path) -> dict[Path, str]: + """Read the JSON checksum file and return it as python dictionary object.""" + try: + with open(checksum_file, 'r') as f: + checksums = json.load(f) + except FileNotFoundError: + print( + f'{colorama.Fore.YELLOW}Checksum file not found: {colorama.Fore.RESET}' + f"'{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}'{colorama.Fore.YELLOW} " + 'Creating new empty checksum file...' + ) + checksum_file.parent.mkdir(parents=True, exist_ok=True) + checksums = {} + with open(checksum_file, 'w') as f: + json.dump(checksums, f, indent=4) + return checksums + except PermissionError: + print( + f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}' + 'to run tamper-check you must have read access to checksum file: ' + f"'{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}' (forgot sudo?)" + ) + exit(2) + except json.decoder.JSONDecodeError as e: + print( + f'{colorama.Fore.RED}Checksum file is corrupted, unable to decode JSON. ' + f"{colorama.Fore.RESET}('{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}').\n" + f'Error text: {e}' + ) + exit(3) + else: + dct = {} + for file_str, checksum in checksums.items(): + dct[Path(file_str)] = checksum + return dct + + +def _get_checksum(file: Path) -> str: + """Obtain a checksum of given file""" + proc = subprocess.run(['sha256sum', file], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + proc_stdout = proc.stdout.decode('utf-8') + + if "No such file or directory" in proc_stdout: + raise FileNotFoundError(f"'{file}' not found, can't produce sha256 checksum") + elif "Permission denied" in proc_stdout: + raise PermissionError(f"Unable to read file '{file}'") + elif "Is a directory" in proc_stdout: + raise RuntimeError(f"{file} is a directory, can't produce sha256sum") + + return proc_stdout.replace(f' {file}\n', '') + + +def _update_checksum(file_path: Path, checksum: str, checksum_file: Path, new_entry: bool = False) -> None: + """Update existing checksums or add new file entries in checksum_file""" + checksums = _get_checksum_dict(checksum_file) + + if new_entry and file_path in checksums: + print( + f"{colorama.Fore.RED}Path {colorama.Fore.RESET}" + f"'{colorama.Fore.BLUE}{file_path}{colorama.Fore.RESET}' {colorama.Fore.RED}" + "is already in the checksum file perhaps you wanted `--update`?" + ) + raise SystemExit(3) + + checksums[file_path] = checksum + + writeable_checksums = {str(file_path): file_checksum for file_path, file_checksum in checksums.items()} + try: + with open(checksum_file, 'w') as f: + json.dump(writeable_checksums, f, indent=4) + except PermissionError: + print( + f'{colorama.Fore.RED}PermissionError: {colorama.Fore.RESET}' + 'To add a new rule, you must have write access to: ' + f"'{colorama.Fore.BLUE}{checksum_file}{colorama.Fore.RESET}' (forgot sudo?)" + ) + raise SystemExit(2) + + +def update(file_path: Path, checksum_file: Path, text: str, no_confirm: bool = False) -> bool: + """Ask user if a file should be updated, or update automatically if no_confirm is True""" + new_checksum = _get_checksum(file_path) + + if no_confirm: + print(text + ' checksum auto-updating') + elif not _yes_no(text + ' update checksum?'): + print(f'{colorama.Fore.RED} -> Staying mismatched') + return False + + _update_checksum(file_path, new_checksum, checksum_file) + print(f'{colorama.Fore.GREEN} -> Updated') + return True + + +def run_check(checksum_file: Path, verbose: bool) -> list[Path]: + """ + Go through all files listed in checksum_file and make sure that the checksums are matching. + Return all entries which didn't match. + """ + checksums = _get_checksum_dict(checksum_file) + + not_matched = [] + for file, stored_checksum in checksums.items(): + line = f"Checksum of '{colorama.Fore.BLUE}{file}{colorama.Fore.RESET}': " + + try: + real_sha256_sum = _get_checksum(file) + except PermissionError as exc: + print(line + f'{colorama.Fore.YELLOW}SKIPPED [PermissionError - no read perms]') + if verbose: + print(f' -> Error text: {colorama.Fore.CYAN}{exc}') + continue + except FileNotFoundError as exc: + print(line + f'{colorama.Fore.YELLOW}FAILED [FileNotFound - fix checksum file]') + if verbose: + print(f' -> Error text: {colorama.Fore.CYAN}{exc}') + continue + except RuntimeError as exc: + print(line + f'{colorama.Fore.YELLOW}FAILED [{exc.__class__.__name__}: {exc} - fix checksum file]') + if verbose: + print(f' -> Error text: {colorama.Fore.CYAN}{exc}') + continue + + if real_sha256_sum == stored_checksum: + print(line + f'{colorama.Fore.GREEN}OK') + else: + not_matched.append(file) + print(line + f'{colorama.Fore.RED}FAIL [Checksum Mismatch]') + if verbose: + print(f' -> detected: {colorama.Fore.CYAN}{real_sha256_sum}') + print(f' -> stored: {colorama.Fore.CYAN}{stored_checksum}') + + return not_matched + + +def parse_args(*, checksum_file_default) -> dict: + parser = argparse.ArgumentParser( + description='tamper-check is a command line utility to automate checking for file edits. ' + 'This is achieved by storing sha256 checksums of each added file and comparing them.' + ) + parser.add_argument( + '-v', '--verbose', action='store_true', + help='Verbose mode, show checksums on failures and some more info' + ) + parser.add_argument( + '-u', '--update', action='store_true', + help='If invalid checksum is found, ask user if it should be updated (y/n)' + ) + parser.add_argument( + '--no-confirm', action='store_true', + help='Used in combination with `--update`, automatically assumes `y` for all questions' + ) + parser.add_argument( + '--checksum-file', metavar='FILE', type=Path, default=checksum_file_default, + help='JSON file storing the file checksums' + ) + parser.add_argument( + '-a', '--add', metavar='FILE', nargs='+', action='extend', type=Path, default=[], + dest='files_to_add', help='Add a new file to the list of check entries' + ) + + namespace = parser.parse_args() + cli_args = {k: v for k, v in vars(namespace).items()} + + # Handle non-existing paths + for path in cli_args['files_to_add']: + if not path.exists(): + raise FileNotFoundError(path) + if not path.is_file(): + raise RuntimeError("Can't add a directory") + if not cli_args['checksum_file'].exists(): + raise FileNotFoundError(cli_args['checksum_file']) + + return cli_args + + +def main() -> int: + """Run the program as intended, return the exit code""" + try: + run_parameters = parse_args(checksum_file_default=CHECKSUM_FILE) + except FileNotFoundError as exc: + path = exc.args[0] + print( + f'{colorama.Fore.RED}FileNotFoundError: {colorama.Fore.RESET}' + f"'{colorama.Fore.BLUE}{path}{colorama.Fore.RESET}' -> invalid path" + ) + return 2 + except RuntimeError as exc: + print( + f'{colorama.Fore.RED}{exc.__class__.__name__}: {colorama.Fore.RESET}' + f"'{colorama.Fore.BLUE}{exc}{colorama.Fore.RESET}'" + ) + return 2 + except SystemExit as exc: + return exc.code + + if len(run_parameters["files_to_add"]) > 0: + for file_to_add in run_parameters["files_to_add"]: + checksum = _get_checksum(file_to_add) + try: + _update_checksum( + file_to_add, checksum, + run_parameters["checksum_file"], + new_entry=True + ) + except SystemExit as e: + return e.code + + print( + f"Added '{colorama.Fore.BLUE}{file_to_add}{colorama.Fore.RESET}': " + f"'{colorama.Fore.CYAN}{checksum}{colorama.Fore.RESET}'" + ) + + return 0 # don't proceed to check if we're adding files + + # Run the check + mismatched_files = run_check(run_parameters["checksum_file"], run_parameters["verbose"]) + if len(mismatched_files) == 0: + return 0 # all files are ok + + print("\nFiles with mismatched checksums:") + prefix = f"{colorama.Fore.RED} - {colorama.Fore.RESET}" + unfixed = [] + for mismatched_file in mismatched_files: + line = prefix + f"'{colorama.Fore.BLUE}{mismatched_file}{colorama.Fore.RESET}'" + + if not run_parameters["update"]: + unfixed.append(mismatched_file) + print(line) + continue + + if not update( + file_path=mismatched_file, + checksum_file=run_parameters["checksum_file"], + no_confirm=run_parameters["no_confirm"], + text=line + ): + unfixed.append(mismatched_file) + + if len(unfixed) > 0: + return 1 + + print(f'\n{colorama.Fore.GREEN}All checksums are correct') + return 0 + + +if __name__ == '__main__': + exit_code = main() + try: + exit(exit_code) # exit gracefully, with silent exit code + except TypeError: + # Some python interpreters/extensions (such as IPython) don't like exit. + # sys.exit will raise a full exception and go to python traceback, exiting + # with code 1. The real exit code will be preserved in the traceback. + # This isn't ideal, but it's better than out of the place TypeError + # and with exit code 0, this will exit normally. + # CPython doesn't do this and most users will never experience this. + sys.exit(exit_code)